mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
修复上游倍率探测导致账号列表整页刷新的问题
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// upstreamBillingRatesResponse is deliberately smaller than the regular
|
||||
// account-list response. The frontend uses the returned ID order to detect a
|
||||
// page boundary/order change and only applies snapshots when the page is
|
||||
// otherwise stable.
|
||||
type upstreamBillingRatesResponse struct {
|
||||
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
|
||||
// current account-list page. It never probes an upstream and never loads
|
||||
// today-stats, usage, concurrency, or credentials for the response.
|
||||
//
|
||||
// GET /api/v1/admin/accounts/upstream-billing-rates
|
||||
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
|
||||
if h.adminService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
accountType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if len(search) > 100 {
|
||||
search = search[:100]
|
||||
}
|
||||
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
|
||||
sortBy := c.DefaultQuery("sort_by", "name")
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var groupID int64
|
||||
if groupQuery := c.Query("group"); 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")
|
||||
return
|
||||
}
|
||||
groupID = parsed
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
payload := upstreamBillingRatesResponse{
|
||||
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
etag := buildUpstreamBillingRatesETag(payload)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "\"" + hex.EncodeToString(sum[:]) + "\""
|
||||
}
|
||||
@@ -358,6 +358,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts := admin.Group("/accounts")
|
||||
{
|
||||
accounts.GET("", h.Admin.Account.List)
|
||||
accounts.GET("/upstream-billing-rates", h.Admin.Account.GetUpstreamBillingRates)
|
||||
accounts.GET("/upstream-billing-probe/settings", h.Admin.Account.GetUpstreamBillingProbeSettings)
|
||||
accounts.PUT("/upstream-billing-probe/settings", h.Admin.Account.UpdateUpstreamBillingProbeSettings)
|
||||
accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch)
|
||||
|
||||
@@ -128,6 +128,35 @@ type UpstreamBillingProbeResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// UpstreamBillingRateSnapshotItem is the compact representation used by the
|
||||
// account table's background refresh. It intentionally excludes credentials,
|
||||
// runtime counters, and usage data from the response.
|
||||
type UpstreamBillingRateSnapshotItem struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Snapshot *UpstreamBillingProbeSnapshot `json:"snapshot"`
|
||||
}
|
||||
|
||||
// BuildUpstreamBillingRateSnapshotItems projects account rows into the
|
||||
// read-only payload used by the rate refresh endpoint. Decode snapshots here
|
||||
// so malformed or legacy extra data is handled consistently with probe logic.
|
||||
func BuildUpstreamBillingRateSnapshotItems(accounts []Account) []UpstreamBillingRateSnapshotItem {
|
||||
items := make([]UpstreamBillingRateSnapshotItem, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
var snapshot *UpstreamBillingProbeSnapshot
|
||||
// The billing endpoint is supported by every API-key platform; limiting
|
||||
// this projection to OpenAI would make the background refresh erase the
|
||||
// other platforms' persisted snapshots from the table.
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
snapshot = decodeUpstreamBillingProbeSnapshot(account.Extra)
|
||||
}
|
||||
items = append(items, UpstreamBillingRateSnapshotItem{
|
||||
AccountID: account.ID,
|
||||
Snapshot: snapshot,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
type upstreamBillingProbeResponse struct {
|
||||
Object string `json:"object"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
|
||||
@@ -29,6 +29,45 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
|
||||
require.False(t, isUpstreamBillingProbeAccount(nil))
|
||||
}
|
||||
|
||||
func TestBuildUpstreamBillingRateSnapshotItemsPreservesAllAPIKeyPlatforms(t *testing.T) {
|
||||
makeAccount := func(id int64, platform, accountType string, snapshot any) Account {
|
||||
return Account{
|
||||
ID: id,
|
||||
Platform: platform,
|
||||
Type: accountType,
|
||||
Extra: map[string]any{UpstreamBillingProbeExtraKey: snapshot},
|
||||
}
|
||||
}
|
||||
|
||||
accounts := []Account{
|
||||
makeAccount(1, PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
||||
"status": "ok",
|
||||
"data": map[string]any{"effective_rate_multiplier": 0.045},
|
||||
}),
|
||||
makeAccount(2, PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
||||
"status": "ok",
|
||||
"data": map[string]any{"effective_rate_multiplier": 0.047},
|
||||
}),
|
||||
makeAccount(3, PlatformGemini, AccountTypeAPIKey, map[string]any{
|
||||
"status": "failed",
|
||||
}),
|
||||
makeAccount(4, PlatformOpenAI, AccountTypeOAuth, map[string]any{
|
||||
"status": "ok",
|
||||
}),
|
||||
}
|
||||
|
||||
items := BuildUpstreamBillingRateSnapshotItems(accounts)
|
||||
require.Len(t, items, len(accounts))
|
||||
require.Equal(t, int64(1), items[0].AccountID)
|
||||
require.NotNil(t, items[0].Snapshot)
|
||||
require.NotNil(t, items[1].Snapshot)
|
||||
require.NotNil(t, items[2].Snapshot)
|
||||
require.Equal(t, 0.045, items[0].Snapshot.Data["effective_rate_multiplier"])
|
||||
require.Equal(t, 0.047, items[1].Snapshot.Data["effective_rate_multiplier"])
|
||||
require.Equal(t, UpstreamBillingProbeStatusFailed, items[2].Snapshot.Status)
|
||||
require.Nil(t, items[3].Snapshot)
|
||||
}
|
||||
|
||||
func upstreamBillingProbeValidBody() io.ReadCloser {
|
||||
return io.NopCloser(strings.NewReader(`{
|
||||
"object":"sub2api.key_billing",
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
CheckMixedChannelResponse,
|
||||
UpstreamBillingProbeResult,
|
||||
UpstreamBillingProbeSettings,
|
||||
UpstreamBillingRatesResponse,
|
||||
OllamaCloudUsageSettings,
|
||||
OllamaCloudUsageState
|
||||
} from '@/types'
|
||||
@@ -70,6 +71,45 @@ export interface AccountListWithEtagResult {
|
||||
data: PaginatedResponse<Account> | null
|
||||
}
|
||||
|
||||
export interface AccountUpstreamBillingRatesWithEtagResult {
|
||||
notModified: boolean
|
||||
etag: string | null
|
||||
data: UpstreamBillingRatesResponse | null
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
etag?: string | null
|
||||
}
|
||||
): Promise<AccountUpstreamBillingRatesWithEtagResult> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (options?.etag) headers['If-None-Match'] = options.etag
|
||||
|
||||
const response = await apiClient.get<UpstreamBillingRatesResponse>('/admin/accounts/upstream-billing-rates', {
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
|
||||
})
|
||||
|
||||
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
|
||||
if (response.status === 304) return { notModified: true, etag: etagHeader, data: null }
|
||||
return { notModified: false, etag: etagHeader, data: response.data }
|
||||
}
|
||||
|
||||
export async function listWithEtag(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
@@ -988,6 +1028,7 @@ export async function refreshOllamaCloudUsage(id: number): Promise<OllamaCloudUs
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
getUpstreamBillingRatesWithEtag,
|
||||
getById,
|
||||
create,
|
||||
duplicate,
|
||||
|
||||
@@ -1060,6 +1060,18 @@ export interface UpstreamBillingProbeResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface UpstreamBillingRateSnapshotItem {
|
||||
account_id: number
|
||||
snapshot?: UpstreamBillingProbeSnapshot | null
|
||||
}
|
||||
|
||||
export interface UpstreamBillingRatesResponse {
|
||||
items: UpstreamBillingRateSnapshotItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export type OllamaCloudUsageStatus = 'ok' | 'unauthorized' | 'failed'
|
||||
|
||||
export interface OllamaCloudUsageWindow {
|
||||
|
||||
@@ -613,7 +613,9 @@ const exportingData = ref(false)
|
||||
const probingUpstreamBilling = reactive(new Set<number>())
|
||||
const upstreamBillingProbeGloballyEnabled = ref<boolean | undefined>(undefined)
|
||||
const upstreamBillingNow = ref(Date.now())
|
||||
let lastUpstreamBillingSortRefreshMinute = -1
|
||||
const upstreamBillingRateETag = ref<string | null>(null)
|
||||
const upstreamBillingRateRefreshing = ref(false)
|
||||
let upstreamBillingRateAbortController: AbortController | null = null
|
||||
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
|
||||
|
||||
// Account tools dropdown
|
||||
@@ -1137,19 +1139,17 @@ useSwipeSelect(accountTableRef, {
|
||||
|
||||
const resetAutoRefreshCache = () => {
|
||||
autoRefreshETag.value = null
|
||||
upstreamBillingRateETag.value = null
|
||||
}
|
||||
|
||||
const isFirstLoad = ref(true)
|
||||
|
||||
function markUpstreamBillingSortRefresh() {
|
||||
if (sortState.sort_by === 'upstream_billing_rate') {
|
||||
lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000)
|
||||
}
|
||||
type AccountLoadOptions = {
|
||||
refreshTodayStats?: boolean
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const load = async (options: AccountLoadOptions = {}) => {
|
||||
const requestParams = params as any
|
||||
markUpstreamBillingSortRefresh()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -1162,11 +1162,10 @@ const load = async () => {
|
||||
isFirstLoad.value = false
|
||||
delete requestParams.lite
|
||||
}
|
||||
await refreshTodayStatsBatch()
|
||||
if (options.refreshTodayStats !== false) await refreshTodayStatsBatch()
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
markUpstreamBillingSortRefresh()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -1175,19 +1174,120 @@ const reload = async () => {
|
||||
await refreshTodayStatsBatch()
|
||||
}
|
||||
|
||||
const refreshUpstreamBillingSortedList = async (force = false) => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate') return
|
||||
|
||||
const minute = Math.floor(upstreamBillingNow.value / 60_000)
|
||||
if (!force && lastUpstreamBillingSortRefreshMinute === minute) return
|
||||
lastUpstreamBillingSortRefreshMinute = minute
|
||||
try {
|
||||
await reload()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh upstream billing sort:', error)
|
||||
const buildUpstreamBillingRateFilters = () => {
|
||||
const rawParams = toRaw(params) as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const sameAccountIDOrder = (left: number[], right: number[]) =>
|
||||
left.length === right.length && left.every((id, index) => id === right[index])
|
||||
|
||||
const upstreamBillingRateContextKey = () => JSON.stringify({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.page_size,
|
||||
filters: buildUpstreamBillingRateFilters()
|
||||
})
|
||||
|
||||
const applyUpstreamBillingRateSnapshots = async (
|
||||
result: NonNullable<Awaited<ReturnType<typeof adminAPI.accounts.getUpstreamBillingRatesWithEtag>>['data']>
|
||||
) => {
|
||||
const nextIDs = result.items.map(item => item.account_id)
|
||||
const currentIDs = accounts.value.map(account => account.id)
|
||||
|
||||
// The compact response cannot fill a row that crossed a page boundary.
|
||||
// Only that case needs the expensive, full account-list request.
|
||||
if (result.total !== pagination.total || !sameAccountIDOrder(nextIDs, currentIDs)) {
|
||||
try {
|
||||
await load({ refreshTodayStats: false })
|
||||
} catch (error) {
|
||||
console.error('Failed to reconcile upstream billing sort:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const itemsByID = new Map(result.items.map(item => [item.account_id, item]))
|
||||
let changed = false
|
||||
const nextAccounts = accounts.value.map(account => {
|
||||
const item = itemsByID.get(account.id)
|
||||
if (!item) return account
|
||||
const nextSnapshot = item.snapshot ?? null
|
||||
const previousSnapshot = account.extra?.upstream_billing_probe ?? null
|
||||
if (JSON.stringify(previousSnapshot) === JSON.stringify(nextSnapshot)) return account
|
||||
|
||||
const nextExtra = { ...(account.extra ?? {}) }
|
||||
if (nextSnapshot) nextExtra.upstream_billing_probe = nextSnapshot
|
||||
else delete nextExtra.upstream_billing_probe
|
||||
const nextAccount = {
|
||||
...account,
|
||||
...(typeof nextSnapshot?.synced_rate_multiplier === 'number'
|
||||
? { rate_multiplier: nextSnapshot.synced_rate_multiplier }
|
||||
: {}),
|
||||
extra: nextExtra
|
||||
}
|
||||
syncAccountRefs(nextAccount)
|
||||
changed = true
|
||||
return nextAccount
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
accounts.value = nextAccounts
|
||||
upstreamBillingNow.value = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUpstreamBillingRates = async (force = false) => {
|
||||
if (upstreamBillingRateRefreshing.value || loading.value || accounts.value.length === 0) return
|
||||
if (!force && (
|
||||
probingUpstreamBilling.size > 0 ||
|
||||
isAnyModalOpen.value ||
|
||||
menu.show ||
|
||||
showAccountToolsDropdown.value ||
|
||||
showAutoRefreshDropdown.value ||
|
||||
(typeof document !== 'undefined' && document.hidden)
|
||||
)) return
|
||||
|
||||
const controller = new AbortController()
|
||||
upstreamBillingRateAbortController = controller
|
||||
upstreamBillingRateRefreshing.value = true
|
||||
try {
|
||||
syncAccountListDerivedParams()
|
||||
const requestContextKey = upstreamBillingRateContextKey()
|
||||
const result = await adminAPI.accounts.getUpstreamBillingRatesWithEtag(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
buildUpstreamBillingRateFilters(),
|
||||
{ etag: force ? null : upstreamBillingRateETag.value, signal: controller.signal }
|
||||
)
|
||||
if (loading.value || requestContextKey !== upstreamBillingRateContextKey()) return
|
||||
if (result.etag) upstreamBillingRateETag.value = result.etag
|
||||
if (!result.notModified && result.data) await applyUpstreamBillingRateSnapshots(result.data)
|
||||
} catch (error) {
|
||||
const refreshError = error as { name?: string; code?: string }
|
||||
if (refreshError.name !== 'AbortError' && refreshError.name !== 'CanceledError' && refreshError.code !== 'ERR_CANCELED') {
|
||||
console.error('Failed to refresh upstream billing rates:', error)
|
||||
}
|
||||
} finally {
|
||||
if (upstreamBillingRateAbortController === controller) upstreamBillingRateAbortController = null
|
||||
upstreamBillingRateRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUpstreamBillingSortedList = async (force = false) => {
|
||||
if (!force && sortState.sort_by !== 'upstream_billing_rate') return
|
||||
await refreshUpstreamBillingRates(force)
|
||||
}
|
||||
|
||||
useIntervalFn(() => { void refreshUpstreamBillingRates() }, 5 * 60_000, { immediate: false })
|
||||
|
||||
const debouncedReload = () => {
|
||||
clearSelection()
|
||||
syncAccountListDerivedParams()
|
||||
@@ -1255,12 +1355,6 @@ watch(accounts, (rows) => {
|
||||
)
|
||||
})
|
||||
|
||||
watch(upstreamBillingNow, () => {
|
||||
if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
void refreshUpstreamBillingSortedList()
|
||||
})
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
@@ -1373,7 +1467,6 @@ const refreshAccountsIncrementally = async () => {
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
markUpstreamBillingSortRefresh()
|
||||
}
|
||||
upstreamBillingNow.value = Date.now()
|
||||
|
||||
@@ -2150,19 +2243,17 @@ const patchAccountInList = (updatedAccount: Account) => {
|
||||
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
|
||||
const account = accounts.value.find(item => item.id === accountID)
|
||||
if (!account) return
|
||||
markUpstreamBillingSortRefresh()
|
||||
upstreamBillingNow.value = Date.now()
|
||||
patchAccountInList({
|
||||
...account,
|
||||
...(typeof snapshot.synced_rate_multiplier === 'number'
|
||||
? { rate_multiplier: snapshot.synced_rate_multiplier }
|
||||
: {}),
|
||||
extra: { ...account.extra, upstream_billing_probe: snapshot }
|
||||
})
|
||||
}
|
||||
const refreshAccountsAfterUpstreamBillingProbe = async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh accounts after upstream billing probe:', error)
|
||||
}
|
||||
await refreshUpstreamBillingSortedList(true)
|
||||
}
|
||||
const handleProbeUpstreamBilling = async (account: Account) => {
|
||||
if (probingUpstreamBilling.has(account.id)) return
|
||||
@@ -2481,6 +2572,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
upstreamBillingRateAbortController?.abort()
|
||||
if (usageBatchFlushTimer !== null) {
|
||||
clearTimeout(usageBatchFlushTimer)
|
||||
usageBatchFlushTimer = null
|
||||
|
||||
@@ -6,6 +6,7 @@ import AccountsView from '../AccountsView.vue'
|
||||
const {
|
||||
listAccounts,
|
||||
listWithEtag,
|
||||
getUpstreamBillingRatesWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
getAllProxies,
|
||||
@@ -17,6 +18,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
listAccounts: vi.fn(),
|
||||
listWithEtag: vi.fn(),
|
||||
getUpstreamBillingRatesWithEtag: vi.fn(),
|
||||
getBatchTodayStats: vi.fn(),
|
||||
getUpstreamBillingProbeSettings: vi.fn(),
|
||||
getAllProxies: vi.fn(),
|
||||
@@ -32,6 +34,7 @@ vi.mock('@/api/admin', () => ({
|
||||
accounts: {
|
||||
list: listAccounts,
|
||||
listWithEtag,
|
||||
getUpstreamBillingRatesWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
delete: vi.fn(),
|
||||
@@ -127,6 +130,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
|
||||
listAccounts.mockReset()
|
||||
listWithEtag.mockReset()
|
||||
getUpstreamBillingRatesWithEtag.mockReset()
|
||||
getBatchTodayStats.mockReset()
|
||||
getUpstreamBillingProbeSettings.mockReset()
|
||||
getAllProxies.mockReset()
|
||||
@@ -148,6 +152,11 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
etag: null,
|
||||
data: null
|
||||
})
|
||||
getUpstreamBillingRatesWithEtag.mockResolvedValue({
|
||||
notModified: true,
|
||||
etag: null,
|
||||
data: null
|
||||
})
|
||||
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
|
||||
getAllProxies.mockResolvedValue([])
|
||||
@@ -400,7 +409,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
|
||||
})
|
||||
|
||||
it('refreshes the current page after a batch probe and displays the synced rate', async () => {
|
||||
it('updates the current page locally after a batch probe', async () => {
|
||||
const account = (id: number, rateMultiplier: number) => ({
|
||||
id,
|
||||
name: `account-${id}`,
|
||||
@@ -422,6 +431,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
data: { effective_rate_multiplier: 0.065 },
|
||||
synced_rate_multiplier: 0.065,
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T00:30:00Z'
|
||||
}
|
||||
@@ -471,12 +481,11 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([11])
|
||||
expect(listAccounts).toHaveBeenCalledTimes(3)
|
||||
expect(listAccounts.mock.calls[2]?.[0]).toBe(2)
|
||||
expect(listAccounts).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
|
||||
})
|
||||
|
||||
it('does not report a successful batch probe as failed when the list refresh fails', async () => {
|
||||
it('does not report a successful batch probe as failed when reconciliation is skipped', async () => {
|
||||
const account = {
|
||||
id: 7,
|
||||
name: 'account-7',
|
||||
@@ -490,7 +499,6 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
}
|
||||
listAccounts
|
||||
.mockResolvedValueOnce({ items: [account], total: 1, page: 1, page_size: 20, pages: 1 })
|
||||
.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
probeUpstreamBillingBatch.mockResolvedValue([
|
||||
{
|
||||
account_id: 7,
|
||||
@@ -549,7 +557,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('refreshes the account row after a successful single-account probe', async () => {
|
||||
it('updates the account row after a successful single-account probe', async () => {
|
||||
const account = (rateMultiplier: number) => ({
|
||||
id: 7,
|
||||
name: 'account-7',
|
||||
@@ -570,6 +578,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
data: { effective_rate_multiplier: 0.065 },
|
||||
synced_rate_multiplier: 0.065,
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T00:30:00Z'
|
||||
}
|
||||
@@ -615,7 +624,7 @@ describe('admin AccountsView bulk edit scope', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(probeUpstreamBilling).toHaveBeenCalledWith(7)
|
||||
expect(listAccounts).toHaveBeenCalledTimes(2)
|
||||
expect(listAccounts).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user