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