diff --git a/frontend/src/routes/admin/[[tab]]/page.test.ts b/frontend/src/routes/admin/[[tab]]/page.test.ts index 8f26dc44..4ec8fa19 100644 --- a/frontend/src/routes/admin/[[tab]]/page.test.ts +++ b/frontend/src/routes/admin/[[tab]]/page.test.ts @@ -1,12 +1,46 @@ import { it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -const { session, ui } = vi.hoisted(() => ({ +const { session, ui, pageState } = vi.hoisted(() => ({ session: { user: { id: '1', username: 'admin', role: 'admin' } }, - ui: { notify: vi.fn() } + ui: { notify: vi.fn() }, + // Mock of SvelteKit's `$app/state` `page` — post-URL-routing + // the admin page reads `page.params.tab` to derive which + // section to render. Tests set the tab via `setTab(...)` + // BEFORE `render(AdminPage)`; the derived picks it up on + // initial mount. Previously the tab was chosen by clicking a + // horizontal-tab button that no longer exists. + pageState: { + page: { + url: new URL('http://localhost/admin'), + params: {} as Record, + route: { id: '/admin/[[tab]]' }, + status: 200, + error: null, + data: {}, + form: null, + state: {} + } + } })); vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/ui.svelte', () => ({ ui })); +vi.mock('$app/state', () => pageState); +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/paths', () => ({ base: '', resolve: (r: string) => r })); + +/** + * Set the current tab BEFORE calling `render(AdminPage)`. The + * admin page reads the URL-derived tab in a `$derived`, which + * captures the value at first render — mutating this mock later + * doesn't retrigger. Tests that exercise multiple tabs render + * once per tab (each in a fresh `render` call — @testing-library + * unmounts between tests via its `beforeEach` cleanup). + */ +function setTab(tab: string | undefined) { + pageState.page.params = tab ? { tab } : {}; + pageState.page.url = new URL(`http://localhost/admin${tab ? '/' + tab : ''}`); +} vi.mock('$lib/api/endpoints/admin', () => ({ clearPluginLogs: vi.fn(), createExternalMount: vi.fn(), @@ -87,6 +121,10 @@ const mount = { beforeEach(() => { vi.clearAllMocks(); + // Reset the tab mock so a test that sets `setTab('users')` + // doesn't leak into the next test (default = /admin → + // dashboard). + setTab(undefined); m(admin.getDashboard).mockResolvedValue(dashboard); m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] }); m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] }); @@ -136,8 +174,8 @@ it('toggles registration from the dashboard', async () => { it('loads users when the users tab is opened and creates a user', async () => { m(admin.createUser).mockResolvedValue(undefined); + setTab('users'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-users-tab')); await waitFor(() => expect(admin.listUsers).toHaveBeenCalled()); await fireEvent.click(await screen.findByTestId('admin-users-create-btn')); await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), { @@ -151,33 +189,33 @@ it('loads users when the users tab is opened and creates a user', async () => { }); it('loads OIDC settings when the OIDC tab is opened', async () => { + setTab('oidc'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-oidc-tab')); await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled()); }); it('loads storage + migration when the storage tab is opened', async () => { + setTab('storage'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-storage-tab')); await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled()); await waitFor(() => expect(admin.getMigration).toHaveBeenCalled()); }); it('loads SMTP info when the SMTP tab is opened', async () => { + setTab('smtp'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-smtp-tab')); await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled()); }); it('loads plugins when the plugins tab is opened', async () => { + setTab('plugins'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-plugins-tab')); await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled()); }); it('loads external mounts when the mounts tab is opened and lists them', async () => { + setTab('mounts'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); await waitFor(() => expect(admin.listExternalMounts).toHaveBeenCalled()); // The configured mount is rendered in the table. expect(await screen.findByText('Media')).toBeTruthy(); @@ -194,8 +232,8 @@ it('creates a mount from the mounts form', async () => { mount_path: 'Personal/Photos', config: { path: '/srv/photos', read_only: false } }); + setTab('mounts'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); await fireEvent.input(await screen.findByTestId('mount-name'), { target: { value: 'Photos' } }); @@ -212,8 +250,8 @@ it('creates a mount from the mounts form', async () => { it('deletes a mount through the confirm modal', async () => { m(admin.deleteExternalMount).mockResolvedValue(undefined); + setTab('mounts'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); await fireEvent.click(await screen.findByTestId('mount-delete')); // deleteMount() gates on the styled confirm modal. await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); @@ -222,8 +260,8 @@ it('deletes a mount through the confirm modal', async () => { it("toggles a user's role through the confirm modal", async () => { m(admin.setUserRole).mockResolvedValue(undefined); + setTab('users'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-users-tab')); await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1')); await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin')); @@ -231,8 +269,8 @@ it("toggles a user's role through the confirm modal", async () => { it('deactivates a user through the confirm modal', async () => { m(admin.setUserActive).mockResolvedValue(undefined); + setTab('users'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-users-tab')); await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1')); await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false)); @@ -240,8 +278,8 @@ it('deactivates a user through the confirm modal', async () => { it('saves OIDC settings from the OIDC form', async () => { m(admin.saveOidc).mockResolvedValue(undefined); + setTab('oidc'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-oidc-tab')); await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), { target: { value: 'https://idp.test' } }); @@ -251,8 +289,8 @@ it('saves OIDC settings from the OIDC form', async () => { it('sends an SMTP test email', async () => { m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never); + setTab('smtp'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-smtp-tab')); await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), { target: { value: 'to@x.test' } }); @@ -263,8 +301,8 @@ it('sends an SMTP test email', async () => { it('saves storage settings and starts a migration', async () => { m(admin.saveStorage).mockResolvedValue(undefined); m(admin.migrationAction).mockResolvedValue(undefined); + setTab('storage'); render(AdminPage); - await fireEvent.click(await screen.findByTestId('admin-storage-tab')); await fireEvent.submit(await screen.findByTestId('admin-storage-form')); await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled()); await fireEvent.click(await screen.findByTestId('admin-migration-start-btn')); diff --git a/src/infrastructure/services/drives_consistency_service.rs b/src/infrastructure/services/drives_consistency_service.rs index a484f585..c4436d52 100644 --- a/src/infrastructure/services/drives_consistency_service.rs +++ b/src/infrastructure/services/drives_consistency_service.rs @@ -410,12 +410,24 @@ mod integration_tests { // 5. Set the artificially-wrong cached used_bytes. LAST, so // no INSERT-side trigger overwrites our fake (there is no // such trigger today, but ordering is cheap insurance). - sqlx::query("UPDATE storage.drives SET used_bytes = $1 WHERE id = $2") - .bind(cached) - .bind(drive_id) - .execute(pool) - .await - .expect("set fake used_bytes"); + // Also backdate `created_at` past the tenant's 1-hour + // grace window (`created_at < NOW() - INTERVAL '1 hour'` + // in the SQL) — freshly-seeded fixtures are by definition + // younger than that window and would otherwise be + // silently skipped by the scan, leaving `scanned_count` + // at 0 and the drift-detection assertions with nothing + // to compare against. + sqlx::query( + "UPDATE storage.drives \ + SET used_bytes = $1, \ + created_at = NOW() - INTERVAL '2 hours' \ + WHERE id = $2", + ) + .bind(cached) + .bind(drive_id) + .execute(pool) + .await + .expect("set fake used_bytes + backdate"); drive_id } diff --git a/tests/e2e/spa/admin.spec.ts b/tests/e2e/spa/admin.spec.ts index 4f02b6f0..26285b89 100644 --- a/tests/e2e/spa/admin.spec.ts +++ b/tests/e2e/spa/admin.spec.ts @@ -15,44 +15,47 @@ test.beforeEach(async ({ page }) => { test('walk every admin tab', async ({ page }) => { await page.goto('/admin'); - await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 }); - // Dashboard is the default tab. + // Dashboard is the default section on `/admin` (bare path). + // Assert on the sidebar entry (now the source of admin navigation) + // and on content that only renders when Dashboard is active. + await expect(page.getByTestId('appshell-nav-admin-dashboard-link')).toBeVisible({ + timeout: 15_000 + }); await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible(); - await page.getByTestId('admin-users-tab').click(); + await page.goto('/admin/users'); await expect(page.getByTestId('admin-users-create-btn')).toBeVisible(); - await page.getByTestId('admin-oidc-tab').click(); + await page.goto('/admin/oidc'); await expect(page.getByTestId('admin-oidc-form')).toBeVisible(); - await page.getByTestId('admin-storage-tab').click(); + await page.goto('/admin/storage'); await expect(page.getByTestId('admin-storage-form')).toBeVisible(); - await page.getByTestId('admin-smtp-tab').click(); + await page.goto('/admin/smtp'); await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible(); - await page.getByTestId('admin-plugins-tab').click(); - // Plugins panel content is conditional; assert the tab became active. - await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true'); + await page.goto('/admin/plugins'); + // Plugins panel content is conditional; assert the URL landed + // on the plugins section (proves routing worked; no content + // guarantee). + await expect(page).toHaveURL(/\/admin\/plugins$/); }); test('open the create-user form', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-users-tab').click(); + await page.goto('/admin/users'); await page.getByTestId('admin-users-create-btn').click(); await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 }); }); test('storage tab: change backend select', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-storage-tab').click(); + await page.goto('/admin/storage'); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {}); }); test('storage tab: save the local backend settings', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-storage-tab').click(); + await page.goto('/admin/storage'); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); // Keep the (safe) local backend and save — exercises the save handler without // reconfiguring storage to a remote backend. @@ -62,8 +65,7 @@ test('storage tab: save the local backend settings', async ({ page }) => { }); test('oidc tab: toggle enabled and fill issuer', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-oidc-tab').click(); + await page.goto('/admin/oidc'); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {}); await page @@ -84,8 +86,7 @@ async function createUserRow( page: import('@playwright/test').Page, uname: string, ): Promise> { - await page.goto('/admin'); - await page.getByTestId('admin-users-tab').click(); + await page.goto('/admin/users'); await page.getByTestId('admin-users-create-btn').click(); await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-create-user-username-input').fill(uname); @@ -165,8 +166,7 @@ test('save a user quota and deactivate the user', async ({ page }) => { }); test('save oidc settings', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-oidc-tab').click(); + await page.goto('/admin/oidc'); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {}); await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {}); @@ -175,9 +175,8 @@ test('save oidc settings', async ({ page }) => { }); test('install, toggle, and delete a plugin', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-plugins-tab').click(); - await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true'); + await page.goto('/admin/plugins'); + await expect(page).toHaveURL(/\/admin\/plugins$/); // Install the example hello plugin (plugins are enabled in the coverage env). await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); @@ -200,8 +199,7 @@ test('install, toggle, and delete a plugin', async ({ page }) => { }); test('view plugin logs and details', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-plugins-tab').click(); + await page.goto('/admin/plugins'); await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({ timeout: 20_000, @@ -223,9 +221,8 @@ test('view plugin logs and details', async ({ page }) => { }); test('plugins tab: save retention settings', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-plugins-tab').click(); - await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true'); + await page.goto('/admin/plugins'); + await expect(page).toHaveURL(/\/admin\/plugins$/); // The retention form is conditional; fill + save it when present. const retention = page.getByTestId('admin-plugin-retention-form'); if (await retention.isVisible().catch(() => false)) { @@ -243,8 +240,7 @@ test('toggle the dashboard registration setting', async ({ page }) => { }); test('send a test email from the smtp tab', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-smtp-tab').click(); + await page.goto('/admin/smtp'); await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-smtp-to-input').fill('test@example.test'); await page.getByTestId('admin-smtp-send-btn').click(); @@ -252,8 +248,7 @@ test('send a test email from the smtp tab', async ({ page }) => { }); test('storage tab: fill the S3 backend fields', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-storage-tab').click(); + await page.goto('/admin/storage'); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); // Switch to S3 to reveal + fill the conditional fields (no save — that would @@ -270,8 +265,7 @@ test('storage tab: fill the S3 backend fields', async ({ page }) => { }); test('oidc tab: run discovery against a bogus issuer', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-oidc-tab').click(); + await page.goto('/admin/oidc'); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {}); // Discovery fails (no real IdP) — exercises the discover + error path. @@ -286,8 +280,7 @@ test('users tab: paginate the user list', async ({ page }) => { for (let i = 0; i < 26; i++) { await apiAdminCreateUser(page, `pageu${Date.now()}${i}`); } - await page.goto('/admin'); - await page.getByTestId('admin-users-tab').click(); + await page.goto('/admin/users'); await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('admin-users-pager-next-btn').click(); await page.waitForTimeout(400); @@ -295,8 +288,7 @@ test('users tab: paginate the user list', async ({ page }) => { }); test('plugins tab: install then save retention settings', async ({ page }) => { - await page.goto('/admin'); - await page.getByTestId('admin-plugins-tab').click(); + await page.goto('/admin/plugins'); await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({ timeout: 20_000,