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 }