| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- //go:build unit
- package repository
- import (
- "context"
- "database/sql"
- "errors"
- "testing"
- "time"
- "github.com/DATA-DOG/go-sqlmock"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
- )
- func TestDashboardAggregationRepositorySyncGroupUsageRollupsNoopsAtCurrentDate(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow("2026-08-14", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectCommit()
- require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositorySyncGroupUsageRollupsRebuildsWhenTimezoneChanges(t *testing.T) {
- useGroupUsageRepositoryTestTimezone(t, "America/New_York")
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
- retainedFrom := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow("2026-03-09", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
- WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom))
- mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
- WithArgs("2026-03-01", "2026-03-01", "2026-03-09").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
- WithArgs(retainedFrom, todayStart, "America/New_York").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs("2026-03-09", retainedFrom, "America/New_York").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectCommit()
- require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositorySyncGroupUsageRollupsPublishesWatermarkLast(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
- retainedFrom := time.Date(2026, 5, 1, 3, 0, 0, 0, time.UTC)
- rebuildStart := time.Date(2026, 8, 12, 16, 0, 0, 0, time.UTC)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow("2026-08-13", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
- WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom))
- mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
- WithArgs("2026-05-01", "2026-08-13", "2026-08-14").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
- WithArgs(rebuildStart, todayStart, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 2))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs("2026-08-14", retainedFrom, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectCommit()
- require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositorySyncGroupUsageRollupsRejectsFutureWatermark(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow("2026-08-15", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectRollback()
- err := repo.SyncGroupUsageRollups(context.Background(), todayStart)
- require.ErrorContains(t, err, "未来")
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryRecomputeRangeInvalidatesGroupRollupsBeforeDashboardRebuild(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
- end := start.Add(time.Hour)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(start, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`DELETE FROM usage_dashboard_hourly`).
- WillReturnError(sql.ErrConnDone)
- mock.ExpectRollback()
- err := repo.RecomputeRange(context.Background(), start, end)
- require.ErrorIs(t, err, sql.ErrConnDone)
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryRecomputeRangeRebuildsGroupRollupsBeforeCommit(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
- end := start.Add(time.Hour)
- fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
- repo.clock = func() time.Time { return fixedNow }
- todayStart := service.GroupUsageTodayStart(fixedNow)
- startDate := service.GroupUsageDate(start)
- rebuildStart, err := service.ParseGroupUsageDate(startDate)
- require.NoError(t, err)
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(start, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- for _, query := range []string{
- `DELETE FROM usage_dashboard_hourly WHERE`,
- `DELETE FROM usage_dashboard_hourly_users WHERE`,
- `DELETE FROM usage_dashboard_daily WHERE`,
- `DELETE FROM usage_dashboard_daily_users WHERE`,
- `INSERT INTO usage_dashboard_hourly_users`,
- `INSERT INTO usage_dashboard_daily_users`,
- `INSERT INTO usage_dashboard_hourly`,
- `INSERT INTO usage_dashboard_daily`,
- } {
- mock.ExpectExec(query).WillReturnResult(sqlmock.NewResult(0, 1))
- }
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow(startDate, time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
- WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(start))
- mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
- WithArgs(startDate, startDate, service.GroupUsageDate(todayStart)).
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
- WithArgs(rebuildStart.UTC(), todayStart.UTC(), "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(service.GroupUsageDate(todayStart), start, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectCommit()
- require.NoError(t, repo.RecomputeRange(context.Background(), start, end))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedInvalidatesEachBatchAndSyncs(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
- earliestDeletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC)
- fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
- repo.clock = func() time.Time { return fixedNow }
- todayStart := service.GroupUsageTodayStart(fixedNow)
- mock.ExpectQuery(`SELECT EXISTS`).
- WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectQuery(`(?s)DELETE FROM usage_logs.*RETURNING created_at`).
- WithArgs(cutoff, usageLogsCleanupBatchSize).
- WillReturnRows(sqlmock.NewRows([]string{"created_at"}).
- AddRow(earliestDeletedAt.Add(time.Hour)).
- AddRow(earliestDeletedAt))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(earliestDeletedAt, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectCommit()
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectCommit()
- require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionedSortsAndInvalidatesEachDropBeforeSync(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC)
- aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
- juneStart := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
- fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
- repo.clock = func() time.Time { return fixedNow }
- todayStart := service.GroupUsageTodayStart(fixedNow)
- mock.ExpectQuery(`SELECT EXISTS`).
- WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
- mock.ExpectQuery(`SELECT c.relname`).
- WillReturnRows(sqlmock.NewRows([]string{"relname"}).
- AddRow("usage_logs_202606").
- AddRow("usage_logs_invalid").
- AddRow("usage_logs_202604").
- AddRow("usage_logs_202607"))
- for _, partition := range []struct {
- name string
- start time.Time
- }{
- {name: "usage_logs_202604", start: aprilStart},
- {name: "usage_logs_202606", start: juneStart},
- } {
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(partition.start, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`DROP TABLE IF EXISTS "` + partition.name + `"`).
- WillReturnResult(sqlmock.NewResult(0, 0))
- mock.ExpectCommit()
- }
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
- AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai"))
- mock.ExpectCommit()
- require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff))
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedFailureRollsBackWithoutSync(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
- deletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC)
- mock.ExpectQuery(`SELECT EXISTS`).
- WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectQuery(`(?s)SELECT ctid.*ORDER BY created_at ASC, id ASC.*DELETE FROM usage_logs.*RETURNING created_at`).
- WithArgs(cutoff, usageLogsCleanupBatchSize).
- WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(deletedAt))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(deletedAt, "Asia/Shanghai").
- WillReturnError(sql.ErrConnDone)
- mock.ExpectRollback()
- err := repo.CleanupUsageLogs(context.Background(), cutoff)
- require.ErrorIs(t, err, sql.ErrConnDone)
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionFailureRollsBackAndStops(t *testing.T) {
- setGroupUsageRollupTestTimezone(t)
- db, mock := newSQLMock(t)
- repo := newDashboardAggregationRepositoryWithSQL(db)
- cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC)
- aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
- dropErr := errors.New("drop partition failed")
- mock.ExpectQuery(`SELECT EXISTS`).
- WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
- mock.ExpectQuery(`SELECT c.relname`).
- WillReturnRows(sqlmock.NewRows([]string{"relname"}).
- AddRow("usage_logs_202606").
- AddRow("usage_logs_202604"))
- mock.ExpectBegin()
- mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
- WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
- mock.ExpectExec(`UPDATE usage_group_rollup_state`).
- WithArgs(aprilStart, "Asia/Shanghai").
- WillReturnResult(sqlmock.NewResult(0, 1))
- mock.ExpectExec(`DROP TABLE IF EXISTS "usage_logs_202604"`).
- WillReturnError(dropErr)
- mock.ExpectRollback()
- err := repo.CleanupUsageLogs(context.Background(), cutoff)
- require.ErrorIs(t, err, dropErr)
- require.NoError(t, mock.ExpectationsWereMet())
- }
- func setGroupUsageRollupTestTimezone(t *testing.T) {
- t.Helper()
- useGroupUsageRepositoryTestTimezone(t, "Asia/Shanghai")
- }
|