feat(analysis): admin UI — reader context-menu action + dashboard section
Surfaces the scoped re-enqueue and per-page force-analyze in the UI:
- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
(canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
(whole library / one manga / one chapter), id input, and an
"include already-analyzed" toggle (default off), with busy/notice/error.
Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
124
frontend/e2e/admin-analysis.spec.ts
Normal file
124
frontend/e2e/admin-analysis.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// E2E for the admin Analysis section: scoped re-enqueue (all / manga /
|
||||
// chapter) with the include-already-analyzed toggle. Fully mocked.
|
||||
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const adminUser = {
|
||||
id: 'u11111111-1111-1111-1111-111111111111',
|
||||
username: 'admin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true
|
||||
};
|
||||
|
||||
const systemStats = {
|
||||
disk: null,
|
||||
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
|
||||
cpu: { percent_used: 0 },
|
||||
alerts: []
|
||||
};
|
||||
|
||||
type Captured = { body: Record<string, unknown> | null };
|
||||
|
||||
async function mockAdmin(page: Page, captured: Captured) {
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: adminUser })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
||||
})
|
||||
);
|
||||
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/admin/system', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(systemStats)
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
|
||||
captured.body = JSON.parse(route.request().postData() ?? '{}');
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enqueued: 12 })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('/admin/analysis', () => {
|
||||
test('default whole-library re-enqueue posts only_unanalyzed=true', async ({
|
||||
page
|
||||
}) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-form')).toBeVisible();
|
||||
await page.getByTestId('admin-analysis-submit').click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
||||
'Enqueued 12 pages'
|
||||
);
|
||||
expect(captured.body).toEqual({ only_unanalyzed: true });
|
||||
});
|
||||
|
||||
test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', async ({
|
||||
page
|
||||
}) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId('admin-analysis-scope-manga').check();
|
||||
await page
|
||||
.getByTestId('admin-analysis-manga-id')
|
||||
.fill('a9999999-9999-9999-9999-999999999999');
|
||||
await page.getByTestId('admin-analysis-include-analyzed').check();
|
||||
await page.getByTestId('admin-analysis-submit').click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
|
||||
expect(captured.body).toEqual({
|
||||
only_unanalyzed: false,
|
||||
manga_id: 'a9999999-9999-9999-9999-999999999999'
|
||||
});
|
||||
});
|
||||
|
||||
test('chapter scope requires an id before posting', async ({ page }) => {
|
||||
const captured: Captured = { body: null };
|
||||
await mockAdmin(page, captured);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/admin/analysis');
|
||||
|
||||
await page.getByTestId('admin-analysis-scope-chapter').check();
|
||||
await page.getByTestId('admin-analysis-submit').click();
|
||||
|
||||
await expect(page.getByTestId('admin-analysis-error')).toContainText(
|
||||
'Enter a chapter id'
|
||||
);
|
||||
expect(captured.body).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,12 @@ const collectionFixture = {
|
||||
*/
|
||||
async function mockReader(
|
||||
page: Page,
|
||||
state: { collectionsContainingPage: string[]; tagsOnPage: string[] }
|
||||
state: {
|
||||
collectionsContainingPage: string[];
|
||||
tagsOnPage: string[];
|
||||
admin?: boolean;
|
||||
analyzeCalls?: number;
|
||||
}
|
||||
) {
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
route.fulfill({
|
||||
@@ -86,9 +91,20 @@ async function mockReader(
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
body: JSON.stringify({
|
||||
user: { ...userFixture, is_admin: state.admin ?? false }
|
||||
})
|
||||
})
|
||||
);
|
||||
// Admin force-analyze endpoint — counts invocations for assertions.
|
||||
await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => {
|
||||
state.analyzeCalls = (state.analyzeCalls ?? 0) + 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enqueued: true })
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -266,6 +282,37 @@ test.describe('page context menu (desktop right-click)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('non-admin does not see the Queue for analysis action', async ({ page }) => {
|
||||
await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||
|
||||
await page.getByTestId('reader-page').click({ button: 'right' });
|
||||
await expect(page.getByTestId('page-context-menu')).toBeVisible();
|
||||
await expect(page.getByTestId('page-context-analyze')).toBeHidden();
|
||||
});
|
||||
|
||||
test('admin can queue the page for analysis from the menu', async ({ page }) => {
|
||||
const state = {
|
||||
collectionsContainingPage: [],
|
||||
tagsOnPage: [],
|
||||
admin: true,
|
||||
analyzeCalls: 0
|
||||
};
|
||||
await mockReader(page, state);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||
|
||||
await page.getByTestId('reader-page').click({ button: 'right' });
|
||||
const item = page.getByTestId('page-context-analyze');
|
||||
await expect(item).toBeVisible();
|
||||
await expect(item).toContainText('Queue for analysis');
|
||||
|
||||
await item.click();
|
||||
await expect(item).toContainText('Queued for analysis');
|
||||
expect(state.analyzeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('Shift+right-click falls through to the browser native menu', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user