package usecase_test import ( "context" "fmt" "testing" "time" "apps/backend/internal/module/usage/domain" "apps/backend/internal/module/usage/repository" "apps/backend/internal/module/usage/usecase" "github.com/stretchr/testify/require" ) func newUsage(uid int64, mode string) *usecase.Service { r := repository.NewMemory() key := func(m string) string { return fmt.Sprintf("%d:%s", uid, m) } res := &usecase.StaticResolver{Map: map[string]string{ key(domain.MeterAICopy): mode, key(domain.MeterAIResearch): mode, key(domain.MeterWebSearch): mode, key(domain.MeterAIImage): mode, }} // also allow mixed tests if mode == "mixed" { res.Map = map[string]string{ key(domain.MeterAICopy): domain.KeyModeByok, key(domain.MeterAIResearch): domain.KeyModeByok, key(domain.MeterWebSearch): domain.KeyModePlatform, key(domain.MeterAIImage): domain.KeyModeByok, } } return usecase.New(r, res) } func TestUSG_01_NewMemberSummarySplit(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) sum, err := svc.GetSummary(context.Background(), 1_000_001, "") require.NoError(t, err) require.Equal(t, domain.PlanFree, sum.PlanID) require.Equal(t, 0, sum.Platform.CreditsUsed) require.Equal(t, 0, sum.Byok.CallCount) require.Greater(t, sum.Platform.CreditsRemaining, 0) } func TestUSG_02_PlatformOnly(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "copy", "test") require.NoError(t, err) sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) require.NoError(t, err) require.Equal(t, 1, sum.Platform.CreditsUsed) require.Equal(t, 0, sum.Byok.CallCount) } func TestUSG_03_ByokOnly(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModeByok) before, _ := svc.GetSummary(context.Background(), 1_000_001, "") _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "copy", "test") require.NoError(t, err) sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) require.NoError(t, err) require.Equal(t, before.Platform.CreditsRemaining, sum.Platform.CreditsRemaining) require.Equal(t, 1, sum.Byok.CallCount) require.Equal(t, 0, sum.Platform.CreditsUsed) } func TestUSG_04_MixedAIByokSearchPlatform(t *testing.T) { svc := newUsage(1_000_001, "mixed") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "ai", "t") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "search", "t") sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) require.NoError(t, err) require.Equal(t, 1, sum.Platform.CreditsUsed) // search only require.Equal(t, 1, sum.Byok.CallCount) // ai only } func TestUSG_05_ListEventsHasKeyMode(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") list, err := svc.ListEvents(context.Background(), 1_000_001, "", "all", 10) require.NoError(t, err) require.NotEmpty(t, list) require.Equal(t, domain.KeyModePlatform, list[0].KeyMode) require.NotEmpty(t, list[0].Meter) } func TestUSG_06_FilterByok(t *testing.T) { svc := newUsage(1_000_001, "mixed") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") list, err := svc.ListEvents(context.Background(), 1_000_001, "", domain.KeyModeByok, 50) require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, domain.KeyModeByok, list[0].KeyMode) } func TestUSG_07_FilterPlatform(t *testing.T) { svc := newUsage(1_000_001, "mixed") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") list, err := svc.ListEvents(context.Background(), 1_000_001, "", domain.KeyModePlatform, 50) require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, domain.KeyModePlatform, list[0].KeyMode) } func TestUSG_08_NoMergedTotalOnly(t *testing.T) { svc := newUsage(1_000_001, "mixed") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) require.NoError(t, err) // Must have separate blocks; platform remaining is not reduced by byok require.NotNil(t, sum.Platform) require.NotNil(t, sum.Byok) require.Equal(t, 1, sum.Platform.CallCount) require.Equal(t, 1, sum.Byok.CallCount) // total_credits is plan allotment (platform), not call merge require.Equal(t, sum.Platform.CreditsTotal, sum.TotalCredits) } func TestST_07_ByokNoCreditCharge(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModeByok) before, _ := svc.GetSummary(context.Background(), 1_000_001, "") mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.NoError(t, err) require.Equal(t, domain.KeyModeByok, mode) _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, mode, "byok", "ai") require.NoError(t, err) after, _ := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) require.Equal(t, before.Platform.CreditsRemaining, after.Platform.CreditsRemaining) require.Equal(t, 1, after.Byok.CallCount) } func TestST_09_NoKeyFails(t *testing.T) { r := repository.NewMemory() svc := usecase.New(r, &usecase.StaticResolver{Map: map[string]string{}}) _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.ErrorIs(t, err, domain.ErrNoKey) } func TestST_13_PlatformQuota(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) // free: ai_copy soft cap 60pt;先燒滿分項 for i := 0; i < 60; i++ { _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") require.NoError(t, err) } _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.ErrorIs(t, err, domain.ErrMeterCapExceeded) // 分項鎖死後,其他 meter 在總池仍有餘額時可繼續 _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch) require.NoError(t, err) // 燒光剩餘總池:search 30 + research 15 + image 15 = 60 for i := 0; i < 30; i++ { _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") require.NoError(t, err) } for i := 0; i < 5; i++ { // 5*3=15 _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIResearch, domain.KeyModePlatform, "r", "t") require.NoError(t, err) } // image 15pt = 3 張 for i := 0; i < 3; i++ { _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t") require.NoError(t, err) } _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch) require.ErrorIs(t, err, domain.ErrQuotaExceeded) } func TestUSG_MeterCap_Image(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) // free ai_image cap 15pt = 3 次;第 4 次因分項上限擋 for i := 0; i < 3; i++ { _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t") require.NoError(t, err) } _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAIImage) require.ErrorIs(t, err, domain.ErrMeterCapExceeded) } func TestUSG_PlatformCapacity_BlocksPlatformOnly(t *testing.T) { // platform user blocked when gate cools down plat := newUsage(1_000_001, domain.KeyModePlatform) plat.Gate = usecase.NewMemoryPlatformGate(0, 0) plat.MarkPlatformLimited(time.Now().Add(time.Hour)) _, err := plat.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.ErrorIs(t, err, domain.ErrPlatformCapacity) // BYOK still works (gate not consulted) byok := newUsage(1_000_002, domain.KeyModeByok) byok.Gate = usecase.NewMemoryPlatformGate(0, 0) byok.MarkPlatformLimited(time.Now().Add(time.Hour)) mode, err := byok.PrepareCall(context.Background(), 1_000_002, domain.MeterAICopy) require.NoError(t, err) require.Equal(t, domain.KeyModeByok, mode) } func TestUSG_PlatformCapacity_RPM(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) svc.Gate = usecase.NewMemoryPlatformGate(2, 0) // 2 AI / min _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.NoError(t, err) _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.NoError(t, err) _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) require.ErrorIs(t, err, domain.ErrPlatformCapacity) } func TestUSG_11_PurchasePlan(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) p, err := svc.PurchasePlan(context.Background(), 1_000_001, domain.PlanStarter, "mock-pay-1") require.NoError(t, err) require.Equal(t, domain.PlanStarter, p.PlanID) prefs, _ := svc.GetPrefs(context.Background(), 1_000_001) require.Equal(t, domain.PlanStarter, prefs.PlanID) list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10) require.NoError(t, err) require.NotEmpty(t, list) // 購買後 summary 配給必須是 Starter 600,不是 Free sum, err := svc.GetSummary(context.Background(), 1_000_001, "") require.NoError(t, err) require.Equal(t, domain.PlanStarter, sum.PlanID) require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsTotal) require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.TotalCredits) require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsRemaining) // 大小寫/空白 plan_id 也要生效 _, err = svc.PurchasePlan(context.Background(), 1_000_001, " Pro ", "mock-pay-2") require.NoError(t, err) sum, err = svc.GetSummary(context.Background(), 1_000_001, "") require.NoError(t, err) require.Equal(t, domain.PlanPro, sum.PlanID) require.Equal(t, 2000, sum.Platform.CreditsTotal) } func TestUSG_10_TenantAnalyticsSplit(t *testing.T) { svc := newUsage(1_000_001, "mixed") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") ten, err := svc.TenantSummary(context.Background(), domain.CurrentMonthKey()) require.NoError(t, err) require.Equal(t, 1, ten.PlatformCreditsUsed) require.Equal(t, 1, ten.ByokCallCount) } func TestUSG_14_MemberCannotSetOthersPrefs(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) _, err := svc.SetPrefs(context.Background(), 1_000_001, 1_000_002, false, domain.PlanPro, nil) require.ErrorIs(t, err, domain.ErrForbidden) }