package usecase import ( "context" "errors" "testing" "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/repository" ) func newTestService() *Service { return New(repository.NewMemory()) } func sampleProfile() *domain.ServiceProfile { return &domain.ServiceProfile{ Services: []domain.ServiceItem{ {Name: "婚禮攝影", PriceMin: 18000, PriceMax: 36000}, {Name: "活動紀錄", PriceMin: 8000}, }, Cases: []domain.ServiceCase{{Title: "陽明山戶外婚禮", Summary: "全天紀錄", Link: "https://example.com/case/1"}}, Forbidden: []string{"保證接到案", " 保證接到案 ", "最便宜"}, Faq: []domain.FaqItem{{Question: "可以加時嗎?", Answer: "可以,每小時 3000。"}}, ServiceAreas: []string{"TPE", "nwt", "TPE"}, Availability: "平日全天、週末僅早場", ToneNote: "親切、不推銷", } } func TestUpsertThenGetKeepsEveryField(t *testing.T) { svc := newTestService() ctx := context.Background() saved, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()) if err != nil { t.Fatalf("upsert: %v", err) } got, err := svc.GetServiceProfile(ctx, 42) if err != nil { t.Fatalf("get: %v", err) } if got.OwnerUID != 42 { t.Fatalf("owner_uid = %d, want 42", got.OwnerUID) } if len(got.Services) != 2 || got.Services[0].Name != "婚禮攝影" { t.Fatalf("services not round-tripped: %+v", got.Services) } // 只填 price_min 是合法的「起價」,幣別要自動補上才顯示得出來。 if got.Services[1].Currency != "TWD" { t.Fatalf("currency = %q, want TWD", got.Services[1].Currency) } if len(got.Cases) != 1 || got.Cases[0].Title != "陽明山戶外婚禮" { t.Fatalf("cases not round-tripped: %+v", got.Cases) } // forbidden 是回覆生成的硬性過濾詞,讀不到就等於過濾失效。 if len(got.Forbidden) != 2 { t.Fatalf("forbidden = %v, want 2 deduped items", got.Forbidden) } if len(got.Faq) != 1 || got.Faq[0].Answer == "" { t.Fatalf("faq not round-tripped: %+v", got.Faq) } if len(got.ServiceAreas) != 2 || got.ServiceAreas[0] != "TPE" || got.ServiceAreas[1] != "NWT" { t.Fatalf("service_areas = %v, want [TPE NWT]", got.ServiceAreas) } if got.Availability == "" || got.ToneNote == "" { t.Fatalf("availability/tone_note lost: %+v", got) } if got.UpdatedAt <= 0 || got.CreatedAt <= 0 { t.Fatalf("timestamps unset: created=%d updated=%d", got.CreatedAt, got.UpdatedAt) } if saved.UpdatedAt != got.UpdatedAt { t.Fatalf("upsert returned a different revision than get") } } func TestUpsertRejectsReversedPriceRange(t *testing.T) { svc := newTestService() p := sampleProfile() p.Services[0].PriceMin = 50000 p.Services[0].PriceMax = 10000 _, err := svc.UpsertServiceProfile(context.Background(), 42, p) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } } func TestUpsertRejectsUnknownServiceAreaCode(t *testing.T) { svc := newTestService() p := sampleProfile() // 縣市只吃代碼白名單;接受「台北」會讓判定的地區比對變成模糊猜測。 p.ServiceAreas = []string{"台北"} _, err := svc.UpsertServiceProfile(context.Background(), 42, p) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } } func TestUpsertRequiresAreasUnlessRemote(t *testing.T) { svc := newTestService() ctx := context.Background() p := sampleProfile() p.ServiceAreas = nil if _, err := svc.UpsertServiceProfile(ctx, 42, p); !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation for no area and no remote", err) } p = sampleProfile() p.ServiceAreas = nil p.RemoteOk = true if _, err := svc.UpsertServiceProfile(ctx, 42, p); err != nil { t.Fatalf("remote-only profile rejected: %v", err) } } func TestUpsertRequiresAtLeastOneService(t *testing.T) { svc := newTestService() p := sampleProfile() p.Services = nil _, err := svc.UpsertServiceProfile(context.Background(), 42, p) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } } // 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。 func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) { svc := newTestService() ctx := context.Background() _, err := svc.GetServiceProfile(ctx, 42) if !errors.Is(err, domain.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } has, err := svc.HasServiceProfile(ctx, 42) if err != nil || has { t.Fatalf("HasServiceProfile = (%v, %v), want (false, nil)", has, err) } if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("upsert: %v", err) } has, err = svc.HasServiceProfile(ctx, 42) if err != nil || !has { t.Fatalf("HasServiceProfile = (%v, %v), want (true, nil)", has, err) } } func TestUpsertIsWholeDocumentReplacementAndKeepsCreatedAt(t *testing.T) { svc := newTestService() ctx := context.Background() first, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()) if err != nil { t.Fatalf("upsert: %v", err) } trimmed := sampleProfile() trimmed.Services = trimmed.Services[:1] trimmed.Forbidden = nil second, err := svc.UpsertServiceProfile(ctx, 42, trimmed) if err != nil { t.Fatalf("second upsert: %v", err) } // 整份覆寫:刪掉的項目要真的消失,否則使用者永遠刪不掉一個服務或禁語。 if len(second.Services) != 1 { t.Fatalf("services = %d, want 1 after replacement", len(second.Services)) } if len(second.Forbidden) != 0 { t.Fatalf("forbidden = %v, want empty after replacement", second.Forbidden) } if second.CreatedAt != first.CreatedAt { t.Fatalf("created_at changed on update: %d → %d", first.CreatedAt, second.CreatedAt) } } func TestProfilesAreIsolatedPerOwner(t *testing.T) { svc := newTestService() ctx := context.Background() if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("upsert: %v", err) } if _, err := svc.GetServiceProfile(ctx, 43); !errors.Is(err, domain.ErrNotFound) { t.Fatalf("owner 43 saw owner 42's profile: %v", err) } } func TestMatchesRegion(t *testing.T) { p := &domain.ServiceProfile{ServiceAreas: []string{"TPE"}} if !p.MatchesRegion("tpe") { t.Fatal("TPE should match regardless of case") } if p.MatchesRegion("KHH") { t.Fatal("KHH must not match a Taipei-only profile") } // 判不出縣市時不算符合,但也不該被當成不符合 —— 那是呼叫端的 unknown 分支。 if p.MatchesRegion("") { t.Fatal("empty region must not count as a match") } remote := &domain.ServiceProfile{RemoteOk: true} if !remote.MatchesRegion("") { t.Fatal("remote_ok profile serves any region") } }