account_ollama_cloud_usage_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. package admin
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "strconv"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type ollamaCloudUsageHandlerTestRepo struct {
  16. service.AccountRepository
  17. account *service.Account
  18. accounts []*service.Account
  19. groupResolveCalls int
  20. }
  21. func (r *ollamaCloudUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
  22. if r.account != nil && r.account.ID == id {
  23. return r.account, nil
  24. }
  25. for _, account := range r.accounts {
  26. if account.ID == id {
  27. return account, nil
  28. }
  29. }
  30. return nil, service.ErrAccountNotFound
  31. }
  32. func (r *ollamaCloudUsageHandlerTestRepo) ListOllamaCloudUsageGroupAccounts(_ context.Context, _ []*service.Account) ([]service.Account, error) {
  33. r.groupResolveCalls++
  34. result := make([]service.Account, 0, len(r.accounts)+1)
  35. if r.account != nil {
  36. result = append(result, *r.account)
  37. }
  38. for _, account := range r.accounts {
  39. result = append(result, *account)
  40. }
  41. return result, nil
  42. }
  43. func (r *ollamaCloudUsageHandlerTestRepo) SaveOllamaCloudUsageSession(context.Context, *service.Account, string, bool) error {
  44. return nil
  45. }
  46. func (r *ollamaCloudUsageHandlerTestRepo) DeleteOllamaCloudUsageSession(context.Context, *service.Account) error {
  47. return nil
  48. }
  49. func (r *ollamaCloudUsageHandlerTestRepo) SetOllamaCloudUsageAutoRefresh(context.Context, *service.Account, bool) error {
  50. return nil
  51. }
  52. func (r *ollamaCloudUsageHandlerTestRepo) UpdateOllamaCloudUsageSnapshot(context.Context, *service.Account, *service.OllamaCloudUsageSnapshot) error {
  53. return nil
  54. }
  55. func (r *ollamaCloudUsageHandlerTestRepo) DisableOllamaCloudUsageAutoRefresh(context.Context, *service.Account) error {
  56. return nil
  57. }
  58. func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) {
  59. return nil, nil
  60. }
  61. func newOllamaCloudUsageHandlerTestService(t *testing.T) *service.OllamaCloudUsageService {
  62. t.Helper()
  63. svc := service.NewOllamaCloudUsageService(nil, nil, nil, nil, false)
  64. t.Cleanup(svc.Stop)
  65. return svc
  66. }
  67. func newOllamaCloudUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) {
  68. recorder := httptest.NewRecorder()
  69. request := httptest.NewRequest(method, target, bytes.NewBufferString(body))
  70. request.Header.Set("Content-Type", "application/json")
  71. ctx, _ := gin.CreateTestContext(recorder)
  72. ctx.Request = request
  73. if id != "" {
  74. ctx.Params = gin.Params{{Key: "id", Value: id}}
  75. }
  76. return ctx, recorder
  77. }
  78. func TestOllamaCloudUsageHandlersValidateRequestsAndDependencies(t *testing.T) {
  79. gin.SetMode(gin.TestMode)
  80. svc := newOllamaCloudUsageHandlerTestService(t)
  81. t.Run("invalid account id", func(t *testing.T) {
  82. ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/ollama-cloud-usage", "", "not-an-id")
  83. (&AccountHandler{ollamaCloudUsage: svc}).GetOllamaCloudUsage(ctx)
  84. require.Equal(t, http.StatusBadRequest, recorder.Code)
  85. })
  86. t.Run("empty session", func(t *testing.T) {
  87. ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/session", `{"session":""}`, "7")
  88. (&AccountHandler{ollamaCloudUsage: svc}).SaveOllamaCloudUsageSession(ctx)
  89. require.Equal(t, http.StatusBadRequest, recorder.Code)
  90. })
  91. t.Run("missing enabled", func(t *testing.T) {
  92. ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/auto-refresh", `{}`, "7")
  93. (&AccountHandler{ollamaCloudUsage: svc}).SetOllamaCloudUsageAutoRefresh(ctx)
  94. require.Equal(t, http.StatusBadRequest, recorder.Code)
  95. })
  96. t.Run("service unavailable", func(t *testing.T) {
  97. ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/7/ollama-cloud-usage", "", "7")
  98. (&AccountHandler{}).GetOllamaCloudUsage(ctx)
  99. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  100. require.Contains(t, recorder.Body.String(), "OLLAMA_CLOUD_USAGE_UNAVAILABLE")
  101. })
  102. }
  103. func TestOllamaCloudUsageEncryptionKeyStateConsistentAcrossAccountResponses(t *testing.T) {
  104. gin.SetMode(gin.TestMode)
  105. for _, configured := range []bool{false, true} {
  106. t.Run("configured="+strconv.FormatBool(configured), func(t *testing.T) {
  107. account := &service.Account{
  108. ID: 7,
  109. Name: "ollama",
  110. Platform: service.PlatformOpenAI,
  111. Type: service.AccountTypeAPIKey,
  112. Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "test-key"},
  113. Extra: map[string]any{},
  114. Status: service.StatusActive,
  115. }
  116. adminService := newStubAdminService()
  117. adminService.accounts = []service.Account{*account}
  118. adminService.getAccountResult = account
  119. usageService := service.NewOllamaCloudUsageService(
  120. &ollamaCloudUsageHandlerTestRepo{account: account}, nil, nil, nil, configured,
  121. )
  122. t.Cleanup(usageService.Stop)
  123. handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  124. handler.SetOllamaCloudUsageService(usageService)
  125. router := gin.New()
  126. router.GET("/accounts", handler.List)
  127. router.GET("/accounts/:id", handler.GetByID)
  128. router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
  129. listRecorder := httptest.NewRecorder()
  130. router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
  131. require.Equal(t, http.StatusOK, listRecorder.Code)
  132. var listPayload struct {
  133. Data struct {
  134. Items []struct {
  135. OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
  136. } `json:"items"`
  137. } `json:"data"`
  138. }
  139. require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
  140. require.Len(t, listPayload.Data.Items, 1)
  141. require.NotNil(t, listPayload.Data.Items[0].OllamaCloudUsage)
  142. detailRecorder := httptest.NewRecorder()
  143. router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil))
  144. require.Equal(t, http.StatusOK, detailRecorder.Code)
  145. var detailPayload struct {
  146. Data struct {
  147. OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
  148. } `json:"data"`
  149. }
  150. require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
  151. require.NotNil(t, detailPayload.Data.OllamaCloudUsage)
  152. stateRecorder := httptest.NewRecorder()
  153. router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/ollama-cloud-usage", nil))
  154. require.Equal(t, http.StatusOK, stateRecorder.Code)
  155. var statePayload struct {
  156. Data service.OllamaCloudUsageState `json:"data"`
  157. }
  158. require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
  159. listConfigured := listPayload.Data.Items[0].OllamaCloudUsage.EncryptionKeyConfigured
  160. detailConfigured := detailPayload.Data.OllamaCloudUsage.EncryptionKeyConfigured
  161. require.Equal(t, configured, listConfigured)
  162. require.Equal(t, statePayload.Data.EncryptionKeyConfigured, listConfigured)
  163. require.Equal(t, statePayload.Data.EncryptionKeyConfigured, detailConfigured)
  164. })
  165. }
  166. }
  167. func TestOllamaCloudUsageSharedStateMatchesListDetailAndSpecialEndpointWithoutListNPlusOne(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. now := time.Now().UTC()
  170. source := &service.Account{
  171. ID: 7, Name: "source", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
  172. Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "shared-secret-key"},
  173. Extra: map[string]any{
  174. service.OllamaCloudUsageSessionExtraKey: "ciphertext-secret",
  175. service.OllamaCloudUsageAutoRefreshExtraKey: true,
  176. service.OllamaCloudUsageSnapshotExtraKey: &service.OllamaCloudUsageSnapshot{
  177. Status: service.OllamaCloudUsageStatusOK, Data: &service.OllamaCloudUsageData{Plan: "pro"},
  178. LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
  179. },
  180. },
  181. Status: service.StatusActive,
  182. }
  183. sibling := &service.Account{
  184. ID: 8, Name: "sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
  185. Credentials: map[string]any{"base_url": "HTTPS://WWW.OLLAMA.COM:443/v1", "api_key": "shared-secret-key"},
  186. Extra: map[string]any{}, Status: service.StatusActive,
  187. }
  188. repo := &ollamaCloudUsageHandlerTestRepo{accounts: []*service.Account{source, sibling}}
  189. adminService := newStubAdminService()
  190. adminService.accounts = []service.Account{*source, *sibling}
  191. adminService.getAccountResult = sibling
  192. usageService := service.NewOllamaCloudUsageService(repo, nil, nil, nil, true)
  193. t.Cleanup(usageService.Stop)
  194. handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  195. handler.SetOllamaCloudUsageService(usageService)
  196. router := gin.New()
  197. router.GET("/accounts", handler.List)
  198. router.GET("/accounts/:id", handler.GetByID)
  199. router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
  200. listRecorder := httptest.NewRecorder()
  201. router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
  202. require.Equal(t, http.StatusOK, listRecorder.Code)
  203. require.Equal(t, 1, repo.groupResolveCalls, "the full list page must use one group-resolution batch")
  204. var listPayload struct {
  205. Data struct {
  206. Items []struct {
  207. ID int64 `json:"id"`
  208. OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
  209. } `json:"items"`
  210. } `json:"data"`
  211. }
  212. require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
  213. require.Len(t, listPayload.Data.Items, 2)
  214. for _, item := range listPayload.Data.Items {
  215. require.True(t, item.OllamaCloudUsage.Configured)
  216. require.Equal(t, "pro", item.OllamaCloudUsage.Snapshot.Data.Plan)
  217. }
  218. detailRecorder := httptest.NewRecorder()
  219. router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8", nil))
  220. require.Equal(t, http.StatusOK, detailRecorder.Code)
  221. var detailPayload struct {
  222. Data struct {
  223. OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
  224. } `json:"data"`
  225. }
  226. require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
  227. stateRecorder := httptest.NewRecorder()
  228. router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8/ollama-cloud-usage", nil))
  229. require.Equal(t, http.StatusOK, stateRecorder.Code)
  230. var statePayload struct {
  231. Data service.OllamaCloudUsageState `json:"data"`
  232. }
  233. require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
  234. require.Equal(t, statePayload.Data.Configured, detailPayload.Data.OllamaCloudUsage.Configured)
  235. require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OllamaCloudUsage.Snapshot)
  236. for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} {
  237. require.NotContains(t, body, "shared-secret-key")
  238. require.NotContains(t, body, "ciphertext-secret")
  239. }
  240. }
  241. func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) {
  242. gin.SetMode(gin.TestMode)
  243. ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/ollama-cloud-usage/settings", "", "")
  244. handler := &AccountHandler{ollamaCloudUsage: newOllamaCloudUsageHandlerTestService(t)}
  245. handler.GetOllamaCloudUsageSettings(ctx)
  246. require.Equal(t, http.StatusOK, recorder.Code)
  247. require.Contains(t, recorder.Body.String(), `"enabled":false`)
  248. require.Contains(t, recorder.Body.String(), `"interval_minutes":60`)
  249. require.Contains(t, recorder.Body.String(), `"debounce_minutes":1`)
  250. }