redeem_code_repo_integration_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "time"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/stretchr/testify/suite"
  12. )
  13. type RedeemCodeRepoSuite struct {
  14. suite.Suite
  15. ctx context.Context
  16. client *dbent.Client
  17. repo *redeemCodeRepository
  18. }
  19. func (s *RedeemCodeRepoSuite) SetupTest() {
  20. tx := testEntTx(s.T())
  21. s.ctx = dbent.NewTxContext(context.Background(), tx)
  22. s.client = tx.Client()
  23. s.repo = NewRedeemCodeRepository(s.client).(*redeemCodeRepository)
  24. }
  25. func TestRedeemCodeRepoSuite(t *testing.T) {
  26. suite.Run(t, new(RedeemCodeRepoSuite))
  27. }
  28. func (s *RedeemCodeRepoSuite) createUser(email string) *dbent.User {
  29. u, err := s.client.User.Create().
  30. SetEmail(email).
  31. SetPasswordHash("test-password-hash").
  32. Save(s.ctx)
  33. s.Require().NoError(err, "create user")
  34. return u
  35. }
  36. func (s *RedeemCodeRepoSuite) createGroup(name string) *dbent.Group {
  37. g, err := s.client.Group.Create().
  38. SetName(name).
  39. Save(s.ctx)
  40. s.Require().NoError(err, "create group")
  41. return g
  42. }
  43. // --- Create / CreateBatch / GetByID / GetByCode ---
  44. func (s *RedeemCodeRepoSuite) TestCreate() {
  45. expiresAt := time.Now().UTC().Add(2 * time.Hour)
  46. code := &service.RedeemCode{
  47. Code: "TEST-CREATE",
  48. Type: service.RedeemTypeBalance,
  49. Value: 100,
  50. Status: service.StatusUnused,
  51. ExpiresAt: &expiresAt,
  52. }
  53. err := s.repo.Create(s.ctx, code)
  54. s.Require().NoError(err, "Create")
  55. s.Require().NotZero(code.ID, "expected ID to be set")
  56. got, err := s.repo.GetByID(s.ctx, code.ID)
  57. s.Require().NoError(err, "GetByID")
  58. s.Require().Equal("TEST-CREATE", got.Code)
  59. s.Require().NotNil(got.ExpiresAt)
  60. s.Require().WithinDuration(expiresAt, *got.ExpiresAt, time.Second)
  61. }
  62. func (s *RedeemCodeRepoSuite) TestCreateBatch() {
  63. codes := []service.RedeemCode{
  64. {Code: "BATCH-1", Type: service.RedeemTypeBalance, Value: 10, Status: service.StatusUnused},
  65. {Code: "BATCH-2", Type: service.RedeemTypeBalance, Value: 20, Status: service.StatusUnused},
  66. }
  67. err := s.repo.CreateBatch(s.ctx, codes)
  68. s.Require().NoError(err, "CreateBatch")
  69. got1, err := s.repo.GetByCode(s.ctx, "BATCH-1")
  70. s.Require().NoError(err)
  71. s.Require().Equal(float64(10), got1.Value)
  72. got2, err := s.repo.GetByCode(s.ctx, "BATCH-2")
  73. s.Require().NoError(err)
  74. s.Require().Equal(float64(20), got2.Value)
  75. }
  76. func (s *RedeemCodeRepoSuite) TestGetByID_NotFound() {
  77. _, err := s.repo.GetByID(s.ctx, 999999)
  78. s.Require().Error(err, "expected error for non-existent ID")
  79. s.Require().ErrorIs(err, service.ErrRedeemCodeNotFound)
  80. }
  81. func (s *RedeemCodeRepoSuite) TestGetByCode() {
  82. _, err := s.client.RedeemCode.Create().
  83. SetCode("GET-BY-CODE").
  84. SetType(service.RedeemTypeBalance).
  85. SetStatus(service.StatusUnused).
  86. SetValue(0).
  87. SetNotes("").
  88. SetValidityDays(30).
  89. Save(s.ctx)
  90. s.Require().NoError(err, "seed redeem code")
  91. got, err := s.repo.GetByCode(s.ctx, "GET-BY-CODE")
  92. s.Require().NoError(err, "GetByCode")
  93. s.Require().Equal("GET-BY-CODE", got.Code)
  94. }
  95. func (s *RedeemCodeRepoSuite) TestGetByCode_NotFound() {
  96. _, err := s.repo.GetByCode(s.ctx, "NON-EXISTENT")
  97. s.Require().Error(err, "expected error for non-existent code")
  98. s.Require().ErrorIs(err, service.ErrRedeemCodeNotFound)
  99. }
  100. // --- Delete ---
  101. func (s *RedeemCodeRepoSuite) TestDelete() {
  102. created, err := s.client.RedeemCode.Create().
  103. SetCode("TO-DELETE").
  104. SetType(service.RedeemTypeBalance).
  105. SetStatus(service.StatusUnused).
  106. SetValue(0).
  107. SetNotes("").
  108. SetValidityDays(30).
  109. Save(s.ctx)
  110. s.Require().NoError(err)
  111. err = s.repo.Delete(s.ctx, created.ID)
  112. s.Require().NoError(err, "Delete")
  113. _, err = s.repo.GetByID(s.ctx, created.ID)
  114. s.Require().Error(err, "expected error after delete")
  115. s.Require().ErrorIs(err, service.ErrRedeemCodeNotFound)
  116. }
  117. // --- List / ListWithFilters ---
  118. func (s *RedeemCodeRepoSuite) TestList() {
  119. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "LIST-1", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  120. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "LIST-2", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  121. codes, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
  122. s.Require().NoError(err, "List")
  123. s.Require().Len(codes, 2)
  124. s.Require().Equal(int64(2), page.Total)
  125. }
  126. func (s *RedeemCodeRepoSuite) TestListWithFilters_Type() {
  127. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "TYPE-BAL", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  128. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "TYPE-SUB", Type: service.RedeemTypeSubscription, Value: 0, Status: service.StatusUnused}))
  129. codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.RedeemTypeSubscription, "", "")
  130. s.Require().NoError(err)
  131. s.Require().Len(codes, 1)
  132. s.Require().Equal(service.RedeemTypeSubscription, codes[0].Type)
  133. }
  134. func (s *RedeemCodeRepoSuite) TestListWithFilters_Status() {
  135. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-UNUSED", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  136. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-USED", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUsed}))
  137. codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusUsed, "")
  138. s.Require().NoError(err)
  139. s.Require().Len(codes, 1)
  140. s.Require().Equal(service.StatusUsed, codes[0].Status)
  141. }
  142. func (s *RedeemCodeRepoSuite) TestListWithFilters_StatusExpiredByExpiresAt() {
  143. past := time.Now().UTC().Add(-time.Hour)
  144. future := time.Now().UTC().Add(time.Hour)
  145. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-EXPIRED-BY-TIME", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &past}))
  146. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-UNUSED-FUTURE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &future}))
  147. expired, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusExpired, "")
  148. s.Require().NoError(err)
  149. s.Require().Len(expired, 1)
  150. s.Require().Equal("STAT-EXPIRED-BY-TIME", expired[0].Code)
  151. unused, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusUnused, "")
  152. s.Require().NoError(err)
  153. s.Require().Len(unused, 1)
  154. s.Require().Equal("STAT-UNUSED-FUTURE", unused[0].Code)
  155. }
  156. func (s *RedeemCodeRepoSuite) TestListWithFilters_Search() {
  157. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "ALPHA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  158. s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "BETA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
  159. codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "alpha")
  160. s.Require().NoError(err)
  161. s.Require().Len(codes, 1)
  162. s.Require().Contains(codes[0].Code, "ALPHA")
  163. }
  164. func (s *RedeemCodeRepoSuite) TestListWithFilters_GroupPreload() {
  165. group := s.createGroup(uniqueTestValue(s.T(), "g-preload"))
  166. _, err := s.client.RedeemCode.Create().
  167. SetCode("WITH-GROUP").
  168. SetType(service.RedeemTypeSubscription).
  169. SetStatus(service.StatusUnused).
  170. SetValue(0).
  171. SetNotes("").
  172. SetValidityDays(30).
  173. SetGroupID(group.ID).
  174. Save(s.ctx)
  175. s.Require().NoError(err)
  176. codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "")
  177. s.Require().NoError(err)
  178. s.Require().Len(codes, 1)
  179. s.Require().NotNil(codes[0].Group, "expected Group preload")
  180. s.Require().Equal(group.ID, codes[0].Group.ID)
  181. }
  182. // --- Update ---
  183. func (s *RedeemCodeRepoSuite) TestUpdate() {
  184. code := &service.RedeemCode{
  185. Code: "UPDATE-ME",
  186. Type: service.RedeemTypeBalance,
  187. Value: 10,
  188. Status: service.StatusUnused,
  189. }
  190. s.Require().NoError(s.repo.Create(s.ctx, code))
  191. code.Value = 50
  192. err := s.repo.Update(s.ctx, code)
  193. s.Require().NoError(err, "Update")
  194. got, err := s.repo.GetByID(s.ctx, code.ID)
  195. s.Require().NoError(err)
  196. s.Require().Equal(float64(50), got.Value)
  197. }
  198. func (s *RedeemCodeRepoSuite) TestBatchUpdate_PartialFieldsAndClear() {
  199. group := s.createGroup(uniqueTestValue(s.T(), "batch-update-group"))
  200. groupID := group.ID
  201. expiresAt := time.Now().UTC().Add(2 * time.Hour)
  202. status := service.StatusDisabled
  203. notes := "batch note"
  204. codeA := &service.RedeemCode{
  205. Code: "BATCH-UP-A",
  206. Type: service.RedeemTypeBalance,
  207. Value: 10,
  208. Status: service.StatusUnused,
  209. Notes: "old",
  210. }
  211. codeB := &service.RedeemCode{
  212. Code: "BATCH-UP-B",
  213. Type: service.RedeemTypeBalance,
  214. Value: 20,
  215. Status: service.StatusUnused,
  216. Notes: "old",
  217. }
  218. untouched := &service.RedeemCode{
  219. Code: "BATCH-UP-C",
  220. Type: service.RedeemTypeBalance,
  221. Value: 30,
  222. Status: service.StatusUnused,
  223. Notes: "keep",
  224. }
  225. s.Require().NoError(s.repo.Create(s.ctx, codeA))
  226. s.Require().NoError(s.repo.Create(s.ctx, codeB))
  227. s.Require().NoError(s.repo.Create(s.ctx, untouched))
  228. updated, err := s.repo.BatchUpdate(s.ctx, []int64{codeA.ID, codeB.ID}, service.RedeemCodeBatchUpdateFields{
  229. Status: &status,
  230. ExpiresAt: service.NullableTimeUpdate{Set: true, Value: &expiresAt},
  231. Notes: &notes,
  232. GroupID: service.NullableInt64Update{Set: true, Value: &groupID},
  233. })
  234. s.Require().NoError(err)
  235. s.Require().Equal(int64(2), updated)
  236. gotA, err := s.repo.GetByID(s.ctx, codeA.ID)
  237. s.Require().NoError(err)
  238. s.Require().Equal("BATCH-UP-A", gotA.Code)
  239. s.Require().Equal(service.RedeemTypeBalance, gotA.Type)
  240. s.Require().Equal(float64(10), gotA.Value)
  241. s.Require().Equal(service.StatusDisabled, gotA.Status)
  242. s.Require().Equal(notes, gotA.Notes)
  243. s.Require().NotNil(gotA.ExpiresAt)
  244. s.Require().WithinDuration(expiresAt, *gotA.ExpiresAt, time.Second)
  245. s.Require().NotNil(gotA.GroupID)
  246. s.Require().Equal(groupID, *gotA.GroupID)
  247. gotB, err := s.repo.GetByID(s.ctx, codeB.ID)
  248. s.Require().NoError(err)
  249. s.Require().Equal(service.StatusDisabled, gotB.Status)
  250. s.Require().Equal(notes, gotB.Notes)
  251. gotUntouched, err := s.repo.GetByID(s.ctx, untouched.ID)
  252. s.Require().NoError(err)
  253. s.Require().Equal(service.StatusUnused, gotUntouched.Status)
  254. s.Require().Equal("keep", gotUntouched.Notes)
  255. s.Require().Nil(gotUntouched.ExpiresAt)
  256. s.Require().Nil(gotUntouched.GroupID)
  257. updated, err = s.repo.BatchUpdate(s.ctx, []int64{codeA.ID}, service.RedeemCodeBatchUpdateFields{
  258. ExpiresAt: service.NullableTimeUpdate{Set: true},
  259. GroupID: service.NullableInt64Update{Set: true},
  260. })
  261. s.Require().NoError(err)
  262. s.Require().Equal(int64(1), updated)
  263. gotA, err = s.repo.GetByID(s.ctx, codeA.ID)
  264. s.Require().NoError(err)
  265. s.Require().Nil(gotA.ExpiresAt)
  266. s.Require().Nil(gotA.GroupID)
  267. }
  268. func (s *RedeemCodeRepoSuite) TestBatchUpdate_InvalidIDRollsBack() {
  269. code := &service.RedeemCode{
  270. Code: "BATCH-UP-ROLLBACK",
  271. Type: service.RedeemTypeBalance,
  272. Value: 10,
  273. Status: service.StatusUnused,
  274. Notes: "keep",
  275. }
  276. s.Require().NoError(s.repo.Create(s.ctx, code))
  277. notes := "changed"
  278. _, err := s.repo.BatchUpdate(s.ctx, []int64{code.ID, 999999}, service.RedeemCodeBatchUpdateFields{Notes: &notes})
  279. s.Require().Error(err)
  280. s.Require().True(errors.Is(err, service.ErrRedeemCodeNotFound))
  281. got, getErr := s.repo.GetByID(s.ctx, code.ID)
  282. s.Require().NoError(getErr)
  283. s.Require().Equal("keep", got.Notes)
  284. }
  285. func (s *RedeemCodeRepoSuite) TestBatchUpdate_UsedCodeRejectsSensitiveFields() {
  286. code := &service.RedeemCode{
  287. Code: "BATCH-UP-USED",
  288. Type: service.RedeemTypeBalance,
  289. Value: 10,
  290. Status: service.StatusUsed,
  291. }
  292. s.Require().NoError(s.repo.Create(s.ctx, code))
  293. status := service.StatusDisabled
  294. _, err := s.repo.BatchUpdate(s.ctx, []int64{code.ID}, service.RedeemCodeBatchUpdateFields{Status: &status})
  295. s.Require().Error(err)
  296. s.Require().True(errors.Is(err, service.ErrRedeemCodeUsed))
  297. got, getErr := s.repo.GetByID(s.ctx, code.ID)
  298. s.Require().NoError(getErr)
  299. s.Require().Equal(service.StatusUsed, got.Status)
  300. }
  301. // --- Use ---
  302. func (s *RedeemCodeRepoSuite) TestUse() {
  303. user := s.createUser(uniqueTestValue(s.T(), "use") + "@example.com")
  304. code := &service.RedeemCode{Code: "USE-ME", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}
  305. s.Require().NoError(s.repo.Create(s.ctx, code))
  306. err := s.repo.Use(s.ctx, code.ID, user.ID)
  307. s.Require().NoError(err, "Use")
  308. got, err := s.repo.GetByID(s.ctx, code.ID)
  309. s.Require().NoError(err)
  310. s.Require().Equal(service.StatusUsed, got.Status)
  311. s.Require().NotNil(got.UsedBy)
  312. s.Require().Equal(user.ID, *got.UsedBy)
  313. s.Require().NotNil(got.UsedAt)
  314. }
  315. func (s *RedeemCodeRepoSuite) TestUse_Idempotency() {
  316. user := s.createUser(uniqueTestValue(s.T(), "idem") + "@example.com")
  317. code := &service.RedeemCode{Code: "IDEM-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}
  318. s.Require().NoError(s.repo.Create(s.ctx, code))
  319. err := s.repo.Use(s.ctx, code.ID, user.ID)
  320. s.Require().NoError(err, "Use first time")
  321. // Second use should fail
  322. err = s.repo.Use(s.ctx, code.ID, user.ID)
  323. s.Require().Error(err, "Use expected error on second call")
  324. s.Require().ErrorIs(err, service.ErrRedeemCodeUsed)
  325. }
  326. func (s *RedeemCodeRepoSuite) TestUse_AlreadyUsed() {
  327. user := s.createUser(uniqueTestValue(s.T(), "already") + "@example.com")
  328. code := &service.RedeemCode{Code: "ALREADY-USED", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUsed}
  329. s.Require().NoError(s.repo.Create(s.ctx, code))
  330. err := s.repo.Use(s.ctx, code.ID, user.ID)
  331. s.Require().Error(err, "expected error for already used code")
  332. s.Require().ErrorIs(err, service.ErrRedeemCodeUsed)
  333. }
  334. // --- ListByUser ---
  335. func (s *RedeemCodeRepoSuite) TestListByUser() {
  336. user := s.createUser(uniqueTestValue(s.T(), "listby") + "@example.com")
  337. base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
  338. usedAt1 := base
  339. _, err := s.client.RedeemCode.Create().
  340. SetCode("USER-1").
  341. SetType(service.RedeemTypeBalance).
  342. SetStatus(service.StatusUsed).
  343. SetValue(0).
  344. SetNotes("").
  345. SetValidityDays(30).
  346. SetUsedBy(user.ID).
  347. SetUsedAt(usedAt1).
  348. Save(s.ctx)
  349. s.Require().NoError(err)
  350. usedAt2 := base.Add(1 * time.Hour)
  351. _, err = s.client.RedeemCode.Create().
  352. SetCode("USER-2").
  353. SetType(service.RedeemTypeBalance).
  354. SetStatus(service.StatusUsed).
  355. SetValue(0).
  356. SetNotes("").
  357. SetValidityDays(30).
  358. SetUsedBy(user.ID).
  359. SetUsedAt(usedAt2).
  360. Save(s.ctx)
  361. s.Require().NoError(err)
  362. codes, err := s.repo.ListByUser(s.ctx, user.ID, 10)
  363. s.Require().NoError(err, "ListByUser")
  364. s.Require().Len(codes, 2)
  365. // Ordered by used_at DESC, so USER-2 first
  366. s.Require().Equal("USER-2", codes[0].Code)
  367. s.Require().Equal("USER-1", codes[1].Code)
  368. }
  369. func (s *RedeemCodeRepoSuite) TestListByUser_WithGroupPreload() {
  370. user := s.createUser(uniqueTestValue(s.T(), "grp") + "@example.com")
  371. group := s.createGroup(uniqueTestValue(s.T(), "g-listby"))
  372. _, err := s.client.RedeemCode.Create().
  373. SetCode("WITH-GRP").
  374. SetType(service.RedeemTypeSubscription).
  375. SetStatus(service.StatusUsed).
  376. SetValue(0).
  377. SetNotes("").
  378. SetValidityDays(30).
  379. SetUsedBy(user.ID).
  380. SetUsedAt(time.Now()).
  381. SetGroupID(group.ID).
  382. Save(s.ctx)
  383. s.Require().NoError(err)
  384. codes, err := s.repo.ListByUser(s.ctx, user.ID, 10)
  385. s.Require().NoError(err)
  386. s.Require().Len(codes, 1)
  387. s.Require().NotNil(codes[0].Group)
  388. s.Require().Equal(group.ID, codes[0].Group.ID)
  389. }
  390. func (s *RedeemCodeRepoSuite) TestListByUser_DefaultLimit() {
  391. user := s.createUser(uniqueTestValue(s.T(), "deflimit") + "@example.com")
  392. _, err := s.client.RedeemCode.Create().
  393. SetCode("DEF-LIM").
  394. SetType(service.RedeemTypeBalance).
  395. SetStatus(service.StatusUsed).
  396. SetValue(0).
  397. SetNotes("").
  398. SetValidityDays(30).
  399. SetUsedBy(user.ID).
  400. SetUsedAt(time.Now()).
  401. Save(s.ctx)
  402. s.Require().NoError(err)
  403. // limit <= 0 should default to 10
  404. codes, err := s.repo.ListByUser(s.ctx, user.ID, 0)
  405. s.Require().NoError(err)
  406. s.Require().Len(codes, 1)
  407. }
  408. // --- Combined original test ---
  409. func (s *RedeemCodeRepoSuite) TestCreateBatch_Filters_Use_Idempotency_ListByUser() {
  410. user := s.createUser(uniqueTestValue(s.T(), "rc") + "@example.com")
  411. group := s.createGroup(uniqueTestValue(s.T(), "g-rc"))
  412. groupID := group.ID
  413. codes := []service.RedeemCode{
  414. {Code: "CODEA", Type: service.RedeemTypeBalance, Value: 1, Status: service.StatusUnused, Notes: ""},
  415. {Code: "CODEB", Type: service.RedeemTypeSubscription, Value: 0, Status: service.StatusUnused, Notes: "", GroupID: &groupID, ValidityDays: 7},
  416. }
  417. s.Require().NoError(s.repo.CreateBatch(s.ctx, codes), "CreateBatch")
  418. list, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.RedeemTypeSubscription, service.StatusUnused, "code")
  419. s.Require().NoError(err, "ListWithFilters")
  420. s.Require().Equal(int64(1), page.Total)
  421. s.Require().Len(list, 1)
  422. s.Require().NotNil(list[0].Group, "expected Group preload")
  423. s.Require().Equal(group.ID, list[0].Group.ID)
  424. codeB, err := s.repo.GetByCode(s.ctx, "CODEB")
  425. s.Require().NoError(err, "GetByCode")
  426. s.Require().NoError(s.repo.Use(s.ctx, codeB.ID, user.ID), "Use")
  427. err = s.repo.Use(s.ctx, codeB.ID, user.ID)
  428. s.Require().Error(err, "Use expected error on second call")
  429. s.Require().ErrorIs(err, service.ErrRedeemCodeUsed)
  430. codeA, err := s.repo.GetByCode(s.ctx, "CODEA")
  431. s.Require().NoError(err, "GetByCode")
  432. // Use fixed time instead of time.Sleep for deterministic ordering.
  433. _, err = s.client.RedeemCode.UpdateOneID(codeB.ID).
  434. SetUsedAt(time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)).
  435. Save(s.ctx)
  436. s.Require().NoError(err)
  437. s.Require().NoError(s.repo.Use(s.ctx, codeA.ID, user.ID), "Use codeA")
  438. _, err = s.client.RedeemCode.UpdateOneID(codeA.ID).
  439. SetUsedAt(time.Date(2025, 1, 1, 13, 0, 0, 0, time.UTC)).
  440. Save(s.ctx)
  441. s.Require().NoError(err)
  442. used, err := s.repo.ListByUser(s.ctx, user.ID, 10)
  443. s.Require().NoError(err, "ListByUser")
  444. s.Require().Len(used, 2, "expected 2 used codes")
  445. s.Require().Equal("CODEA", used[0].Code, "expected newest used code first")
  446. }