account_handler_list_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. package admin
  2. import (
  3. "bytes"
  4. "compress/gzip"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "strconv"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
  17. router, adminSvc := setupAccountListRouter()
  18. now := time.Now().UTC()
  19. groupID := int64(77)
  20. adminSvc.accounts = []service.Account{{
  21. ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
  22. Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
  23. Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
  24. Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
  25. Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
  26. AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
  27. CreatedAt: now, UpdatedAt: now,
  28. }}
  29. rec := httptest.NewRecorder()
  30. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
  31. router.ServeHTTP(rec, req)
  32. require.Equal(t, http.StatusOK, rec.Code)
  33. require.NotEmpty(t, rec.Header().Get("ETag"))
  34. var litePayload struct {
  35. Data struct {
  36. Items []map[string]any `json:"items"`
  37. } `json:"data"`
  38. }
  39. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
  40. require.Len(t, litePayload.Data.Items, 1)
  41. liteItem := litePayload.Data.Items[0]
  42. require.Equal(t, float64(501), liteItem["id"])
  43. require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
  44. require.Equal(t, true, liteItem["schedulable"])
  45. require.NotContains(t, liteItem, "groups")
  46. require.NotContains(t, liteItem, "account_groups")
  47. credentials, ok := liteItem["credentials"].(map[string]any)
  48. require.True(t, ok)
  49. require.Equal(t, "compact@example.com", credentials["email"])
  50. require.NotContains(t, credentials, "access_token")
  51. credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
  52. require.True(t, ok)
  53. require.Equal(t, true, credentialsStatus["has_access_token"])
  54. // The ETag must represent the same compact body and return 304 on refresh.
  55. rec304 := httptest.NewRecorder()
  56. req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
  57. req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
  58. router.ServeHTTP(rec304, req304)
  59. require.Equal(t, http.StatusNotModified, rec304.Code)
  60. // Omitting lite preserves the legacy full response shape.
  61. recFull := httptest.NewRecorder()
  62. reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
  63. router.ServeHTTP(recFull, reqFull)
  64. require.Equal(t, http.StatusOK, recFull.Code)
  65. var fullPayload struct {
  66. Data struct {
  67. Items []map[string]any `json:"items"`
  68. } `json:"data"`
  69. }
  70. require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
  71. require.Contains(t, fullPayload.Data.Items[0], "groups")
  72. require.Contains(t, fullPayload.Data.Items[0], "account_groups")
  73. }
  74. func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
  75. router, adminSvc := setupAccountListRouter()
  76. now := time.Now().UTC()
  77. accounts := make([]service.Account, 20)
  78. for i := range accounts {
  79. id := int64(600 + i)
  80. groupID := int64(800 + i)
  81. accounts[i] = service.Account{
  82. ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
  83. Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
  84. Concurrency: 4, GroupIDs: []int64{groupID},
  85. Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
  86. AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
  87. CreatedAt: now, UpdatedAt: now,
  88. }
  89. }
  90. adminSvc.accounts = accounts
  91. rec := httptest.NewRecorder()
  92. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
  93. router.ServeHTTP(rec, req)
  94. require.Equal(t, http.StatusOK, rec.Code)
  95. require.Less(t, rec.Body.Len(), 80*1024)
  96. var compressed bytes.Buffer
  97. zw := gzip.NewWriter(&compressed)
  98. _, err := zw.Write(rec.Body.Bytes())
  99. require.NoError(t, err)
  100. require.NoError(t, zw.Close())
  101. require.Less(t, compressed.Len(), 15*1024)
  102. }
  103. func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
  104. gin.SetMode(gin.TestMode)
  105. router := gin.New()
  106. adminSvc := newStubAdminService()
  107. handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  108. router.GET("/api/v1/admin/accounts", handler.List)
  109. return router, adminSvc
  110. }
  111. func TestAccountHandlerListIncludesCreatedAt(t *testing.T) {
  112. router, adminSvc := setupAccountListRouter()
  113. rec := httptest.NewRecorder()
  114. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&sort_by=created_at&sort_order=desc", nil)
  115. router.ServeHTTP(rec, req)
  116. require.Equal(t, http.StatusOK, rec.Code)
  117. require.Equal(t, "created_at", adminSvc.lastListAccounts.sortBy)
  118. var payload struct {
  119. Data struct {
  120. Items []struct {
  121. ID int64 `json:"id"`
  122. CreatedAt string `json:"created_at"`
  123. } `json:"items"`
  124. } `json:"data"`
  125. }
  126. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  127. require.Len(t, payload.Data.Items, 1)
  128. createdAt := payload.Data.Items[0].CreatedAt
  129. require.NotEmpty(t, createdAt)
  130. require.True(t, strings.HasSuffix(createdAt, "Z"), "created_at should be serialized as UTC")
  131. parsed, err := time.Parse(time.RFC3339Nano, createdAt)
  132. require.NoError(t, err)
  133. _, offset := parsed.Zone()
  134. require.Equal(t, 0, offset)
  135. }
  136. func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) {
  137. router, adminSvc := setupAccountListRouter()
  138. now := time.Now().UTC()
  139. groupID := int64(41)
  140. adminSvc.accounts = []service.Account{
  141. {
  142. ID: 101,
  143. Name: "account-high-priority",
  144. Platform: service.PlatformOpenAI,
  145. Type: service.AccountTypeAPIKey,
  146. Status: service.StatusActive,
  147. Schedulable: true,
  148. Concurrency: 10,
  149. Priority: 1,
  150. AccountGroups: []service.AccountGroup{
  151. {AccountID: 101, GroupID: groupID, Priority: 100, Group: &service.Group{ID: groupID, Name: "openai"}},
  152. },
  153. GroupIDs: []int64{groupID},
  154. CreatedAt: now,
  155. UpdatedAt: now,
  156. },
  157. {
  158. ID: 102,
  159. Name: "account-low-priority",
  160. Platform: service.PlatformOpenAI,
  161. Type: service.AccountTypeAPIKey,
  162. Status: service.StatusActive,
  163. Schedulable: true,
  164. Concurrency: 10,
  165. Priority: 100000,
  166. AccountGroups: []service.AccountGroup{
  167. {AccountID: 102, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
  168. },
  169. GroupIDs: []int64{groupID},
  170. CreatedAt: now,
  171. UpdatedAt: now,
  172. },
  173. }
  174. rec := httptest.NewRecorder()
  175. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai&include_scheduler_score=1", nil)
  176. router.ServeHTTP(rec, req)
  177. require.Equal(t, http.StatusOK, rec.Code)
  178. var payload struct {
  179. Data struct {
  180. Items []struct {
  181. ID int64 `json:"id"`
  182. SchedulerScore struct {
  183. BaseScore float64 `json:"base_score"`
  184. } `json:"scheduler_score"`
  185. SchedulerScores []struct {
  186. GroupID *int64 `json:"group_id"`
  187. GroupName string `json:"group_name"`
  188. GroupPriority *int `json:"group_priority"`
  189. BaseScore float64 `json:"base_score"`
  190. } `json:"scheduler_scores"`
  191. } `json:"items"`
  192. } `json:"data"`
  193. }
  194. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  195. require.Len(t, payload.Data.Items, 2)
  196. var high, low *struct {
  197. ID int64 `json:"id"`
  198. SchedulerScore struct {
  199. BaseScore float64 `json:"base_score"`
  200. } `json:"scheduler_score"`
  201. SchedulerScores []struct {
  202. GroupID *int64 `json:"group_id"`
  203. GroupName string `json:"group_name"`
  204. GroupPriority *int `json:"group_priority"`
  205. BaseScore float64 `json:"base_score"`
  206. } `json:"scheduler_scores"`
  207. }
  208. for i := range payload.Data.Items {
  209. item := &payload.Data.Items[i]
  210. switch item.ID {
  211. case 101:
  212. high = item
  213. case 102:
  214. low = item
  215. }
  216. }
  217. require.NotNil(t, high)
  218. require.NotNil(t, low)
  219. require.Len(t, high.SchedulerScores, 1)
  220. require.Len(t, low.SchedulerScores, 1)
  221. require.Equal(t, groupID, *high.SchedulerScores[0].GroupID)
  222. require.Equal(t, "openai", high.SchedulerScores[0].GroupName)
  223. require.Equal(t, 100, *high.SchedulerScores[0].GroupPriority)
  224. require.Equal(t, 1, *low.SchedulerScores[0].GroupPriority)
  225. require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore)
  226. }
  227. func TestAccountHandlerListSkipsSchedulerScoresByDefault(t *testing.T) {
  228. router, adminSvc := setupAccountListRouter()
  229. now := time.Now().UTC()
  230. adminSvc.accounts = []service.Account{
  231. {
  232. ID: 110,
  233. Name: "openai-account",
  234. Platform: service.PlatformOpenAI,
  235. Type: service.AccountTypeAPIKey,
  236. Status: service.StatusActive,
  237. Schedulable: true,
  238. Concurrency: 10,
  239. Priority: 1,
  240. CreatedAt: now,
  241. UpdatedAt: now,
  242. },
  243. }
  244. rec := httptest.NewRecorder()
  245. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil)
  246. router.ServeHTTP(rec, req)
  247. require.Equal(t, http.StatusOK, rec.Code)
  248. require.Zero(t, adminSvc.schedulerScoreFilterCalls)
  249. require.Zero(t, adminSvc.openAISchedulerScorePoolCalls)
  250. var payload struct {
  251. Data struct {
  252. Items []map[string]any `json:"items"`
  253. } `json:"data"`
  254. }
  255. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  256. require.Len(t, payload.Data.Items, 1)
  257. require.NotContains(t, payload.Data.Items[0], "scheduler_score")
  258. require.NotContains(t, payload.Data.Items[0], "scheduler_scores")
  259. }
  260. func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) {
  261. router, adminSvc := setupAccountListRouter()
  262. now := time.Now().UTC()
  263. groupID := int64(42)
  264. visibleAccount := service.Account{
  265. ID: 201,
  266. Name: "visible-low-priority",
  267. Platform: service.PlatformOpenAI,
  268. Type: service.AccountTypeAPIKey,
  269. Status: service.StatusActive,
  270. Schedulable: true,
  271. Concurrency: 10,
  272. Priority: 100000,
  273. AccountGroups: []service.AccountGroup{
  274. {AccountID: 201, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
  275. },
  276. GroupIDs: []int64{groupID},
  277. CreatedAt: now,
  278. UpdatedAt: now,
  279. }
  280. hiddenGroupPeer := service.Account{
  281. ID: 202,
  282. Name: "hidden-high-priority",
  283. Platform: service.PlatformOpenAI,
  284. Type: service.AccountTypeAPIKey,
  285. Status: service.StatusActive,
  286. Schedulable: true,
  287. Concurrency: 10,
  288. Priority: 1,
  289. AccountGroups: []service.AccountGroup{
  290. {AccountID: 202, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "openai"}},
  291. },
  292. GroupIDs: []int64{groupID},
  293. CreatedAt: now,
  294. UpdatedAt: now,
  295. }
  296. adminSvc.accounts = []service.Account{visibleAccount}
  297. adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
  298. adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
  299. rec := httptest.NewRecorder()
  300. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai&include_scheduler_score=1", nil)
  301. router.ServeHTTP(rec, req)
  302. require.Equal(t, http.StatusOK, rec.Code)
  303. var payload struct {
  304. Data struct {
  305. Items []struct {
  306. ID int64 `json:"id"`
  307. SchedulerScore struct {
  308. BaseScore float64 `json:"base_score"`
  309. } `json:"scheduler_score"`
  310. SchedulerScores []struct {
  311. GroupID *int64 `json:"group_id"`
  312. BaseScore float64 `json:"base_score"`
  313. } `json:"scheduler_scores"`
  314. } `json:"items"`
  315. } `json:"data"`
  316. }
  317. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  318. require.Len(t, payload.Data.Items, 1)
  319. item := payload.Data.Items[0]
  320. require.Equal(t, int64(201), item.ID)
  321. require.Len(t, item.SchedulerScores, 1)
  322. require.Equal(t, groupID, *item.SchedulerScores[0].GroupID)
  323. require.Equal(t, item.SchedulerScores[0].BaseScore, item.SchedulerScore.BaseScore)
  324. }
  325. func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) {
  326. router, adminSvc := setupAccountListRouter()
  327. now := time.Now().UTC()
  328. visibleAccount := service.Account{
  329. ID: 301,
  330. Name: "visible-low-priority",
  331. Platform: service.PlatformOpenAI,
  332. Type: service.AccountTypeAPIKey,
  333. Status: service.StatusActive,
  334. Schedulable: true,
  335. Concurrency: 10,
  336. Priority: 100000,
  337. CreatedAt: now,
  338. UpdatedAt: now,
  339. }
  340. hiddenFilterPeer := service.Account{
  341. ID: 302,
  342. Name: "hidden-high-priority",
  343. Platform: service.PlatformOpenAI,
  344. Type: service.AccountTypeAPIKey,
  345. Status: service.StatusActive,
  346. Schedulable: true,
  347. Concurrency: 10,
  348. Priority: 1,
  349. CreatedAt: now,
  350. UpdatedAt: now,
  351. }
  352. adminSvc.accounts = []service.Account{visibleAccount}
  353. adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer}
  354. rec := httptest.NewRecorder()
  355. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai&include_scheduler_score=1", nil)
  356. router.ServeHTTP(rec, req)
  357. require.Equal(t, http.StatusOK, rec.Code)
  358. var payload struct {
  359. Data struct {
  360. Items []struct {
  361. ID int64 `json:"id"`
  362. SchedulerScore struct {
  363. BaseScore float64 `json:"base_score"`
  364. } `json:"scheduler_score"`
  365. SchedulerScores []struct {
  366. GroupID *int64 `json:"group_id"`
  367. BaseScore float64 `json:"base_score"`
  368. } `json:"scheduler_scores"`
  369. } `json:"items"`
  370. } `json:"data"`
  371. }
  372. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  373. require.Len(t, payload.Data.Items, 1)
  374. require.Equal(t, int64(301), payload.Data.Items[0].ID)
  375. require.Less(t, payload.Data.Items[0].SchedulerScore.BaseScore, 3.75)
  376. require.Empty(t, payload.Data.Items[0].SchedulerScores)
  377. }