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

229 lines
7.9 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
package radar
import (
"context"
"net/http"
"strings"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func watchCtx(t *testing.T, uid int64, maxActive int, withProfile bool) (context.Context, *svc.ServiceContext) {
t.Helper()
radar := radarUC.New(radarRepo.NewMemory())
radar.Quota = radarUC.FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30}
svcCtx := &svc.ServiceContext{Radar: radar}
ctx := middleware.WithUID(context.Background(), uid)
if withProfile {
if _, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("seed profile: %v", err)
}
}
return ctx, svcCtx
}
func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, term string, enabled bool) *types.RadarWatchPublic {
t.Helper()
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{term},
Enabled: enabled,
})
if err != nil {
t.Fatalf("create watch %q: %v", term, err)
}
return w
}
// SP-01沒建服務檔案就建 active 訂閱 → 400100訊息要指向服務檔案而不是只說「失敗」。
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, false)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("active watch created without a service profile")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "service-profile") {
t.Fatalf("message must point at the service profile, got %q", env.Message)
}
}
// RW-01上限 1 時第二個 active 被拒,訊息要有目前上限與升級提示。
func TestCreateWatchOverQuotaCarriesLimitAndUpgradeHint(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 1, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"活動紀錄"},
Enabled: true,
})
if err == nil {
t.Fatal("second active watch accepted on a 1-watch plan")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "1") || !strings.Contains(env.Message, "upgrade") {
t.Fatalf("message = %q, want the current limit plus an upgrade hint", env.Message)
}
}
func TestListWatchesReportsQuotaAndProfileState(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
createWatch(t, ctx, svcCtx, "活動紀錄", false)
data, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(data.List) != 2 || data.Pagination.Total != 2 {
t.Fatalf("list = %d items, total = %d", len(data.List), data.Pagination.Total)
}
// 前端要靠這三個值在建立之前就決定顯示引導還是升級提示。
if data.ActiveCount != 1 || data.MaxActive != 5 || !data.ProfileExists {
t.Fatalf("quota fields = active %d / max %d / profile %v", data.ActiveCount, data.MaxActive, data.ProfileExists)
}
paused, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20, Status: "paused"})
if err != nil {
t.Fatalf("list paused: %v", err)
}
if len(paused.List) != 1 || paused.List[0].Status != "paused" {
t.Fatalf("status filter returned %+v", paused.List)
}
}
// RW-02RW-04pause 退出 active 清單archive 之後不能 resume。
func TestPauseResumeArchiveThroughHTTPLayer(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
paused, err := NewPauseWatchLogic(ctx, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || paused.Status != "paused" {
t.Fatalf("pause = %+v, err = %v", paused, err)
}
after, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil || after.ActiveCount != 0 {
t.Fatalf("active_count = %d after pause (err %v)", after.ActiveCount, err)
}
resumed, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || resumed.Status != "active" {
t.Fatalf("resume = %+v, err = %v", resumed, err)
}
ok, err := NewArchiveWatchLogic(ctx, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || !ok.Ok {
t.Fatalf("archive = %+v, err = %v", ok, err)
}
if _, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id}); err == nil {
t.Fatal("archived watch was resumed")
} else {
assertStatus(t, err, http.StatusBadRequest, 400100)
}
// 軟刪:列表還看得到,只是不再是 active。
list, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list.List) != 1 || list.List[0].Status != "archived" || list.ActiveCount != 0 {
t.Fatalf("archive should be a soft delete, got %+v", list.List)
}
}
func TestUpdateWatchLeavesOmittedFieldsAlone(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
created, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
ExcludeTerms: []string{"徵才"},
Regions: []string{"TPE"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
updated, err := NewUpdateWatchLogic(ctx, svcCtx).UpdateWatch(&types.UpdateWatchReq{
Id: created.Id,
Regions: []string{"KHH"},
})
if err != nil {
t.Fatalf("update: %v", err)
}
if len(updated.Terms) != 1 || len(updated.ExcludeTerms) != 1 {
t.Fatalf("omitted fields were cleared: %+v", updated)
}
if len(updated.Regions) != 1 || updated.Regions[0] != "KHH" {
t.Fatalf("regions = %v", updated.Regions)
}
}
func TestWatchEndpointsRejectOtherOwners(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
other := middleware.WithUID(context.Background(), 43)
for name, call := range map[string]func() error{
"get": func() error {
_, err := NewGetWatchLogic(other, svcCtx).GetWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"pause": func() error {
_, err := NewPauseWatchLogic(other, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"resume": func() error {
_, err := NewResumeWatchLogic(other, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"archive": func() error {
_, err := NewArchiveWatchLogic(other, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"update": func() error {
_, err := NewUpdateWatchLogic(other, svcCtx).UpdateWatch(&types.UpdateWatchReq{Id: w.Id, Regions: []string{"KHH"}})
return err
},
} {
t.Run(name, func(t *testing.T) {
err := call()
if err == nil {
t.Fatalf("owner 43 could %s owner 42's watch", name)
}
assertStatus(t, err, http.StatusForbidden, 403003)
})
}
}
func TestWatchEndpointsRequireAuth(t *testing.T) {
_, svcCtx := watchCtx(t, 42, 5, true)
anon := context.Background()
if _, err := NewListWatchesLogic(anon, svcCtx).ListWatches(&types.ListWatchesReq{}); err == nil {
t.Fatal("list without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
if _, err := NewCreateWatchLogic(anon, svcCtx).CreateWatch(&types.CreateWatchReq{Terms: []string{"婚攝"}}); err == nil {
t.Fatal("create without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
}
func TestMissingWatchReportsNotFound(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
if _, err := NewGetWatchLogic(ctx, svcCtx).GetWatch(&types.WatchIdReq{Id: "does-not-exist"}); err == nil {
t.Fatal("missing watch returned success")
} else {
assertStatus(t, err, http.StatusNotFound, 404001)
}
}