account_handler_simple_mode_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. package admin
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type simpleModeAccountService struct {
  16. *stubAdminService
  17. account service.Account
  18. createCalls int
  19. updateCalls int
  20. bulkCalls int
  21. }
  22. func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
  23. return &s.account, nil
  24. }
  25. func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
  26. if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
  27. return nil, err
  28. }
  29. s.createCalls++
  30. return &s.account, nil
  31. }
  32. func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
  33. if input.GroupIDs != nil {
  34. if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
  35. return nil, err
  36. }
  37. }
  38. s.updateCalls++
  39. return &s.account, nil
  40. }
  41. func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
  42. if input.GroupIDs != nil {
  43. if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
  44. return nil, err
  45. }
  46. }
  47. s.bulkCalls++
  48. return &service.BulkUpdateAccountsResult{}, nil
  49. }
  50. func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
  51. for _, id := range groupIDs {
  52. for i := range s.groups {
  53. if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
  54. return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
  55. }
  56. }
  57. }
  58. return nil
  59. }
  60. func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
  61. gin.SetMode(gin.TestMode)
  62. richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
  63. historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
  64. account := service.Account{
  65. ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
  66. GroupIDs: []int64{7, 9},
  67. Groups: []*service.Group{richGroup, historicalComposite},
  68. AccountGroups: []service.AccountGroup{
  69. {AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
  70. {AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
  71. },
  72. }
  73. svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
  74. svc.accounts = []service.Account{account}
  75. h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  76. h.cfg = &config.Config{RunMode: config.RunModeSimple}
  77. r := gin.New()
  78. r.GET("/accounts", h.List)
  79. r.GET("/accounts/:id", h.GetByID)
  80. r.POST("/accounts", h.Create)
  81. r.PUT("/accounts/:id", h.Update)
  82. tests := []struct {
  83. method string
  84. path string
  85. body string
  86. }{
  87. {http.MethodGet, "/accounts", ""},
  88. {http.MethodGet, "/accounts/3", ""},
  89. {http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
  90. {http.MethodPut, "/accounts/3", `{"name":"account"}`},
  91. }
  92. for _, tt := range tests {
  93. t.Run(tt.method+tt.path, func(t *testing.T) {
  94. req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
  95. req.Header.Set("Content-Type", "application/json")
  96. res := httptest.NewRecorder()
  97. r.ServeHTTP(res, req)
  98. require.Equal(t, http.StatusOK, res.Code, res.Body.String())
  99. var payload map[string]any
  100. require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
  101. data, ok := payload["data"].(map[string]any)
  102. require.True(t, ok)
  103. if items, ok := data["items"].([]any); ok {
  104. require.Len(t, items, 1)
  105. data, ok = items[0].(map[string]any)
  106. require.True(t, ok)
  107. }
  108. groups, ok := data["groups"].([]any)
  109. require.True(t, ok)
  110. require.Len(t, groups, 1)
  111. require.Equal(t, []any{float64(7)}, data["group_ids"])
  112. group, ok := groups[0].(map[string]any)
  113. require.True(t, ok)
  114. require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
  115. accountGroups, ok := data["account_groups"].([]any)
  116. require.True(t, ok)
  117. require.Len(t, accountGroups, 1)
  118. accountGroup, ok := accountGroups[0].(map[string]any)
  119. require.True(t, ok)
  120. require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
  121. nestedGroup, ok := accountGroup["group"].(map[string]any)
  122. require.True(t, ok)
  123. require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
  124. })
  125. }
  126. }
  127. func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
  128. richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
  129. historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
  130. account := service.Account{
  131. ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
  132. GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
  133. AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
  134. }
  135. svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
  136. svc.accounts = []service.Account{account}
  137. h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  138. h.cfg = &config.Config{RunMode: config.RunModeSimple}
  139. r := gin.New()
  140. r.GET("/accounts", h.List)
  141. res := httptest.NewRecorder()
  142. r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
  143. require.Equal(t, http.StatusOK, res.Code, res.Body.String())
  144. require.NotEmpty(t, res.Header().Get("ETag"))
  145. var payload struct {
  146. Data struct {
  147. Items []map[string]any `json:"items"`
  148. } `json:"data"`
  149. }
  150. require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
  151. require.Len(t, payload.Data.Items, 1)
  152. require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
  153. require.NotContains(t, payload.Data.Items[0], "groups")
  154. require.NotContains(t, payload.Data.Items[0], "account_groups")
  155. notModified := httptest.NewRecorder()
  156. req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
  157. req.Header.Set("If-None-Match", res.Header().Get("ETag"))
  158. r.ServeHTTP(notModified, req)
  159. require.Equal(t, http.StatusNotModified, notModified.Code)
  160. }
  161. func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
  162. gin.SetMode(gin.TestMode)
  163. for _, tt := range []struct{ name, method, path, body string }{
  164. {"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
  165. {"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
  166. {"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
  167. } {
  168. t.Run(tt.name, func(t *testing.T) {
  169. svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
  170. svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
  171. h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  172. h.cfg = &config.Config{RunMode: config.RunModeSimple}
  173. r := gin.New()
  174. r.POST("/accounts", h.Create)
  175. r.PUT("/accounts/:id", h.Update)
  176. r.POST("/accounts/bulk-update", h.BulkUpdate)
  177. res := httptest.NewRecorder()
  178. req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
  179. req.Header.Set("Content-Type", "application/json")
  180. r.ServeHTTP(res, req)
  181. require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
  182. require.Zero(t, svc.createCalls)
  183. require.Zero(t, svc.updateCalls)
  184. require.Zero(t, svc.bulkCalls)
  185. })
  186. }
  187. }
  188. func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
  189. svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
  190. svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
  191. h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  192. h.cfg = &config.Config{RunMode: config.RunModeSimple}
  193. r := gin.New()
  194. r.POST("/accounts", h.Create)
  195. res := httptest.NewRecorder()
  196. req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
  197. req.Header.Set("Content-Type", "application/json")
  198. r.ServeHTTP(res, req)
  199. require.Equal(t, http.StatusOK, res.Code, res.Body.String())
  200. require.Equal(t, 1, svc.createCalls)
  201. }
  202. func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
  203. svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
  204. svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
  205. h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  206. h.cfg = &config.Config{RunMode: config.RunModeSimple}
  207. r := gin.New()
  208. r.POST("/accounts/batch", h.BatchCreate)
  209. res := httptest.NewRecorder()
  210. req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
  211. req.Header.Set("Content-Type", "application/json")
  212. r.ServeHTTP(res, req)
  213. require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
  214. require.Zero(t, svc.createCalls)
  215. }
  216. func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
  217. group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
  218. h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  219. raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
  220. require.NoError(t, err)
  221. require.Contains(t, string(raw), `"rate_multiplier":9`)
  222. require.Contains(t, string(raw), `"subscription_type":"subscription"`)
  223. }