Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so covers can be replaced or cleared after creation, and wires a dedicated /manga/[id]/edit SvelteKit route that combines the existing PATCH with the new cover endpoints. Cover PUT cleans up the old blob when the extension changes, swallowing StorageError::NotFound so a manually-gone file doesn't surface as a 404 to the client. Edit link on the manga detail page is gated on session.user, matching the auth posture of the underlying handlers. Also pins the local-dev port story via loadEnv() in vite.config.ts so VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL stable across runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
const userFixture = {
|
|
id: 'u1',
|
|
username: 'alice',
|
|
created_at: '2026-01-01T00:00:00Z'
|
|
};
|
|
|
|
const baseManga = {
|
|
id: 'm1',
|
|
title: 'Berserk',
|
|
status: 'ongoing',
|
|
alt_titles: ['Old Alt'],
|
|
description: 'Original description',
|
|
cover_image_path: null,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
|
genres: [],
|
|
tags: []
|
|
};
|
|
|
|
async function stubAuthenticatedAndGenres(page: Page) {
|
|
await page.route('**/api/v1/auth/me', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ user: userFixture })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/genres', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{ id: 'g-action', name: 'Action' },
|
|
{ id: 'g-fantasy', name: 'Fantasy' }
|
|
])
|
|
})
|
|
);
|
|
}
|
|
|
|
test('anonymous user sees sign-in prompt on /manga/[id]/edit', async ({ page }) => {
|
|
await page.route('**/api/v1/auth/me', (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/genres', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
|
);
|
|
await page.route('**/api/v1/mangas/m1', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(baseManga)
|
|
})
|
|
);
|
|
|
|
await page.goto('/manga/m1/edit');
|
|
await expect(page.getByTestId('edit-signin')).toBeVisible();
|
|
});
|
|
|
|
test('/manga/[id]/edit PATCHes the changed metadata and lands on the manga page', async ({
|
|
page
|
|
}) => {
|
|
await stubAuthenticatedAndGenres(page);
|
|
|
|
let patchBody: Record<string, unknown> | null = null;
|
|
let mangaAfter = { ...baseManga };
|
|
await page.route('**/api/v1/mangas/m1', async (route) => {
|
|
const method = route.request().method();
|
|
if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mangaAfter)
|
|
});
|
|
} else if (method === 'PATCH') {
|
|
patchBody = JSON.parse(route.request().postData() ?? '{}');
|
|
mangaAfter = {
|
|
...mangaAfter,
|
|
title: (patchBody.title as string) ?? mangaAfter.title,
|
|
description:
|
|
'description' in (patchBody as Record<string, unknown>)
|
|
? ((patchBody.description as string | null) ?? null)
|
|
: mangaAfter.description
|
|
};
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mangaAfter)
|
|
});
|
|
} else {
|
|
await route.fallback();
|
|
}
|
|
});
|
|
await page.route('**/api/v1/mangas/m1/chapters*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [],
|
|
page: { limit: 50, offset: 0, total: 0 }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [],
|
|
page: { limit: 50, offset: 0, total: 0 }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/read-progress/m1', (route) =>
|
|
route.fulfill({
|
|
status: 404,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: { code: 'not_found', message: 'no progress' }
|
|
})
|
|
})
|
|
);
|
|
|
|
await page.goto('/manga/m1');
|
|
// Edit link is gated on session.user — it should be visible to the
|
|
// stubbed authenticated user.
|
|
await page.getByTestId('edit-manga-link').click();
|
|
await expect(page).toHaveURL(/\/manga\/m1\/edit$/);
|
|
|
|
const titleInput = page.getByTestId('manga-title');
|
|
await expect(titleInput).toHaveValue('Berserk');
|
|
await titleInput.fill('Berserk (Deluxe)');
|
|
await page.getByTestId('manga-edit-submit').click();
|
|
|
|
await expect(page).toHaveURL(/\/manga\/m1$/);
|
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk (Deluxe)');
|
|
expect(patchBody).not.toBeNull();
|
|
expect((patchBody as Record<string, unknown>).title).toBe('Berserk (Deluxe)');
|
|
});
|