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 }