2026-07-23 05:56:42 +00:00
|
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
"apps/backend/internal/module/growth/domain"
|
|
|
|
|
|
"apps/backend/internal/module/growth/repository"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func TestOC_01_CreateOnPublished(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
const originalSentAt int64 = 1_700_000_000_000_000_000
|
|
|
|
|
|
e, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.StatusObserving, e.Status)
|
|
|
|
|
|
require.Equal(t, domain.KindReach, e.Kind)
|
|
|
|
|
|
// idempotent
|
|
|
|
|
|
e2, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt+1_000_000)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, e.ID, e2.ID)
|
|
|
|
|
|
require.Equal(t, originalSentAt, e2.SentAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestOC_02_03_ApplySignals(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0)
|
|
|
|
|
|
e, err := svc.ApplyObserveSignals(context.Background(), e.ID, 1, 0, 0)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.ConfidenceConfirmed, e.Confidence)
|
|
|
|
|
|
require.Equal(t, domain.KindReply, e.Kind)
|
|
|
|
|
|
|
|
|
|
|
|
e3, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p2", "a", 0)
|
|
|
|
|
|
e3, err = svc.ApplyObserveSignals(context.Background(), e3.ID, 0, 0, 2)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.ConfidencePossible, e3.Confidence)
|
|
|
|
|
|
sum, err := svc.Summary(context.Background(), 1, 0, 0)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, 2, sum.FollowsPossible)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestOC_04_05_Conversion(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0)
|
|
|
|
|
|
e, err := svc.ReportConversion(context.Background(), 1, e.ID, 1000, "成交", "TWD")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.True(t, e.HasConversion)
|
|
|
|
|
|
require.Equal(t, domain.KindConversion, e.Kind)
|
|
|
|
|
|
sum, err := svc.Summary(context.Background(), 1, 0, 0)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, 1, sum.Conversions)
|
|
|
|
|
|
require.Equal(t, 1000.0, sum.ConversionAmount)
|
|
|
|
|
|
require.NoError(t, svc.DeleteConversion(context.Background(), 1, e.ID))
|
|
|
|
|
|
sum, _ = svc.Summary(context.Background(), 1, 0, 0)
|
|
|
|
|
|
require.Equal(t, 0, sum.Conversions)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestOC_08_DegradedStillWorks(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
require.Equal(t, "degraded", svc.SignalProbe)
|
|
|
|
|
|
e, err := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "", 0)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
_, err = svc.ReportConversion(context.Background(), 1, e.ID, 100, "", "")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
sum, err := svc.Summary(context.Background(), 1, 0, 0)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, 1, sum.Reach)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestCU_01_02_03_GenerateCheckup(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
_, _ = svc.RecordPublished(context.Background(), 9, domain.SourceScoutOutreach, "x", "a", 0)
|
|
|
|
|
|
c, err := svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", false)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.CheckupReady, c.Status)
|
|
|
|
|
|
require.Len(t, c.Actions, 3)
|
|
|
|
|
|
for _, f := range c.Findings {
|
|
|
|
|
|
require.NotEmpty(t, f.Evidence)
|
|
|
|
|
|
}
|
|
|
|
|
|
// first force ok (total gens=2); third gen in 7d window blocked
|
|
|
|
|
|
_, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
_, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true)
|
|
|
|
|
|
require.ErrorIs(t, err, domain.ErrRateLimited)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAH_01_03_HealthGate(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
h, err := svc.RecomputeHealth(context.Background(), 1, "acc", HealthSignals{
|
|
|
|
|
|
Sends24h: 30, IntervalViolations: 5, FailRate7d: 0.6, TokenBad: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.HealthThrottle, h.Level)
|
|
|
|
|
|
_, err = svc.EnsureAutoSendAllowed(context.Background(), 1, "acc")
|
|
|
|
|
|
require.ErrorIs(t, err, domain.ErrThrottle)
|
|
|
|
|
|
|
|
|
|
|
|
h2, err := svc.RecomputeHealth(context.Background(), 1, "acc2", HealthSignals{
|
|
|
|
|
|
Sends24h: 15, IntervalViolations: 2, FailRate7d: 0.3,
|
|
|
|
|
|
})
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.HealthWarn, h2.Level)
|
|
|
|
|
|
warn, err := svc.EnsureAutoSendAllowed(context.Background(), 1, "acc2")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.NotEmpty(t, warn)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestIR_01_03_InviteReward(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
svc.PlanQuota = func(id string) int {
|
|
|
|
|
|
if id == "@inviter" {
|
|
|
|
|
|
return 2000 // pro inviter → month cap 1000
|
|
|
|
|
|
}
|
|
|
|
|
|
return 120
|
|
|
|
|
|
}
|
|
|
|
|
|
r, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "starter")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.RewardCredited, r.Status)
|
|
|
|
|
|
require.Equal(t, 100, r.Points)
|
|
|
|
|
|
// second time skipped
|
|
|
|
|
|
r2, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "pro")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, domain.RewardSkipped, r2.Status)
|
|
|
|
|
|
// pro first
|
|
|
|
|
|
r3, err := svc.OnFirstPaidPlan(context.Background(), 3, 1, "pro")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, 300, r3.Points)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestWS_01_02_Workspace(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
list, cur, err := svc.ListWorkspaces(context.Background(), 5)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Len(t, list, 1)
|
|
|
|
|
|
require.NotEmpty(t, cur)
|
|
|
|
|
|
w2, err := svc.CreateWorkspace(context.Background(), 5, "客戶 A")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.NoError(t, svc.SwitchWorkspace(context.Background(), 5, w2.ID))
|
|
|
|
|
|
list, cur, err = svc.ListWorkspaces(context.Background(), 5)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, w2.ID, cur)
|
|
|
|
|
|
require.Len(t, list, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestWS_03_04_ReviewGate(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
list, _, _ := svc.ListWorkspaces(context.Background(), 7)
|
|
|
|
|
|
ws := list[0]
|
|
|
|
|
|
on := true
|
|
|
|
|
|
_, err := svc.UpdateWorkspace(context.Background(), 7, ws.ID, "", &on)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
err = svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1")
|
|
|
|
|
|
require.True(t, errors.Is(err, domain.ErrReviewPending))
|
|
|
|
|
|
r, err := svc.SubmitReview(context.Background(), 7, "play", "p1", ws.ID)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
_, err = svc.DecideReview(context.Background(), 7, r.ID, domain.ReviewApproved, "")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.NoError(t, svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestWS_05_ExportReport(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
list, _, _ := svc.ListWorkspaces(context.Background(), 8)
|
|
|
|
|
|
fn, body, err := svc.ExportReportMarkdown(context.Background(), 8, list[0].ID, "2026-07")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Contains(t, fn, "2026-07")
|
|
|
|
|
|
require.Contains(t, body, "月報")
|
|
|
|
|
|
}
|
2026-07-28 06:33:40 +00:00
|
|
|
|
|
|
|
|
|
|
// /u/{code} 是免登入 302,任何存進去的 destination 都會變成本站可用的跳板。
|
|
|
|
|
|
// 舊檢查只看 "http" 前綴,"httpx://" 與 "//evil" 都能過。
|
|
|
|
|
|
func TestUTM_RejectsDestinationsThatAreNotHttpURLs(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
for _, dest := range []string{
|
|
|
|
|
|
"javascript:alert(1)",
|
|
|
|
|
|
"httpx://evil.example",
|
|
|
|
|
|
"//evil.example/path",
|
|
|
|
|
|
"http://",
|
|
|
|
|
|
"data:text/html,<script>alert(1)</script>",
|
|
|
|
|
|
"",
|
|
|
|
|
|
" ",
|
|
|
|
|
|
} {
|
|
|
|
|
|
_, err := svc.CreateUtmLink(ctx, 1, dest, "", "label")
|
|
|
|
|
|
require.ErrorIsf(t, err, domain.ErrValidation, "destination %q should be rejected", dest)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUTM_AcceptsHttpAndHttpsAndRedirectsThere(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
for _, dest := range []string{
|
|
|
|
|
|
"https://shop.example/product?utm_source=threads",
|
|
|
|
|
|
"http://shop.example",
|
|
|
|
|
|
} {
|
|
|
|
|
|
link, err := svc.CreateUtmLink(ctx, 1, dest, "", "label")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
|
|
got, err := svc.TrackUtmClick(ctx, link.Code)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.Equal(t, dest, got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 這個檢查是後來才加的,先前寫進 DB 的列可能還帶著壞值,所以出門前要再驗一次。
|
|
|
|
|
|
func TestUTM_RefusesToRedirectToAStoredBadDestination(t *testing.T) {
|
|
|
|
|
|
repo := repository.NewMemory()
|
|
|
|
|
|
svc := New(repo)
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
link, err := svc.CreateUtmLink(ctx, 1, "https://shop.example", "", "label")
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
link.DestinationURL = "javascript:alert(1)"
|
|
|
|
|
|
require.NoError(t, repo.SaveUtmLink(ctx, link))
|
|
|
|
|
|
|
|
|
|
|
|
_, err = svc.TrackUtmClick(ctx, link.Code)
|
|
|
|
|
|
require.ErrorIs(t, err, domain.ErrValidation)
|
|
|
|
|
|
}
|
2026-07-30 01:25:34 +00:00
|
|
|
|
|
|
|
|
|
|
// 互動率是比率,走 Round2 會被輾成整數百分比(0.0234 → 0.02 → 前端 2.00%)。
|
|
|
|
|
|
func TestRoundRateKeepsTwoDecimalsOfAPercentage(t *testing.T) {
|
|
|
|
|
|
require.Equal(t, 0.0234, RoundRate(0.023449))
|
|
|
|
|
|
require.Equal(t, 0.1235, RoundRate(0.12345))
|
|
|
|
|
|
require.Equal(t, 0.0, RoundRate(0))
|
|
|
|
|
|
require.Equal(t, 0.02, Round2(0.023449), "Round2 仍供瀏覽數這類整數量使用")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestBenchmarkBucketsIgnoreCaseAndPadding(t *testing.T) {
|
|
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
for uid := int64(1); uid <= 5; uid++ {
|
|
|
|
|
|
require.NoError(t, svc.ContributeBenchmark(ctx, uid, " SkinCare ", 0.02, 100))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size, medEng, medViews, available, _ := svc.GetBenchmark(ctx, "skincare", 0.02, 100)
|
|
|
|
|
|
require.True(t, available)
|
|
|
|
|
|
require.Equal(t, 5, size)
|
|
|
|
|
|
require.Equal(t, 0.02, medEng)
|
|
|
|
|
|
require.Equal(t, 100.0, medViews)
|
|
|
|
|
|
}
|