229 lines
7.9 KiB
Go
229 lines
7.9 KiB
Go
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)
|
||
}
|
||
}
|