Files
Genarrative/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx
T
suzmii 2532922ea2 后台数据库表查询支持结构化筛选与详情优化
替换手写筛选 JSON 为结构化条件编辑器

支持条件勾选、列表值编辑和三态排序

优化行详情、字段复制 Toast 与主题 JSON 预览

修复数值范围筛选和异步请求竞态

补充后台查询接口校验、回归测试与文档
2026-08-31 21:11:50 +08:00

586 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import {
AdminDatabaseTablesPage,
resolveAdminDatabaseUserReference,
} from './AdminDatabaseTablesPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminDatabaseTableRows: vi.fn(),
getAdminDatabaseTables: vi.fn(),
isAdminApiError: vi.fn(() => false),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string;
}) => (
<button
aria-label={`查看用户 ${userId || publicUserCode}`}
data-public-user-code={publicUserCode}
data-user-id={userId}
type="button"
onClick={(event) => event.stopPropagation()}
/>
),
}));
const referralRows = [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
];
beforeEach(() => {
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
tables: ['profile_referral_relation'],
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
rows: referralRows,
page: 1,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
});
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup();
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id'],
limit: 100,
page: 1,
rows: [
{
cells: { invitee_user_id: 'u-b' },
raw: ['u-b'],
},
],
scannedCount: 50000,
scanLimit: 50000,
scanLimitReached: true,
tableName: 'profile_referral_relation',
totalMatched: 250,
totalReturned: 1,
});
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
expect(pagination.closest('.admin-panel')).toBeNull();
expect(
container.querySelector('.admin-database-tables-page')?.lastElementChild,
).toBe(pagination);
expect(
screen.getByText(
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
),
).toBeTruthy();
await user.type(
screen.getByRole('textbox', { name: '关键词' }),
'未执行条件',
);
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: '',
limit: 100,
page: 2,
search: '',
}),
);
});
});
test('后台表查询页不会让旧表请求覆盖新表结果', async () => {
const user = userEvent.setup();
let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void;
const firstRequest = new Promise<AdminDatabaseTableRowsResponse>(
(resolve) => {
resolveFirstRequest = resolve;
},
);
const oldTableResponse = {
columns: ['id'],
limit: 100,
page: 1,
rows: [{ cells: { id: 'old-row' }, raw: ['old-row'] }],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
} satisfies AdminDatabaseTableRowsResponse;
const newTableResponse = {
...oldTableResponse,
rows: [{ cells: { id: 'new-row' }, raw: ['new-row'] }],
tableName: 'profile_wallet',
} satisfies AdminDatabaseTableRowsResponse;
vi.mocked(getAdminDatabaseTables).mockResolvedValueOnce({
fetchErrors: [],
tables: ['profile_referral_relation', 'profile_wallet'],
});
vi.mocked(getAdminDatabaseTableRows)
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce(newTableResponse);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const tableSelect = await screen.findByRole('combobox');
await user.selectOptions(tableSelect, 'profile_wallet');
expect(await screen.findByText('new-row')).toBeTruthy();
resolveFirstRequest(oldTableResponse);
await waitFor(() => {
expect(screen.queryByText('old-row')).toBeNull();
});
expect(screen.getByText('new-row')).toBeTruthy();
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await screen.findByText('2026-05-02 08:00:00');
const tableWrap = container.querySelector('.admin-table-wrap');
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
expect(
screen
.getByRole('option', { name: '邀请关系(profile_referral_relation)' })
.getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByText('已选表:邀请关系(profile_referral_relation)'),
).toBeTruthy();
expect(
screen.getByRole('heading', { name: '邀请关系' }).getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击列名可在正序、倒序和不排序之间循环切换。 当前状态:不排序。',
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'asc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'desc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: referralRows,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: undefined,
sortDirection: undefined,
}),
);
expect(
screen
.getAllByRole('columnheader', { name: '邀请人ID' })
.filter((header) => header.textContent?.trim() === '邀请人ID')
.some((header) => header.getAttribute('aria-sort') === 'none'),
).toBe(true);
});
});
test('后台表查询页行详情将对象值以语法高亮 JSON 预览', async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'metadata'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-1',
metadata: { enabled: true, count: 2, label: 'demo' },
},
raw: ['row-1', { enabled: true, count: 2, label: 'demo' }],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-1');
expect(screen.queryByRole('button', { name: /^详情$/ })).toBeNull();
await user.click(screen.getByRole('row', { name: /row-1/ }));
const jsonPreview = document.querySelector('pre.admin-json-preview');
expect(jsonPreview).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-key')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-boolean')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-number')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-string')).toBeTruthy();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '复制元数据' }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
JSON.stringify({ enabled: true, count: 2, label: 'demo' }, null, 2),
);
});
expect(screen.getByRole('status').textContent).toBe('已复制 元数据');
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
).toEqual({ userId: 'u-1' });
expect(
resolveAdminDatabaseUserReference(
'auth_store_projection',
'public_user_code',
'TN1001',
),
).toEqual({ publicUserCode: 'TN1001' });
expect(
resolveAdminDatabaseUserReference('audit_log', 'operator_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference(
'profile_wallet',
'user_id',
'admin:root',
),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
test('后台表查询页字段条件构建后透传 filters 并支持列表值', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
expect(screen.getAllByRole('columnheader', { name: '字段' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '条件' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '值' })).toHaveLength(1);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 运算符' }),
'in',
);
expect(screen.getByLabelText('条件 2 值列表').className).toContain(
'admin-database-filter-list-editor',
);
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-a,b',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-c',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
expect(
screen.getByText('u-a,b').closest('.admin-database-filter-values-row'),
).toBeTruthy();
expect(
screen
.getAllByText('u-c')
.some((element) => element.closest('.admin-database-filter-values-row')),
).toBe(true);
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
{
column: 'inviter_user_id',
op: 'in',
value: ['u-a,b', 'u-c'],
},
]),
page: 1,
}),
);
});
});
test('后台表查询页可以勾选或取消勾选条件来切换筛选组合', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.type(screen.getByRole('textbox', { name: '条件 2 值' }), 'u-a');
await user.click(screen.getByRole('checkbox', { name: '启用条件 2' }));
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
]),
page: 1,
}),
);
});
});
test('后台表查询页会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getFormSubmitQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页列头筛选按钮会带字段添加条件', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(
screen.getByRole('button', { name: '按被邀请人ID添加条件' }),
);
const columnSelect = screen.getByRole('combobox', {
name: '条件 1 字段',
}) as HTMLSelectElement;
expect(columnSelect.value).toBe('invitee_user_id');
await user.type(screen.getByRole('textbox', { name: '条件 1 值' }), 'u-b');
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invitee_user_id', op: 'eq', value: 'u-b' },
]),
page: 1,
}),
);
});
});
test('后台表查询页行详情仅保留复制操作', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByText('INV-1001').closest('tr')!);
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.queryByRole('button', { name: '按邀请码筛选' })).toBeNull();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4);
});
function getFormSubmitQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[buttons.length - 1]!;
}
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
);
}