affiliate_repo_integration_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "fmt"
  6. "testing"
  7. "time"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func querySingleFloat(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) float64 {
  13. t.Helper()
  14. rows, err := client.QueryContext(ctx, query, args...)
  15. require.NoError(t, err)
  16. defer func() { _ = rows.Close() }()
  17. require.True(t, rows.Next(), "expected one row")
  18. var value float64
  19. require.NoError(t, rows.Scan(&value))
  20. require.NoError(t, rows.Err())
  21. return value
  22. }
  23. func querySingleInt(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) int {
  24. t.Helper()
  25. rows, err := client.QueryContext(ctx, query, args...)
  26. require.NoError(t, err)
  27. defer func() { _ = rows.Close() }()
  28. require.True(t, rows.Next(), "expected one row")
  29. var value int
  30. require.NoError(t, rows.Scan(&value))
  31. require.NoError(t, rows.Err())
  32. return value
  33. }
  34. func TestAffiliateRepository_TransferQuotaToBalance_UsesClaimedQuotaBeforeClear(t *testing.T) {
  35. ctx := context.Background()
  36. tx := testEntTx(t)
  37. txCtx := dbent.NewTxContext(ctx, tx)
  38. client := tx.Client()
  39. repo := NewAffiliateRepository(client, integrationDB)
  40. u := mustCreateUser(t, client, &service.User{
  41. Email: fmt.Sprintf("affiliate-transfer-%d@example.com", time.Now().UnixNano()),
  42. PasswordHash: "hash",
  43. Role: service.RoleUser,
  44. Status: service.StatusActive,
  45. Balance: 5.5,
  46. Concurrency: 5,
  47. })
  48. affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
  49. _, err := client.ExecContext(txCtx, `
  50. INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
  51. VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
  52. require.NoError(t, err)
  53. transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
  54. require.NoError(t, err)
  55. require.InDelta(t, 12.34, transferred, 1e-9)
  56. require.InDelta(t, 17.84, balance, 1e-9)
  57. affQuota := querySingleFloat(t, txCtx, client,
  58. "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID)
  59. require.InDelta(t, 0.0, affQuota, 1e-9)
  60. persistedBalance := querySingleFloat(t, txCtx, client,
  61. "SELECT balance::double precision FROM users WHERE id = $1", u.ID)
  62. require.InDelta(t, 17.84, persistedBalance, 1e-9)
  63. ledgerCount := querySingleInt(t, txCtx, client,
  64. "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
  65. require.Equal(t, 1, ledgerCount)
  66. rows, err := client.QueryContext(txCtx, `
  67. SELECT amount::double precision,
  68. balance_after::double precision,
  69. aff_quota_after::double precision,
  70. aff_frozen_quota_after::double precision,
  71. aff_history_quota_after::double precision
  72. FROM user_affiliate_ledger
  73. WHERE user_id = $1 AND action = 'transfer'
  74. LIMIT 1`, u.ID)
  75. require.NoError(t, err)
  76. defer func() { _ = rows.Close() }()
  77. require.True(t, rows.Next(), "expected transfer ledger")
  78. var amount, balanceAfter, quotaAfter, frozenAfter, historyAfter float64
  79. require.NoError(t, rows.Scan(&amount, &balanceAfter, &quotaAfter, &frozenAfter, &historyAfter))
  80. require.InDelta(t, 12.34, amount, 1e-9)
  81. require.InDelta(t, 17.84, balanceAfter, 1e-9)
  82. require.InDelta(t, 0.0, quotaAfter, 1e-9)
  83. require.InDelta(t, 0.0, frozenAfter, 1e-9)
  84. require.InDelta(t, 12.34, historyAfter, 1e-9)
  85. }
  86. // TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
  87. // cross-layer tx propagation invariant: when AccrueQuota is called with a ctx
  88. // that already carries a transaction (via dbent.NewTxContext), repo.withTx
  89. // must reuse that tx rather than opening a nested one. If this invariant
  90. // breaks, AccrueQuota would commit independently and survive a rollback of
  91. // the outer tx, which would violate payment_fulfillment's all-or-nothing
  92. // semantics.
  93. func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
  94. ctx := context.Background()
  95. outerTx, err := integrationEntClient.Tx(ctx)
  96. require.NoError(t, err, "begin outer tx")
  97. // Defensive cleanup: if any require.* below fires before the explicit
  98. // Rollback, this prevents the tx from leaking until container teardown.
  99. // Rollback is idempotent at the driver level (extra rollback returns an
  100. // error we ignore).
  101. t.Cleanup(func() { _ = outerTx.Rollback() })
  102. client := outerTx.Client()
  103. txCtx := dbent.NewTxContext(ctx, outerTx)
  104. inviter := mustCreateUser(t, client, &service.User{
  105. Email: fmt.Sprintf("affiliate-inviter-%d@example.com", time.Now().UnixNano()),
  106. PasswordHash: "hash",
  107. Role: service.RoleUser,
  108. Status: service.StatusActive,
  109. Concurrency: 5,
  110. })
  111. invitee := mustCreateUser(t, client, &service.User{
  112. Email: fmt.Sprintf("affiliate-invitee-%d@example.com", time.Now().UnixNano()+1),
  113. PasswordHash: "hash",
  114. Role: service.RoleUser,
  115. Status: service.StatusActive,
  116. Concurrency: 5,
  117. })
  118. repo := NewAffiliateRepository(client, integrationDB)
  119. _, err = repo.EnsureUserAffiliate(txCtx, inviter.ID)
  120. require.NoError(t, err)
  121. _, err = repo.EnsureUserAffiliate(txCtx, invitee.ID)
  122. require.NoError(t, err)
  123. bound, err := repo.BindInviter(txCtx, invitee.ID, inviter.ID)
  124. require.NoError(t, err)
  125. require.True(t, bound, "invitee must bind to inviter")
  126. applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0, nil)
  127. require.NoError(t, err)
  128. require.True(t, applied, "AccrueQuota must report applied=true")
  129. // Visible inside the outer tx.
  130. innerQuota := querySingleFloat(t, txCtx, client,
  131. "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", inviter.ID)
  132. require.InDelta(t, 3.5, innerQuota, 1e-9)
  133. // Roll back the outer tx; if AccrueQuota had opened its own inner tx and
  134. // committed it, the rows would still be visible to the global client.
  135. require.NoError(t, outerTx.Rollback())
  136. rows, err := integrationEntClient.QueryContext(ctx,
  137. "SELECT COUNT(*) FROM user_affiliates WHERE user_id IN ($1, $2)",
  138. inviter.ID, invitee.ID)
  139. require.NoError(t, err)
  140. defer func() { _ = rows.Close() }()
  141. require.True(t, rows.Next())
  142. var postRollbackCount int
  143. require.NoError(t, rows.Scan(&postRollbackCount))
  144. require.Equal(t, 0, postRollbackCount,
  145. "AccrueQuota must propagate the outer tx — found persisted rows after rollback")
  146. }
  147. func TestAffiliateRepository_TransferQuotaToBalance_EmptyQuota(t *testing.T) {
  148. ctx := context.Background()
  149. tx := testEntTx(t)
  150. txCtx := dbent.NewTxContext(ctx, tx)
  151. client := tx.Client()
  152. repo := NewAffiliateRepository(client, integrationDB)
  153. u := mustCreateUser(t, client, &service.User{
  154. Email: fmt.Sprintf("affiliate-empty-%d@example.com", time.Now().UnixNano()),
  155. PasswordHash: "hash",
  156. Role: service.RoleUser,
  157. Status: service.StatusActive,
  158. Balance: 3.21,
  159. Concurrency: 5,
  160. })
  161. affCode := fmt.Sprintf("AFF%09d", time.Now().UnixNano()%1_000_000_000)
  162. _, err := client.ExecContext(txCtx, `
  163. INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_history_quota, created_at, updated_at)
  164. VALUES ($1, $2, 0, 0, NOW(), NOW())`, u.ID, affCode)
  165. require.NoError(t, err)
  166. transferred, balance, err := repo.TransferQuotaToBalance(txCtx, u.ID)
  167. require.ErrorIs(t, err, service.ErrAffiliateQuotaEmpty)
  168. require.InDelta(t, 0.0, transferred, 1e-9)
  169. require.InDelta(t, 0.0, balance, 1e-9)
  170. persistedBalance := querySingleFloat(t, txCtx, client,
  171. "SELECT balance::double precision FROM users WHERE id = $1", u.ID)
  172. require.InDelta(t, 3.21, persistedBalance, 1e-9)
  173. }
  174. // TestAffiliateRepository_AdminCustomCode covers the success path of admin
  175. // invite-code rewrite + reset within a shared test transaction:
  176. // - UpdateUserAffCode replaces aff_code, sets aff_code_custom=true, lookup works
  177. // - the old code can no longer be found
  178. // - ResetUserAffCode reverts aff_code_custom and assigns a new system-format code
  179. //
  180. // The conflict path (duplicate code → ErrAffiliateCodeTaken) lives in its own
  181. // test because a unique-violation aborts the surrounding Postgres tx, which
  182. // would poison subsequent assertions in the same transaction.
  183. func TestAffiliateRepository_AdminCustomCode(t *testing.T) {
  184. ctx := context.Background()
  185. tx := testEntTx(t)
  186. txCtx := dbent.NewTxContext(ctx, tx)
  187. client := tx.Client()
  188. repo := NewAffiliateRepository(client, integrationDB)
  189. u := mustCreateUser(t, client, &service.User{
  190. Email: fmt.Sprintf("affiliate-custom-%d@example.com", time.Now().UnixNano()),
  191. PasswordHash: "hash",
  192. Role: service.RoleUser,
  193. Status: service.StatusActive,
  194. })
  195. original, err := repo.EnsureUserAffiliate(txCtx, u.ID)
  196. require.NoError(t, err)
  197. require.False(t, original.AffCodeCustom, "system-generated codes start as non-custom")
  198. originalCode := original.AffCode
  199. // Rewrite to a custom code
  200. customCode := fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)
  201. require.NoError(t, repo.UpdateUserAffCode(txCtx, u.ID, customCode))
  202. updated, err := repo.EnsureUserAffiliate(txCtx, u.ID)
  203. require.NoError(t, err)
  204. require.Equal(t, customCode, updated.AffCode)
  205. require.True(t, updated.AffCodeCustom)
  206. // Lookup by new custom code finds the user
  207. byCode, err := repo.GetAffiliateByCode(txCtx, customCode)
  208. require.NoError(t, err)
  209. require.Equal(t, u.ID, byCode.UserID)
  210. // Old system code should no longer match
  211. _, err = repo.GetAffiliateByCode(txCtx, originalCode)
  212. require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
  213. // Reset back to a fresh system code, clears custom flag
  214. newSysCode, err := repo.ResetUserAffCode(txCtx, u.ID)
  215. require.NoError(t, err)
  216. require.NotEqual(t, customCode, newSysCode)
  217. reset, err := repo.EnsureUserAffiliate(txCtx, u.ID)
  218. require.NoError(t, err)
  219. require.Equal(t, newSysCode, reset.AffCode)
  220. require.False(t, reset.AffCodeCustom)
  221. // The old custom code is now free again
  222. _, err = repo.GetAffiliateByCode(txCtx, customCode)
  223. require.ErrorIs(t, err, service.ErrAffiliateProfileNotFound)
  224. }
  225. // TestAffiliateRepository_AdminCustomCode_Conflict isolates the unique-violation
  226. // path. PostgreSQL aborts the enclosing tx when a unique constraint fires, so
  227. // this test must be the only assertion and run in its own tx — production
  228. // callers each have their own outer tx, so this matches real behavior.
  229. func TestAffiliateRepository_AdminCustomCode_Conflict(t *testing.T) {
  230. ctx := context.Background()
  231. tx := testEntTx(t)
  232. txCtx := dbent.NewTxContext(ctx, tx)
  233. client := tx.Client()
  234. repo := NewAffiliateRepository(client, integrationDB)
  235. taker := mustCreateUser(t, client, &service.User{
  236. Email: fmt.Sprintf("affiliate-conflict-taker-%d@example.com", time.Now().UnixNano()),
  237. PasswordHash: "hash",
  238. Role: service.RoleUser, Status: service.StatusActive,
  239. })
  240. requester := mustCreateUser(t, client, &service.User{
  241. Email: fmt.Sprintf("affiliate-conflict-req-%d@example.com", time.Now().UnixNano()),
  242. PasswordHash: "hash",
  243. Role: service.RoleUser, Status: service.StatusActive,
  244. })
  245. takenCode := fmt.Sprintf("HOT%09d", time.Now().UnixNano()%1_000_000_000)
  246. require.NoError(t, repo.UpdateUserAffCode(txCtx, taker.ID, takenCode))
  247. // Now requester tries to grab the same code → conflict.
  248. err := repo.UpdateUserAffCode(txCtx, requester.ID, takenCode)
  249. require.ErrorIs(t, err, service.ErrAffiliateCodeTaken)
  250. }
  251. // TestAffiliateRepository_AdminRebateRate covers per-user exclusive rate
  252. // set/clear and the Batch variant including NULL semantics.
  253. func TestAffiliateRepository_AdminRebateRate(t *testing.T) {
  254. ctx := context.Background()
  255. tx := testEntTx(t)
  256. txCtx := dbent.NewTxContext(ctx, tx)
  257. client := tx.Client()
  258. repo := NewAffiliateRepository(client, integrationDB)
  259. u1 := mustCreateUser(t, client, &service.User{
  260. Email: fmt.Sprintf("affiliate-rate-%d-a@example.com", time.Now().UnixNano()),
  261. PasswordHash: "hash",
  262. Role: service.RoleUser,
  263. Status: service.StatusActive,
  264. })
  265. u2 := mustCreateUser(t, client, &service.User{
  266. Email: fmt.Sprintf("affiliate-rate-%d-b@example.com", time.Now().UnixNano()),
  267. PasswordHash: "hash",
  268. Role: service.RoleUser,
  269. Status: service.StatusActive,
  270. })
  271. // Set exclusive rate for u1
  272. rate := 42.5
  273. require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, &rate))
  274. got, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
  275. require.NoError(t, err)
  276. require.NotNil(t, got.AffRebateRatePercent)
  277. require.InDelta(t, 42.5, *got.AffRebateRatePercent, 1e-9)
  278. // Clear exclusive rate
  279. require.NoError(t, repo.SetUserRebateRate(txCtx, u1.ID, nil))
  280. cleared, err := repo.EnsureUserAffiliate(txCtx, u1.ID)
  281. require.NoError(t, err)
  282. require.Nil(t, cleared.AffRebateRatePercent)
  283. // Batch set both users
  284. batchRate := 15.0
  285. require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, &batchRate))
  286. for _, uid := range []int64{u1.ID, u2.ID} {
  287. v, err := repo.EnsureUserAffiliate(txCtx, uid)
  288. require.NoError(t, err)
  289. require.NotNil(t, v.AffRebateRatePercent)
  290. require.InDelta(t, 15.0, *v.AffRebateRatePercent, 1e-9)
  291. }
  292. // Batch clear
  293. require.NoError(t, repo.BatchSetUserRebateRate(txCtx, []int64{u1.ID, u2.ID}, nil))
  294. for _, uid := range []int64{u1.ID, u2.ID} {
  295. v, err := repo.EnsureUserAffiliate(txCtx, uid)
  296. require.NoError(t, err)
  297. require.Nil(t, v.AffRebateRatePercent)
  298. }
  299. }
  300. // TestAffiliateRepository_ListUsersWithCustomSettings verifies the admin list
  301. // only includes users with at least one override applied.
  302. func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) {
  303. ctx := context.Background()
  304. tx := testEntTx(t)
  305. txCtx := dbent.NewTxContext(ctx, tx)
  306. client := tx.Client()
  307. repo := NewAffiliateRepository(client, integrationDB)
  308. // User without any custom config — should NOT appear in the list.
  309. plainEmail := fmt.Sprintf("affiliate-plain-%d@example.com", time.Now().UnixNano())
  310. uPlain := mustCreateUser(t, client, &service.User{
  311. Email: plainEmail, PasswordHash: "hash",
  312. Role: service.RoleUser, Status: service.StatusActive,
  313. })
  314. _, err := repo.EnsureUserAffiliate(txCtx, uPlain.ID)
  315. require.NoError(t, err)
  316. // User with a custom code — should appear.
  317. uCode := mustCreateUser(t, client, &service.User{
  318. Email: fmt.Sprintf("affiliate-codeonly-%d@example.com", time.Now().UnixNano()),
  319. PasswordHash: "hash",
  320. Role: service.RoleUser, Status: service.StatusActive,
  321. })
  322. require.NoError(t, repo.UpdateUserAffCode(txCtx, uCode.ID, fmt.Sprintf("VIP%09d", time.Now().UnixNano()%1_000_000_000)))
  323. // User with only an exclusive rate — should appear.
  324. uRate := mustCreateUser(t, client, &service.User{
  325. Email: fmt.Sprintf("affiliate-rateonly-%d@example.com", time.Now().UnixNano()),
  326. PasswordHash: "hash",
  327. Role: service.RoleUser, Status: service.StatusActive,
  328. })
  329. r := 33.3
  330. require.NoError(t, repo.SetUserRebateRate(txCtx, uRate.ID, &r))
  331. entries, total, err := repo.ListUsersWithCustomSettings(txCtx, service.AffiliateAdminFilter{
  332. Page: 1, PageSize: 100,
  333. })
  334. require.NoError(t, err)
  335. // Build a quick lookup to assert per-user attributes (other tests may have
  336. // inserted custom rows in the same DB; we only care about our 3).
  337. byUserID := make(map[int64]service.AffiliateAdminEntry, len(entries))
  338. for _, e := range entries {
  339. byUserID[e.UserID] = e
  340. }
  341. require.NotContains(t, byUserID, uPlain.ID, "users without overrides must not appear")
  342. codeEntry, ok := byUserID[uCode.ID]
  343. require.True(t, ok, "custom-code user missing from list")
  344. require.True(t, codeEntry.AffCodeCustom)
  345. require.Nil(t, codeEntry.AffRebateRatePercent)
  346. rateEntry, ok := byUserID[uRate.ID]
  347. require.True(t, ok, "custom-rate user missing from list")
  348. require.False(t, rateEntry.AffCodeCustom)
  349. require.NotNil(t, rateEntry.AffRebateRatePercent)
  350. require.InDelta(t, 33.3, *rateEntry.AffRebateRatePercent, 1e-9)
  351. require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows")
  352. }