package main import ( "context" "database/sql" "encoding/json" "fmt" "regexp" "testing" "github.com/DATA-DOG/go-sqlmock" domain "hyapp/services/activity-service/internal/domain/wheel" ) func TestAuditRowCoinStates(t *testing.T) { tests := []struct { name string prepare func(sqlmock.Sqlmock, rewardPayload) wantState string wantItem string }{ { name: "fully paid", prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) { metadata, _ := json.Marshal(map[string]any{ "target_user_id": payload.UserID, "amount": int64(200), "draw_id": payload.DrawID, "wheel_id": payload.WheelID, "selected_tier_id": "coin-200", "reason": "wheel_reward", "visible_region_id": payload.VisibleRegionID, }) mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_reward:draw-1"). WillReturnRows(coinAuditRows().AddRow("wtx-1", "wheel_reward", "succeeded", string(metadata))) }, wantState: "fully_paid", wantItem: "paid", }, { name: "fully unpaid uses errors Is", prepare: func(mock sqlmock.Sqlmock, _ rewardPayload) { mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_reward:draw-1"). WillReturnError(fmt.Errorf("wrapped wallet miss: %w", sql.ErrNoRows)) }, wantState: "fully_unpaid", wantItem: "unpaid", }, { name: "conflict on immutable amount", prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) { metadata, _ := json.Marshal(map[string]any{ "target_user_id": payload.UserID, "amount": int64(201), "draw_id": payload.DrawID, "wheel_id": payload.WheelID, "selected_tier_id": "coin-200", "reason": "wheel_reward", "visible_region_id": payload.VisibleRegionID, }) mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_reward:draw-1"). WillReturnRows(coinAuditRows().AddRow("wtx-conflict", "wheel_reward", "succeeded", string(metadata))) }, wantState: "conflict", wantItem: "conflict", }, { name: "conflict on biz type", prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) { metadata := matchingCoinMetadata(payload) mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_reward:draw-1"). WillReturnRows(coinAuditRows().AddRow("wtx-wrong-biz", "task_reward", "succeeded", metadata)) }, wantState: "conflict", wantItem: "conflict", }, { name: "conflict on visible region", prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) { metadata := matchingCoinMetadata(payload) var values map[string]any _ = json.Unmarshal([]byte(metadata), &values) values["visible_region_id"] = int64(99) mismatch, _ := json.Marshal(values) mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_reward:draw-1"). WillReturnRows(coinAuditRows().AddRow("wtx-wrong-region", "wheel_reward", "succeeded", string(mismatch))) }, wantState: "conflict", wantItem: "conflict", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sqlmock: %v", err) } defer db.Close() payload := rewardPayload{ AppCode: "fami", DrawID: "draw-1", UserID: 1001, WheelID: "classic", VisibleRegionID: 9, Rewards: []domain.DrawReward{{SelectedTierID: "coin-200", RewardType: domain.RewardTypeCoin, RewardCoins: 200}}, } test.prepare(mock, payload) audit, err := auditRow(context.Background(), db, "hyapp_wallet", auditOutboxRow(t, payload)) if err != nil { t.Fatalf("auditRow returned error: %v", err) } if audit.State != test.wantState || len(audit.Items) != 1 || audit.Items[0].State != test.wantItem { t.Fatalf("unexpected audit: %+v", audit) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } }) } } func TestAuditRowResourceStates(t *testing.T) { tests := []struct { name string prepare func(sqlmock.Sqlmock) wantState string wantItem string }{ { name: "fully paid", prepare: func(mock sqlmock.Sqlmock) { mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_resource:draw-1"). WillReturnRows(resourceAuditRows().AddRow("rgr-1", "succeeded", int64(1001), "wheel_reward", "resource", "326", "wheel_reward", int64(1001), int64(2), int64(5000), int64(326))) }, wantState: "fully_paid", wantItem: "paid", }, { name: "fully unpaid uses errors Is", prepare: func(mock sqlmock.Sqlmock) { mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_resource:draw-1"). WillReturnError(fmt.Errorf("wrapped resource miss: %w", sql.ErrNoRows)) }, wantState: "fully_unpaid", wantItem: "unpaid", }, { name: "conflict rejects legacy active status", prepare: func(mock sqlmock.Sqlmock) { mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))). WithArgs("fami", "wheel_resource:draw-1"). WillReturnRows(resourceAuditRows().AddRow("rgr-conflict", "active", int64(1001), "wheel_reward", "resource", "326", "wheel_reward", int64(1001), int64(2), int64(5000), int64(326))) }, wantState: "conflict", wantItem: "conflict", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sqlmock: %v", err) } defer db.Close() payload := rewardPayload{ AppCode: "fami", DrawID: "draw-1", UserID: 1001, WheelID: "classic", Rewards: []domain.DrawReward{{ SelectedTierID: "gift-326", RewardType: domain.RewardTypeGift, RewardID: "326", RewardCount: 2, MetadataJSON: `{"resource_id":326,"duration_ms":5000}`, }}, } test.prepare(mock) audit, err := auditRow(context.Background(), db, "hyapp_wallet", auditOutboxRow(t, payload)) if err != nil { t.Fatalf("auditRow returned error: %v", err) } if audit.State != test.wantState || len(audit.Items) != 1 || audit.Items[0].State != test.wantItem { t.Fatalf("unexpected audit: %+v", audit) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } }) } } func TestLoadUnsettledRowsUsesAppScopedIndexesAndGlobalLimit(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sqlmock: %v", err) } defer db.Close() const limit = 3 columns := []string{"app_code", "outbox_id", "payload", "status", "retry_count", "created_at_ms"} // normalizeAuditAppCodes sorts and deduplicates, so SQL order is deterministic regardless of flag order. mock.ExpectQuery(regexp.QuoteMeta(unsettledRetryQuery())). WithArgs("fami", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusPending, domain.OutboxStatusRetryable, limit). WillReturnRows(sqlmock.NewRows(columns). AddRow("fami", "fami-pending", []byte(`{}`), domain.OutboxStatusPending, 0, int64(20))) mock.ExpectQuery(regexp.QuoteMeta(unsettledDeliveringQuery())). WithArgs("fami", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusDelivering, limit). WillReturnRows(sqlmock.NewRows(columns). AddRow("fami", "fami-delivering", []byte(`{}`), domain.OutboxStatusDelivering, 1, int64(10))) mock.ExpectQuery(regexp.QuoteMeta(unsettledRetryQuery())). WithArgs("lalu", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusPending, domain.OutboxStatusRetryable, 1). WillReturnRows(sqlmock.NewRows(columns). AddRow("lalu", "lalu-oldest", []byte(`{}`), domain.OutboxStatusRetryable, 2, int64(5))) mock.ExpectQuery(regexp.QuoteMeta(unsettledDeliveringQuery())). WithArgs("lalu", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusDelivering, 1). WillReturnRows(sqlmock.NewRows(columns). AddRow("lalu", "lalu-delivering", []byte(`{}`), domain.OutboxStatusDelivering, 1, int64(15))) rows, err := loadUnsettledRows(context.Background(), db, []string{"lalu", "fami", "fami"}, limit) if err != nil { t.Fatalf("loadUnsettledRows returned error: %v", err) } if len(rows) != limit { t.Fatalf("global limit mismatch: got=%d want=%d rows=%+v", len(rows), limit, rows) } wantIDs := []string{"fami-delivering", "fami-pending", "lalu-oldest"} for index, wantID := range wantIDs { if rows[index].OutboxID != wantID { t.Fatalf("deterministic order[%d]=%q want=%q rows=%+v", index, rows[index].OutboxID, wantID, rows) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } } func TestNormalizeAuditAppCodesRequiresRepeatedExplicitScope(t *testing.T) { if _, err := normalizeAuditAppCodes(nil); err == nil { t.Fatal("missing --app-code must be rejected before opening or querying MySQL") } values, err := normalizeAuditAppCodes([]string{" LALU ", "fami", "lalu", "huwaa"}) if err != nil { t.Fatalf("normalizeAuditAppCodes returned error: %v", err) } want := []string{"fami", "huwaa", "lalu"} if len(values) != len(want) { t.Fatalf("normalized app count=%d want=%d values=%+v", len(values), len(want), values) } for index := range want { if values[index] != want[index] { t.Fatalf("normalized app[%d]=%q want=%q", index, values[index], want[index]) } } } func auditOutboxRow(t *testing.T, payload rewardPayload) outboxRow { t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } return outboxRow{AppCode: payload.AppCode, OutboxID: "wheel_reward_" + payload.DrawID, Payload: body, Status: domain.OutboxStatusPending} } func resourceAuditRows() *sqlmock.Rows { return sqlmock.NewRows([]string{ "grant_id", "status", "target_user_id", "grant_source", "grant_subject_type", "grant_subject_id", "reason", "operator_user_id", "quantity", "duration_ms", "resource_id", }) } func coinAuditRows() *sqlmock.Rows { return sqlmock.NewRows([]string{"transaction_id", "biz_type", "status", "metadata_json"}) } func matchingCoinMetadata(payload rewardPayload) string { body, _ := json.Marshal(map[string]any{ "target_user_id": payload.UserID, "amount": int64(200), "draw_id": payload.DrawID, "wheel_id": payload.WheelID, "selected_tier_id": "coin-200", "reason": "wheel_reward", "visible_region_id": payload.VisibleRegionID, }) return string(body) }