package finance import ( "context" "testing" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "github.com/stretchr/testify/require" "gorm.io/gorm" ) // newTestSvc creates an in-memory SQLite repo, creates all required tables, // and returns (Service, *gorm.DB) for test use. // NOTE: Uses manual CREATE TABLE instead of AutoMigrate to avoid CURRENT_TIMESTAMP(3) // SQLite incompatibility present in the GORM model tags. func newTestSvc(t *testing.T) (Service, *gorm.DB) { t.Helper() repo, err := mysql.NewSQLiteRepoForTest() require.NoError(t, err) db := repo.GetDbR() // Create tables manually — SQLite does not support CURRENT_TIMESTAMP(3) // which is present in the GORM model default tags. stmts := []string{ `CREATE TABLE IF NOT EXISTS orders ( id integer primary key, created_at datetime, updated_at datetime, user_id integer not null default 0, order_no text not null default '', source_type integer not null default 1, total_amount integer not null default 0, discount_amount integer not null default 0, points_amount integer not null default 0, actual_amount integer not null default 0, status integer not null default 1, pay_preorder_id integer, paid_at datetime, cancelled_at datetime, user_address_id integer, is_consumed integer not null default 0, points_ledger_id integer, coupon_id integer, item_card_id integer, remark text, ext_order_id text not null default '' )`, `CREATE TABLE IF NOT EXISTS user_inventory ( id integer primary key, created_at datetime, updated_at datetime, user_id integer not null default 0, product_id integer, value_cents integer not null default 0, value_source integer not null default 0, value_snapshot_at datetime, order_id integer, activity_id integer, reward_id integer, status integer not null default 1, shipping_no text not null default '', remark text )`, `CREATE TABLE IF NOT EXISTS user_points_ledger ( id integer primary key, created_at datetime, user_id integer not null default 0, action text not null default '', points integer not null default 0, ref_table text, ref_id text, remark text )`, `CREATE TABLE IF NOT EXISTS user_coupon_ledger ( id integer primary key, user_id integer not null default 0, user_coupon_id integer not null default 0, change_amount integer not null default 0, balance_after integer not null default 0, order_id integer, action text not null default '', created_at datetime )`, `CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)`, `CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)`, `CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)`, `CREATE TABLE IF NOT EXISTS user_item_cards (id integer primary key, card_id integer)`, `CREATE TABLE IF NOT EXISTS system_item_cards (id integer primary key, reward_multiplier_x1000 integer)`, `CREATE TABLE IF NOT EXISTS system_configs (id integer primary key, config_key text, config_value text)`, } for _, stmt := range stmts { require.NoError(t, db.Exec(stmt).Error) } l, err := logger.NewCustomLogger(logger.WithOutputInConsole()) require.NoError(t, err) svc := New(l, repo) return svc, db } // --- Seed helpers --- func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) { t.Helper() require.NoError(t, db.Create(&o).Error) } func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) { t.Helper() require.NoError(t, db.Create(&inv).Error) } func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) { t.Helper() require.NoError(t, db.Create(&row).Error) } func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) { t.Helper() require.NoError(t, db.Create(&row).Error) } // seedActivitySetup creates minimal activity + issue + draw_log for JOIN tests. func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) { t.Helper() require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error) require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error) require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error) } // --- Plan 01 contract tests --- func TestAssetTypeConstants(t *testing.T) { require.Equal(t, AssetType(0), AssetTypeAll) require.Equal(t, AssetType(1), AssetTypePoints) require.Equal(t, AssetType(2), AssetTypeCoupon) require.Equal(t, AssetType(3), AssetTypeItemCard) require.Equal(t, AssetType(4), AssetTypeProduct) require.Equal(t, AssetType(5), AssetTypeFragment) } func TestNew_ReturnsService(t *testing.T) { svc, _ := newTestSvc(t) require.NotNil(t, svc) } func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) { svc, _ := newTestSvc(t) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) require.NoError(t, err) _ = result } func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) { svc, _ := newTestSvc(t) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) require.NoError(t, err) _ = result } // --- Plan 02 QueryUserProfitLoss integration tests --- func TestQueryUserProfitLoss_CashOrder(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ ID: 1, UserID: 101, Status: 2, SourceType: 2, OrderNo: "O20260321001", ActualAmount: 800, DiscountAmount: 200, }) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount") } func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ ID: 2, UserID: 102, Status: 4, // refunded SourceType: 2, OrderNo: "O20260321002", ActualAmount: 1000, DiscountAmount: 0, }) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue") } func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) { svc, db := newTestSvc(t) seedInventory(t, db, model.UserInventory{ ID: 1, UserID: 103, Status: 2, // voided status ValueCents: 5000, OrderID: 0, }) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost") } func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) { svc, db := newTestSvc(t) seedInventory(t, db, model.UserInventory{ ID: 2, UserID: 104, Status: 1, // valid status ValueCents: 3000, OrderID: 0, Remark: "void_20260101", }) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost") } func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) { svc, db := newTestSvc(t) seedInventory(t, db, model.UserInventory{ ID: 3, UserID: 105, Status: 1, ValueCents: 2000, OrderID: 0, Remark: "", }) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)") } func TestQueryUserProfitLoss_AllUsers(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100}) seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200}) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) require.NoError(t, err) require.NotNil(t, result) userIDs := make(map[int64]bool) for _, d := range result.Details { userIDs[d.UserID] = true } require.True(t, userIDs[201], "user 201 must be in results") require.True(t, userIDs[202], "user 202 must be in results") } func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500}) seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600}) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}}) require.NoError(t, err) require.NotNil(t, result) for _, d := range result.Details { require.Equal(t, int64(301), d.UserID, "only user 301 should appear") } } func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200}) seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""}) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(1200), result.TotalRevenue) require.Equal(t, int64(800), result.TotalCost) require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost") } func TestQueryUserProfitLoss_ResultShape(t *testing.T) { svc, _ := newTestSvc(t) result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Details, "Details must be non-nil slice") require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)") } // --- Plan 03 QueryActivityProfitLoss integration tests --- func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ ID: 50, UserID: 501, Status: 2, SourceType: 2, OrderNo: "A001", ActualAmount: 600, DiscountAmount: 150, }) seedActivitySetup(t, db, 1001, 2001, 50, 501, 100) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)") } func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ ID: 51, UserID: 502, Status: 4, // refunded SourceType: 2, OrderNo: "A002", ActualAmount: 800, DiscountAmount: 0, }) seedActivitySetup(t, db, 1002, 2002, 51, 502, 100) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue") } func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) { svc, db := newTestSvc(t) seedInventory(t, db, model.UserInventory{ ID: 20, UserID: 503, ActivityID: 1003, Status: 2, // voided ValueCents: 4000, OrderID: 0, }) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost") } func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) { svc, db := newTestSvc(t) seedInventory(t, db, model.UserInventory{ ID: 21, UserID: 504, ActivityID: 1004, Status: 1, ValueCents: 3500, OrderID: 0, Remark: "", }) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)") } func TestQueryActivityProfitLoss_AllActivities(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100}) seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200}) seedActivitySetup(t, db, 2001, 3001, 60, 601, 50) seedActivitySetup(t, db, 2002, 3002, 61, 602, 50) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) require.NoError(t, err) require.NotNil(t, result) actIDs := make(map[int64]bool) for _, d := range result.Details { actIDs[d.ActivityID] = true } require.True(t, actIDs[2001], "activity 2001 must be in results") require.True(t, actIDs[2002], "activity 2002 must be in results") } func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300}) seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400}) seedActivitySetup(t, db, 3001, 4001, 70, 701, 50) seedActivitySetup(t, db, 3002, 4002, 71, 702, 50) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}}) require.NoError(t, err) require.NotNil(t, result) for _, d := range result.Details { require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear") } } func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) { svc, db := newTestSvc(t) seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500}) seedActivitySetup(t, db, 4001, 5001, 80, 801, 100) seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80}) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)") require.Equal(t, int64(1200), result.TotalCost) require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200") } func TestQueryActivityProfitLoss_ResultShape(t *testing.T) { svc, _ := newTestSvc(t) result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.Details, "Details must be non-nil slice") require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice") }