migrations_schema_integration_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestMigrationsRunner_ConcurrentInstancesSerializeOnSessionLock(t *testing.T) {
  12. const instances = 2
  13. errorsByInstance := make([]error, instances)
  14. var wg sync.WaitGroup
  15. for i := 0; i < instances; i++ {
  16. wg.Add(1)
  17. go func(index int) {
  18. defer wg.Done()
  19. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  20. defer cancel()
  21. errorsByInstance[index] = ApplyMigrations(ctx, integrationDB)
  22. }(i)
  23. }
  24. wg.Wait()
  25. for i, err := range errorsByInstance {
  26. require.NoErrorf(t, err, "migration instance %d", i)
  27. }
  28. }
  29. func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
  30. tx := testTx(t)
  31. // Re-apply migrations to verify idempotency (no errors, no duplicate rows).
  32. require.NoError(t, ApplyMigrations(context.Background(), integrationDB))
  33. // schema_migrations should have at least the current migration set.
  34. var applied int
  35. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations").Scan(&applied))
  36. require.GreaterOrEqual(t, applied, 7, "expected schema_migrations to contain applied migrations")
  37. // users: columns required by repository queries
  38. requireColumn(t, tx, "users", "username", "character varying", 100, false)
  39. requireColumn(t, tx, "users", "notes", "text", 0, false)
  40. // accounts: schedulable and rate-limit fields
  41. requireColumn(t, tx, "accounts", "notes", "text", 0, true)
  42. requireColumn(t, tx, "accounts", "schedulable", "boolean", 0, false)
  43. requireColumn(t, tx, "accounts", "rate_limited_at", "timestamp with time zone", 0, true)
  44. requireColumn(t, tx, "accounts", "rate_limit_reset_at", "timestamp with time zone", 0, true)
  45. requireColumn(t, tx, "accounts", "overload_until", "timestamp with time zone", 0, true)
  46. requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true)
  47. requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due")
  48. // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。
  49. requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false)
  50. requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false)
  51. requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false)
  52. // api_keys: key length should be 128
  53. requireColumn(t, tx, "api_keys", "key", "character varying", 128, false)
  54. // redeem_codes: subscription fields
  55. requireColumn(t, tx, "redeem_codes", "group_id", "bigint", 0, true)
  56. requireColumn(t, tx, "redeem_codes", "validity_days", "integer", 0, false)
  57. // usage_logs: billing_type used by filters/stats
  58. requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false)
  59. requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false)
  60. requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false)
  61. requireColumn(t, tx, "usage_logs", "native_compaction_v2", "boolean", 0, false)
  62. requireColumnDefaultContains(t, tx, "usage_logs", "native_compaction_v2", "false")
  63. requireColumn(t, tx, "usage_logs", "image_input_size", "character varying", 32, true)
  64. requireColumn(t, tx, "usage_logs", "image_output_size", "character varying", 32, true)
  65. requireColumn(t, tx, "usage_logs", "image_size_source", "character varying", 16, true)
  66. requireColumn(t, tx, "usage_logs", "image_size_breakdown", "jsonb", 0, true)
  67. requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false)
  68. requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true)
  69. requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true)
  70. requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true)
  71. requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true)
  72. requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex)
  73. var mismatchIndexDef string
  74. require.NoError(t, tx.QueryRowContext(context.Background(), `
  75. SELECT pg_get_indexdef(i.indexrelid)
  76. FROM pg_class idx
  77. JOIN pg_index i ON i.indexrelid = idx.oid
  78. JOIN pg_class tbl ON tbl.oid = i.indrelid
  79. JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
  80. WHERE ns.nspname = 'public'
  81. AND tbl.relname = 'usage_logs'
  82. AND idx.relname = $1
  83. `, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef))
  84. require.Contains(t, mismatchIndexDef, "created_at DESC")
  85. require.Contains(t, mismatchIndexDef, "id DESC")
  86. require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)")
  87. requireConstraintDefinitionContains(
  88. t,
  89. tx,
  90. "usage_logs",
  91. "usage_logs_image_size_source_check",
  92. "image_size_source",
  93. "'output'",
  94. "'input'",
  95. "'default'",
  96. "'legacy'",
  97. )
  98. requireConstraintDefinitionContains(
  99. t,
  100. tx,
  101. "usage_logs",
  102. "usage_logs_image_billing_size_check",
  103. "image_count",
  104. "billing_mode",
  105. "'video'",
  106. "video_count",
  107. "image_size IS NOT NULL",
  108. "'1K'",
  109. "'2K'",
  110. "'4K'",
  111. "'mixed'",
  112. )
  113. // usage_billing_dedup: billing idempotency narrow table
  114. var usageBillingDedupRegclass sql.NullString
  115. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.usage_billing_dedup')").Scan(&usageBillingDedupRegclass))
  116. require.True(t, usageBillingDedupRegclass.Valid, "expected usage_billing_dedup table to exist")
  117. requireColumn(t, tx, "usage_billing_dedup", "request_fingerprint", "character varying", 64, false)
  118. requireIndex(t, tx, "usage_billing_dedup", "idx_usage_billing_dedup_request_api_key")
  119. requireIndex(t, tx, "usage_billing_dedup", "idx_usage_billing_dedup_created_at_brin")
  120. var usageBillingDedupArchiveRegclass sql.NullString
  121. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.usage_billing_dedup_archive')").Scan(&usageBillingDedupArchiveRegclass))
  122. require.True(t, usageBillingDedupArchiveRegclass.Valid, "expected usage_billing_dedup_archive table to exist")
  123. requireColumn(t, tx, "usage_billing_dedup_archive", "request_fingerprint", "character varying", 64, false)
  124. requireIndex(t, tx, "usage_billing_dedup_archive", "usage_billing_dedup_archive_pkey")
  125. // settings table should exist
  126. var settingsRegclass sql.NullString
  127. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.settings')").Scan(&settingsRegclass))
  128. require.True(t, settingsRegclass.Valid, "expected settings table to exist")
  129. // security_secrets table should exist
  130. var securitySecretsRegclass sql.NullString
  131. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.security_secrets')").Scan(&securitySecretsRegclass))
  132. require.True(t, securitySecretsRegclass.Valid, "expected security_secrets table to exist")
  133. // scheduler_outbox pending dedup support
  134. requireColumn(t, tx, "scheduler_outbox", "dedup_key", "text", 0, true)
  135. requireIndex(t, tx, "scheduler_outbox", "idx_scheduler_outbox_pending_dedup_key")
  136. // ops_system_logs: API key id index for operational log triage
  137. requireColumn(t, tx, "ops_system_logs", "api_key_id", "bigint", 0, true)
  138. requireIndex(t, tx, "ops_system_logs", "idx_ops_system_logs_api_key_id_created_at")
  139. // Bounded ingress rejection security aggregates.
  140. requireColumn(t, tx, "ops_ingress_reject_aggregates", "bucket_start", "timestamp with time zone", 0, false)
  141. requireColumn(t, tx, "ops_ingress_reject_aggregates", "client_ip", "inet", 0, false)
  142. requireColumn(t, tx, "ops_ingress_reject_aggregates", "request_count", "bigint", 0, false)
  143. requireIndex(t, tx, "ops_ingress_reject_aggregates", "idx_ops_ingress_reject_aggregates_bucket")
  144. requireIndex(t, tx, "ops_ingress_reject_aggregates", "idx_ops_ingress_reject_aggregates_ip_bucket")
  145. // user_allowed_groups table should exist
  146. var uagRegclass sql.NullString
  147. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.user_allowed_groups')").Scan(&uagRegclass))
  148. require.True(t, uagRegclass.Valid, "expected user_allowed_groups table to exist")
  149. // user_subscriptions: deleted_at for soft delete support (migration 012)
  150. requireColumn(t, tx, "user_subscriptions", "deleted_at", "timestamp with time zone", 0, true)
  151. // orphan_allowed_groups_audit table should exist (migration 013)
  152. var orphanAuditRegclass sql.NullString
  153. require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.orphan_allowed_groups_audit')").Scan(&orphanAuditRegclass))
  154. require.True(t, orphanAuditRegclass.Valid, "expected orphan_allowed_groups_audit table to exist")
  155. // account_groups: created_at should be timestamptz
  156. requireColumn(t, tx, "account_groups", "created_at", "timestamp with time zone", 0, false)
  157. // user_allowed_groups: created_at should be timestamptz
  158. requireColumn(t, tx, "user_allowed_groups", "created_at", "timestamp with time zone", 0, false)
  159. }
  160. func TestMigrationsRunner_AuthIdentityAndPaymentSchemaStayAligned(t *testing.T) {
  161. tx := testTx(t)
  162. requireColumn(t, tx, "auth_identity_migration_reports", "report_type", "character varying", 80, false)
  163. requireColumn(t, tx, "users", "signup_source", "character varying", 20, false)
  164. requireColumnDefaultContains(t, tx, "users", "signup_source", "email")
  165. requireConstraintDefinitionContains(
  166. t,
  167. tx,
  168. "users",
  169. "users_signup_source_check",
  170. "signup_source",
  171. "'email'",
  172. "'linuxdo'",
  173. "'wechat'",
  174. "'oidc'",
  175. )
  176. requireForeignKeyOnDelete(t, tx, "auth_identities", "user_id", "users", "CASCADE")
  177. requireForeignKeyOnDelete(t, tx, "auth_identity_channels", "identity_id", "auth_identities", "CASCADE")
  178. requireForeignKeyOnDelete(t, tx, "pending_auth_sessions", "target_user_id", "users", "SET NULL")
  179. requireForeignKeyOnDelete(t, tx, "identity_adoption_decisions", "pending_auth_session_id", "pending_auth_sessions", "CASCADE")
  180. requireForeignKeyOnDelete(t, tx, "identity_adoption_decisions", "identity_id", "auth_identities", "SET NULL")
  181. requireIndex(t, tx, "payment_orders", "paymentorder_out_trade_no")
  182. requirePartialUniqueIndexDefinition(t, tx, "payment_orders", "paymentorder_out_trade_no", "out_trade_no", "WHERE")
  183. requireIndexAbsent(t, tx, "payment_orders", "paymentorder_out_trade_no_unique")
  184. }
  185. func requireIndex(t *testing.T, tx *sql.Tx, table, index string) {
  186. t.Helper()
  187. var exists bool
  188. err := tx.QueryRowContext(context.Background(), `
  189. SELECT EXISTS (
  190. SELECT 1
  191. FROM pg_indexes
  192. WHERE schemaname = 'public'
  193. AND tablename = $1
  194. AND indexname = $2
  195. )
  196. `, table, index).Scan(&exists)
  197. require.NoError(t, err, "query pg_indexes for %s.%s", table, index)
  198. require.True(t, exists, "expected index %s on %s", index, table)
  199. }
  200. func requireIndexAbsent(t *testing.T, tx *sql.Tx, table, index string) {
  201. t.Helper()
  202. var exists bool
  203. err := tx.QueryRowContext(context.Background(), `
  204. SELECT EXISTS (
  205. SELECT 1
  206. FROM pg_indexes
  207. WHERE schemaname = 'public'
  208. AND tablename = $1
  209. AND indexname = $2
  210. )
  211. `, table, index).Scan(&exists)
  212. require.NoError(t, err, "query pg_indexes for %s.%s", table, index)
  213. require.False(t, exists, "expected index %s on %s to be absent", index, table)
  214. }
  215. func requirePartialUniqueIndexDefinition(t *testing.T, tx *sql.Tx, table, index string, fragments ...string) {
  216. t.Helper()
  217. var (
  218. unique bool
  219. def string
  220. )
  221. err := tx.QueryRowContext(context.Background(), `
  222. SELECT
  223. i.indisunique,
  224. pg_get_indexdef(i.indexrelid)
  225. FROM pg_class idx
  226. JOIN pg_index i ON i.indexrelid = idx.oid
  227. JOIN pg_class tbl ON tbl.oid = i.indrelid
  228. JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
  229. WHERE ns.nspname = 'public'
  230. AND tbl.relname = $1
  231. AND idx.relname = $2
  232. `, table, index).Scan(&unique, &def)
  233. require.NoError(t, err, "query index definition for %s.%s", table, index)
  234. require.True(t, unique, "expected index %s on %s to be unique", index, table)
  235. for _, fragment := range fragments {
  236. require.Contains(t, def, fragment, "expected index definition for %s.%s to contain %q", table, index, fragment)
  237. }
  238. }
  239. func requireForeignKeyOnDelete(t *testing.T, tx *sql.Tx, table, column, refTable, expected string) {
  240. t.Helper()
  241. var actual string
  242. err := tx.QueryRowContext(context.Background(), `
  243. SELECT CASE c.confdeltype
  244. WHEN 'a' THEN 'NO ACTION'
  245. WHEN 'r' THEN 'RESTRICT'
  246. WHEN 'c' THEN 'CASCADE'
  247. WHEN 'n' THEN 'SET NULL'
  248. WHEN 'd' THEN 'SET DEFAULT'
  249. END
  250. FROM pg_constraint c
  251. JOIN pg_class tbl ON tbl.oid = c.conrelid
  252. JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
  253. JOIN pg_class ref_tbl ON ref_tbl.oid = c.confrelid
  254. JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = ANY(c.conkey)
  255. WHERE ns.nspname = 'public'
  256. AND c.contype = 'f'
  257. AND tbl.relname = $1
  258. AND attr.attname = $2
  259. AND ref_tbl.relname = $3
  260. LIMIT 1
  261. `, table, column, refTable).Scan(&actual)
  262. require.NoError(t, err, "query foreign key action for %s.%s -> %s", table, column, refTable)
  263. require.Equal(t, expected, actual, "unexpected ON DELETE action for %s.%s -> %s", table, column, refTable)
  264. }
  265. func requireConstraintDefinitionContains(t *testing.T, tx *sql.Tx, table, constraint string, fragments ...string) {
  266. t.Helper()
  267. var def string
  268. err := tx.QueryRowContext(context.Background(), `
  269. SELECT pg_get_constraintdef(c.oid)
  270. FROM pg_constraint c
  271. JOIN pg_class tbl ON tbl.oid = c.conrelid
  272. JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
  273. WHERE ns.nspname = 'public'
  274. AND tbl.relname = $1
  275. AND c.conname = $2
  276. `, table, constraint).Scan(&def)
  277. require.NoError(t, err, "query constraint definition for %s.%s", table, constraint)
  278. for _, fragment := range fragments {
  279. require.Contains(t, def, fragment, "expected constraint definition for %s.%s to contain %q", table, constraint, fragment)
  280. }
  281. }
  282. func requireColumnDefaultContains(t *testing.T, tx *sql.Tx, table, column string, fragments ...string) {
  283. t.Helper()
  284. var columnDefault sql.NullString
  285. err := tx.QueryRowContext(context.Background(), `
  286. SELECT column_default
  287. FROM information_schema.columns
  288. WHERE table_schema = 'public'
  289. AND table_name = $1
  290. AND column_name = $2
  291. `, table, column).Scan(&columnDefault)
  292. require.NoError(t, err, "query column_default for %s.%s", table, column)
  293. require.True(t, columnDefault.Valid, "expected column_default for %s.%s", table, column)
  294. for _, fragment := range fragments {
  295. require.Contains(t, columnDefault.String, fragment, "expected default for %s.%s to contain %q", table, column, fragment)
  296. }
  297. }
  298. func requireColumn(t *testing.T, tx *sql.Tx, table, column, dataType string, maxLen int, nullable bool) {
  299. t.Helper()
  300. var row struct {
  301. DataType string
  302. MaxLen sql.NullInt64
  303. Nullable string
  304. }
  305. err := tx.QueryRowContext(context.Background(), `
  306. SELECT
  307. data_type,
  308. character_maximum_length,
  309. is_nullable
  310. FROM information_schema.columns
  311. WHERE table_schema = 'public'
  312. AND table_name = $1
  313. AND column_name = $2
  314. `, table, column).Scan(&row.DataType, &row.MaxLen, &row.Nullable)
  315. require.NoError(t, err, "query information_schema.columns for %s.%s", table, column)
  316. require.Equal(t, dataType, row.DataType, "data_type mismatch for %s.%s", table, column)
  317. if maxLen > 0 {
  318. require.True(t, row.MaxLen.Valid, "expected maxLen for %s.%s", table, column)
  319. require.Equal(t, int64(maxLen), row.MaxLen.Int64, "maxLen mismatch for %s.%s", table, column)
  320. }
  321. if nullable {
  322. require.Equal(t, "YES", row.Nullable, "nullable mismatch for %s.%s", table, column)
  323. } else {
  324. require.Equal(t, "NO", row.Nullable, "nullable mismatch for %s.%s", table, column)
  325. }
  326. }