399 lines
14 KiB
Go
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)
|
|
}
|