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) }