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.PlaceCandidate, error) { captured = query return []domain.PlaceCandidate{{ SourceRecordID: "station-1", PlaceType: "fire_check_station", Name: "观水检查站", MatchedField: "name", MatchedText: "观水检查站", MatchKind: "partial", LocationKind: "recorded_point", }}, 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) if err != nil { t.Fatalf("SearchPlaceCandidates() error = %v", err) } if captured.PlaceName != "观水" || captured.Limit != 10 || !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) 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.WaterSource, error) { captured = query return []domain.WaterSource{{SourceRecordID: "152", Category: "water_source"}}, 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) 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.WaterSource, error) { captured = query return []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) 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}) 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 { t.Fatalf("unexpected no-results response: %#v", result) } if len(result.Warnings) == 0 { t.Fatal("no-results response must retain data-quality warnings") } } 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}) return err }, }, { name: "water radius", call: func() error { _, err := spatial.FindNearbyWaterSources(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 30_001, 1) return err }, }, { name: "access radius", call: func() error { _, err := spatial.ListNearbyAccessLines(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 10_001, 1) return err }, }, { name: "limit", call: func() error { _, err := spatial.FindNearbyRiskAreas(context.Background(), domain.Coordinate{Longitude: 121, Latitude: 37}, 100, 21) 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.WaterSource, error) { return nil, 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) 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.WaterSource, error) { <-ctx.Done() return nil, 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) 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 } type fakeSpatialRepository struct { places func(context.Context, domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) resolve func(context.Context, domain.Coordinate, domain.SpatialScope) ([]domain.IncidentContext, error) water func(context.Context, domain.NearbyQuery) ([]domain.WaterSource, error) commandPost func(context.Context, domain.NearbyQuery) ([]domain.CommandPostCandidate, error) access func(context.Context, domain.NearbyQuery) ([]domain.AccessLine, error) units func(context.Context, domain.Coordinate, domain.SpatialScope) ([]domain.ResponsibleUnit, error) risks func(context.Context, domain.NearbyQuery) ([]domain.RiskArea, error) } func (f *fakeSpatialRepository) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) { if f.places == nil { return []domain.PlaceCandidate{}, nil } return f.places(ctx, query) } func (f *fakeSpatialRepository) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.IncidentContext, error) { if f.resolve == nil { return []domain.IncidentContext{}, nil } return f.resolve(ctx, point, scope) } func (f *fakeSpatialRepository) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) ([]domain.WaterSource, error) { if f.water == nil { return []domain.WaterSource{}, nil } return f.water(ctx, query) } func (f *fakeSpatialRepository) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) ([]domain.CommandPostCandidate, error) { if f.commandPost == nil { return []domain.CommandPostCandidate{}, nil } return f.commandPost(ctx, query) } func (f *fakeSpatialRepository) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) ([]domain.AccessLine, error) { if f.access == nil { return []domain.AccessLine{}, nil } return f.access(ctx, query) } func (f *fakeSpatialRepository) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.ResponsibleUnit, error) { if f.units == nil { return []domain.ResponsibleUnit{}, nil } return f.units(ctx, point, scope) } func (f *fakeSpatialRepository) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) ([]domain.RiskArea, error) { if f.risks == nil { return []domain.RiskArea{}, nil } return f.risks(ctx, query) }