thread-master/apps/backend/internal/logic/radar/service_profile_logic_test.go

165 lines
5.6 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
package radar
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func testCtx(uid int64) (context.Context, *svc.ServiceContext) {
svcCtx := &svc.ServiceContext{Radar: radarUC.New(radarRepo.NewMemory())}
return middleware.WithUID(context.Background(), uid), svcCtx
}
func validProfileReq() *types.UpsertServiceProfileReq {
return &types.UpsertServiceProfileReq{
Services: []types.ServiceItem{{Name: "婚禮攝影", PriceMin: 18000, PriceMax: 36000, Currency: "TWD"}},
Cases: []types.ServiceCasePublic{{Title: "陽明山戶外婚禮"}},
Forbidden: []string{"保證接到案"},
Faq: []types.FaqItem{{Question: "可以加時嗎?", Answer: "可以"}},
ServiceAreas: []string{"TPE"},
Availability: "平日全天",
ToneNote: "親切、不推銷",
}
}
// SP-02填完服務檔案後 GET 要能讀回全部欄位forbidden[] 尤其重要 —— 它是回覆生成的硬性過濾詞。
func TestServiceProfileRoundTripsThroughHTTPTypes(t *testing.T) {
ctx, svcCtx := testCtx(42)
if _, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := NewGetServiceProfileLogic(ctx, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get: %v", err)
}
if !got.Exists {
t.Fatal("exists = false after upsert")
}
if len(got.Services) != 1 || got.Services[0].Name != "婚禮攝影" || got.Services[0].PriceMax != 36000 {
t.Fatalf("services = %+v", got.Services)
}
if len(got.Forbidden) != 1 || got.Forbidden[0] != "保證接到案" {
t.Fatalf("forbidden = %v", got.Forbidden)
}
if len(got.Faq) != 1 || len(got.Cases) != 1 || len(got.ServiceAreas) != 1 {
t.Fatalf("faq/cases/areas lost: %+v", got)
}
if got.Availability != "平日全天" || got.ToneNote != "親切、不推銷" {
t.Fatalf("free-text fields lost: %+v", got)
}
if got.UpdatedAt <= 0 {
t.Fatalf("updated_at = %d, want ns timestamp", got.UpdatedAt)
}
}
// 未建檔要回 exists=false 的 200不是 404表單得先開得起來才有東西填。
func TestGetBeforeFirstSaveReportsAbsentProfile(t *testing.T) {
ctx, svcCtx := testCtx(42)
got, err := NewGetServiceProfileLogic(ctx, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Exists {
t.Fatal("exists = true without any saved profile")
}
// 空陣列而非 null前端直接 map不必每個欄位判 null。
if got.Services == nil || got.Cases == nil || got.Forbidden == nil || got.Faq == nil || got.ServiceAreas == nil {
t.Fatalf("absent profile must use empty slices, got %+v", got)
}
}
func TestServiceProfileIsolatedByJWTOwner(t *testing.T) {
ctxA, svcCtx := testCtx(42)
if _, err := NewUpsertServiceProfileLogic(ctxA, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("upsert as 42: %v", err)
}
// 同一個 store、另一個登入者request 沒有任何欄位能指定 owner所以看不到別人的檔案。
ctxB := middleware.WithUID(context.Background(), 43)
got, err := NewGetServiceProfileLogic(ctxB, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get as 43: %v", err)
}
if got.Exists {
t.Fatal("owner 43 read owner 42's service profile")
}
// 43 覆寫自己的檔案不會動到 42 的。
req := validProfileReq()
req.Services[0].Name = "另一種服務"
if _, err := NewUpsertServiceProfileLogic(ctxB, svcCtx).UpsertServiceProfile(req); err != nil {
t.Fatalf("upsert as 43: %v", err)
}
back, err := NewGetServiceProfileLogic(ctxA, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get as 42: %v", err)
}
if back.Services[0].Name != "婚禮攝影" {
t.Fatalf("owner 42's profile was overwritten by owner 43: %+v", back.Services)
}
}
func TestServiceProfileRequiresAuth(t *testing.T) {
svcCtx := &svc.ServiceContext{Radar: radarUC.New(radarRepo.NewMemory())}
anon := context.Background()
if _, err := NewGetServiceProfileLogic(anon, svcCtx).GetServiceProfile(); err == nil {
t.Fatal("GET without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
if _, err := NewUpsertServiceProfileLogic(anon, svcCtx).UpsertServiceProfile(validProfileReq()); err == nil {
t.Fatal("PUT without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
}
// 驗證失敗要是明確的 400100能對到欄位的訊息不是 500 也不是空成功。
func TestInvalidProfileMapsToValidationError(t *testing.T) {
ctx, svcCtx := testCtx(42)
req := validProfileReq()
req.Services[0].PriceMin = 50000
req.Services[0].PriceMax = 10000
_, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(req)
if err == nil {
t.Fatal("reversed price range was accepted")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if env.Message == "" {
t.Fatal("validation error carried no message")
}
}
func assertStatus(t *testing.T, err error, wantHTTP int, wantCode int64) response.Envelope {
t.Helper()
rec := httptest.NewRecorder()
response.Write(context.Background(), rec, nil, err)
if rec.Code != wantHTTP {
t.Fatalf("HTTP = %d, want %d (err=%v)", rec.Code, wantHTTP, err)
}
var env response.Envelope
if decErr := json.NewDecoder(rec.Body).Decode(&env); decErr != nil {
t.Fatalf("decode envelope: %v", decErr)
}
if env.Code != wantCode {
t.Fatalf("code = %d, want %d", env.Code, wantCode)
}
return env
}