intelligent_test_integration_test.go 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "net/url"
  7. "os"
  8. "strings"
  9. "sync"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/google/uuid"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func intelligentTestDB(t *testing.T) *sql.DB {
  16. t.Helper()
  17. dsn := os.Getenv("INTELLIGENT_TESTS_TEST_DSN")
  18. if dsn == "" {
  19. if os.Getenv("REQUIRE_DATABASE_TESTS") == "1" {
  20. t.Fatal("INTELLIGENT_TESTS_TEST_DSN is required in the database CI job")
  21. }
  22. t.Skip("INTELLIGENT_TESTS_TEST_DSN must point to an isolated PostgreSQL test database")
  23. }
  24. parsed, err := url.Parse(dsn)
  25. require.NoError(t, err)
  26. require.True(t, parsed.Scheme == "postgres" || parsed.Scheme == "postgresql", "test DSN must be a PostgreSQL URL")
  27. admin, err := sql.Open("postgres", dsn)
  28. require.NoError(t, err)
  29. require.NoError(t, admin.Ping())
  30. schema := "it_" + strings.ReplaceAll(uuid.NewString(), "-", "")
  31. _, err = admin.Exec(`CREATE SCHEMA ` + schema)
  32. require.NoError(t, err)
  33. q := parsed.Query()
  34. q.Set("search_path", schema)
  35. q.Set("connect_timeout", "10")
  36. q.Set("statement_timeout", "30000")
  37. parsed.RawQuery = q.Encode()
  38. db, err := sql.Open("postgres", parsed.String())
  39. require.NoError(t, err)
  40. db.SetMaxOpenConns(4)
  41. db.SetMaxIdleConns(4)
  42. t.Cleanup(func() {
  43. _ = db.Close()
  44. _, dropErr := admin.Exec(`DROP SCHEMA ` + schema + ` CASCADE`)
  45. if dropErr != nil {
  46. t.Error(dropErr)
  47. }
  48. _ = admin.Close()
  49. })
  50. _, err = db.Exec(`CREATE TABLE users(id BIGINT PRIMARY KEY,email TEXT NOT NULL DEFAULT '',role TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'active',deleted_at TIMESTAMPTZ,restrict_public_groups BOOLEAN NOT NULL DEFAULT false);
  51. CREATE TABLE accounts(id BIGINT PRIMARY KEY,name TEXT NOT NULL DEFAULT '',notes TEXT,platform TEXT NOT NULL DEFAULT 'openai',type TEXT NOT NULL DEFAULT 'apikey',status TEXT NOT NULL DEFAULT 'active',extra JSONB NOT NULL DEFAULT '{}',deleted_at TIMESTAMPTZ);
  52. CREATE TABLE groups(id BIGINT PRIMARY KEY,is_exclusive BOOLEAN NOT NULL DEFAULT false,status TEXT NOT NULL DEFAULT 'active',subscription_type TEXT NOT NULL DEFAULT 'standard',deleted_at TIMESTAMPTZ);
  53. CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT);
  54. CREATE TABLE user_allowed_groups(user_id BIGINT,group_id BIGINT);
  55. CREATE TABLE user_subscriptions(user_id BIGINT,group_id BIGINT,status TEXT,expires_at TIMESTAMPTZ,deleted_at TIMESTAMPTZ);
  56. CREATE TABLE audit_logs(id BIGSERIAL PRIMARY KEY,actor_user_id BIGINT,actor_email TEXT,actor_role TEXT,action TEXT,method TEXT,path TEXT,status_code INTEGER,extra JSONB);
  57. INSERT INTO users(id,email,role) VALUES(1,'admin@test','super_admin'),(2,'user@test','user'),(3,'other@test','user');
  58. INSERT INTO accounts(id,name,extra) VALUES(10,'Pro #10','{"anti_degradation":true}'),(11,'Pro #11','{"anti_degradation":false}'),(12,'Legacy','{"anti_degrade":{"enabled":true}}');
  59. INSERT INTO groups(id,is_exclusive) VALUES(100,true),(101,true);
  60. INSERT INTO account_groups VALUES(10,100),(11,101),(12,100);
  61. INSERT INTO user_allowed_groups VALUES(2,100),(3,101);`)
  62. require.NoError(t, err)
  63. migration, err := os.ReadFile("../../migrations/247_intelligent_tests.sql")
  64. require.NoError(t, err)
  65. _, err = db.Exec(string(migration))
  66. require.NoError(t, err)
  67. _, err = db.Exec(string(migration))
  68. require.NoError(t, err, "migration must be safely repeatable")
  69. migration, err = os.ReadFile("../../migrations/248_intelligent_assessment_v2.sql")
  70. require.NoError(t, err)
  71. _, err = db.Exec(string(migration))
  72. require.NoError(t, err)
  73. _, err = db.Exec(string(migration))
  74. require.NoError(t, err, "assessment migration must be safely repeatable")
  75. return db
  76. }
  77. func TestIntelligentRepositoryIntegration(t *testing.T) {
  78. db := intelligentTestDB(t)
  79. repo := &intelligentTestRepository{db: db}
  80. ctx := context.Background()
  81. ok, err := repo.IsAdmin(ctx, 1)
  82. require.NoError(t, err)
  83. require.True(t, ok)
  84. ok, err = repo.IsAdmin(ctx, 2)
  85. require.NoError(t, err)
  86. require.False(t, ok)
  87. svc := service.NewIntelligentTestService(repo, nil)
  88. _, err = svc.Enqueue(ctx, 2, service.IntelligentTestEnqueue{})
  89. require.ErrorIs(t, err, service.ErrIntelligentTestForbidden)
  90. request := service.IntelligentTestEnqueue{AccountIDs: []int64{10, 11, 12}, TestTypes: []string{"pelican", "candy"}, IdempotencyKey: uuid.NewString()}
  91. first, err := repo.Enqueue(ctx, 1, request)
  92. require.NoError(t, err)
  93. require.Len(t, first.Records, 6)
  94. replay, err := repo.Enqueue(ctx, 1, request)
  95. require.NoError(t, err)
  96. require.True(t, replay.Reused)
  97. require.Equal(t, first.Records[0].ID, replay.Records[0].ID)
  98. altered := request
  99. altered.AccountIDs = []int64{10}
  100. _, err = repo.Enqueue(ctx, 1, altered)
  101. require.ErrorIs(t, err, service.ErrIntelligentTestConflict)
  102. // A different idempotency key still cannot schedule duplicate active work.
  103. request.IdempotencyKey = uuid.NewString()
  104. dedup, err := repo.Enqueue(ctx, 1, request)
  105. require.NoError(t, err)
  106. require.True(t, dedup.Reused)
  107. require.Equal(t, first.Records[0].ID, dedup.Records[0].ID)
  108. var total int
  109. require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM account_tests`).Scan(&total))
  110. require.Equal(t, 6, total)
  111. var wg sync.WaitGroup
  112. claims := make(chan *service.IntelligentTestRecord, 8)
  113. failures := make(chan error, 8)
  114. for i := 0; i < 8; i++ {
  115. wg.Add(1)
  116. go func() {
  117. defer wg.Done()
  118. record, err := repo.Claim(ctx)
  119. if err != nil {
  120. failures <- err
  121. }
  122. if record != nil {
  123. claims <- record
  124. }
  125. }()
  126. }
  127. wg.Wait()
  128. close(claims)
  129. close(failures)
  130. for err := range failures {
  131. require.NoError(t, err)
  132. }
  133. byAccount := map[int64]*service.IntelligentTestRecord{}
  134. for record := range claims {
  135. require.Nil(t, byAccount[record.AccountID], "parallel tests must not collide on one account")
  136. byAccount[record.AccountID] = record
  137. }
  138. require.Len(t, byAccount, 3)
  139. require.True(t, byAccount[10].AntiDegradation)
  140. require.False(t, byAccount[11].AntiDegradation)
  141. require.True(t, byAccount[12].AntiDegradation)
  142. for _, record := range byAccount {
  143. record.Status = "success"
  144. record.Result = "ANSWER: 12"
  145. record.RawResponse = "private raw response"
  146. record.ErrorMessage = "private upstream details"
  147. record.ResultImage = `<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>`
  148. require.NoError(t, repo.Finish(ctx, record))
  149. }
  150. // Results are hidden by default for list, direct ID, and account history.
  151. owned := byAccount[10]
  152. _, err = repo.PublicGet(ctx, 2, owned.ID)
  153. require.ErrorIs(t, err, service.ErrIntelligentTestNotFound)
  154. f := service.IntelligentTestFilter{Page: 1, PageSize: 24}
  155. capabilities, count, err := repo.Capabilities(ctx, 2, f)
  156. require.NoError(t, err)
  157. require.Zero(t, count)
  158. require.Empty(t, capabilities)
  159. settings, err := repo.Settings(ctx)
  160. require.NoError(t, err)
  161. for _, setting := range settings {
  162. setting.UserVisible = true
  163. require.NoError(t, repo.UpdateSetting(ctx, 1, &setting))
  164. }
  165. public, err := repo.PublicGet(ctx, 2, owned.ID)
  166. require.NoError(t, err)
  167. require.Equal(t, "ANSWER: 12", public.Result)
  168. _, err = repo.PublicGet(ctx, 3, owned.ID)
  169. require.ErrorIs(t, err, service.ErrIntelligentTestNotFound, "a visible result still requires group authorization")
  170. capabilities, count, err = repo.Capabilities(ctx, 2, f)
  171. require.NoError(t, err)
  172. require.EqualValues(t, 2, count)
  173. require.Len(t, capabilities, 2)
  174. history, err := repo.PublicRecords(ctx, 3, service.IntelligentTestFilter{AccountID: 10, Page: 1, PageSize: 24})
  175. require.NoError(t, err)
  176. require.Empty(t, history.Items)
  177. // Independent switches: disabling new work does not unpublish history.
  178. for _, setting := range settings {
  179. setting.UserVisible = true
  180. setting.Enabled = false
  181. require.NoError(t, repo.UpdateSetting(ctx, 1, &setting))
  182. }
  183. _, err = repo.PublicGet(ctx, 2, owned.ID)
  184. require.NoError(t, err)
  185. request.IdempotencyKey = uuid.NewString()
  186. _, err = repo.Enqueue(ctx, 1, request)
  187. require.Error(t, err)
  188. for _, setting := range settings {
  189. setting.UserVisible = false
  190. require.NoError(t, repo.UpdateSetting(ctx, 1, &setting))
  191. }
  192. _, err = repo.PublicGet(ctx, 2, owned.ID)
  193. require.ErrorIs(t, err, service.ErrIntelligentTestNotFound)
  194. for _, setting := range settings {
  195. setting.Enabled = true
  196. require.NoError(t, repo.UpdateSetting(ctx, 1, &setting))
  197. }
  198. // A new process sees the same queued jobs. Expired running work becomes failed,
  199. // never silently replayed (the upstream may already have charged it).
  200. restarted := &intelligentTestRepository{db: db}
  201. running, err := restarted.Claim(ctx)
  202. require.NoError(t, err)
  203. require.NotNil(t, running)
  204. _, err = db.Exec(`UPDATE account_tests SET lease_until=NOW()-INTERVAL '1 second' WHERE id=$1`, running.ID)
  205. require.NoError(t, err)
  206. _, err = restarted.Claim(ctx)
  207. require.NoError(t, err)
  208. expired, err := repo.Get(ctx, running.ID)
  209. require.NoError(t, err)
  210. require.Equal(t, "failed", expired.Status)
  211. require.Contains(t, expired.ErrorMessage, "重复计费")
  212. running.Status = "success"
  213. require.Error(t, repo.Finish(ctx, running), "expired worker cannot overwrite recovery result")
  214. // Filters and overview are executed against PostgreSQL, not inferred from SQL text.
  215. accounts, err := repo.Accounts(ctx, service.IntelligentTestFilter{Page: 1, PageSize: 24, Search: "10"})
  216. require.NoError(t, err)
  217. require.EqualValues(t, 1, accounts.Total)
  218. _, err = repo.Accounts(ctx, service.IntelligentTestFilter{Page: 1, PageSize: 24, Status: "success", OnlyAbnormal: true, TestType: "pelican"})
  219. require.NoError(t, err)
  220. _, err = repo.Accounts(ctx, service.IntelligentTestFilter{Page: 1, PageSize: 24, TestType: "candy"})
  221. require.NoError(t, err, "test type alone must not leave unused SQL parameters")
  222. records, err := repo.Records(ctx, f)
  223. require.NoError(t, err)
  224. require.EqualValues(t, 6, records.Total)
  225. for _, record := range records.Items {
  226. require.Empty(t, record.RawResponse)
  227. require.Empty(t, record.Input)
  228. }
  229. var audits int
  230. require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM audit_logs`).Scan(&audits))
  231. require.GreaterOrEqual(t, audits, 2)
  232. var accountsBefore int
  233. require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&accountsBefore))
  234. require.Equal(t, 3, accountsBefore)
  235. t.Log(fmt.Sprintf("verified %d durable records, %d audit entries; isolated schema only", records.Total, audits))
  236. }