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(); });