scheduler_outbox_repo_test.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. package repository
  2. import (
  3. "context"
  4. "regexp"
  5. "testing"
  6. "time"
  7. sqlmock "github.com/DATA-DOG/go-sqlmock"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func TestSchedulerOutboxRepositoryFirstCreatedAtAfter(t *testing.T) {
  11. db, mock, err := sqlmock.New()
  12. require.NoError(t, err)
  13. defer func() { _ = db.Close() }()
  14. repo := &schedulerOutboxRepository{db: db}
  15. createdAt := time.Now().UTC().Truncate(time.Microsecond)
  16. const expectedSQL = `
  17. SELECT created_at
  18. FROM scheduler_outbox
  19. WHERE id > $1
  20. ORDER BY id ASC
  21. LIMIT 1
  22. `
  23. mock.ExpectQuery(regexp.QuoteMeta(expectedSQL)).
  24. WithArgs(int64(42)).
  25. WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(createdAt))
  26. got, ok, err := repo.FirstCreatedAtAfter(context.Background(), 42)
  27. require.NoError(t, err)
  28. require.True(t, ok)
  29. require.Equal(t, createdAt, got)
  30. require.NoError(t, mock.ExpectationsWereMet())
  31. }
  32. func TestSchedulerOutboxRepositoryFirstCreatedAtAfterReturnsNotFound(t *testing.T) {
  33. db, mock, err := sqlmock.New()
  34. require.NoError(t, err)
  35. defer func() { _ = db.Close() }()
  36. repo := &schedulerOutboxRepository{db: db}
  37. const expectedSQL = `
  38. SELECT created_at
  39. FROM scheduler_outbox
  40. WHERE id > $1
  41. ORDER BY id ASC
  42. LIMIT 1
  43. `
  44. mock.ExpectQuery(regexp.QuoteMeta(expectedSQL)).
  45. WithArgs(int64(42)).
  46. WillReturnRows(sqlmock.NewRows([]string{"created_at"}))
  47. got, ok, err := repo.FirstCreatedAtAfter(context.Background(), 42)
  48. require.NoError(t, err)
  49. require.False(t, ok)
  50. require.True(t, got.IsZero())
  51. require.NoError(t, mock.ExpectationsWereMet())
  52. }
  53. func TestSchedulerOutboxRepositoryDeleteConsumedUpToUsesBoundedCTE(t *testing.T) {
  54. db, mock, err := sqlmock.New()
  55. require.NoError(t, err)
  56. defer func() { _ = db.Close() }()
  57. repo := &schedulerOutboxRepository{db: db}
  58. const expectedSQL = `
  59. WITH doomed AS (
  60. SELECT id
  61. FROM scheduler_outbox
  62. WHERE id <= $1
  63. AND created_at < NOW() - INTERVAL '10 seconds'
  64. ORDER BY id ASC
  65. LIMIT $2
  66. )
  67. DELETE FROM scheduler_outbox o
  68. USING doomed d
  69. WHERE o.id = d.id
  70. `
  71. mock.ExpectExec(regexp.QuoteMeta(expectedSQL)).
  72. WithArgs(int64(42), 5000).
  73. WillReturnResult(sqlmock.NewResult(0, 17))
  74. deleted, err := repo.DeleteConsumedUpTo(context.Background(), 42, 5000)
  75. require.NoError(t, err)
  76. require.EqualValues(t, 17, deleted)
  77. require.NoError(t, mock.ExpectationsWereMet())
  78. }
  79. func TestSchedulerOutboxRepositoryDeleteConsumedUpToSkipsNonPositiveWatermark(t *testing.T) {
  80. db, mock, err := sqlmock.New()
  81. require.NoError(t, err)
  82. defer func() { _ = db.Close() }()
  83. repo := &schedulerOutboxRepository{db: db}
  84. deleted, err := repo.DeleteConsumedUpTo(context.Background(), 0, 5000)
  85. require.NoError(t, err)
  86. require.EqualValues(t, 0, deleted)
  87. require.NoError(t, mock.ExpectationsWereMet())
  88. }
  89. func TestSchedulerOutboxRepositoryTryAcquireCleanupLock(t *testing.T) {
  90. db, mock, err := sqlmock.New()
  91. require.NoError(t, err)
  92. defer func() { _ = db.Close() }()
  93. repo := &schedulerOutboxRepository{db: db}
  94. mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
  95. WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true))
  96. mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock(hashtext('scheduler_outbox_cleanup'))")).
  97. WillReturnResult(sqlmock.NewResult(0, 1))
  98. lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
  99. require.NoError(t, err)
  100. require.True(t, acquired)
  101. require.NotNil(t, lease)
  102. lease.Release()
  103. require.NoError(t, mock.ExpectationsWereMet())
  104. }
  105. func TestSchedulerOutboxRepositoryTryAcquireCleanupLockUnavailable(t *testing.T) {
  106. db, mock, err := sqlmock.New()
  107. require.NoError(t, err)
  108. defer func() { _ = db.Close() }()
  109. repo := &schedulerOutboxRepository{db: db}
  110. mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
  111. WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(false))
  112. lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
  113. require.NoError(t, err)
  114. require.False(t, acquired)
  115. require.Nil(t, lease)
  116. require.NoError(t, mock.ExpectationsWereMet())
  117. }
  118. // buildSchedulerGroupPayload 在 groupIDs 为空时必须返回 untyped nil(any),
  119. // 否则 enqueueSchedulerOutbox 的 "payload != nil" 接口判空会被 typed-nil 欺骗,
  120. // 把 payload marshal 成 "null" 写入 dedup_key 哈希,破坏与其他 nil-payload
  121. // 调用的去重一致性。本测试用 ungrouped 账号场景验证两条路径的 dedup_key 一致。
  122. func TestEnqueueSchedulerOutbox_UngroupedAccountDedupesWithLiteralNilPayload(t *testing.T) {
  123. accountID := int64(42)
  124. // Path A: 显式 nil payload(如 SetError、SetStatus 等调用模式)
  125. keyLiteralNil := schedulerOutboxDedupKey("account_changed", &accountID, nil, nil)
  126. // Path B: buildSchedulerGroupPayload(account.GroupIDs) 当账号没有任何分组
  127. emptyGroupsPayload := buildSchedulerGroupPayload(nil)
  128. require.Nil(t, emptyGroupsPayload,
  129. "buildSchedulerGroupPayload(empty) must return untyped-nil any to avoid typed-nil marshal")
  130. // 模拟 enqueueSchedulerOutbox 内部的判空逻辑
  131. var payloadJSON []byte
  132. if emptyGroupsPayload != nil {
  133. t.Fatalf("typed-nil regression: buildSchedulerGroupPayload(empty) interface should be nil")
  134. }
  135. keyEmptyGroups := schedulerOutboxDedupKey("account_changed", &accountID, nil, payloadJSON)
  136. require.Equal(t, keyLiteralNil, keyEmptyGroups,
  137. "ungrouped-account account_changed must share dedup_key with other nil-payload variants")
  138. }