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"} indexes := outboxAuditIndexes{Retry: outboxAuditIndexLuckyRetry, Lock: outboxAuditIndexLuckyLock} retryQuery, err := unsettledRetryQuery(indexes.Retry) if err != nil { t.Fatalf("build retry query: %v", err) } deliveringQuery, err := unsettledDeliveringQuery(indexes.Lock) if err != nil { t.Fatalf("build delivering query: %v", err) } // normalizeAuditAppCodes sorts and deduplicates, so SQL order is deterministic regardless of flag order. mock.ExpectQuery(regexp.QuoteMeta(retryQuery)). 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(deliveringQuery)). 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(retryQuery)). 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(deliveringQuery)). 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, indexes, []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 TestDiscoverOutboxAuditIndexes(t *testing.T) { indexColumns := []string{"index_name", "seq_in_index", "column_name"} addPrefix := func(rows *sqlmock.Rows, indexName string, columns ...string) *sqlmock.Rows { for position, column := range columns { rows.AddRow(indexName, position+1, column) } return rows } tests := []struct { name string rows func() *sqlmock.Rows want outboxAuditIndexes wantError bool }{ { name: "prefers current event indexes", rows: func() *sqlmock.Rows { rows := sqlmock.NewRows(indexColumns) addPrefix(rows, "idx_activity_outbox_lucky_retry", "app_code", "event_type", "status") addPrefix(rows, "idx_activity_outbox_event_retry", "app_code", "event_type", "status") addPrefix(rows, "idx_activity_outbox_lucky_lock", "app_code", "event_type", "status") addPrefix(rows, "idx_activity_outbox_event_lock", "app_code", "event_type", "status") return rows }, want: outboxAuditIndexes{Retry: outboxAuditIndexEventRetry, Lock: outboxAuditIndexEventLock}, }, { name: "falls back to deployed lucky indexes", rows: func() *sqlmock.Rows { rows := sqlmock.NewRows(indexColumns) addPrefix(rows, "idx_activity_outbox_lucky_retry", "app_code", "event_type", "status") addPrefix(rows, "idx_activity_outbox_lucky_lock", "app_code", "event_type", "status") return rows }, want: outboxAuditIndexes{Retry: outboxAuditIndexLuckyRetry, Lock: outboxAuditIndexLuckyLock}, }, { name: "rejects wrong prefix without an index-free fallback", rows: func() *sqlmock.Rows { rows := sqlmock.NewRows(indexColumns) addPrefix(rows, "idx_activity_outbox_event_retry", "event_type", "app_code", "status") addPrefix(rows, "idx_activity_outbox_event_lock", "app_code", "event_type", "status") return rows }, wantError: true, }, } 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() mock.ExpectQuery(regexp.QuoteMeta(outboxAuditIndexDiscoveryQuery())).WillReturnRows(test.rows()) got, err := discoverOutboxAuditIndexes(context.Background(), db) if test.wantError { if err == nil { t.Fatalf("discoverOutboxAuditIndexes returned indexes=%+v, want error", got) } } else if err != nil || got != test.want { t.Fatalf("discoverOutboxAuditIndexes=(%+v, %v), want=(%+v, nil)", got, err, test.want) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } }) } } func TestUnsettledQueriesRejectNonWhitelistedIndexes(t *testing.T) { if _, err := unsettledRetryQuery(outboxAuditIndexLuckyLock); err == nil { t.Fatal("retry query accepted a lock index") } if _, err := unsettledDeliveringQuery(outboxAuditIndexLuckyRetry); err == nil { t.Fatal("delivering query accepted a retry index") } if _, err := unsettledRetryQuery(outboxAuditIndex(255)); err == nil { t.Fatal("retry query accepted an arbitrary identifier enum") } } 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) }