thread-master/apps/backend/internal/module/radar/usecase/service_profile_test.go

211 lines
6.6 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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")
}
}