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

951 lines
33 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
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
}
2026-07-15 15:23:59 +00:00
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()
}
}
2026-07-13 01:15:30 +00:00
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",
2026-07-15 15:23:59 +00:00
Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "主貼"}},
2026-07-13 01:15:30 +00:00
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)
}
2026-07-13 08:59:13 +00:00
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)
}
2026-07-13 01:15:30 +00:00
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())
}
2026-07-15 15:23:59 +00:00
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)
}
2026-07-13 01:15:30 +00:00
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)
}