171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package repository
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"regexp"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryFirstCreatedAtAfter(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
createdAt := time.Now().UTC().Truncate(time.Microsecond)
|
|||
|
|
const expectedSQL = `
|
|||
|
|
SELECT created_at
|
|||
|
|
FROM scheduler_outbox
|
|||
|
|
WHERE id > $1
|
|||
|
|
ORDER BY id ASC
|
|||
|
|
LIMIT 1
|
|||
|
|
`
|
|||
|
|
mock.ExpectQuery(regexp.QuoteMeta(expectedSQL)).
|
|||
|
|
WithArgs(int64(42)).
|
|||
|
|
WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(createdAt))
|
|||
|
|
|
|||
|
|
got, ok, err := repo.FirstCreatedAtAfter(context.Background(), 42)
|
|||
|
|
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.True(t, ok)
|
|||
|
|
require.Equal(t, createdAt, got)
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryFirstCreatedAtAfterReturnsNotFound(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
const expectedSQL = `
|
|||
|
|
SELECT created_at
|
|||
|
|
FROM scheduler_outbox
|
|||
|
|
WHERE id > $1
|
|||
|
|
ORDER BY id ASC
|
|||
|
|
LIMIT 1
|
|||
|
|
`
|
|||
|
|
mock.ExpectQuery(regexp.QuoteMeta(expectedSQL)).
|
|||
|
|
WithArgs(int64(42)).
|
|||
|
|
WillReturnRows(sqlmock.NewRows([]string{"created_at"}))
|
|||
|
|
|
|||
|
|
got, ok, err := repo.FirstCreatedAtAfter(context.Background(), 42)
|
|||
|
|
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.False(t, ok)
|
|||
|
|
require.True(t, got.IsZero())
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryDeleteConsumedUpToUsesBoundedCTE(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
const expectedSQL = `
|
|||
|
|
WITH doomed AS (
|
|||
|
|
SELECT id
|
|||
|
|
FROM scheduler_outbox
|
|||
|
|
WHERE id <= $1
|
|||
|
|
AND created_at < NOW() - INTERVAL '10 seconds'
|
|||
|
|
ORDER BY id ASC
|
|||
|
|
LIMIT $2
|
|||
|
|
)
|
|||
|
|
DELETE FROM scheduler_outbox o
|
|||
|
|
USING doomed d
|
|||
|
|
WHERE o.id = d.id
|
|||
|
|
`
|
|||
|
|
mock.ExpectExec(regexp.QuoteMeta(expectedSQL)).
|
|||
|
|
WithArgs(int64(42), 5000).
|
|||
|
|
WillReturnResult(sqlmock.NewResult(0, 17))
|
|||
|
|
|
|||
|
|
deleted, err := repo.DeleteConsumedUpTo(context.Background(), 42, 5000)
|
|||
|
|
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.EqualValues(t, 17, deleted)
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryDeleteConsumedUpToSkipsNonPositiveWatermark(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
|
|||
|
|
deleted, err := repo.DeleteConsumedUpTo(context.Background(), 0, 5000)
|
|||
|
|
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.EqualValues(t, 0, deleted)
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryTryAcquireCleanupLock(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
|
|||
|
|
WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true))
|
|||
|
|
mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock(hashtext('scheduler_outbox_cleanup'))")).
|
|||
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|||
|
|
|
|||
|
|
lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.True(t, acquired)
|
|||
|
|
require.NotNil(t, lease)
|
|||
|
|
|
|||
|
|
lease.Release()
|
|||
|
|
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSchedulerOutboxRepositoryTryAcquireCleanupLockUnavailable(t *testing.T) {
|
|||
|
|
db, mock, err := sqlmock.New()
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
defer func() { _ = db.Close() }()
|
|||
|
|
|
|||
|
|
repo := &schedulerOutboxRepository{db: db}
|
|||
|
|
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock(hashtext('scheduler_outbox_cleanup'))")).
|
|||
|
|
WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(false))
|
|||
|
|
|
|||
|
|
lease, acquired, err := repo.TryAcquireCleanupLock(context.Background())
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.False(t, acquired)
|
|||
|
|
require.Nil(t, lease)
|
|||
|
|
|
|||
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// buildSchedulerGroupPayload 在 groupIDs 为空时必须返回 untyped nil(any),
|
|||
|
|
// 否则 enqueueSchedulerOutbox 的 "payload != nil" 接口判空会被 typed-nil 欺骗,
|
|||
|
|
// 把 payload marshal 成 "null" 写入 dedup_key 哈希,破坏与其他 nil-payload
|
|||
|
|
// 调用的去重一致性。本测试用 ungrouped 账号场景验证两条路径的 dedup_key 一致。
|
|||
|
|
func TestEnqueueSchedulerOutbox_UngroupedAccountDedupesWithLiteralNilPayload(t *testing.T) {
|
|||
|
|
accountID := int64(42)
|
|||
|
|
|
|||
|
|
// Path A: 显式 nil payload(如 SetError、SetStatus 等调用模式)
|
|||
|
|
keyLiteralNil := schedulerOutboxDedupKey("account_changed", &accountID, nil, nil)
|
|||
|
|
|
|||
|
|
// Path B: buildSchedulerGroupPayload(account.GroupIDs) 当账号没有任何分组
|
|||
|
|
emptyGroupsPayload := buildSchedulerGroupPayload(nil)
|
|||
|
|
require.Nil(t, emptyGroupsPayload,
|
|||
|
|
"buildSchedulerGroupPayload(empty) must return untyped-nil any to avoid typed-nil marshal")
|
|||
|
|
|
|||
|
|
// 模拟 enqueueSchedulerOutbox 内部的判空逻辑
|
|||
|
|
var payloadJSON []byte
|
|||
|
|
if emptyGroupsPayload != nil {
|
|||
|
|
t.Fatalf("typed-nil regression: buildSchedulerGroupPayload(empty) interface should be nil")
|
|||
|
|
}
|
|||
|
|
keyEmptyGroups := schedulerOutboxDedupKey("account_changed", &accountID, nil, payloadJSON)
|
|||
|
|
|
|||
|
|
require.Equal(t, keyLiteralNil, keyEmptyGroups,
|
|||
|
|
"ungrouped-account account_changed must share dedup_key with other nil-payload variants")
|
|||
|
|
}
|