117dc0d6a7
Closes HIF-3 (Stage 1 P0 from QA smoke - third and final of the same class)
F6 (P0): GrantLedger.Payload=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:方式 A 对称守卫 Payload=\"{}\" (与 PR E UnmetReasons=\"[]\", PR C PrizeSnapshot.Config=\"{}\" 三处守卫模式统一)
- QA 已 sweep 全部 6 个 lottery JSON 列,这是最后一处漏守
- handler 成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶段用 {} 兜底
回归护栏:TestReserve_EmptyPayloadDefaultsToEmptyJSONObject 用 payloadNotEmptyString per-arg matcher
CI 全绿;2 files, +83/-0
架构师复盘:三次同一 pattern 漏检查(F2 audit ctx keys / F4 UnmetReasons / F6 Payload)。Stage 2 起硬性规则:grep -RIn 'type:json' internal/model/ 全量清单逐列 sweep + 每列至少一条 per-arg matcher 单测。感谢 QA 三次挖坑。
189 lines
5.7 KiB
Go
189 lines
5.7 KiB
Go
package lottery
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"testing"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
|
|
db, mock, cleanup := newLotteryTestDB(t)
|
|
defer cleanup()
|
|
|
|
svc := NewLedgerService()
|
|
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
|
WillReturnResult(sqlmock.NewResult(7, 1))
|
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
|
WithArgs("lottery:100:200", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
|
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
|
mock.ExpectCommit()
|
|
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
|
ExternalRef: "lottery:100:200",
|
|
HandlerType: "vpn_duration",
|
|
UserId: 42,
|
|
ActivityId: 100,
|
|
DrawId: 200,
|
|
Amount: 3,
|
|
})
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if existed {
|
|
t.Fatalf("expected not existed")
|
|
}
|
|
if row.Id != 7 {
|
|
t.Fatalf("expected reloaded id=7, got %d", row.Id)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("tx: %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
|
|
db, mock, cleanup := newLotteryTestDB(t)
|
|
defer cleanup()
|
|
|
|
svc := NewLedgerService()
|
|
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
|
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
|
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
|
WithArgs("lottery:100:200", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
|
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
|
mock.ExpectCommit()
|
|
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
|
ExternalRef: "lottery:100:200",
|
|
HandlerType: "vpn_duration",
|
|
})
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if !existed {
|
|
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
|
|
}
|
|
if row.Id != 9 {
|
|
t.Fatalf("expected stored id=9, got %d", row.Id)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("tx: %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
|
|
svc := NewLedgerService()
|
|
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
|
|
t.Fatalf("expected error when tx is nil")
|
|
}
|
|
}
|
|
|
|
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
|
|
db, _, cleanup := newLotteryTestDB(t)
|
|
defer cleanup()
|
|
|
|
svc := NewLedgerService()
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
|
|
return e
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error on empty ExternalRef")
|
|
}
|
|
}
|
|
|
|
// TestReserve_EmptyPayloadDefaultsToEmptyJSONObject is the F6 regression guard.
|
|
//
|
|
// Before PR F, Reserve created lottery_grant_ledger rows with the caller's
|
|
// empty entry.Payload written verbatim ("") into the `payload` JSON column —
|
|
// MySQL error 3140 rejects empty strings on JSON columns, so every real
|
|
// draw's ledger INSERT died. sqlmock does no JSON validation so the earlier
|
|
// tests were silent about it.
|
|
//
|
|
// Guard the exact Go-layer value we send by asserting the INSERT arg for
|
|
// `payload` is "{}" (never ""). This is the same pattern as PR E's
|
|
// EligibilitySnapshot.UnmetReasons guard.
|
|
func TestReserve_EmptyPayloadDefaultsToEmptyJSONObject(t *testing.T) {
|
|
db, mock, cleanup := newLotteryTestDB(t)
|
|
defer cleanup()
|
|
|
|
// GORM omits granted_at from the INSERT column list because it has
|
|
// `<-:create;default:CURRENT_TIMESTAMP` — 7 args, not 8. Column order:
|
|
// external_ref, handler_type, user_id, activity_id, draw_id, amount, payload.
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
|
WithArgs(
|
|
sqlmock.AnyArg(), // external_ref
|
|
sqlmock.AnyArg(), // handler_type
|
|
sqlmock.AnyArg(), // user_id
|
|
sqlmock.AnyArg(), // activity_id
|
|
sqlmock.AnyArg(), // draw_id
|
|
sqlmock.AnyArg(), // amount
|
|
payloadNotEmptyString{t}, // MUST be "{}", never ""
|
|
).
|
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
|
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
|
WithArgs("lottery:100:200", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref"}).AddRow(int64(1), "lottery:100:200"))
|
|
mock.ExpectCommit()
|
|
|
|
svc := NewLedgerService()
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
// Intentionally leave Payload empty — the guard must default it.
|
|
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
|
ExternalRef: "lottery:100:200",
|
|
HandlerType: "vpn_duration",
|
|
UserId: 42,
|
|
ActivityId: 100,
|
|
DrawId: 200,
|
|
Amount: 3,
|
|
})
|
|
return e
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Reserve: %v", err)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// payloadNotEmptyString is a per-arg matcher: the value MUST be a non-empty
|
|
// string; specifically "{}" per the PR F guard. Empty string is the exact F6
|
|
// regression symptom (MySQL error 3140).
|
|
type payloadNotEmptyString struct{ t *testing.T }
|
|
|
|
func (m payloadNotEmptyString) Match(v driver.Value) bool {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
m.t.Fatalf("F6 guard: expected string for Payload, got %T (%v)", v, v)
|
|
}
|
|
if s == "" {
|
|
m.t.Fatalf("F6 regression: Payload must not be empty string (MySQL error 3140)")
|
|
}
|
|
if s != "{}" {
|
|
m.t.Fatalf("F6 guard: expected Payload==%q, got %q", "{}", s)
|
|
}
|
|
return true
|
|
}
|