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-02/RW-04:pause 退出 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) } }