thread-master/apps/backend/internal/module/studio/usecase/m4_test.go

1075 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase_test
import (
"context"
"errors"
"fmt"
"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
}
type blockingPublishTransport struct {
entered chan struct{}
release chan struct{}
}
func (t *blockingPublishTransport) Publish(ctx context.Context, _ domain.PublishRequest) (*domain.PublishResult, error) {
close(t.entered)
select {
case <-t.release:
return &domain.PublishResult{MediaID: "renewed-media"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
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 setupUID(svc *usecase.Service, uid int64) {
setupUIDWithPlan(svc, uid, usageDomain.PlanPro, true)
}
func setupUIDWithPlan(svc *usecase.Service, uid int64, planID string, unlimited bool) {
_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
UID: uid, PlanID: planID, Unlimited: unlimited, UpdatedAt: domain.NowNano(),
})
if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok {
if sr.Map == nil {
sr.Map = map[string]string{}
}
// StaticResolver keys on "uid:meter".
sr.Map[fmt.Sprintf("%d:%s", uid, usageDomain.MeterAICopy)] = usageDomain.KeyModePlatform
}
}
// platformCreditsUsed reports what the member has actually been charged this month.
func platformCreditsUsed(t *testing.T, svc *usecase.Service, uid int64) int {
t.Helper()
sum, err := svc.Usage.GetSummary(context.Background(), uid, "")
require.NoError(t, err)
return sum.Platform.CreditsUsed
}
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,
}
}
// ---- AI billing ----
// A request the service itself rejects must not consume credits. These used to charge the
// member up front, so every early return below was a silent debit for work never done.
func TestBilling_RejectedRequestIsNotCharged(t *testing.T) {
uid := int64(4_009_001)
cases := []struct {
name string
call func(svc *usecase.Service) error
}{
{
name: "mimic without persona",
call: func(svc *usecase.Service) error {
_, err := svc.Mimic(context.Background(), uid, "來源文字", "", "", "")
return err
},
},
{
name: "mimic with unknown persona",
call: func(svc *usecase.Service) error {
_, err := svc.Mimic(context.Background(), uid, "來源文字", "does-not-exist", "", "")
return err
},
},
{
name: "analyze post that has no text",
call: func(svc *usecase.Service) error {
post := &domain.OwnPost{
ID: "post-no-text", OwnerUID: uid, Text: "", PublishedAt: domain.NowNano(),
}
require.NoError(t, svc.Repo.SaveOwnPost(context.Background(), post))
_, err := svc.AnalyzePost(context.Background(), uid, post.ID)
return err
},
},
{
name: "generate play step without context",
call: func(svc *usecase.Service) error {
_, err := svc.GeneratePlayStep(context.Background(), uid, "", "", "", "", true, "root")
return err
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc, _, _ := newStudio()
// A metered plan, so any charge is visible.
setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)
require.Error(t, tc.call(svc))
require.Zero(t, platformCreditsUsed(t, svc, uid), "rejected request must not be charged")
})
}
}
// A persona analysis that cannot gather enough samples fails after billing has been reserved.
func TestBilling_FailedPersonaAnalysisIsRefunded(t *testing.T) {
svc, _, _ := newStudio()
uid := int64(4_009_002)
setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)
p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "小海"})
require.NoError(t, err)
// One sample is below the two-sample minimum, so the analysis is rejected.
_, err = svc.ExecuteAnalyzeFromText(context.Background(), uid, p.ID, "只有一段文字", "手動", nil)
require.Error(t, err)
require.Zero(t, platformCreditsUsed(t, svc, uid))
}
// The success path must still charge exactly once.
func TestBilling_SuccessfulCallIsChargedOnce(t *testing.T) {
svc, _, _ := newStudio()
uid := int64(4_009_003)
setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)
_, err := svc.AnalyzeViral(context.Background(), uid, "這是一段要分析的貼文內容,講一個轉折。")
require.NoError(t, err)
require.Equal(t, usageDomain.MeterCost(usageDomain.MeterAICopy), platformCreditsUsed(t, svc, uid))
}
// ---- 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"})
_, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, "這是一段足夠完整的人設樣本文字,語氣自然也有明確節奏。\n---\n第二段會換個角度說同一件事保留這個人平常說話的情緒。", "")
require.NoError(t, err)
out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "換成討論產品改版的取捨", "")
require.NoError(t, err)
require.NotEmpty(t, out)
}
func TestCP_01_MimicRequiresSelectedReadyPersona(t *testing.T) {
svc, _, _ := newStudio()
uid := int64(4_002_004)
setupUID(svc, uid)
_, err := svc.Mimic(context.Background(), uid, "參考貼文", "missing", "新方向", "")
require.ErrorIs(t, err, domain.ErrValidation)
p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "未分析"})
require.NoError(t, err)
_, err = svc.Mimic(context.Background(), uid, "參考貼文", p.ID, "新方向", "")
require.ErrorIs(t, err, domain.ErrValidation)
}
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_PublishRenewsStepLease(t *testing.T) {
repo := repository.NewMemory()
transport := &blockingPublishTransport{entered: make(chan struct{}), release: make(chan struct{})}
svc := usecase.New(repo, transport)
svc.OutboxLeaseDuration = 90 * time.Millisecond
now := domain.NowNano()
require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
ID: "lease-bundle", Steps: []domain.OutboxStep{{ID: "lease-step", Kind: domain.StepRoot, Status: domain.StepScheduled, ScheduledAt: now}},
}))
done := make(chan error, 1)
go func() {
_, err := svc.ProcessDueSteps(context.Background(), now)
done <- err
}()
<-transport.entered
claimed, err := repo.GetOutbox(context.Background(), "lease-bundle")
require.NoError(t, err)
initialExpiry := claimed.Steps[0].LeaseExpiresAt
require.Eventually(t, func() bool {
got, getErr := repo.GetOutbox(context.Background(), "lease-bundle")
return getErr == nil && got.Steps[0].LeaseExpiresAt > initialExpiry
}, time.Second, 10*time.Millisecond)
close(transport.release)
require.NoError(t, <-done)
got, err := repo.GetOutbox(context.Background(), "lease-bundle")
require.NoError(t, err)
require.Equal(t, domain.StepPublished, got.Steps[0].Status)
}
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_03_GenerateReplyRejectsMissingReply(t *testing.T) {
svc, _, acc := newStudio()
uid := int64(4_004_013)
setupUID(svc, uid)
addAccount(acc, uid, "acc1", "me")
list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
_, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "missing-reply", "")
require.ErrorIs(t, err, domain.ErrValidation)
}
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)
}
// fakeInsightsMedia serves a fixed post list and lets a test make the insights call fail, which
// is what a Threads rate limit looks like mid-sync.
type fakeInsightsMedia struct {
threads []usecase.FetchedThread
insights usecase.FetchedInsights
insightsErr error
}
func (f *fakeInsightsMedia) ListThreads(context.Context, string, int) ([]usecase.FetchedThread, error) {
return f.threads, nil
}
func (f *fakeInsightsMedia) GetInsights(context.Context, string, string) (usecase.FetchedInsights, error) {
if f.insightsErr != nil {
return usecase.FetchedInsights{}, f.insightsErr
}
return f.insights, nil
}
func (f *fakeInsightsMedia) ListConversation(context.Context, string, string, int) ([]usecase.FetchedReply, error) {
return nil, nil
}
func (f *fakeInsightsMedia) ListMentions(context.Context, string, string, int) ([]usecase.FetchedMention, error) {
return nil, nil
}
func (f *fakeInsightsMedia) ListProfilePosts(context.Context, string, string, int) ([]usecase.FetchedThread, error) {
return nil, nil
}
// A single rate-limited insights call used to overwrite a post's real like/view counts with
// zeros, which then corrupted the insights summary, benchmark and account health downstream.
func TestSyncOwnPosts_FailedInsightsKeepsPreviousCounts(t *testing.T) {
svc, _, acc := newStudio()
uid := int64(4_004_020)
setupUID(svc, uid)
addAccount(acc, uid, "acc1", "me")
media := &fakeInsightsMedia{
threads: []usecase.FetchedThread{{ID: "m_1", Text: "貼文", PublishedAt: domain.NowNano()}},
insights: usecase.FetchedInsights{Views: 500, Likes: 42, Replies: 7, Status: "ok"},
}
svc.Media = media
first, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
require.NoError(t, err)
require.Len(t, first, 1)
require.Equal(t, 42, first[0].LikeCount)
require.Equal(t, 500, first[0].ViewCount)
// Threads starts rate limiting; the next sync must not zero the numbers out.
media.insightsErr = errors.New("rate limited")
second, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
require.NoError(t, err)
require.Len(t, second, 1)
require.Equal(t, 42, second[0].LikeCount, "a failed insights fetch must not erase real counts")
require.Equal(t, 500, second[0].ViewCount)
require.Equal(t, 7, second[0].ReplyCount)
require.Equal(t, "error", second[0].InsightsStatus, "but the failure must still be visible")
}
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)
}