165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
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
|
||
}
|