2026-07-13 22:44:01 +08:00

399 lines
14 KiB
Go

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)
}