- overview.md: M9 row flipped to shipped, links to spec/plan added,
roadmap paragraph updated with actual deliverables, conventions
updated to reflect dual-theme support
- tests/e2e/auth.spec.ts: add GET /event/list override so the dashboard
renders after the magic-link → token flow (was 500ing with no override)
- tests/e2e/profile.spec.ts: use { exact: true } on username assertion to
avoid strict-mode violation (username 'alice' matched 3 elements)
- Formatting: prettier --write pass on polish spec/plan, layout.css,
layout.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
import { test, expect } from './helpers/fixtures';
|
|
import { mock } from './helpers/mock';
|
|
|
|
test('own profile renders in view mode', async ({ page, loggedInUser }) => {
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
// Email appears in both the nav menu and the profile dl; scope to main to be unambiguous.
|
|
await expect(page.getByRole('main').getByText(loggedInUser.email)).toBeVisible();
|
|
await expect(
|
|
page.getByRole('main').getByText(loggedInUser.username, { exact: true })
|
|
).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /编辑/ })).toBeVisible();
|
|
});
|
|
|
|
test('username-taken error renders on the username field', async ({ page, loggedInUser }) => {
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.getByRole('button', { name: /编辑/ }).click();
|
|
// <label for="username"> + <label class="input"> wrapping the input both
|
|
// associate with the input; use the input id directly to avoid strict-mode ambiguity.
|
|
await page.locator('#username').fill('alice2');
|
|
|
|
await mock.override('PATCH', '/user/update', {
|
|
status: 409,
|
|
body: { status: 409, msg: '用户名已被使用' }
|
|
});
|
|
await page.getByRole('button', { name: /保存/ }).click();
|
|
|
|
await expect(page.getByText('用户名已被使用')).toBeVisible();
|
|
// Form stays in edit mode — username input still visible
|
|
await expect(page.locator('#username')).toBeVisible();
|
|
});
|
|
|
|
test('rejects non-http avatar', async ({ page, loggedInUser }) => {
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.getByRole('button', { name: /编辑/ }).click();
|
|
await page.locator('#avatar').fill('data:image/png;base64,xxx');
|
|
await page.getByRole('button', { name: /保存/ }).click();
|
|
await expect(page.getByText('头像必须是 http(s) 链接')).toBeVisible();
|
|
});
|
|
|
|
test('editing nickname re-renders + sends correct PATCH body', async ({ page, loggedInUser }) => {
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.getByRole('button', { name: /编辑/ }).click();
|
|
await page.locator('#nickname').fill('Bob');
|
|
|
|
await mock.override('PATCH', '/user/update', { status: 200, body: { status: 200 } });
|
|
await mock.override('GET', '/user/info', {
|
|
status: 200,
|
|
body: { status: 200, data: { ...loggedInUser, nickname: 'Bob' } }
|
|
});
|
|
await page.getByRole('button', { name: /保存/ }).click();
|
|
|
|
await expect(page.getByRole('main').getByText('Bob')).toBeVisible();
|
|
|
|
const patches = await mock.requests({ method: 'PATCH', path: '/user/update' });
|
|
expect(patches).toHaveLength(1);
|
|
expect(patches[0].body).toMatchObject({ nickname: 'Bob' });
|
|
});
|
|
|
|
test('visiting /profile redirects to canonical /profile/<own_id>', async ({
|
|
page,
|
|
loggedInUser
|
|
}) => {
|
|
await page.goto('/app/profile');
|
|
await expect(page).toHaveURL(new RegExp(`/app/profile/${loggedInUser.user_id}$`));
|
|
});
|
|
|
|
test('other public profile renders view-only', async ({ page, loggedInUser }) => {
|
|
void loggedInUser; // fixture required to gate auth; profile under test is someone else
|
|
await mock.override('GET', '/user/info/42', {
|
|
status: 200,
|
|
body: {
|
|
status: 200,
|
|
data: {
|
|
user_id: '42',
|
|
email: 'bob@test.local',
|
|
username: 'bob',
|
|
nickname: 'Bob',
|
|
permission_level: 10,
|
|
allow_public: true
|
|
}
|
|
}
|
|
});
|
|
await page.goto('/app/profile/42');
|
|
// Scope to the h2 (display name) to avoid strict-mode ambiguity with the page title h1.
|
|
await expect(page.getByRole('heading', { name: 'Bob', level: 2 })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /编辑/ })).toHaveCount(0);
|
|
});
|
|
|
|
test('other private profile shows access-denied error', async ({ page, loggedInUser }) => {
|
|
void loggedInUser; // fixture required to gate auth; profile under test is someone else
|
|
await mock.override('GET', '/user/info/99', {
|
|
status: 403,
|
|
body: { status: 403, msg: 'forbidden' }
|
|
});
|
|
await page.goto('/app/profile/99');
|
|
await expect(page.getByText('该用户未公开个人资料')).toBeVisible();
|
|
});
|
|
|
|
test('bio renders decoded markdown in view mode', async ({ page, loggedInUser }) => {
|
|
const bioText = '这是我的**个人简介**。';
|
|
const bioBase64 = Buffer.from(bioText).toString('base64');
|
|
|
|
// Override GET /user/info to include a base64-encoded bio BEFORE navigating.
|
|
// Last-writer-wins: this replaces the loggedInUser fixture's override.
|
|
await mock.override('GET', '/user/info', {
|
|
status: 200,
|
|
body: { status: 200, data: { ...loggedInUser, bio: bioBase64 } }
|
|
});
|
|
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
|
|
// Decoded content appears in the prose container (not raw base64)
|
|
const prose = page.getByRole('main').locator('.prose');
|
|
await expect(prose).toBeVisible();
|
|
await expect(prose).toContainText('这是我的');
|
|
// Markdown bold → <strong>
|
|
await expect(prose.locator('strong')).toContainText('个人简介');
|
|
});
|
|
|
|
test('bio edit pre-fills with decoded text and re-encodes on save', async ({
|
|
page,
|
|
loggedInUser
|
|
}) => {
|
|
const bioText = '初始简介内容';
|
|
const bioBase64 = Buffer.from(bioText).toString('base64');
|
|
|
|
await mock.override('GET', '/user/info', {
|
|
status: 200,
|
|
body: { status: 200, data: { ...loggedInUser, bio: bioBase64 } }
|
|
});
|
|
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.getByRole('button', { name: /编辑/ }).click();
|
|
|
|
// Bio textarea pre-fills with decoded (plain text) value
|
|
await expect(page.locator('textarea[name="bio"]')).toHaveValue(bioText);
|
|
|
|
// Change the bio
|
|
const newBio = '新的个人简介';
|
|
await page.locator('textarea[name="bio"]').fill(newBio);
|
|
|
|
await mock.override('PATCH', '/user/update', { status: 200, body: { status: 200 } });
|
|
await mock.override('GET', '/user/info', {
|
|
status: 200,
|
|
body: {
|
|
status: 200,
|
|
data: { ...loggedInUser, bio: Buffer.from(newBio).toString('base64') }
|
|
}
|
|
});
|
|
|
|
await page.getByRole('button', { name: /保存/ }).click();
|
|
|
|
// Wait for onUpdated to fire and switch back to view mode (edit button reappears).
|
|
await expect(page.getByRole('button', { name: /编辑/ })).toBeVisible();
|
|
|
|
// PATCH body must contain re-encoded bio
|
|
const patches = await mock.requests({ method: 'PATCH', path: '/user/update' });
|
|
expect(patches).toHaveLength(1);
|
|
expect(patches[0].body).toMatchObject({ bio: Buffer.from(newBio).toString('base64') });
|
|
});
|
|
|
|
test('no bio shows empty state placeholder', async ({ page, loggedInUser }) => {
|
|
// loggedInUser fixture has no bio — default mock has no bio field
|
|
await page.goto(`/app/profile/${loggedInUser.user_id}`);
|
|
await expect(page.getByText('暂无简介')).toBeVisible();
|
|
});
|