From 68fc26cc5c520f10b3d0129aa111bc860bbd5aea Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 17:46:26 +0800 Subject: [PATCH] fix(accounts): upstream billing rates resolve the page the account list shows GET /admin/accounts/upstream-billing-rates answers for "the current list page", and the account page applies its snapshots only when the total and the ID order equal the list's; otherwise it reloads the whole list. The endpoint still read upstream's single-value filters and "group", while this fork's list reads "group_id" and the multi-value console filters, and the page sent the endpoint filter keys it no longer sets. With any platform, type, status, plan, proxy, folder, tag, group or account_ids filter active the two never agreed, so the refresh after a billing probe always reloaded the list. The endpoint now resolves accounts the way List does for the same query: group from group_id (the older group only when group_id is absent), and the console service when console filters are present. The page sends it the list request's filter parameters. The console query now breaks ties by account ID, as the plain list ordering does, so one query always yields one order: console pages do not overlap, and the rates request sees the page the list request saw. Co-Authored-By: Claude Opus 5.5 --- .../admin/account_upstream_billing_rates.go | 39 +++- .../account_upstream_billing_rates_test.go | 220 ++++++++++++++++++ .../account_taxonomy_integration_test.go | 28 +++ .../service/admin_account_taxonomy.go | 6 +- frontend/src/api/admin/accounts.ts | 11 +- frontend/src/views/admin/AccountsView.vue | 15 +- .../__tests__/AccountsView.console.spec.ts | 99 ++++++++ 7 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 backend/internal/handler/admin/account_upstream_billing_rates_test.go diff --git a/backend/internal/handler/admin/account_upstream_billing_rates.go b/backend/internal/handler/admin/account_upstream_billing_rates.go index 5c3b322b70ec..3d42e2278183 100644 --- a/backend/internal/handler/admin/account_upstream_billing_rates.go +++ b/backend/internal/handler/admin/account_upstream_billing_rates.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -28,6 +29,10 @@ type upstreamBillingRatesResponse struct { // current account-list page. It never probes an upstream and never loads // today-stats, usage, concurrency, or credentials for the response. // +// It must resolve the same page as List for the same query: the account page +// applies the snapshots only when the total and the ID order equal the list's, +// and reloads the whole list otherwise. +// // GET /api/v1/admin/accounts/upstream-billing-rates func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) { if h.adminService == nil { @@ -47,24 +52,48 @@ func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) { sortBy := c.DefaultQuery("sort_by", "name") sortOrder := c.DefaultQuery("sort_order", "asc") + // group_id is the name List reads; the older group applies only when + // group_id is absent. + groupQuery, hasGroupID := c.GetQuery("group_id") + if !hasGroupID { + groupQuery = c.Query("group") + } var groupID int64 - if groupQuery := c.Query("group"); groupQuery != "" { + if groupQuery != "" { if groupQuery == accountListGroupUngroupedQueryValue { groupID = service.AccountListGroupUngrouped } else { parsed, err := strconv.ParseInt(groupQuery, 10, 64) if err != nil || parsed < 0 { - response.BadRequest(c, "invalid group filter") + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) return } groupID = parsed } } - accounts, total, err := h.adminService.ListAccounts( - c.Request.Context(), page, pageSize, platform, accountType, status, - search, groupID, privacyMode, sortBy, sortOrder, + var ( + accounts []service.Account + total int64 + err error ) + if hasAccountConsoleFilters(c) { + filters, filterErr := parseAccountConsoleFilters(c, groupID) + if filterErr != nil { + response.ErrorFrom(c, filterErr) + return + } + var consoleService accountConsoleAdminService + consoleService, err = h.accountConsoleService() + if err == nil { + accounts, total, err = consoleService.ListAccountsConsole(c.Request.Context(), page, pageSize, filters) + } + } else { + accounts, total, err = h.adminService.ListAccounts( + c.Request.Context(), page, pageSize, platform, accountType, status, + search, groupID, privacyMode, sortBy, sortOrder, + ) + } if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/account_upstream_billing_rates_test.go b/backend/internal/handler/admin/account_upstream_billing_rates_test.go new file mode 100644 index 000000000000..8b8a1b802385 --- /dev/null +++ b/backend/internal/handler/admin/account_upstream_billing_rates_test.go @@ -0,0 +1,220 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const ( + accountListRoute = "/api/v1/admin/accounts" + upstreamBillingRatesRoute = "/api/v1/admin/accounts/upstream-billing-rates" +) + +// accountPageCall is one list call as the admin service received it: the scalar arguments of ListAccounts, or the +// filters of ListAccountsConsole when console is set. +type accountPageCall struct { + page, pageSize int + platform, accountType, status, search string + groupID int64 + privacyMode, sortBy, sortOrder string + console *service.AccountConsoleFilters +} + +// group is the group the call filtered by, whichever list method received it. +func (c accountPageCall) group() int64 { + if c.console != nil { + return c.console.GroupID + } + return c.groupID +} + +// billingRatesAdmin records the account page each request resolves and answers every list call with rows and total. +type billingRatesAdmin struct { + *accountTaxonomyHandlerStub + calls []accountPageCall + rows []service.Account + total int64 + listErr error +} + +func (s *billingRatesAdmin) ListAccounts(_ context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]service.Account, int64, error) { + s.calls = append(s.calls, accountPageCall{ + page: page, pageSize: pageSize, platform: platform, accountType: accountType, status: status, search: search, + groupID: groupID, privacyMode: privacyMode, sortBy: sortBy, sortOrder: sortOrder, + }) + return s.rows, s.total, s.listErr +} + +func (s *billingRatesAdmin) ListAccountsConsole(_ context.Context, page, pageSize int, filters service.AccountConsoleFilters) ([]service.Account, int64, error) { + s.calls = append(s.calls, accountPageCall{page: page, pageSize: pageSize, console: &filters}) + return s.rows, s.total, s.listErr +} + +func setupUpstreamBillingRatesRouter() (*gin.Engine, *billingRatesAdmin) { + gin.SetMode(gin.TestMode) + router := gin.New() + admin := &billingRatesAdmin{accountTaxonomyHandlerStub: newAccountTaxonomyHandlerStub()} + handler := NewAccountHandler(admin, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.GET(accountListRoute, handler.List) + router.GET(upstreamBillingRatesRoute, handler.GetUpstreamBillingRates) + return router, admin +} + +// requestAccountPage sends the query to the route and returns the response with the list calls the request made. +func requestAccountPage(router *gin.Engine, admin *billingRatesAdmin, route, query string) (*httptest.ResponseRecorder, []accountPageCall) { + admin.calls = nil + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, route+"?"+query, nil)) + return recorder, admin.calls +} + +func TestAccountHandlerGetUpstreamBillingRatesResolvesSamePageAsList(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + digestTerm := "sha256:" + strings.Repeat("ab", 32) + over100Bytes := strings.Repeat("k", 120) + for _, test := range []struct { + name string + query string + console bool + }{ + {name: "no_filter"}, + {name: "search", query: "search=" + digestTerm}, + {name: "search_over_100_bytes", query: "search=" + over100Bytes}, + {name: "group", query: "group_id=12"}, + {name: "ungrouped", query: "group_id=ungrouped"}, + {name: "privacy_and_billing_rate_sort", query: "privacy_mode=training_off&sort_by=upstream_billing_rate&sort_order=desc"}, + {name: "scalar_filters", query: "platform=openai&type=apikey&status=active"}, + {name: "page", query: "page=3&page_size=50"}, + {name: "statuses", query: "statuses=active,error", console: true}, + {name: "console_ungrouped", query: "plans=plus&group_id=ungrouped", console: true}, + {name: "console_search_over_100_bytes", query: "tags=3&search=" + over100Bytes, console: true}, + {name: "console_page", query: "page=2&page_size=5&account_ids=11,12", console: true}, + { + name: "full_console", + query: "platforms=openai,anthropic&types=apikey,oauth&statuses=active,error&plans=plus,team" + + "&proxies=direct,4&folder=uncategorized,7&tags=3,9&account_ids=11,12,13&group_id=12" + + "&privacy_mode=training_off&search=" + digestTerm + "&sort_by=upstream_billing_rate&sort_order=desc", + console: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + listResponse, listed := requestAccountPage(router, admin, accountListRoute, test.query) + ratesResponse, rated := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, http.StatusOK, listResponse.Code) + require.Equal(t, http.StatusOK, ratesResponse.Code) + require.Len(t, listed, 1) + require.Equal(t, test.console, listed[0].console != nil, "the query must take the list path the case is about") + require.Equal(t, listed, rated) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesFailsLikeList(t *testing.T) { + listErr := errors.New("synthetic list outage") + for _, test := range []struct { + name string + query string + listErr error + wantStatus int + wantCalls int + }{ + {name: "invalid_group", query: "group_id=abc", wantStatus: http.StatusBadRequest}, + {name: "negative_group", query: "group_id=-3", wantStatus: http.StatusBadRequest}, + {name: "console_invalid_group", query: "statuses=active&group_id=abc", wantStatus: http.StatusBadRequest}, + {name: "console_invalid_filter", query: "tags=abc", wantStatus: http.StatusBadRequest}, + {name: "list_error", listErr: listErr, wantStatus: http.StatusInternalServerError, wantCalls: 1}, + {name: "console_list_error", query: "statuses=active", listErr: listErr, wantStatus: http.StatusInternalServerError, wantCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + admin.listErr = test.listErr + listResponse, _ := requestAccountPage(router, admin, accountListRoute, test.query) + ratesResponse, rated := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, test.wantStatus, listResponse.Code) + require.Equal(t, listResponse.Code, ratesResponse.Code) + require.JSONEq(t, listResponse.Body.String(), ratesResponse.Body.String()) + require.Len(t, rated, test.wantCalls) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesFallsBackToOlderGroupName(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + for _, test := range []struct { + query string + wantGroup int64 + }{ + {query: "group=12", wantGroup: 12}, + {query: "group=12&statuses=active", wantGroup: 12}, + {query: "group=12&group_id=73", wantGroup: 73}, + {query: "group=12&group_id=73&statuses=active", wantGroup: 73}, + // An empty group_id is still the name List reads, and means no group filter there. + {query: "group=12&group_id=", wantGroup: 0}, + } { + t.Run(test.query, func(t *testing.T) { + recorder, calls := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, calls, 1) + require.Equal(t, test.wantGroup, calls[0].group()) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesKeepsResponseShapeWithConsoleFilters(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + admin.rows = []service.Account{ + {ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Extra: map[string]any{ + service.UpstreamBillingProbeExtraKey: map[string]any{ + "status": service.UpstreamBillingProbeStatusOK, "data": map[string]any{"effective_rate_multiplier": 0.045}, + "last_attempt_at": "2026-08-01T00:00:00Z", "next_probe_at": "2026-08-01T00:30:00Z", + }, + }}, + {ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}, + {ID: 19, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, + } + admin.total = 57 + const query = "page=2&page_size=3&statuses=active&tags=3&sort_by=upstream_billing_rate&sort_order=desc" + + recorder, calls := requestAccountPage(router, admin, upstreamBillingRatesRoute, query) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, calls, 1) + require.NotNil(t, calls[0].console) + + var body struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.JSONEq(t, `{ + "items": [ + {"account_id": 42, "snapshot": { + "status": "ok", "data": {"effective_rate_multiplier": 0.045}, + "last_attempt_at": "2026-08-01T00:00:00Z", "next_probe_at": "2026-08-01T00:30:00Z" + }}, + {"account_id": 7, "snapshot": null}, + {"account_id": 19, "snapshot": null} + ], + "total": 57, "page": 2, "page_size": 3 + }`, string(body.Data)) + require.Equal(t, "private, no-cache", recorder.Header().Get("Cache-Control")) + etag := recorder.Header().Get("ETag") + require.NotEmpty(t, etag) + + request := httptest.NewRequest(http.MethodGet, upstreamBillingRatesRoute+"?"+query, nil) + request.Header.Set("If-None-Match", etag) + notModified := httptest.NewRecorder() + router.ServeHTTP(notModified, request) + require.Equal(t, http.StatusNotModified, notModified.Code) + require.Empty(t, notModified.Body.String()) +} diff --git a/backend/internal/repository/account_taxonomy_integration_test.go b/backend/internal/repository/account_taxonomy_integration_test.go index f19edc74bddf..decd95b64fda 100644 --- a/backend/internal/repository/account_taxonomy_integration_test.go +++ b/backend/internal/repository/account_taxonomy_integration_test.go @@ -417,6 +417,34 @@ func TestAccountTaxonomyConsoleSearchByAPIKeyDigestIntegration(t *testing.T) { }) } +func TestAccountTaxonomyConsoleOrderBreaksTiesByIDIntegration(t *testing.T) { + ctx := context.Background() + prefix := taxonomyIntegrationPrefix("tie-order") + client, admin := newAccountTaxonomyIntegrationAdmin(t, prefix) + + // Five accounts that are equal in the sorted column. + ids := make([]int64, 0, 5) + for _, name := range []string{"c", "a", "e", "b", "d"} { + ids = append(ids, mustCreateAccount(t, client, &service.Account{Name: prefix + name, Priority: 7}).ID) + } + list := func(order string, page, pageSize int) []int64 { + accounts, total, err := admin.ListAccountsConsole(ctx, page, pageSize, service.AccountConsoleFilters{ + Search: prefix, SortBy: "priority", SortOrder: order, + }) + require.NoError(t, err) + require.Equal(t, int64(len(ids)), total) + return idsOfAccounts(accounts) + } + + descending := []int64{ids[4], ids[3], ids[2], ids[1], ids[0]} + require.Equal(t, ids, list("asc", 1, 20)) + require.Equal(t, descending, list("desc", 1, 20)) + // Pages of one ordering neither overlap nor skip. + require.Equal(t, descending[:2], list("desc", 1, 2)) + require.Equal(t, descending[2:4], list("desc", 2, 2)) + require.Equal(t, descending[4:], list("desc", 3, 2)) +} + func TestAccountTaxonomyBulkUpdateAndOrderingIntegration(t *testing.T) { ctx := context.Background() prefix := taxonomyIntegrationPrefix("bulk") diff --git a/backend/internal/service/admin_account_taxonomy.go b/backend/internal/service/admin_account_taxonomy.go index 19494e79ec7f..522dd253832f 100644 --- a/backend/internal/service/admin_account_taxonomy.go +++ b/backend/internal/service/admin_account_taxonomy.go @@ -753,10 +753,12 @@ func (s *adminServiceImpl) accountConsoleQuery(filters AccountConsoleFilters) *d if field == "" { field = dbaccount.FieldName } + // The account ID breaks ties, as in the plain list's ordering, so one query always yields one order: pages do + // not overlap, and the upstream billing rates request sees the page the list request saw. if strings.EqualFold(filters.SortOrder, "desc") { - query = query.Order(dbent.Desc(field)) + query = query.Order(dbent.Desc(field), dbent.Desc(dbaccount.FieldID)) } else { - query = query.Order(dbent.Asc(field)) + query = query.Order(dbent.Asc(field), dbent.Asc(dbaccount.FieldID)) } return query } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index f3edca31b891..df548b6e0e73 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -117,16 +117,7 @@ export interface AccountUpstreamBillingRatesWithEtagResult { export async function getUpstreamBillingRatesWithEtag( page: number = 1, pageSize: number = 20, - filters?: { - platform?: string - type?: string - status?: string - group?: string - search?: string - privacy_mode?: string - sort_by?: string - sort_order?: 'asc' | 'desc' - }, + filters?: AccountListFilters, options?: { signal?: AbortSignal etag?: string | null diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 232952988a71..b3965f5183be 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1802,19 +1802,8 @@ const reload = async () => { await refreshTodayStatsBatch() } -const buildUpstreamBillingRateFilters = () => { - const rawParams = toRaw(params) as Record - return { - platform: typeof rawParams.platform === 'string' ? rawParams.platform : '', - type: typeof rawParams.type === 'string' ? rawParams.type : '', - status: typeof rawParams.status === 'string' ? rawParams.status : '', - group: typeof rawParams.group === 'string' ? rawParams.group : '', - search: typeof rawParams.search === 'string' ? rawParams.search : '', - privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '', - sort_by: sortState.sort_by, - sort_order: sortState.sort_order - } -} +// The rates endpoint answers for the page the list request returns, so it is asked with that request's filters. +const buildUpstreamBillingRateFilters = () => buildConsoleAPIParams(true) const sameAccountIDOrder = (left: number[], right: number[]) => left.length === right.length && left.every((id, index) => id === right[index]) diff --git a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts index f90530122f23..c27ede44207c 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts @@ -14,6 +14,7 @@ const { getById, getAvailableModels, listWithEtag, + getUpstreamBillingRatesWithEtag, getFacets, listFolders, listTags, @@ -33,6 +34,7 @@ const { getById: vi.fn(), getAvailableModels: vi.fn(), listWithEtag: vi.fn(), + getUpstreamBillingRatesWithEtag: vi.fn(), getFacets: vi.fn(), listFolders: vi.fn(), listTags: vi.fn(), @@ -73,6 +75,7 @@ vi.mock('@/api/admin', () => ({ getAPIKeyVisibility: vi.fn().mockResolvedValue({ enabled: false }), getAvailableModels, listWithEtag, + getUpstreamBillingRatesWithEtag, getFacets, listFolders, listTags, @@ -295,6 +298,10 @@ const recordListSearches = () => { const accountRequestArguments = () => JSON.stringify(Object.values(adminAPI.accounts).map(request => vi.mocked(request).mock.calls)) +// the refresh of the upstream billing rates the page makes after a billing probe +const refreshBillingRates = (wrapper: ReturnType): Promise => + (wrapper.vm as any).refreshAccountsAfterUpstreamBillingProbe() + describe('admin AccountsView Cockpit console', () => { it('preserves the submitted bulk-edit selection in the original window after clearing page selection', async () => { const wrapper = mountView() @@ -322,6 +329,7 @@ describe('admin AccountsView Cockpit console', () => { getById.mockReset().mockResolvedValue(account) getAvailableModels.mockReset().mockResolvedValue(structuredClone(modelDisplayContract.expected)) listWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: null, data: null }) + getUpstreamBillingRatesWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: null, data: null }) getFacets.mockReset().mockResolvedValue({ total: 1, uncategorized_count: 1, platforms: [], types: [], statuses: [], plans: [], proxies: [], folders: [], tags: [] }) listFolders.mockReset().mockResolvedValue([]) listTags.mockReset().mockResolvedValue([]) @@ -740,6 +748,97 @@ describe('admin AccountsView Cockpit console', () => { wrapper.unmount() }) + it('asks for upstream billing rates of the current page with the filters of the list request', async () => { + sessionStorage.setItem('account-console-sensitive-filters-v1', JSON.stringify({ search: 'console', account_ids: [] })) + listFolders.mockResolvedValue([{ id: 7, name: 'Production', sort_order: 0, account_count: 1 }]) + listTags.mockResolvedValue([{ id: 3, name: 'canary', sort_order: 0, account_count: 1 }]) + // useTableLoader hands every list request the same params object, so the filters of a request are copied as it is made + const listRequests: Record[] = [] + listAccounts.mockImplementation(async (_page: number, _pageSize: number, filters: Record) => { + listRequests.push({ ...filters }) + return { items: [account], total: 51, page: 2, page_size: 50, pages: 2 } + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/admin/accounts', component: { template: '
' } }] + }) + await router.push('/admin/accounts?folder=7&statuses=active&group_id=23&tags=3&sort_by=status&sort_order=desc&page=2&page_size=50') + await router.isReady() + const wrapper = mountView([router]) + try { + // the restored search text is looked up before the first list request + await vi.waitFor(() => expect(listRequests.length).toBeGreaterThan(0)) + await flushPromises() + + await refreshBillingRates(wrapper) + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + const [page, pageSize, rateFilters] = getUpstreamBillingRatesWithEtag.mock.calls[0] + expect([page, pageSize]).toEqual([2, 50]) + expect(rateFilters).toEqual({ + folder: '7', statuses: 'active', group_id: '23', tags: '3', search: 'console', sort_by: 'status', sort_order: 'desc' + }) + for (const legacyKey of ['platform', 'type', 'status', 'group']) expect(rateFilters).not.toHaveProperty(legacyKey) + // the filters of the list request: all it sends but lite and include_scheduler_score, which only shape its rows + const { lite: _lite, include_scheduler_score: _includeSchedulerScore, ...listFilters } = listRequests[listRequests.length - 1] + expect(rateFilters).toEqual(listFilters) + } finally { + wrapper.unmount() + } + }) + + it('applies upstream billing rates answered for the listed accounts without requesting the list again', async () => { + const snapshot = { + status: 'ok', + data: { effective_rate_multiplier: 0.065 }, + synced_rate_multiplier: 0.065, + last_attempt_at: '2026-07-29T00:00:00Z', + next_probe_at: '2026-07-29T00:30:00Z' + } + getUpstreamBillingRatesWithEtag.mockResolvedValue({ + notModified: false, + etag: '"rates"', + data: { items: [{ account_id: account.id, snapshot }], total: 1, page: 1, page_size: 20 } + }) + const wrapper = mountView() + try { + await flushPromises() + listAccounts.mockClear() + + await refreshBillingRates(wrapper) + await flushPromises() + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + expect(listAccounts).not.toHaveBeenCalled() + const [row] = (wrapper.vm as any).accounts + expect(row.extra.upstream_billing_probe).toEqual(snapshot) + expect(row.rate_multiplier).toBe(snapshot.synced_rate_multiplier) + } finally { + wrapper.unmount() + } + }) + + it('asks for upstream billing rates by digest term when the search box holds the API key of an account', async () => { + const key = 'sk-rated-upstream-key' + hasAPIKeyDigest.mockResolvedValue(true) + const wrapper = mountView() + try { + await flushPromises() + const searches = recordListSearches() + typeSearch(wrapper, key) + await vi.waitFor(() => expect(searches).toEqual([keyDigestTerm(key)]), SEARCH_RELOAD_WAIT) + await flushPromises() + + await refreshBillingRates(wrapper) + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + expect(getUpstreamBillingRatesWithEtag.mock.calls[0][2].search).toBe(keyDigestTerm(key)) + expect(JSON.stringify(getUpstreamBillingRatesWithEtag.mock.calls)).not.toContain(key) + } finally { + wrapper.unmount() + } + }) + it('preserves the account list and performs no reads when account testing closes', async () => { const router = createRouter({ history: createMemoryHistory(),