Files
cms-client/tests/e2e/admin-users.spec.ts
2026-04-18 12:14:30 +08:00

230 lines
8.7 KiB
TypeScript

import { expect } from '@playwright/test';
import { test } from './helpers/fixtures';
import { mock } from './helpers/mock';
// ─── Shared mock data ────────────────────────────────────────────────────────
const makeUser = (id: string, username: string, level: number) => ({
user_id: id,
username,
nickname: username,
email: `${username}@test.local`,
avatar: null,
permission_level: level
});
const users = [makeUser('u1', 'alice', 10), makeUser('u2', 'bob', 30), makeUser('u3', 'carol', 15)];
function overrideUserList(items = users, total = items.length) {
return mock.override('GET', '/user/list', {
status: 200,
body: { status: 200, data: { items, total } }
});
}
// ─── List rendering ──────────────────────────────────────────────────────────
test('users list renders usernames', async ({ page, superAdminUser }) => {
void superAdminUser;
await overrideUserList();
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await expect(page.getByText('alice').first()).toBeVisible();
await expect(page.getByText('bob').first()).toBeVisible();
await expect(page.getByText('carol').first()).toBeVisible();
});
test('users list shows total count', async ({ page, superAdminUser }) => {
void superAdminUser;
await overrideUserList(users, 42);
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await expect(page.getByText(/共 42 人/)).toBeVisible();
});
// ─── Sort ────────────────────────────────────────────────────────────────────
test('clicking 权限等级 header flips sort_order in URL', async ({ page, superAdminUser }) => {
void superAdminUser;
await overrideUserList();
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
// First click → sort_by=permission_level&sort_order=asc
await overrideUserList();
await page.getByRole('link', { name: /权限等级/ }).click();
await page.waitForURL(/sort_by=permission_level/);
expect(page.url()).toContain('sort_by=permission_level');
expect(page.url()).toContain('sort_order=asc');
// Second click on same column → sort_order=desc
await overrideUserList();
await page.getByRole('link', { name: /权限等级/ }).click();
await page.waitForURL(/sort_order=desc/);
expect(page.url()).toContain('sort_order=desc');
});
// ─── Filter ──────────────────────────────────────────────────────────────────
test('level filter navigates with level= param', async ({ page, superAdminUser }) => {
void superAdminUser;
await overrideUserList();
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await overrideUserList([makeUser('u2', 'bob', 30)], 1);
await page.selectOption('#level-filter', '30');
await page.waitForURL(/level=30/);
expect(page.url()).toContain('level=30');
await expect(page.getByText('bob').first()).toBeVisible();
});
// ─── Pagination ──────────────────────────────────────────────────────────────
test('prev page link is absent on page 1', async ({ page, superAdminUser }) => {
void superAdminUser;
await overrideUserList(users, 3); // 3 items, page size 20 → only 1 page
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('link', { name: '上一页' })).not.toBeVisible();
});
test('next page link appears when there are more items', async ({ page, superAdminUser }) => {
void superAdminUser;
// Return 20 items but total=25 → next page exists
const manyUsers = Array.from({ length: 20 }, (_, i) => makeUser(`u${i}`, `user${i}`, 10));
await overrideUserList(manyUsers, 25);
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('link', { name: '下一页' })).toBeVisible();
});
// ─── Inline edit ─────────────────────────────────────────────────────────────
test('Edit button absent for own row', async ({ page, superAdminUser }) => {
// superAdminUser has user_id: '1' (set in fixtures.ts via loginAsMockUser default)
const selfUser = makeUser('1', 'superadmin', 40);
void superAdminUser;
await overrideUserList([selfUser, makeUser('u2', 'bob', 10)]);
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
// The row for 'superadmin' (own row) should have no Edit button
const superadminRow = page.getByRole('row', { name: /superadmin/ });
await expect(superadminRow.getByRole('button', { name: /编辑/ })).not.toBeVisible();
// The row for 'bob' should have an Edit button (Lv40 can assign Lv10)
const bobRow = page.getByRole('row', { name: /bob/ });
await expect(bobRow.getByRole('button', { name: /编辑/ })).toBeVisible();
});
test('inline edit happy path: confirm updates permission level', async ({
page,
superAdminUser
}) => {
void superAdminUser;
const targetUser = makeUser('u2', 'bob', 10);
await overrideUserList([targetUser]);
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
// Set up the PATCH mock before clicking Edit
await mock.override('PATCH', '/user/update/u2', {
status: 200,
body: { status: 200, data: {} }
});
// Click Edit on bob's row
const bobRow = page.getByRole('row', { name: /bob/ });
await bobRow.getByRole('button', { name: /编辑/ }).click();
// Select Lv30 in the inline select
await bobRow.getByRole('combobox').selectOption('30');
// Set up updated list for the re-fetch after success
await overrideUserList([{ ...targetUser, permission_level: 30 }]);
// Confirm
await bobRow.getByRole('button', { name: '确认' }).click();
// Wait for inline edit to close (success: onResult sets editingUserId = null)
await expect(bobRow.getByRole('button', { name: '确认' })).not.toBeVisible();
await page.waitForLoadState('networkidle');
// Verify PATCH was called with correct body
const requests = await mock.requests({ method: 'PATCH', path: '/user/update/u2' });
expect(requests[0].body).toMatchObject({ permission_level: 30 });
});
test('permission-matrix violation shows inline error', async ({ page, superAdminUser }) => {
void superAdminUser;
// Use a Lv10 target so the chosen level (30) is within the editor's assignable set;
// the backend mock returns 403 to simulate a policy violation at the API layer.
const targetUser = makeUser('u5', 'eve', 10);
await overrideUserList([targetUser]);
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await mock.override('PATCH', '/user/update/u5', {
status: 403,
body: { status: 403, msg: '权限矩阵限制' }
});
// 403 from PATCH does NOT trigger the 401 interceptor — no /auth/refresh needed
const eveRow = page.getByRole('row', { name: /eve/ });
await eveRow.getByRole('button', { name: /编辑/ }).click();
await expect(eveRow.getByRole('button', { name: '确认' })).toBeVisible();
// Pick a valid level so the server-side guard passes and the PATCH fires
await eveRow.getByRole('combobox').selectOption('30');
await eveRow.getByRole('button', { name: '确认' }).click();
await expect(page.getByText('权限矩阵限制')).toBeVisible();
});
// ─── Permission gate ─────────────────────────────────────────────────────────
test('Lv30 user is denied access to users page', async ({ page, _autoResetMock }) => {
void _autoResetMock;
await page.context().addCookies([
{
name: 'access_token',
value: 'test-access',
domain: 'localhost',
path: '/app',
httpOnly: true,
sameSite: 'Lax'
},
{
name: 'refresh_token',
value: 'test-refresh',
domain: 'localhost',
path: '/app',
httpOnly: true,
sameSite: 'Lax'
}
]);
await mock.override('GET', '/user/info', {
status: 200,
body: {
status: 200,
data: {
user_id: '99',
email: 'organizer@test.local',
username: 'organizer',
permission_level: 30,
allow_public: false
}
}
});
await page.goto('/app/admin/users');
await page.waitForLoadState('networkidle');
await expect(page.getByText('权限不足')).toBeVisible();
});