package usecase_test import ( "context" "testing" "time" "apps/backend/internal/module/ai" "apps/backend/internal/module/studio/domain" "apps/backend/internal/module/studio/publish" "apps/backend/internal/module/studio/repository" "apps/backend/internal/module/studio/usecase" threadsDomain "apps/backend/internal/module/threads/domain" usageDomain "apps/backend/internal/module/usage/domain" usageRepo "apps/backend/internal/module/usage/repository" usageUC "apps/backend/internal/module/usage/usecase" "github.com/stretchr/testify/require" ) type memAccounts struct { byID map[string]*threadsDomain.Account } func (m *memAccounts) Get(_ context.Context, id string) (*threadsDomain.Account, error) { a, ok := m.byID[id] if !ok { return nil, threadsDomain.ErrNotFound } cp := *a return &cp, nil } func (m *memAccounts) List(_ context.Context, ownerUID int64) ([]*threadsDomain.Account, error) { var out []*threadsDomain.Account for _, a := range m.byID { if a.OwnerUID == ownerUID { cp := *a out = append(out, &cp) } } return out, nil } func (m *memAccounts) AccessToken(_ context.Context, a *threadsDomain.Account) (string, error) { return "tok-" + a.ID, nil } func newStudio() (*usecase.Service, *publish.FakeTransport, *memAccounts) { repo := repository.NewMemory() tp := publish.NewFake() acc := &memAccounts{byID: map[string]*threadsDomain.Account{}} svc := usecase.New(repo, tp) svc.Accounts = acc svc.AI = &ai.FakeClient{} // usage with unlimited platform ures := usageRepo.NewMemory() uresolver := &usageUC.StaticResolver{Map: map[string]string{}} us := usageUC.New(ures, uresolver) svc.Usage = us return svc, tp, acc } func withUsage(svc *usecase.Service, uid int64) { // unlimited + platform key _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), }) if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok { if sr.Map == nil { sr.Map = map[string]string{} } sr.Map[fmtUIDMeter(uid, usageDomain.MeterAICopy)] = usageDomain.KeyModePlatform } } func fmtUIDMeter(uid int64, meter string) string { return string(rune(0)) // placeholder — use fmt } func setupUID(svc *usecase.Service, uid int64) { _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), }) if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok { if sr.Map == nil { sr.Map = map[string]string{} } key := "" // StaticResolver uses fmt.Sprintf("%d:%s", uid, meter) key = sprintf("%d:%s", uid, usageDomain.MeterAICopy) sr.Map[key] = usageDomain.KeyModePlatform } } func sprintf(f string, a ...any) string { return format(f, a...) } // avoid importing fmt in helpers clutter — use strconv func format(f string, a ...any) string { // minimal for our key only if f == "%d:%s" && len(a) == 2 { return itoa(a[0].(int64)) + ":" + a[1].(string) } return f } func itoa(n int64) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b [32]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) } func addAccount(acc *memAccounts, ownerUID int64, id, user string) { acc.byID[id] = &threadsDomain.Account{ ID: id, OwnerUID: ownerUID, Username: user, DisplayName: user, Connection: threadsDomain.ConnectionConnected, IsUsable: true, } } // ---- PE ---- func TestPE_01_CreateSave(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_000_001) setupUID(svc, uid) p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "小海", Brief: "溫暖"}) require.NoError(t, err) require.NotEmpty(t, p.ID) list, err := svc.ListPersonas(context.Background(), uid) require.NoError(t, err) require.Len(t, list, 1) } func TestPE_02_SetActive(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_000_002) setupUID(svc, uid) a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"}) b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"}) require.NoError(t, svc.SetActivePersonaID(context.Background(), uid, b.ID)) id, err := svc.GetActivePersonaID(context.Background(), uid) require.NoError(t, err) require.Equal(t, b.ID, id) _ = a } func TestPE_03_RemoveActiveResets(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_000_003) setupUID(svc, uid) a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"}) b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"}) _ = svc.SetActivePersonaID(context.Background(), uid, a.ID) require.NoError(t, svc.RemovePersona(context.Background(), uid, a.ID)) id, _ := svc.GetActivePersonaID(context.Background(), uid) require.Equal(t, b.ID, id) } func TestPE_04_AnalyzeFromText(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_000_004) setupUID(svc, uid) p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "分析"}) raw := "今天天氣真好,可是我還是好累有沒有人懂\n\n---\n\n大家早安,後來我改成先寫使用情境再問問題" out, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, raw, "貼文") require.NoError(t, err) require.Equal(t, domain.PersonaReady, out.Status) require.True(t, out.Style.AnalyzedAt > 0) require.GreaterOrEqual(t, out.Style.SampleCount, 2) // 概要 + 指紋 + 8D 都要填滿 require.NotEmpty(t, out.Brief) require.NotEmpty(t, out.Style.DraftText) require.NotEmpty(t, out.Style.Draft.Tone) require.NotEmpty(t, out.Style.Draft.Identity) require.Contains(t, out.Style.DraftText, "【") require.Len(t, out.Style.Dimensions, 8) require.NotEmpty(t, out.Style.Dimensions["d1Tone"].Summary) require.NotEmpty(t, out.Guard.Avoid) } func TestPE_05_AnalyzeFromAccount(t *testing.T) { restore := usecase.SetProfileFetchForTest(func(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { return []string{ "有時候真的會卡在不知道問誰,後來我改成先寫自己的使用情境。", "懂那種越研究越焦慮的感覺。你們如果有推的,最好附一句為什麼。", "講真的上次踩雷之後我都先問實際用起來再下單。", }, nil }) t.Cleanup(restore) svc, _, _ := newStudio() uid := int64(4_000_005) setupUID(svc, uid) p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "對標"}) out, err := svc.AnalyzeFromAccount(context.Background(), uid, p.ID, "cool_creator") require.NoError(t, err) require.Equal(t, domain.PersonaReady, out.Status) require.Equal(t, "cool_creator", out.Style.BenchmarkUsername) require.GreaterOrEqual(t, out.Style.SampleCount, 2) require.NotContains(t, out.Style.SamplePreviews[0], "sample post about") require.NotEmpty(t, out.Brief) require.NotEmpty(t, out.Style.DraftText) require.Len(t, out.Style.Dimensions, 8) require.NotEmpty(t, out.Style.Dimensions["d8Risk"].Summary) } func TestPE_06_CrossUidForbidden(t *testing.T) { svc, _, _ := newStudio() setupUID(svc, 4_000_006) setupUID(svc, 4_000_007) p, _ := svc.SavePersona(context.Background(), 4_000_006, &domain.Persona{Name: "私有"}) _, err := svc.GetPersona(context.Background(), 4_000_007, p.ID) require.ErrorIs(t, err, domain.ErrForbidden) err = svc.RemovePersona(context.Background(), 4_000_007, p.ID) require.ErrorIs(t, err, domain.ErrForbidden) } // ---- PL ---- func TestPL_01_SaveGet(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_001) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") play, err := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "方案A", Topic: "主題", LeadAccountID: "acc1", Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "主貼"}}, ScheduleStartAt: domain.NowNano(), }) require.NoError(t, err) got, err := svc.GetPlay(context.Background(), uid, play.ID) require.NoError(t, err) require.Equal(t, "方案A", got.Title) require.Len(t, got.Steps, 1) } func TestPL_02_ReplyAccountNotSpeaker(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_002) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") addAccount(acc, uid, "acc_stranger", "x") play, err := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "bad", LeadAccountID: "acc1", CastAccountIDs: []string{}, Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, {Kind: domain.StepReply, AccountID: "acc_stranger", Text: "reply"}, }, ScheduleStartAt: domain.NowNano(), }) require.NoError(t, err) _, err = svc.SubmitPlay(context.Background(), uid, play.ID) require.Error(t, err) } func TestPL_03_SubmitScheduleAligned(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_003) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") addAccount(acc, uid, "acc2", "cast") // 明確未來開始(>30s)→ 第一則排到該時間 start := domain.NowNano() + int64(time.Hour) play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "sched", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "root", DelayFromPreviousSec: 0}, {Kind: domain.StepReply, AccountID: "acc2", Text: "r1", DelayFromPreviousSec: 60}, }, ScheduleStartAt: start, }) b, err := svc.SubmitPlay(context.Background(), uid, play.ID) require.NoError(t, err) require.Equal(t, start, b.Steps[0].ScheduledAt) // 第二則 = 間隔 60s ± 抖動 delta := b.Steps[1].ScheduledAt - b.Steps[0].ScheduledAt require.GreaterOrEqual(t, delta, int64(15*time.Second)) require.LessOrEqual(t, delta, int64(120*time.Second)) require.Equal(t, domain.StepScheduled, b.Steps[0].Status) // 未設未來開始 → 第一則立刻 due play2, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "now", LeadAccountID: "acc1", Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "r", DelayFromPreviousSec: 0}, }, ScheduleStartAt: 0, }) before := domain.NowNano() b2, err := svc.SubmitPlay(context.Background(), uid, play2.ID) require.NoError(t, err) require.InDelta(t, float64(before), float64(b2.Steps[0].ScheduledAt), float64(2*time.Second)) } func TestPL_04_ResolveExternalLink(t *testing.T) { svc, _, _ := newStudio() t_, err := svc.ResolveExternalLink(context.Background(), "https://www.threads.net/@alice/post/AbC123") require.NoError(t, err) require.Contains(t, t_.URL, "threads.net") require.Equal(t, "alice", t_.AuthorUsername) require.Equal(t, "AbC123", t_.Shortcode) } func TestPL_05_ResolveBadURL(t *testing.T) { svc, _, _ := newStudio() _, err := svc.ResolveExternalLink(context.Background(), "not-a-url") require.ErrorIs(t, err, domain.ErrBadURL) } func TestPL_06_PublishScheduled(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_006) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") future := domain.NowNano() + int64(2*time.Hour) b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "") require.NoError(t, err) require.Len(t, b.Steps, 1) require.Equal(t, future, b.Steps[0].ScheduledAt) } func TestPL_07_PublishDefaultNow(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_007) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") before := domain.NowNano() b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "") require.NoError(t, err) require.InDelta(t, float64(before), float64(b.Steps[0].ScheduledAt), float64(time.Second*2)) } func TestPL_08_NoUsableAccount(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_001_008) setupUID(svc, uid) _, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "") require.ErrorIs(t, err, domain.ErrNoAccount) } func TestQueueExternalReply(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_081) addAccount(acc, uid, "acc1", "replying") before := domain.NowNano() bundle, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "123456789", "Thanks for sharing", "External reply") require.NoError(t, err) require.Equal(t, uid, bundle.OwnerUID) require.Equal(t, "External reply", bundle.Title) require.Equal(t, domain.OBScheduling, bundle.Status) require.Equal(t, "123456789", bundle.ReplyToMediaID) require.Len(t, bundle.Steps, 1) require.Equal(t, domain.StepReply, bundle.Steps[0].Kind) require.Equal(t, "acc1", bundle.Steps[0].AccountID) require.Equal(t, "123456789", bundle.Steps[0].ReplyTo) require.Equal(t, domain.StepScheduled, bundle.Steps[0].Status) require.InDelta(t, float64(before), float64(bundle.Steps[0].ScheduledAt), float64(2*time.Second)) persisted, err := svc.GetOutbox(context.Background(), uid, bundle.ID) require.NoError(t, err) require.Equal(t, bundle, persisted) } func TestQueueExternalReplyValidation(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_082) addAccount(acc, uid, "acc1", "replying") _, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "media_123", "reply", "") require.ErrorIs(t, err, domain.ErrValidation) _, err = svc.QueueExternalReply(context.Background(), uid, "missing", "123", "reply", "") require.ErrorIs(t, err, domain.ErrNoAccount) acc.byID["acc1"].IsUsable = false _, err = svc.QueueExternalReply(context.Background(), uid, "acc1", "123", "reply", "") require.ErrorIs(t, err, domain.ErrNoAccount) } func TestPL_09_RemovePlayKeepsOutbox(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_009) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "del", LeadAccountID: "acc1", Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "r"}}, ScheduleStartAt: domain.NowNano(), }) b, err := svc.SubmitPlay(context.Background(), uid, play.ID) require.NoError(t, err) require.NoError(t, svc.RemovePlay(context.Background(), uid, play.ID)) list, _ := svc.ListPlays(context.Background(), uid) require.Empty(t, list) // outbox retained got, err := svc.GetOutbox(context.Background(), uid, b.ID) require.NoError(t, err) require.Equal(t, b.ID, got.ID) } func TestPL_10_ListByPost(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_010) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "p1", LeadAccountID: "acc1", TargetOwnPostID: "post_x", Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, }) _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "p2", LeadAccountID: "acc1", TargetOwnPostID: "other", Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, }) list, err := svc.ListPlaysByPost(context.Background(), uid, "post_x") require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, "p1", list[0].Title) } func TestPL_11_ListByExternalUrl(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_011) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") url := "https://www.threads.net/@bob/post/ZZ99" _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "ext", LeadAccountID: "acc1", TargetExternal: &domain.ExternalTarget{URL: url}, Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, }) list, err := svc.ListPlaysByExternalURL(context.Background(), uid, url+"?utm=1") require.NoError(t, err) require.Len(t, list, 1) } // ---- CP ---- func TestCP_01_Mimic(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_002_001) setupUID(svc, uid) p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"}) _, _ = svc.AnalyzeFromText(context.Background(), uid, p.ID, "樣本文字風格", "") out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "") require.NoError(t, err) require.NotEmpty(t, out) } func TestCP_02_AnalyzeViral(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_002_002) setupUID(svc, uid) va, err := svc.AnalyzeViral(context.Background(), uid, "爆紅文結構測試") require.NoError(t, err) require.NotEmpty(t, va.Hooks) require.NotEmpty(t, va.Structure) } // ---- OB ---- func TestOB_01_NotDue_NoTransport(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_001) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") future := domain.NowNano() + int64(time.Hour) b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "") n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.NoError(t, err) require.Equal(t, 0, n) require.Equal(t, 0, tp.CallCount()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepScheduled, got.Steps[0].Status) } func TestOB_02_DueStep_PublishedViaTransport(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_002) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "") n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.NoError(t, err) require.Equal(t, 1, n) require.Equal(t, 1, tp.CallCount()) // must go through transport got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepPublished, got.Steps[0].Status) require.Equal(t, domain.OBCompleted, got.Status) require.NotEmpty(t, got.Steps[0].MediaID) } func TestOB_03_TransportFail(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_003) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") tp.Fail = true b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepFailed, got.Steps[0].Status) require.NotEmpty(t, got.Steps[0].Error) require.NotEqual(t, domain.StepPublished, got.Steps[0].Status) } func TestOB_04_RootFail_BlocksReplies(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_004) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") addAccount(acc, uid, "acc2", "cast") tp.FailOnNth = 1 // root fails past := domain.NowNano() - 1 play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "rootfail", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, {Kind: domain.StepReply, AccountID: "acc2", Text: "reply", DelayFromPreviousSec: 0}, }, ScheduleStartAt: past, }) b, err := svc.SubmitPlay(context.Background(), uid, play.ID) require.NoError(t, err) _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepFailed, got.Steps[0].Status) require.NotEqual(t, domain.StepPublished, got.Steps[1].Status) require.Equal(t, domain.StepBlocked, got.Steps[1].Status) } func TestOB_05_RootOk_ReplyPublishes(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_005) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") addAccount(acc, uid, "acc2", "cast") past := domain.NowNano() - 1 play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "ok", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, {Kind: domain.StepReply, AccountID: "acc2", Text: "reply"}, }, ScheduleStartAt: past, }) b, _ := svc.SubmitPlay(context.Background(), uid, play.ID) n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.NoError(t, err) require.Equal(t, 2, n) require.Equal(t, 2, tp.CallCount()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepPublished, got.Steps[1].Status) } func TestOB_06_RetryStep(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_006) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") tp.Fail = true b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) tp.Fail = false got, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) require.NoError(t, err) require.Equal(t, domain.StepScheduled, got.Steps[0].Status) _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ = svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.StepPublished, got.Steps[0].Status) } func TestOB_07_RetryIllegalStatus(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_003_007) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") // scheduled not failed b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "") _, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) require.ErrorIs(t, err, domain.ErrIllegalStatus) } func TestOB_08_AllSuccessCompleted(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_003_008) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "") _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.OBCompleted, got.Status) } func TestOB_09_PartialFailed(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_009) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") addAccount(acc, uid, "acc2", "cast") // fail only second call tp.FailOnNth = 2 past := domain.NowNano() - 1 play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ Title: "partial", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, Steps: []domain.PlayStep{ {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, {Kind: domain.StepReply, AccountID: "acc2", Text: "reply"}, }, ScheduleStartAt: past, }) b, _ := svc.SubmitPlay(context.Background(), uid, play.ID) _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) got, _ := svc.GetOutbox(context.Background(), uid, b.ID) require.Equal(t, domain.OBPartial, got.Status) } func TestOB_10_RemoveStopsWorker(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_003_010) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "") require.NoError(t, svc.RemoveOutbox(context.Background(), uid, b.ID)) n, _ := svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.Equal(t, 0, n) require.Equal(t, 0, tp.CallCount()) } func TestOB_11_CrossUid(t *testing.T) { svc, _, acc := newStudio() uid1, uid2 := int64(4_003_011), int64(4_003_012) setupUID(svc, uid1) setupUID(svc, uid2) addAccount(acc, uid1, "acc1", "lead") b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "") _, err := svc.GetOutbox(context.Background(), uid2, b.ID) require.ErrorIs(t, err, domain.ErrForbidden) _, err = svc.RetryStep(context.Background(), uid2, b.ID, b.Steps[0].ID) require.ErrorIs(t, err, domain.ErrForbidden) } func TestOB_12_LiveRequiresTransport(t *testing.T) { // document: FE simulate is not acceptance — ProcessDueSteps uses transport svc, tp, acc := newStudio() uid := int64(4_003_013) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") _, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "") before := tp.CallCount() _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) require.Greater(t, tp.CallCount(), before) } func TestPL_PublishRejectsPastSchedule(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_001_020) setupUID(svc, uid) addAccount(acc, uid, "acc1", "lead") // 超過 1 分鐘才算過期 past := domain.NowNano() - int64(2*time.Minute) _, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "") require.ErrorIs(t, err, domain.ErrValidation) // 略早於現在(grace 內)應可過,並 clamp 成現在 slight := domain.NowNano() - int64(5*time.Second) b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "") require.NoError(t, err) require.NotNil(t, b) } // ---- OP ---- func TestOP_01_Sync(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_004_001) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1") require.NoError(t, err) require.NotEmpty(t, list) ts, err := svc.LastSyncedAt(context.Background(), uid) require.NoError(t, err) require.True(t, ts > 0) } func TestOP_02_SyncFailDoesNotClear(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_004_002) setupUID(svc, uid) _, err := svc.SyncOwnPosts(context.Background(), uid, "nope") require.Error(t, err) } func TestOP_03_GenerateReply(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_004_003) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") text, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "", "") require.NoError(t, err) require.NotEmpty(t, text) } func TestOP_04_SendReply(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_004_004) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") post, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "thanks!", "acc1", nil) require.NoError(t, err) require.Equal(t, 1, tp.CallCount()) require.True(t, post.Replies[len(post.Replies)-1].IsMine) } func TestOP_05_SendReplyFail(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_004_005) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") before := len(list[0].Replies) tp.Fail = true _, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "nope", "acc1", nil) require.Error(t, err) got, _ := svc.ListOwnPosts(context.Background(), uid, "acc1") // find same post for _, p := range got { if p.ID == list[0].ID { // may have original seed replies only require.LessOrEqual(t, len(p.Replies), before+1) // last must not be ours if failed before save — count isMine mine := 0 for _, r := range p.Replies { if r.IsMine { mine++ } } require.Equal(t, 0, mine) } } } func TestOP_06_MentionMarkAndSkip(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_004_006) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") _ = svc.SeedMention(context.Background(), &domain.Mention{ OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_1", FromUsername: "x", Text: "hi @me", ContextSnippet: "ctx", }) list, _ := svc.ListMentions(context.Background(), uid, "") require.Len(t, list, 1) m, err := svc.MarkMentionReplied(context.Background(), uid, list[0].ID, "ok", nil) require.NoError(t, err) require.Equal(t, domain.MentionReplied, m.Status) require.NotEmpty(t, tp.Calls) require.Equal(t, "media_mention_1", tp.Calls[len(tp.Calls)-1].ReplyTo) _ = svc.SeedMention(context.Background(), &domain.Mention{ OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_2", FromUsername: "y", Text: "skip", ContextSnippet: "c", }) list2, _ := svc.ListMentions(context.Background(), uid, "") var skipID string for _, x := range list2 { if x.Status == domain.MentionPending { skipID = x.ID } } s, err := svc.SkipMention(context.Background(), uid, skipID) require.NoError(t, err) require.Equal(t, domain.MentionSkipped, s.Status) } func TestOP_07_SyncOtherAccountForbidden(t *testing.T) { svc, _, acc := newStudio() uid1, uid2 := int64(4_004_007), int64(4_004_008) setupUID(svc, uid1) setupUID(svc, uid2) addAccount(acc, uid1, "acc1", "a") _, err := svc.SyncOwnPosts(context.Background(), uid2, "acc1") require.ErrorIs(t, err, domain.ErrForbidden) } func TestOP_08_AnalyzePost(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_004_009) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") post, err := svc.AnalyzePost(context.Background(), uid, list[0].ID) require.NoError(t, err) require.NotEmpty(t, post.FormulaSummary) require.NotEmpty(t, post.FormulaDetail) } func TestOP_09_MentionList(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_004_010) setupUID(svc, uid) _ = svc.SeedMention(context.Background(), &domain.Mention{ OwnerUID: uid, AccountID: "a1", FromUsername: "u", Text: "t", ContextSnippet: "c", }) _ = svc.SeedMention(context.Background(), &domain.Mention{ OwnerUID: uid, AccountID: "a2", FromUsername: "u2", Text: "t2", ContextSnippet: "c", }) list, err := svc.ListMentions(context.Background(), uid, "a1") require.NoError(t, err) require.Len(t, list, 1) } func TestOP_10_MentionGenerateReply(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_004_011) setupUID(svc, uid) _ = svc.SeedMention(context.Background(), &domain.Mention{ OwnerUID: uid, AccountID: "a1", FromUsername: "fan", Text: "喜歡你的文", ContextSnippet: "ctx", }) list, _ := svc.ListMentions(context.Background(), uid, "") m, err := svc.GenerateMentionReply(context.Background(), uid, list[0].ID, "") require.NoError(t, err) require.NotEmpty(t, m.DraftText) } func TestOP_11_GenerateFromFormulaRemoved(t *testing.T) { svc, _, _ := newStudio() err := svc.GenerateFromFormula(context.Background(), "p1", "") require.ErrorIs(t, err, domain.ErrFormulaRemove) } type fakeMentionsMedia struct { hits []usecase.FetchedMention err error } func (f *fakeMentionsMedia) ListThreads(context.Context, string, int) ([]usecase.FetchedThread, error) { return nil, nil } func (f *fakeMentionsMedia) GetInsights(context.Context, string, string) (usecase.FetchedInsights, error) { return usecase.FetchedInsights{Status: "ok"}, nil } func (f *fakeMentionsMedia) ListConversation(context.Context, string, string, int) ([]usecase.FetchedReply, error) { return nil, nil } func (f *fakeMentionsMedia) ListMentions(context.Context, string, string, int) ([]usecase.FetchedMention, error) { if f.err != nil { return nil, f.err } return f.hits, nil } func (f *fakeMentionsMedia) ListProfilePosts(context.Context, string, string, int) ([]usecase.FetchedThread, error) { return nil, nil } func TestOP_12_SyncMentionsFromGraph(t *testing.T) { svc, _, acc := newStudio() uid := int64(4_004_012) setupUID(svc, uid) addAccount(acc, uid, "acc1", "me") // real-ish token (not fake-) svc.Media = &fakeMentionsMedia{hits: []usecase.FetchedMention{ {ID: "m_100", Text: "嗨 @me 看看", Username: "fan_a", Permalink: "https://threads.net/t/x", PublishedAt: domain.NowNano()}, {ID: "m_101", Text: "引用一下", Username: "fan_b", IsQuotePost: true, PublishedAt: domain.NowNano()}, }} list, err := svc.SyncMentions(context.Background(), uid, "acc1") require.NoError(t, err) require.Len(t, list, 2) byMedia := map[string]*domain.Mention{} for _, m := range list { byMedia[m.MediaID] = m } require.Contains(t, byMedia, "m_100") require.Equal(t, "fan_a", byMedia["m_100"].FromUsername) // re-sync preserves replied status byMedia["m_100"].Status = domain.MentionReplied byMedia["m_100"].DraftText = "已回" _ = svc.Repo.SaveMention(context.Background(), byMedia["m_100"]) list2, err := svc.SyncMentions(context.Background(), uid, "acc1") require.NoError(t, err) var kept *domain.Mention for _, m := range list2 { if m.MediaID == "m_100" { kept = m } } require.NotNil(t, kept) require.Equal(t, domain.MentionReplied, kept.Status) require.Equal(t, "已回", kept.DraftText) }