用户软删除后使用记录仍在,但身份(邮箱)被 ent 软删除拦截器隐藏。本次在 三条管理员只读路径定点穿透软删除过滤,并把删除状态传播到前端标记,零新表/ 迁移/回填: - 后端穿透:富化 usage 日志(loadUsers)、用户搜索(ListWithFilters + UserListFilters.IncludeDeleted)、点击详情(GetByIDIncludeDeleted / GetUserIncludeDeleted + getById ?include_deleted 分支) - 状态传播:service.User / dto.User 新增 DeletedAt;SearchUsers 标记 deleted - 前端:表格与余额弹窗展示"已删除"徽标、筛选下拉标注并排序、点击走 include_deleted;新增 i18n admin.usage.userDeletedBadge - 安全:普通用户 usage 仅查本人(无 PII 泄漏);主用户列表与默认 getById 行为不变(已删用户仍 404);仅 admin 搜索设 IncludeDeleted 后端 build / 三态 vet / unit 全量 / 仓储集成全绿;前端 typecheck / vitest / 改动文件 eslint 全清。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
11 KiB
TypeScript
413 lines
11 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import { nextTick } from 'vue'
|
|
|
|
import UsageTable from '../UsageTable.vue'
|
|
|
|
const messages: Record<string, string> = {
|
|
'admin.usage.userDeletedBadge': 'Deleted',
|
|
'usage.costDetails': 'Cost Breakdown',
|
|
'admin.usage.inputCost': 'Input Cost',
|
|
'admin.usage.outputCost': 'Output Cost',
|
|
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
|
|
'admin.usage.cacheReadCost': 'Cache Read Cost',
|
|
'usage.inputTokenPrice': 'Input price',
|
|
'usage.outputTokenPrice': 'Output price',
|
|
'usage.perMillionTokens': '/ 1M tokens',
|
|
'usage.serviceTier': 'Service tier',
|
|
'usage.serviceTierPriority': 'Fast',
|
|
'usage.serviceTierFlex': 'Flex',
|
|
'usage.serviceTierStandard': 'Standard',
|
|
'usage.rate': 'Rate',
|
|
'usage.accountMultiplier': 'Account rate',
|
|
'usage.original': 'Original',
|
|
'usage.userBilled': 'User billed',
|
|
'usage.accountBilled': 'Account billed',
|
|
'usage.imageUnit': ' images',
|
|
'usage.imageCount': 'Image count',
|
|
'usage.imageBillingSize': 'Billing size',
|
|
'usage.imageInputSize': 'Input size',
|
|
'usage.imageOutputSize': 'Output size',
|
|
'usage.imageSizeSource': 'Size source',
|
|
'usage.imageSizeBreakdown': 'Size breakdown',
|
|
'usage.imageSizeSourceOutput': 'Upstream output',
|
|
'usage.imageSizeSourceInput': 'Request input',
|
|
'usage.imageSizeSourceDefault': 'Default billing tier',
|
|
'usage.imageSizeSourceLegacy': 'Legacy record',
|
|
'usage.imageSizeSourceMissing': 'Not recorded',
|
|
'usage.imageSizeNotRecorded': 'not recorded',
|
|
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
|
|
'usage.imageSizeUnknown': 'unknown',
|
|
'usage.imageUnitPrice': 'Per-image price',
|
|
'usage.imageTotalPrice': 'Image total price',
|
|
'admin.usage.billingModeToken': 'Token',
|
|
'admin.usage.billingModePerRequest': 'Per request',
|
|
'admin.usage.billingModeImage': 'Image',
|
|
}
|
|
|
|
vi.mock('vue-i18n', async () => {
|
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
|
return {
|
|
...actual,
|
|
useI18n: () => ({
|
|
t: (key: string) => messages[key] ?? key,
|
|
}),
|
|
}
|
|
})
|
|
|
|
const DataTableStub = {
|
|
props: ['data'],
|
|
template: `
|
|
<div>
|
|
<div v-for="row in data" :key="row.request_id">
|
|
<slot name="cell-model" :row="row" :value="row.model" />
|
|
<slot name="cell-billing_mode" :row="row" />
|
|
<slot name="cell-tokens" :row="row" />
|
|
<slot name="cell-cost" :row="row" />
|
|
</div>
|
|
</div>
|
|
`,
|
|
}
|
|
|
|
const baseImageRow = {
|
|
request_id: 'req-admin-image',
|
|
model: 'gpt-image-2',
|
|
actual_cost: 0.4,
|
|
total_cost: 0.4,
|
|
account_rate_multiplier: 1,
|
|
rate_multiplier: 1,
|
|
service_tier: null,
|
|
input_cost: 0,
|
|
output_cost: 0,
|
|
cache_creation_cost: 0,
|
|
cache_read_cost: 0,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_creation_tokens: 0,
|
|
cache_read_tokens: 0,
|
|
cache_creation_5m_tokens: 0,
|
|
cache_creation_1h_tokens: 0,
|
|
cache_ttl_overridden: false,
|
|
billing_mode: 'image',
|
|
image_count: 2,
|
|
image_size: '2K',
|
|
image_input_size: null,
|
|
image_output_size: null,
|
|
image_size_source: null,
|
|
image_size_breakdown: null,
|
|
}
|
|
|
|
describe('admin UsageTable tooltip', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
|
x: 0,
|
|
y: 0,
|
|
top: 20,
|
|
left: 20,
|
|
right: 120,
|
|
bottom: 40,
|
|
width: 100,
|
|
height: 20,
|
|
toJSON: () => ({}),
|
|
} as DOMRect)
|
|
})
|
|
|
|
it('shows service tier and billing breakdown in cost tooltip', async () => {
|
|
const row = {
|
|
request_id: 'req-admin-1',
|
|
actual_cost: 0.092883,
|
|
total_cost: 0.092883,
|
|
account_rate_multiplier: 1,
|
|
rate_multiplier: 1,
|
|
service_tier: 'priority',
|
|
input_cost: 0.020285,
|
|
output_cost: 0.00303,
|
|
cache_creation_cost: 0,
|
|
cache_read_cost: 0.069568,
|
|
input_tokens: 4057,
|
|
output_tokens: 101,
|
|
}
|
|
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [row],
|
|
loading: false,
|
|
columns: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStub,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
const tooltipTriggers = wrapper.findAll('.group.relative')
|
|
await tooltipTriggers[tooltipTriggers.length - 1].trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const text = wrapper.text()
|
|
expect(text).toContain('Service tier')
|
|
expect(text).toContain('Fast')
|
|
expect(text).toContain('Rate')
|
|
expect(text).toContain('1.00x')
|
|
expect(text).toContain('Account rate')
|
|
expect(text).toContain('User billed')
|
|
expect(text).toContain('Account billed')
|
|
expect(text).toContain('$0.092883')
|
|
expect(text).toContain('$5.0000 / 1M tokens')
|
|
expect(text).toContain('$30.0000 / 1M tokens')
|
|
expect(text).toContain('$0.069568')
|
|
})
|
|
|
|
it('shows requested and upstream models separately for admin rows', () => {
|
|
const row = {
|
|
request_id: 'req-admin-model-1',
|
|
model: 'claude-sonnet-4',
|
|
upstream_model: 'claude-sonnet-4-20250514',
|
|
actual_cost: 0,
|
|
total_cost: 0,
|
|
account_rate_multiplier: 1,
|
|
rate_multiplier: 1,
|
|
input_cost: 0,
|
|
output_cost: 0,
|
|
cache_creation_cost: 0,
|
|
cache_read_cost: 0,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
}
|
|
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [row],
|
|
loading: false,
|
|
columns: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStub,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
const text = wrapper.text()
|
|
expect(text).toContain('claude-sonnet-4')
|
|
expect(text).toContain('claude-sonnet-4-20250514')
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
name: 'defaulted row',
|
|
row: {
|
|
...baseImageRow,
|
|
request_id: 'req-admin-default-image',
|
|
image_size: '2K',
|
|
image_input_size: 'auto',
|
|
image_output_size: null,
|
|
image_size_source: 'default',
|
|
},
|
|
expected: ['2K', 'Default billing tier', 'auto', 'unknown'],
|
|
},
|
|
{
|
|
name: 'output-sourced row',
|
|
row: {
|
|
...baseImageRow,
|
|
request_id: 'req-admin-output-image',
|
|
image_size: '4K',
|
|
image_input_size: '1024x1024',
|
|
image_output_size: '3840x2160',
|
|
image_size_source: 'output',
|
|
image_size_breakdown: { '4K': 1 },
|
|
},
|
|
expected: ['4K', 'Upstream output', '1024x1024', '3840x2160', '4K x 1'],
|
|
},
|
|
{
|
|
name: 'input-sourced row',
|
|
row: {
|
|
...baseImageRow,
|
|
request_id: 'req-admin-input-image',
|
|
image_size: '1K',
|
|
image_input_size: '1024x1024',
|
|
image_output_size: null,
|
|
image_size_source: 'input',
|
|
},
|
|
expected: ['1K', 'Request input', '1024x1024', 'unknown'],
|
|
},
|
|
{
|
|
name: 'legacy unstandardized row',
|
|
row: {
|
|
...baseImageRow,
|
|
request_id: 'req-admin-legacy-unstandardized-image',
|
|
image_size: '512x512',
|
|
image_input_size: null,
|
|
image_output_size: null,
|
|
image_size_source: null,
|
|
},
|
|
expected: ['legacy unstandardized: 512x512', 'Legacy record', 'unknown'],
|
|
},
|
|
])('shows image usage metadata for $name', async ({ row, expected }) => {
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [row],
|
|
loading: false,
|
|
columns: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStub,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
await wrapper.find('.group.relative').trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const text = wrapper.text()
|
|
expect(text).toContain('Image count')
|
|
expect(text).toContain('Billing size')
|
|
expect(text).toContain('Size source')
|
|
expect(text).toContain('Input size')
|
|
expect(text).toContain('Output size')
|
|
expect(text).toContain('Per-image price')
|
|
expect(text).toContain('Image total price')
|
|
for (const value of expected) {
|
|
expect(text).toContain(value)
|
|
}
|
|
})
|
|
|
|
it('displays historical image rows with missing billing_mode as image usage without a 2K fallback', async () => {
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [
|
|
{
|
|
...baseImageRow,
|
|
request_id: 'req-admin-legacy-missing-image',
|
|
billing_mode: null,
|
|
image_size: null,
|
|
image_input_size: null,
|
|
image_output_size: null,
|
|
image_size_source: null,
|
|
image_size_breakdown: null,
|
|
},
|
|
],
|
|
loading: false,
|
|
columns: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStub,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
await wrapper.find('.group.relative').trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const text = wrapper.text()
|
|
expect(text).toContain('Image')
|
|
expect(text).toContain('Image count')
|
|
expect(text).toContain('Per-image price')
|
|
expect(text).toContain('not recorded')
|
|
expect(text).not.toContain('(2K)')
|
|
})
|
|
})
|
|
|
|
// A DataTable stub that also renders cell-user, so the deleted badge can be asserted.
|
|
const DataTableStubWithUser = {
|
|
props: ['data'],
|
|
template: `
|
|
<div>
|
|
<div v-for="row in data" :key="row.request_id">
|
|
<slot name="cell-user" :row="row" />
|
|
<slot name="cell-model" :row="row" :value="row.model" />
|
|
<slot name="cell-billing_mode" :row="row" />
|
|
<slot name="cell-tokens" :row="row" />
|
|
<slot name="cell-cost" :row="row" />
|
|
</div>
|
|
</div>
|
|
`,
|
|
}
|
|
|
|
describe('admin UsageTable deleted-user badge', () => {
|
|
it('renders deleted badge for a soft-deleted user row', () => {
|
|
const row = {
|
|
request_id: 'req-deleted-user-1',
|
|
model: 'claude-3',
|
|
user_id: 2,
|
|
user: { id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' },
|
|
actual_cost: 0,
|
|
total_cost: 0,
|
|
input_cost: 0,
|
|
output_cost: 0,
|
|
rate_multiplier: 1,
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
}
|
|
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [row],
|
|
loading: false,
|
|
columns: [{ key: 'user', label: 'User' }],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStubWithUser,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Deleted')
|
|
expect(wrapper.text()).toContain('d@test.com')
|
|
})
|
|
|
|
it('does NOT render deleted badge for an active user row', () => {
|
|
const row = {
|
|
request_id: 'req-active-user-1',
|
|
model: 'claude-3',
|
|
user_id: 3,
|
|
user: { id: 3, email: 'active@test.com', deleted_at: null },
|
|
actual_cost: 0,
|
|
total_cost: 0,
|
|
input_cost: 0,
|
|
output_cost: 0,
|
|
rate_multiplier: 1,
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
}
|
|
|
|
const wrapper = mount(UsageTable, {
|
|
props: {
|
|
data: [row],
|
|
loading: false,
|
|
columns: [{ key: 'user', label: 'User' }],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
DataTable: DataTableStubWithUser,
|
|
EmptyState: true,
|
|
Icon: true,
|
|
Teleport: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).not.toContain('Deleted')
|
|
expect(wrapper.text()).toContain('active@test.com')
|
|
})
|
|
})
|