bddc25b4e0
保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号 建立一级页面权限、实时会话失效和后端逐请求鉴权 统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识 补充账号管理页面、生成绑定、定向测试和技术文档
184 lines
5.6 KiB
TypeScript
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);
|
|
}
|