Files
Genarrative/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx
T
kdletters bddc25b4e0 后台支持多账号与页面访问权限
保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号
建立一级页面权限、实时会话失效和后端逐请求鉴权
统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识
补充账号管理页面、生成绑定、定向测试和技术文档
2026-07-14 19:21:33 +08:00

184 lines
5.6 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
disableProfileRedeemCode,
listProfileRedeemCodes,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes';
import { AdminRedeemCodePage } from './AdminRedeemCodePage';
vi.mock('../api/adminApiClient', () => ({
disableProfileRedeemCode: vi.fn(),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
isAdminApiError: vi.fn(() => false),
listProfileRedeemCodes: vi.fn(),
upsertProfileRedeemCode: vi.fn(),
}));
const baseEntry: ProfileRedeemCodeAdminResponse = {
code: 'LONG-LIVED',
mode: 'public',
rewardPoints: 100,
maxUses: 1,
globalUsedCount: 0,
enabled: true,
allowedUserIds: [],
startsAt: null,
expiresAt: null,
createdBy: 'admin',
createdAt: '2026-07-13T01:00:00Z',
updatedAt: '2026-07-13T01:00:00Z',
};
const entries: ProfileRedeemCodeAdminResponse[] = [
baseEntry,
{
...baseEntry,
code: 'PENDING',
startsAt: '2999-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'EXPIRED',
expiresAt: '2000-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'ACTIVE',
startsAt: '2000-01-01T00:00:00Z',
expiresAt: '2999-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'DISABLED',
enabled: false,
startsAt: '2000-01-01T00:00:00Z',
expiresAt: '2999-01-01T00:00:00Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
entries,
operations: [
{
operationId: 'operation-1',
codeKind: 'redeem',
code: 'LONG-LIVED',
action: 'create',
operatorUserId: 'admin-account-internal-1',
operatorDisplayName: '兑换码运营',
createdAt: '2026-07-13T01:00:00Z',
},
],
});
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
...baseEntry,
enabled: false,
});
});
test('操作记录只展示管理员显示名称', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('兑换码运营')).toBeTruthy();
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
});
test('兑换码列表展示生效状态与日期范围', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效');
expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期');
expect((await rowForCode('PENDING')).textContent).toContain('未生效');
expect((await rowForCode('EXPIRED')).textContent).toContain('已过期');
expect((await rowForCode('ACTIVE')).textContent).toContain('有效');
expect((await rowForCode('DISABLED')).textContent).toContain('停用');
});
test('点击兑换码回填本地日期输入', async () => {
const user = userEvent.setup();
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await user.click(await screen.findByRole('button', { name: 'ACTIVE' }));
expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe(
toLocalInputValue('2000-01-01T00:00:00Z'),
);
expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe(
toLocalInputValue('2999-01-01T00:00:00Z'),
);
});
test('兑换码拒绝截止时间不晚于开始时间的配置', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByRole('button', { name: 'LONG-LIVED' });
fireEvent.change(screen.getByLabelText('Code'), {
target: { value: 'INVALID' },
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: { value: '2026-07-13T10:00' },
});
fireEvent.change(screen.getByLabelText('截止时间'), {
target: { value: '2026-07-13T10:00' },
});
expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileRedeemCode).not.toHaveBeenCalled();
});
test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => {
const user = userEvent.setup();
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByRole('button', { name: 'LONG-LIVED' });
fireEvent.change(screen.getByLabelText('Code'), {
target: { value: 'WINDOWED' },
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: { value: '2026-07-13T10:30' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertProfileRedeemCode).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
code: 'WINDOWED',
startsAt: new Date('2026-07-13T10:30').toISOString(),
expiresAt: null,
}),
);
});
});
async function rowForCode(code: string) {
const button = await screen.findByRole('button', { name: code });
const row = button.closest('tr');
if (!row) {
throw new Error(`未找到兑换码 ${code} 所在行`);
}
return row;
}
function toLocalInputValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}