dashboard_aggregation_group_usage_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. //go:build unit
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "errors"
  7. "testing"
  8. "time"
  9. "github.com/DATA-DOG/go-sqlmock"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func TestDashboardAggregationRepositorySyncGroupUsageRollupsNoopsAtCurrentDate(t *testing.T) {
  14. setGroupUsageRollupTestTimezone(t)
  15. db, mock := newSQLMock(t)
  16. repo := newDashboardAggregationRepositoryWithSQL(db)
  17. todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
  18. mock.ExpectBegin()
  19. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  20. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  21. AddRow("2026-08-14", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  22. mock.ExpectCommit()
  23. require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
  24. require.NoError(t, mock.ExpectationsWereMet())
  25. }
  26. func TestDashboardAggregationRepositorySyncGroupUsageRollupsRebuildsWhenTimezoneChanges(t *testing.T) {
  27. useGroupUsageRepositoryTestTimezone(t, "America/New_York")
  28. db, mock := newSQLMock(t)
  29. repo := newDashboardAggregationRepositoryWithSQL(db)
  30. todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
  31. retainedFrom := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC)
  32. mock.ExpectBegin()
  33. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  34. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  35. AddRow("2026-03-09", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  36. mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
  37. WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom))
  38. mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
  39. WithArgs("2026-03-01", "2026-03-01", "2026-03-09").
  40. WillReturnResult(sqlmock.NewResult(0, 1))
  41. mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
  42. WithArgs(retainedFrom, todayStart, "America/New_York").
  43. WillReturnResult(sqlmock.NewResult(0, 1))
  44. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  45. WithArgs("2026-03-09", retainedFrom, "America/New_York").
  46. WillReturnResult(sqlmock.NewResult(0, 1))
  47. mock.ExpectCommit()
  48. require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
  49. require.NoError(t, mock.ExpectationsWereMet())
  50. }
  51. func TestDashboardAggregationRepositorySyncGroupUsageRollupsPublishesWatermarkLast(t *testing.T) {
  52. setGroupUsageRollupTestTimezone(t)
  53. db, mock := newSQLMock(t)
  54. repo := newDashboardAggregationRepositoryWithSQL(db)
  55. todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
  56. retainedFrom := time.Date(2026, 5, 1, 3, 0, 0, 0, time.UTC)
  57. rebuildStart := time.Date(2026, 8, 12, 16, 0, 0, 0, time.UTC)
  58. mock.ExpectBegin()
  59. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  60. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  61. AddRow("2026-08-13", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  62. mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
  63. WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom))
  64. mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
  65. WithArgs("2026-05-01", "2026-08-13", "2026-08-14").
  66. WillReturnResult(sqlmock.NewResult(0, 1))
  67. mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
  68. WithArgs(rebuildStart, todayStart, "Asia/Shanghai").
  69. WillReturnResult(sqlmock.NewResult(0, 2))
  70. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  71. WithArgs("2026-08-14", retainedFrom, "Asia/Shanghai").
  72. WillReturnResult(sqlmock.NewResult(0, 1))
  73. mock.ExpectCommit()
  74. require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart))
  75. require.NoError(t, mock.ExpectationsWereMet())
  76. }
  77. func TestDashboardAggregationRepositorySyncGroupUsageRollupsRejectsFutureWatermark(t *testing.T) {
  78. setGroupUsageRollupTestTimezone(t)
  79. db, mock := newSQLMock(t)
  80. repo := newDashboardAggregationRepositoryWithSQL(db)
  81. todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
  82. mock.ExpectBegin()
  83. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  84. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  85. AddRow("2026-08-15", time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  86. mock.ExpectRollback()
  87. err := repo.SyncGroupUsageRollups(context.Background(), todayStart)
  88. require.ErrorContains(t, err, "未来")
  89. require.NoError(t, mock.ExpectationsWereMet())
  90. }
  91. func TestDashboardAggregationRepositoryRecomputeRangeInvalidatesGroupRollupsBeforeDashboardRebuild(t *testing.T) {
  92. setGroupUsageRollupTestTimezone(t)
  93. db, mock := newSQLMock(t)
  94. repo := newDashboardAggregationRepositoryWithSQL(db)
  95. start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
  96. end := start.Add(time.Hour)
  97. mock.ExpectBegin()
  98. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  99. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  100. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  101. WithArgs(start, "Asia/Shanghai").
  102. WillReturnResult(sqlmock.NewResult(0, 1))
  103. mock.ExpectExec(`DELETE FROM usage_dashboard_hourly`).
  104. WillReturnError(sql.ErrConnDone)
  105. mock.ExpectRollback()
  106. err := repo.RecomputeRange(context.Background(), start, end)
  107. require.ErrorIs(t, err, sql.ErrConnDone)
  108. require.NoError(t, mock.ExpectationsWereMet())
  109. }
  110. func TestDashboardAggregationRepositoryRecomputeRangeRebuildsGroupRollupsBeforeCommit(t *testing.T) {
  111. setGroupUsageRollupTestTimezone(t)
  112. db, mock := newSQLMock(t)
  113. repo := newDashboardAggregationRepositoryWithSQL(db)
  114. start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
  115. end := start.Add(time.Hour)
  116. fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
  117. repo.clock = func() time.Time { return fixedNow }
  118. todayStart := service.GroupUsageTodayStart(fixedNow)
  119. startDate := service.GroupUsageDate(start)
  120. rebuildStart, err := service.ParseGroupUsageDate(startDate)
  121. require.NoError(t, err)
  122. mock.ExpectBegin()
  123. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  124. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  125. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  126. WithArgs(start, "Asia/Shanghai").
  127. WillReturnResult(sqlmock.NewResult(0, 1))
  128. for _, query := range []string{
  129. `DELETE FROM usage_dashboard_hourly WHERE`,
  130. `DELETE FROM usage_dashboard_hourly_users WHERE`,
  131. `DELETE FROM usage_dashboard_daily WHERE`,
  132. `DELETE FROM usage_dashboard_daily_users WHERE`,
  133. `INSERT INTO usage_dashboard_hourly_users`,
  134. `INSERT INTO usage_dashboard_daily_users`,
  135. `INSERT INTO usage_dashboard_hourly`,
  136. `INSERT INTO usage_dashboard_daily`,
  137. } {
  138. mock.ExpectExec(query).WillReturnResult(sqlmock.NewResult(0, 1))
  139. }
  140. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  141. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  142. AddRow(startDate, time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  143. mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`).
  144. WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(start))
  145. mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`).
  146. WithArgs(startDate, startDate, service.GroupUsageDate(todayStart)).
  147. WillReturnResult(sqlmock.NewResult(0, 1))
  148. mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`).
  149. WithArgs(rebuildStart.UTC(), todayStart.UTC(), "Asia/Shanghai").
  150. WillReturnResult(sqlmock.NewResult(0, 1))
  151. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  152. WithArgs(service.GroupUsageDate(todayStart), start, "Asia/Shanghai").
  153. WillReturnResult(sqlmock.NewResult(0, 1))
  154. mock.ExpectCommit()
  155. require.NoError(t, repo.RecomputeRange(context.Background(), start, end))
  156. require.NoError(t, mock.ExpectationsWereMet())
  157. }
  158. func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedInvalidatesEachBatchAndSyncs(t *testing.T) {
  159. setGroupUsageRollupTestTimezone(t)
  160. db, mock := newSQLMock(t)
  161. repo := newDashboardAggregationRepositoryWithSQL(db)
  162. cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
  163. earliestDeletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC)
  164. fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
  165. repo.clock = func() time.Time { return fixedNow }
  166. todayStart := service.GroupUsageTodayStart(fixedNow)
  167. mock.ExpectQuery(`SELECT EXISTS`).
  168. WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
  169. mock.ExpectBegin()
  170. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  171. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  172. mock.ExpectQuery(`(?s)DELETE FROM usage_logs.*RETURNING created_at`).
  173. WithArgs(cutoff, usageLogsCleanupBatchSize).
  174. WillReturnRows(sqlmock.NewRows([]string{"created_at"}).
  175. AddRow(earliestDeletedAt.Add(time.Hour)).
  176. AddRow(earliestDeletedAt))
  177. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  178. WithArgs(earliestDeletedAt, "Asia/Shanghai").
  179. WillReturnResult(sqlmock.NewResult(0, 1))
  180. mock.ExpectCommit()
  181. mock.ExpectBegin()
  182. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  183. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  184. AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  185. mock.ExpectCommit()
  186. require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff))
  187. require.NoError(t, mock.ExpectationsWereMet())
  188. }
  189. func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionedSortsAndInvalidatesEachDropBeforeSync(t *testing.T) {
  190. setGroupUsageRollupTestTimezone(t)
  191. db, mock := newSQLMock(t)
  192. repo := newDashboardAggregationRepositoryWithSQL(db)
  193. cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC)
  194. aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  195. juneStart := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
  196. fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC)
  197. repo.clock = func() time.Time { return fixedNow }
  198. todayStart := service.GroupUsageTodayStart(fixedNow)
  199. mock.ExpectQuery(`SELECT EXISTS`).
  200. WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
  201. mock.ExpectQuery(`SELECT c.relname`).
  202. WillReturnRows(sqlmock.NewRows([]string{"relname"}).
  203. AddRow("usage_logs_202606").
  204. AddRow("usage_logs_invalid").
  205. AddRow("usage_logs_202604").
  206. AddRow("usage_logs_202607"))
  207. for _, partition := range []struct {
  208. name string
  209. start time.Time
  210. }{
  211. {name: "usage_logs_202604", start: aprilStart},
  212. {name: "usage_logs_202606", start: juneStart},
  213. } {
  214. mock.ExpectBegin()
  215. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  216. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  217. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  218. WithArgs(partition.start, "Asia/Shanghai").
  219. WillReturnResult(sqlmock.NewResult(0, 1))
  220. mock.ExpectExec(`DROP TABLE IF EXISTS "` + partition.name + `"`).
  221. WillReturnResult(sqlmock.NewResult(0, 0))
  222. mock.ExpectCommit()
  223. }
  224. mock.ExpectBegin()
  225. mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`).
  226. WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}).
  227. AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai"))
  228. mock.ExpectCommit()
  229. require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff))
  230. require.NoError(t, mock.ExpectationsWereMet())
  231. }
  232. func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedFailureRollsBackWithoutSync(t *testing.T) {
  233. setGroupUsageRollupTestTimezone(t)
  234. db, mock := newSQLMock(t)
  235. repo := newDashboardAggregationRepositoryWithSQL(db)
  236. cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
  237. deletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC)
  238. mock.ExpectQuery(`SELECT EXISTS`).
  239. WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
  240. mock.ExpectBegin()
  241. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  242. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  243. mock.ExpectQuery(`(?s)SELECT ctid.*ORDER BY created_at ASC, id ASC.*DELETE FROM usage_logs.*RETURNING created_at`).
  244. WithArgs(cutoff, usageLogsCleanupBatchSize).
  245. WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(deletedAt))
  246. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  247. WithArgs(deletedAt, "Asia/Shanghai").
  248. WillReturnError(sql.ErrConnDone)
  249. mock.ExpectRollback()
  250. err := repo.CleanupUsageLogs(context.Background(), cutoff)
  251. require.ErrorIs(t, err, sql.ErrConnDone)
  252. require.NoError(t, mock.ExpectationsWereMet())
  253. }
  254. func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionFailureRollsBackAndStops(t *testing.T) {
  255. setGroupUsageRollupTestTimezone(t)
  256. db, mock := newSQLMock(t)
  257. repo := newDashboardAggregationRepositoryWithSQL(db)
  258. cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC)
  259. aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  260. dropErr := errors.New("drop partition failed")
  261. mock.ExpectQuery(`SELECT EXISTS`).
  262. WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
  263. mock.ExpectQuery(`SELECT c.relname`).
  264. WillReturnRows(sqlmock.NewRows([]string{"relname"}).
  265. AddRow("usage_logs_202606").
  266. AddRow("usage_logs_202604"))
  267. mock.ExpectBegin()
  268. mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`).
  269. WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
  270. mock.ExpectExec(`UPDATE usage_group_rollup_state`).
  271. WithArgs(aprilStart, "Asia/Shanghai").
  272. WillReturnResult(sqlmock.NewResult(0, 1))
  273. mock.ExpectExec(`DROP TABLE IF EXISTS "usage_logs_202604"`).
  274. WillReturnError(dropErr)
  275. mock.ExpectRollback()
  276. err := repo.CleanupUsageLogs(context.Background(), cutoff)
  277. require.ErrorIs(t, err, dropErr)
  278. require.NoError(t, mock.ExpectationsWereMet())
  279. }
  280. func setGroupUsageRollupTestTimezone(t *testing.T) {
  281. t.Helper()
  282. useGroupUsageRepositoryTestTimezone(t, "Asia/Shanghai")
  283. }