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

951 lines
33 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"
"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 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_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_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)
}