Files
fire-safety-ymd/internal/service/spatial_test.go
2026-09-05 15:46:37 +08:00

335 lines
12 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.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)
}