Files
fire-safety-ymd/internal/service/spatial_test.go
2026-09-06 17:57:58 +08:00

453 lines
17 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"fire-safety-ymd/internal/domain"
)
func TestSpatialServiceSearchesUserConfirmablePlaceCandidates(t *testing.T) {
var captured domain.PlaceSearchQuery
repository := &fakeSpatialRepository{
places: func(_ context.Context, query domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error) {
captured = query
return domain.Page[domain.PlaceCandidate]{Items: []domain.PlaceCandidate{{
SourceRecordID: "station-1",
PlaceType: "fire_check_station",
Name: "观水检查站",
MatchedField: "name",
MatchedText: "观水检查站",
MatchKind: "partial",
LocationKind: "recorded_point",
}}, TotalCount: 1}, nil
},
}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllTowns: true}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
result, err := spatial.SearchPlaceCandidates(context.Background(), " 观水 ", 0, 0)
if err != nil {
t.Fatalf("SearchPlaceCandidates() error = %v", err)
}
if captured.PlaceName != "观水" || captured.Limit != 10 || captured.Offset != 0 || !captured.Scope.AllTowns {
t.Fatalf("captured query = %#v", captured)
}
if result.Status != "ok" || result.Metadata.ResultCount != 1 || len(result.Data) != 1 {
t.Fatalf("result = %#v", result)
}
for _, warning := range []string{
invalidGeometryWarning,
"place_search_is_limited_to_existing_fire_safety_records",
"place_candidates_require_user_confirmation",
"representative_points_are_not_exact_incident_locations",
} {
if !containsString(result.Warnings, warning) {
t.Fatalf("warnings = %#v, want %q", result.Warnings, warning)
}
}
}
func TestSpatialServiceRejectsInvalidPlaceSearch(t *testing.T) {
spatial, err := NewSpatialService(&fakeSpatialRepository{}, domain.SpatialScope{AllTowns: true}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
tests := []struct {
name string
placeName string
limit int
}{
{name: "empty", placeName: " "},
{name: "too short", placeName: "山"},
{name: "too long", placeName: strings.Repeat("山", 101)},
{name: "control character", placeName: "观水\n镇"},
{name: "limit", placeName: "观水镇", limit: 21},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := spatial.SearchPlaceCandidates(context.Background(), tt.placeName, tt.limit, 0)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error = %v, want ErrInvalidArgument", err)
}
})
}
}
func TestSpatialServiceUsesTrustedScopeAndDefaults(t *testing.T) {
var captured domain.NearbyQuery
repository := &fakeSpatialRepository{
water: func(_ context.Context, query domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
captured = query
return domain.Page[domain.WaterSource]{Items: []domain.WaterSource{{SourceRecordID: "152", Category: "water_source"}}, TotalCount: 1}, nil
},
}
towns := []string{"莒格庄镇"}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllowedTowns: towns}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
towns[0] = "被调用方篡改"
fixedNow := time.Date(2026, 9, 4, 8, 30, 0, 0, time.FixedZone("CST", 8*60*60))
spatial.now = func() time.Time { return fixedNow }
result, err := spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121.75, Latitude: 37.19}, 0, 0, 0)
if err != nil {
t.Fatalf("FindNearbyWaterSources() error = %v", err)
}
if captured.RadiusMeters != 10_000 || captured.Limit != 10 {
t.Fatalf("captured bounds = radius %.0f limit %d", captured.RadiusMeters, captured.Limit)
}
if captured.Scope.AllTowns || len(captured.Scope.AllowedTowns) != 1 || captured.Scope.AllowedTowns[0] != "莒格庄镇" {
t.Fatalf("trusted scope = %#v", captured.Scope)
}
if result.Status != "ok" || result.Metadata.ResultCount != 1 || result.Metadata.GeneratedAt != "2026-09-04T00:30:00Z" {
t.Fatalf("unexpected result metadata: %#v", result)
}
if result.Metadata.SearchRadiusMeters == nil || *result.Metadata.SearchRadiusMeters != 10_000 {
t.Fatalf("search radius metadata = %#v", result.Metadata.SearchRadiusMeters)
}
if got, want := result.Metadata.DataSources, []string{"water_source", "storage_pool"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("data sources = %#v, want %#v", got, want)
}
if !containsString(result.Warnings, townAllowlistScopeWarning) {
t.Fatalf("warnings = %#v, want town allowlist scope warning", result.Warnings)
}
if !containsString(result.Warnings, invalidGeometryWarning) {
t.Fatalf("warnings = %#v, want invalid-geometry exclusion warning", result.Warnings)
}
}
func TestSpatialServiceUsesAllTownsScope(t *testing.T) {
var captured domain.NearbyQuery
repository := &fakeSpatialRepository{
water: func(_ context.Context, query domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
captured = query
return domain.Page[domain.WaterSource]{Items: []domain.WaterSource{}}, nil
},
}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllTowns: true}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
result, err := spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121.75, Latitude: 37.19}, 100, 1, 0)
if err != nil {
t.Fatalf("FindNearbyWaterSources() error = %v", err)
}
if !captured.Scope.AllTowns || len(captured.Scope.AllowedTowns) != 0 {
t.Fatalf("trusted scope = %#v, want all towns", captured.Scope)
}
if !containsString(result.Warnings, allTownsScopeWarning) || containsString(result.Warnings, townAllowlistScopeWarning) {
t.Fatalf("warnings = %#v, want only all-towns scope warning", result.Warnings)
}
}
func TestNewSpatialServiceRejectsInvalidScope(t *testing.T) {
tests := []struct {
name string
scope domain.SpatialScope
}{
{name: "empty allowlist", scope: domain.SpatialScope{}},
{name: "all with allowlist", scope: domain.SpatialScope{AllTowns: true, AllowedTowns: []string{"莒格庄镇"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := NewSpatialService(&fakeSpatialRepository{}, tt.scope, time.Second); err == nil {
t.Fatal("NewSpatialService() error = nil")
}
})
}
}
func TestSpatialServiceReturnsExplicitNoResults(t *testing.T) {
spatial, err := NewSpatialService(&fakeSpatialRepository{}, domain.SpatialScope{AllowedTowns: []string{"高陵镇"}}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
result, err := spatial.ResolveIncidentContext(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 0, 0)
if err != nil {
t.Fatalf("ResolveIncidentContext() error = %v", err)
}
if result.Status != "no_results" || result.Data == nil || len(result.Data) != 0 || result.Metadata.ResultCount != 0 || result.Metadata.TotalCount != 0 {
t.Fatalf("unexpected no-results response: %#v", result)
}
if result.Metadata.Limit != 20 || result.Metadata.Offset != 0 || result.Metadata.HasMore || result.Metadata.NextOffset != nil {
t.Fatalf("unexpected no-results pagination metadata: %#v", result.Metadata)
}
if len(result.Warnings) == 0 {
t.Fatal("no-results response must retain data-quality warnings")
}
}
func TestSpatialServiceUsesPointQueryPaginationDefaults(t *testing.T) {
var captured domain.PointQuery
repository := &fakeSpatialRepository{
resolve: func(_ context.Context, query domain.PointQuery) (domain.Page[domain.IncidentContext], error) {
captured = query
return domain.Page[domain.IncidentContext]{Items: []domain.IncidentContext{{GridID: "2897"}}, TotalCount: 1}, nil
},
}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllowedTowns: []string{"文化街道"}}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
result, err := spatial.ResolveIncidentContext(context.Background(), domain.Coordinate{Longitude: 121.6, Latitude: 37.3}, 0, 0)
if err != nil {
t.Fatalf("ResolveIncidentContext() error = %v", err)
}
if captured.Limit != 20 || captured.Offset != 0 || captured.Point.Longitude != 121.6 {
t.Fatalf("captured query = %#v", captured)
}
if captured.Scope.AllTowns || len(captured.Scope.AllowedTowns) != 1 || captured.Scope.AllowedTowns[0] != "文化街道" {
t.Fatalf("captured scope = %#v", captured.Scope)
}
if result.Metadata.Limit != 20 || result.Metadata.Offset != 0 || result.Metadata.TotalCount != 1 {
t.Fatalf("metadata = %#v", result.Metadata)
}
}
func TestSpatialServiceReturnsPaginationMetadata(t *testing.T) {
tests := []struct {
name string
offset int
items []domain.WaterSource
totalCount int64
wantStatus string
wantHasMore bool
wantNextOffset *int
}{
{name: "first page", offset: 0, items: make([]domain.WaterSource, 10), totalCount: 25, wantStatus: statusOK, wantHasMore: true, wantNextOffset: intPointer(10)},
{name: "last page", offset: 20, items: make([]domain.WaterSource, 5), totalCount: 25, wantStatus: statusOK},
{name: "past last page", offset: 30, items: []domain.WaterSource{}, totalCount: 25, wantStatus: statusOK},
{name: "no matches", offset: 0, items: nil, totalCount: 0, wantStatus: statusNoResults},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var captured domain.NearbyQuery
repository := &fakeSpatialRepository{
water: func(_ context.Context, query domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
captured = query
return domain.Page[domain.WaterSource]{Items: tt.items, TotalCount: tt.totalCount}, nil
},
}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllTowns: true}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
result, err := spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 100, 10, tt.offset)
if err != nil {
t.Fatalf("FindNearbyWaterSources() error = %v", err)
}
if captured.Limit != 10 || captured.Offset != tt.offset {
t.Fatalf("captured query = %#v", captured)
}
metadata := result.Metadata
if result.Status != tt.wantStatus || metadata.ResultCount != len(tt.items) || metadata.TotalCount != tt.totalCount || metadata.Limit != 10 || metadata.Offset != tt.offset || metadata.HasMore != tt.wantHasMore {
t.Fatalf("result = %#v", result)
}
if (metadata.NextOffset == nil) != (tt.wantNextOffset == nil) || metadata.NextOffset != nil && *metadata.NextOffset != *tt.wantNextOffset {
t.Fatalf("next_offset = %#v, want %#v", metadata.NextOffset, tt.wantNextOffset)
}
if result.Data == nil {
t.Fatal("data must serialize as an array, not null")
}
})
}
}
func TestSpatialServiceValidatesPaginationBounds(t *testing.T) {
spatial, err := NewSpatialService(&fakeSpatialRepository{}, domain.SpatialScope{AllTowns: true}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
point := domain.Coordinate{Longitude: 121, Latitude: 37}
tests := []struct {
name string
call func() error
}{
{name: "negative offset", call: func() error {
_, err := spatial.SearchPlaceCandidates(context.Background(), "观水镇", 10, -1)
return err
}},
{name: "offset too large", call: func() error {
_, err := spatial.GetResponsibleUnits(context.Background(), point, 20, MaxPageOffset+1)
return err
}},
{name: "negative limit", call: func() error { _, err := spatial.ResolveIncidentContext(context.Background(), point, -1, 0); return err }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.call(); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error = %v, want ErrInvalidArgument", err)
}
})
}
if _, err := spatial.FindNearbyRiskAreas(context.Background(), point, 100, 20, MaxPageOffset); err != nil {
t.Fatalf("maximum offset must be accepted: %v", err)
}
}
func TestSpatialServiceRejectsUnboundedInputs(t *testing.T) {
repository := &fakeSpatialRepository{}
spatial, err := NewSpatialService(repository, domain.SpatialScope{AllowedTowns: []string{"高陵镇"}}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
tests := []struct {
name string
call func() error
}{
{
name: "longitude",
call: func() error {
_, err := spatial.ResolveIncidentContext(context.Background(), domain.Coordinate{Longitude: 181, Latitude: 37}, 1, 0)
return err
},
},
{
name: "water radius",
call: func() error {
_, err := spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 30_001, 1, 0)
return err
},
},
{
name: "access radius",
call: func() error {
_, err := spatial.ListNearbyAccessLines(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 10_001, 1, 0)
return err
},
},
{
name: "limit",
call: func() error {
_, err := spatial.FindNearbyRiskAreas(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 100, 21, 0)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.call(); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error = %v, want ErrInvalidArgument", err)
}
})
}
}
func TestSpatialServiceClassifiesRepositoryFailures(t *testing.T) {
t.Run("data source", func(t *testing.T) {
spatial, err := NewSpatialService(&fakeSpatialRepository{
water: func(context.Context, domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
return domain.Page[domain.WaterSource]{}, errors.New("driver detail must stay internal")
},
}, domain.SpatialScope{AllowedTowns: []string{"高陵镇"}}, time.Second)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
_, err = spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 100, 1, 0)
if !errors.Is(err, ErrDataSourceUnavailable) {
t.Fatalf("error = %v, want ErrDataSourceUnavailable", err)
}
})
t.Run("timeout", func(t *testing.T) {
spatial, err := NewSpatialService(&fakeSpatialRepository{
water: func(ctx context.Context, _ domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
<-ctx.Done()
return domain.Page[domain.WaterSource]{}, ctx.Err()
},
}, domain.SpatialScope{AllowedTowns: []string{"高陵镇"}}, 5*time.Millisecond)
if err != nil {
t.Fatalf("NewSpatialService() error = %v", err)
}
_, err = spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 100, 1, 0)
if !errors.Is(err, ErrQueryTimeout) {
t.Fatalf("error = %v, want ErrQueryTimeout", err)
}
})
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func intPointer(value int) *int {
return &value
}
type fakeSpatialRepository struct {
places func(context.Context, domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error)
resolve func(context.Context, domain.PointQuery) (domain.Page[domain.IncidentContext], error)
water func(context.Context, domain.NearbyQuery) (domain.Page[domain.WaterSource], error)
commandPost func(context.Context, domain.NearbyQuery) (domain.Page[domain.CommandPostCandidate], error)
access func(context.Context, domain.NearbyQuery) (domain.Page[domain.AccessLine], error)
units func(context.Context, domain.PointQuery) (domain.Page[domain.ResponsibleUnit], error)
risks func(context.Context, domain.NearbyQuery) (domain.Page[domain.RiskArea], error)
}
func (f *fakeSpatialRepository) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error) {
if f.places == nil {
return domain.Page[domain.PlaceCandidate]{Items: []domain.PlaceCandidate{}}, nil
}
return f.places(ctx, query)
}
func (f *fakeSpatialRepository) ResolveIncidentContext(ctx context.Context, query domain.PointQuery) (domain.Page[domain.IncidentContext], error) {
if f.resolve == nil {
return domain.Page[domain.IncidentContext]{Items: []domain.IncidentContext{}}, nil
}
return f.resolve(ctx, query)
}
func (f *fakeSpatialRepository) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
if f.water == nil {
return domain.Page[domain.WaterSource]{Items: []domain.WaterSource{}}, nil
}
return f.water(ctx, query)
}
func (f *fakeSpatialRepository) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.CommandPostCandidate], error) {
if f.commandPost == nil {
return domain.Page[domain.CommandPostCandidate]{Items: []domain.CommandPostCandidate{}}, nil
}
return f.commandPost(ctx, query)
}
func (f *fakeSpatialRepository) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.AccessLine], error) {
if f.access == nil {
return domain.Page[domain.AccessLine]{Items: []domain.AccessLine{}}, nil
}
return f.access(ctx, query)
}
func (f *fakeSpatialRepository) GetResponsibleUnits(ctx context.Context, query domain.PointQuery) (domain.Page[domain.ResponsibleUnit], error) {
if f.units == nil {
return domain.Page[domain.ResponsibleUnit]{Items: []domain.ResponsibleUnit{}}, nil
}
return f.units(ctx, query)
}
func (f *fakeSpatialRepository) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.RiskArea], error) {
if f.risks == nil {
return domain.Page[domain.RiskArea]{Items: []domain.RiskArea{}}, nil
}
return f.risks(ctx, query)
}