test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
This commit is contained in:
@@ -914,27 +914,57 @@
|
||||
<h1>{t('admin.title', 'Admin')}</h1>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-dashboard-tab"
|
||||
aria-selected={tab === 'dashboard'}
|
||||
onclick={() => (tab = 'dashboard')}
|
||||
>
|
||||
<Icon name="chart-pie" />
|
||||
{t('admin.dashboard', 'Dashboard')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-users-tab"
|
||||
aria-selected={tab === 'users'}
|
||||
onclick={() => (tab = 'users')}
|
||||
>
|
||||
<Icon name="users" />
|
||||
{t('admin.users', 'Users')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-oidc-tab"
|
||||
aria-selected={tab === 'oidc'}
|
||||
onclick={() => (tab = 'oidc')}
|
||||
>
|
||||
<Icon name="key" />
|
||||
{t('admin.oidc', 'OIDC / SSO')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-storage-tab"
|
||||
aria-selected={tab === 'storage'}
|
||||
onclick={() => (tab = 'storage')}
|
||||
>
|
||||
<Icon name="database" />
|
||||
{t('admin.storage_tab', 'Storage')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-smtp-tab"
|
||||
aria-selected={tab === 'smtp'}
|
||||
onclick={() => (tab = 'smtp')}
|
||||
>
|
||||
<Icon name="envelope" />
|
||||
{t('admin.smtp', 'Email (SMTP)')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-plugins-tab"
|
||||
aria-selected={tab === 'plugins'}
|
||||
onclick={() => (tab = 'plugins')}
|
||||
>
|
||||
<Icon name="layer-group" />
|
||||
{t('admin.plugins', 'Plugins')}
|
||||
</button>
|
||||
@@ -1031,6 +1061,7 @@
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-dashboard-registration-checkbox"
|
||||
checked={dashboard.registration_enabled}
|
||||
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
|
||||
/>
|
||||
@@ -1106,9 +1137,17 @@
|
||||
{#if !oidc}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}>
|
||||
<form
|
||||
class="form"
|
||||
data-testid="admin-oidc-form"
|
||||
onsubmit={(e) => (e.preventDefault(), doSaveOidc())}
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.enabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-enabled-checkbox"
|
||||
bind:checked={oidc.enabled}
|
||||
/>
|
||||
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
|
||||
</label>
|
||||
<label
|
||||
@@ -1119,11 +1158,17 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.issuer_url}
|
||||
data-testid="admin-oidc-issuer-input"
|
||||
placeholder="https://idp.example.com"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
|
||||
/></label
|
||||
>
|
||||
<button type="button" class="btn btn-secondary" onclick={runOidcTest}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-oidc-discover-btn"
|
||||
onclick={runOidcTest}
|
||||
>
|
||||
<Icon name="search" />
|
||||
{t('admin.oidc_discover', 'Test / discover')}
|
||||
</button>
|
||||
@@ -1155,6 +1200,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.client_id}
|
||||
data-testid="admin-oidc-client-id-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
|
||||
/></label
|
||||
>
|
||||
@@ -1166,6 +1212,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-oidc-client-secret-input"
|
||||
bind:value={oidc.client_secret}
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
|
||||
placeholder={oidc.client_secret_set
|
||||
@@ -1187,6 +1234,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.scopes}
|
||||
data-testid="admin-oidc-scopes-input"
|
||||
placeholder="openid profile email"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
|
||||
/></label
|
||||
@@ -1199,6 +1247,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.provider_name}
|
||||
data-testid="admin-oidc-provider-name-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
|
||||
/></label
|
||||
>
|
||||
@@ -1210,15 +1259,24 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.admin_groups}
|
||||
data-testid="admin-oidc-admin-groups-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
|
||||
/></label
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.auto_provision} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-auto-provision-checkbox"
|
||||
bind:checked={oidc.auto_provision}
|
||||
/>
|
||||
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.disable_password_login} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-disable-pw-checkbox"
|
||||
bind:checked={oidc.disable_password_login}
|
||||
/>
|
||||
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
|
||||
</label>
|
||||
{#if oidc.callback_url}
|
||||
@@ -1227,6 +1285,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
data-testid="admin-oidc-callback-copy-btn"
|
||||
onclick={() => copyText(oidc?.callback_url ?? '')}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
@@ -1237,7 +1296,12 @@
|
||||
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
|
||||
{oidcMsg.text}
|
||||
</p>{/if}
|
||||
<button class="btn btn-primary" type="submit" disabled={oidcSaving}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
data-testid="admin-oidc-save-btn"
|
||||
disabled={oidcSaving}
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -1249,10 +1313,14 @@
|
||||
{#if !storage}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
|
||||
<form
|
||||
class="form"
|
||||
data-testid="admin-storage-form"
|
||||
onsubmit={(e) => (e.preventDefault(), doSaveStorage())}
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.storage_backend', 'Backend')}</span>
|
||||
<select bind:value={sForm.backend}>
|
||||
<select bind:value={sForm.backend} data-testid="admin-storage-backend-select">
|
||||
<option value="local">local</option>
|
||||
<option value="s3">S3</option>
|
||||
</select></label
|
||||
@@ -1260,7 +1328,11 @@
|
||||
{#if sForm.backend === 's3'}
|
||||
<label
|
||||
><span>{t('admin.storage_preset', 'Preset')}</span>
|
||||
<select bind:value={sForm.preset} onchange={applyPreset}>
|
||||
<select
|
||||
bind:value={sForm.preset}
|
||||
data-testid="admin-storage-preset-select"
|
||||
onchange={applyPreset}
|
||||
>
|
||||
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
@@ -1272,6 +1344,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.endpoint}
|
||||
data-testid="admin-storage-endpoint-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
|
||||
/></label
|
||||
>
|
||||
@@ -1283,6 +1356,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.bucket}
|
||||
data-testid="admin-storage-bucket-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
|
||||
/></label
|
||||
>
|
||||
@@ -1294,6 +1368,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.region}
|
||||
data-testid="admin-storage-region-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
|
||||
/></label
|
||||
>
|
||||
@@ -1305,6 +1380,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.accessKey}
|
||||
data-testid="admin-storage-access-key-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
|
||||
placeholder={storage.s3_access_key_set
|
||||
? t('admin.unchanged', 'Leave blank to keep current')
|
||||
@@ -1319,6 +1395,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-storage-secret-key-input"
|
||||
bind:value={sForm.secretKey}
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
|
||||
placeholder={storage.s3_secret_key_set
|
||||
@@ -1327,7 +1404,11 @@
|
||||
/></label
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={sForm.pathStyle} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-storage-path-style-checkbox"
|
||||
bind:checked={sForm.pathStyle}
|
||||
/>
|
||||
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
@@ -1335,13 +1416,17 @@
|
||||
{storageMsg.text}
|
||||
</p>{/if}
|
||||
<div class="smtp-test">
|
||||
<button class="btn btn-primary" type="submit" disabled={storageBusy}
|
||||
>{t('common.save', 'Save')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
data-testid="admin-storage-save-btn"
|
||||
disabled={storageBusy}>{t('common.save', 'Save')}</button
|
||||
>
|
||||
{#if sForm.backend === 's3'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-storage-test-btn"
|
||||
disabled={storageBusy}
|
||||
onclick={doTestStorage}
|
||||
>
|
||||
@@ -1401,30 +1486,43 @@
|
||||
<div class="smtp-test">
|
||||
<!-- Start: only when no migration is active (running/paused) or completed. -->
|
||||
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('start')}
|
||||
>{t('admin.mig_start', 'Start')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-start-btn"
|
||||
onclick={() => doMigration('start')}>{t('admin.mig_start', 'Start')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'running'}
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
|
||||
>{t('admin.mig_pause', 'Pause')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-pause-btn"
|
||||
onclick={() => doMigration('pause')}>{t('admin.mig_pause', 'Pause')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'paused'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('resume')}
|
||||
>{t('admin.mig_resume', 'Resume')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-resume-btn"
|
||||
onclick={() => doMigration('resume')}>{t('admin.mig_resume', 'Resume')}</button
|
||||
>
|
||||
{/if}
|
||||
<!-- Verify + Finalize: only once the copy phase has completed. -->
|
||||
{#if migration.status === 'completed'}
|
||||
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-verify-btn"
|
||||
disabled={verifying}
|
||||
onclick={doVerify}
|
||||
>
|
||||
<Icon name="check-double" />
|
||||
{verifying
|
||||
? t('admin.mig_verifying', 'Verifying…')
|
||||
: t('admin.mig_verify', 'Verify integrity')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
|
||||
>{t('admin.mig_complete', 'Finalize')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-complete-btn"
|
||||
onclick={() => doMigration('complete')}>{t('admin.mig_complete', 'Finalize')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1540,10 +1638,16 @@
|
||||
<div class="smtp-test">
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-smtp-to-input"
|
||||
bind:value={smtpTo}
|
||||
placeholder={t('admin.smtp_to', 'recipient@example.com')}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-smtp-send-btn"
|
||||
disabled={smtpSending}
|
||||
onclick={runSmtpTest}
|
||||
>
|
||||
<Icon name="paper-plane" />
|
||||
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
|
||||
</button>
|
||||
@@ -1567,7 +1671,11 @@
|
||||
</div>
|
||||
{:else if tab === 'users'}
|
||||
<div class="bar">
|
||||
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
data-testid="admin-users-create-btn"
|
||||
onclick={() => (createOpen = true)}
|
||||
>
|
||||
<Icon name="user-plus" />
|
||||
{t('admin.create_user', 'Create user')}
|
||||
</button>
|
||||
@@ -1644,6 +1752,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-quota-${u.id}`}
|
||||
title={t('admin.edit_quota_title', 'Edit quota')}
|
||||
aria-label={t('admin.edit_quota_title', 'Edit quota')}
|
||||
onclick={() => openQuota(u)}
|
||||
@@ -1653,6 +1762,7 @@
|
||||
{#if !isOidcUser(u)}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-reset-password-${u.id}`}
|
||||
title={t('admin.reset_password_title', 'Reset password')}
|
||||
aria-label={t('admin.reset_password_title', 'Reset password')}
|
||||
onclick={() => openReset(u)}
|
||||
@@ -1662,6 +1772,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-toggle-role-${u.id}`}
|
||||
title={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1671,6 +1782,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
|
||||
data-testid={`admin-user-toggle-active-${u.id}`}
|
||||
title={u.active
|
||||
? t('admin.deactivate_title', 'Deactivate')
|
||||
: t('admin.activate_title', 'Activate')}
|
||||
@@ -1684,6 +1796,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-user-delete-${u.id}`}
|
||||
title={t('admin.delete_title', 'Delete user')}
|
||||
aria-label={t('admin.delete_title', 'Delete user')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1697,10 +1810,16 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-users-pager-prev-btn"
|
||||
disabled={pageIndex === 0}
|
||||
onclick={() => changePage(-1)}>‹</button
|
||||
>
|
||||
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-users-pager-next-btn"
|
||||
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
|
||||
onclick={() => changePage(1)}>›</button
|
||||
>
|
||||
@@ -1725,6 +1844,7 @@
|
||||
: t('admin.plugins_upload', 'Upload .zip')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="admin-plugins-install-input"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
disabled={installing}
|
||||
@@ -1775,6 +1895,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-plugin-details-${p.id}`}
|
||||
title={t('admin.plugins_details', 'Logs & details')}
|
||||
aria-label={t('admin.plugins_details', 'Logs & details')}
|
||||
onclick={() => openLogs(p)}
|
||||
@@ -1783,6 +1904,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
|
||||
data-testid={`admin-plugin-toggle-${p.id}`}
|
||||
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
|
||||
aria-label={p.enabled
|
||||
? t('admin.disable', 'Disable')
|
||||
@@ -1793,6 +1915,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-plugin-delete-${p.id}`}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
onclick={() => removePlugin(p)}
|
||||
@@ -1809,10 +1932,20 @@
|
||||
</main>
|
||||
|
||||
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
|
||||
<form id="create-user-form" onsubmit={submitCreate} class="form">
|
||||
<form
|
||||
id="create-user-form"
|
||||
data-testid="admin-create-user-form"
|
||||
onsubmit={submitCreate}
|
||||
class="form"
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.username', 'Username')}</span>
|
||||
<input bind:value={newUser.username} minlength="3" required /></label
|
||||
<input
|
||||
bind:value={newUser.username}
|
||||
data-testid="admin-create-user-username-input"
|
||||
minlength="3"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span
|
||||
@@ -1821,17 +1954,24 @@
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-create-user-email-input"
|
||||
bind:value={newUser.email}
|
||||
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.password', 'Password')}</span>
|
||||
<input type="password" bind:value={newUser.password} minlength="8" required /></label
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-create-user-password-input"
|
||||
bind:value={newUser.password}
|
||||
minlength="8"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.role', 'Role')}</span>
|
||||
<select bind:value={newUser.role}>
|
||||
<select bind:value={newUser.role} data-testid="admin-create-user-role-select">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select></label
|
||||
@@ -1839,8 +1979,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
|
||||
<select bind:value={newUser.quotaUnit}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-create-user-quota-input"
|
||||
min="0"
|
||||
step="0.1"
|
||||
bind:value={newUser.quotaValue}
|
||||
/>
|
||||
<select bind:value={newUser.quotaUnit} data-testid="admin-create-user-quota-unit-select">
|
||||
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
@@ -1850,8 +1996,18 @@
|
||||
{#if createError}<p class="status--error">{createError}</p>{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-create-user-cancel-btn"
|
||||
onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
data-testid="admin-create-user-submit-btn"
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1867,6 +2023,7 @@
|
||||
<form
|
||||
id="quota-form"
|
||||
class="form"
|
||||
data-testid="admin-quota-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveQuota();
|
||||
@@ -1878,8 +2035,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
|
||||
<select bind:value={quotaModal.unit}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-quota-value-input"
|
||||
min="0"
|
||||
step="0.1"
|
||||
bind:value={quotaModal.value}
|
||||
/>
|
||||
<select bind:value={quotaModal.unit} data-testid="admin-quota-unit-select">
|
||||
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
@@ -1889,8 +2052,15 @@
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="quota-form">
|
||||
<button class="btn" data-testid="admin-quota-cancel-btn" onclick={() => (quotaModal = null)}
|
||||
>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="quota-form"
|
||||
data-testid="admin-quota-save-btn"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1903,20 +2073,41 @@
|
||||
onclose={() => (resetModal = null)}
|
||||
>
|
||||
{#if resetModal}
|
||||
<form id="reset-pw-form" class="form" onsubmit={submitReset}>
|
||||
<form
|
||||
id="reset-pw-form"
|
||||
class="form"
|
||||
data-testid="admin-reset-password-form"
|
||||
onsubmit={submitReset}
|
||||
>
|
||||
<p class="muted">
|
||||
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
|
||||
</p>
|
||||
<label
|
||||
><span>{t('admin.new_password', 'New password')}</span>
|
||||
<input type="password" bind:value={resetPassword} minlength="8" required /></label
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-reset-password-input"
|
||||
bind:value={resetPassword}
|
||||
minlength="8"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
{#if resetError}<p class="status--error">{resetError}</p>{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-reset-password-cancel-btn"
|
||||
onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="reset-pw-form"
|
||||
data-testid="admin-reset-password-submit-btn"
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1930,9 +2121,14 @@
|
||||
>
|
||||
<p>{confirmState?.message}</p>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button
|
||||
<button class="btn" data-testid="admin-confirm-cancel-btn" onclick={() => resolveConfirm(false)}
|
||||
>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
data-testid="admin-confirm-ok-btn"
|
||||
onclick={() => resolveConfirm(true)}
|
||||
>
|
||||
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
|
||||
{t('common.confirm', 'Confirm')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1972,25 +2168,43 @@
|
||||
{/if}
|
||||
|
||||
{#if retention}
|
||||
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}>
|
||||
<form
|
||||
class="form retention-form"
|
||||
data-testid="admin-plugin-retention-form"
|
||||
onsubmit={(e) => (e.preventDefault(), saveRetention())}
|
||||
>
|
||||
<h3>{t('admin.plugins_retention', 'Log retention')}</h3>
|
||||
<label
|
||||
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
|
||||
<input type="number" min="0" bind:value={retentionDays} /></label
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-plugin-retention-days-input"
|
||||
min="0"
|
||||
bind:value={retentionDays}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
|
||||
<input type="number" min="0" bind:value={retentionMb} /></label
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-plugin-retention-max-input"
|
||||
min="0"
|
||||
bind:value={retentionMb}
|
||||
/></label
|
||||
>
|
||||
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
|
||||
<button class="btn btn-secondary" type="submit"
|
||||
<button class="btn btn-secondary" type="submit" data-testid="admin-plugin-retention-save-btn"
|
||||
>{t('admin.plugins_retention_save', 'Save retention')}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="logs-toolbar">
|
||||
<select bind:value={logsLevel} onchange={reloadLogsFromStart}>
|
||||
<select
|
||||
bind:value={logsLevel}
|
||||
data-testid="admin-plugin-logs-level-select"
|
||||
onchange={reloadLogsFromStart}
|
||||
>
|
||||
<option value="">{t('admin.logs_all', 'All levels')}</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
@@ -1998,14 +2212,22 @@
|
||||
</select>
|
||||
<input
|
||||
placeholder={t('admin.logs_search', 'Search…')}
|
||||
data-testid="admin-plugin-logs-search-input"
|
||||
bind:value={logsSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
|
||||
/>
|
||||
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
|
||||
>{t('common.search', 'Search')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-plugin-logs-search-btn"
|
||||
onclick={reloadLogsFromStart}>{t('common.search', 'Search')}</button
|
||||
>
|
||||
<label class="live-toggle">
|
||||
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-plugin-logs-live-checkbox"
|
||||
bind:checked={logsLive}
|
||||
onchange={toggleLive}
|
||||
/>
|
||||
<span>{t('admin.logs_live', 'Live')}</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2044,7 +2266,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="pager logs-pager">
|
||||
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-plugin-logs-pager-prev-btn"
|
||||
disabled={logsPage === 0}
|
||||
onclick={logsPrev}>‹</button
|
||||
>
|
||||
<span>
|
||||
{#if logsTotal === 0}
|
||||
{t('admin.logs_empty', 'No log entries.')}
|
||||
@@ -2060,15 +2287,18 @@
|
||||
)}
|
||||
{/if}
|
||||
</span>
|
||||
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext}
|
||||
>›</button
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-plugin-logs-pager-next-btn"
|
||||
disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal}
|
||||
onclick={logsNext}>›</button
|
||||
>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-danger" onclick={purgeLogs}
|
||||
<button class="btn btn-danger" data-testid="admin-plugin-logs-clear-btn" onclick={purgeLogs}
|
||||
>{t('admin.plugins_clear_logs', 'Clear logs')}</button
|
||||
>
|
||||
<button class="btn btn-secondary" onclick={closeLogs}>
|
||||
<button class="btn btn-secondary" data-testid="admin-plugin-logs-close-btn" onclick={closeLogs}>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: { user: { id: '1', username: 'admin', role: 'admin' } },
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/api/endpoints/admin', () => ({
|
||||
clearPluginLogs: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
deletePlugin: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
getMigration: vi.fn(),
|
||||
getOidcSettings: vi.fn(),
|
||||
getPluginLogs: vi.fn(),
|
||||
getPluginRetention: vi.fn(),
|
||||
getSmtpInfo: vi.fn(),
|
||||
getStorageSettings: vi.fn(),
|
||||
installPlugin: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
migrationAction: vi.fn(),
|
||||
resetUserPassword: vi.fn(),
|
||||
saveOidc: vi.fn(),
|
||||
savePluginRetention: vi.fn(),
|
||||
saveStorage: vi.fn(),
|
||||
sendSmtpTest: vi.fn(),
|
||||
setPluginEnabled: vi.fn(),
|
||||
setRegistrationEnabled: vi.fn(),
|
||||
setUserActive: vi.fn(),
|
||||
setUserQuota: vi.fn(),
|
||||
setUserRole: vi.fn(),
|
||||
testOidc: vi.fn(),
|
||||
testStorage: vi.fn(),
|
||||
verifyMigration: vi.fn()
|
||||
}));
|
||||
|
||||
import * as admin from '$lib/api/endpoints/admin';
|
||||
import AdminPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
const dashboard = {
|
||||
total_users: 3,
|
||||
active_users: 2,
|
||||
admin_users: 1,
|
||||
server_version: '1.0',
|
||||
total_used_bytes: 100,
|
||||
total_quota_bytes: 1000,
|
||||
storage_usage_percent: 10,
|
||||
auth_enabled: true,
|
||||
oidc_configured: false,
|
||||
quotas_enabled: true,
|
||||
registration_enabled: true,
|
||||
users_over_80_percent: 0,
|
||||
users_over_quota: 0
|
||||
};
|
||||
|
||||
const user = {
|
||||
id: 'u1',
|
||||
username: 'bob',
|
||||
email: 'bob@x.test',
|
||||
role: 'user',
|
||||
active: true,
|
||||
is_active: true,
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100,
|
||||
is_external: false
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(admin.getDashboard).mockResolvedValue(dashboard);
|
||||
m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] });
|
||||
m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] });
|
||||
m(admin.getOidcSettings).mockResolvedValue({
|
||||
enabled: false,
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
scopes: null,
|
||||
auto_provision: false,
|
||||
admin_groups: null,
|
||||
disable_password_login: false,
|
||||
provider_name: null,
|
||||
callback_url: 'http://localhost/callback',
|
||||
client_secret_set: false,
|
||||
env_overrides: []
|
||||
});
|
||||
m(admin.getStorageSettings).mockResolvedValue({ backend: 'filesystem', env_overrides: [] });
|
||||
m(admin.getMigration).mockResolvedValue({
|
||||
status: 'idle',
|
||||
total_blobs: 0,
|
||||
migrated_blobs: 0,
|
||||
migrated_bytes: 0
|
||||
});
|
||||
m(admin.getSmtpInfo).mockResolvedValue({
|
||||
enabled: false,
|
||||
host: 'localhost',
|
||||
port: 25,
|
||||
tls: 'none',
|
||||
from: 'a@x.test',
|
||||
user_state: 'unset'
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the dashboard on mount', async () => {
|
||||
render(AdminPage);
|
||||
await waitFor(() => expect(admin.getDashboard).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('toggles registration from the dashboard', async () => {
|
||||
m(admin.setRegistrationEnabled).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
const cb = await screen.findByTestId('admin-dashboard-registration-checkbox');
|
||||
await fireEvent.click(cb);
|
||||
await waitFor(() => expect(admin.setRegistrationEnabled).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads users when the users tab is opened and creates a user', async () => {
|
||||
m(admin.createUser).mockResolvedValue(undefined);
|
||||
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'), {
|
||||
target: { value: 'newbie' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('admin-create-user-password-input'), {
|
||||
target: { value: 'Password123!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('admin-create-user-submit-btn'));
|
||||
await waitFor(() => expect(admin.createUser).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads OIDC settings when the OIDC tab is opened', async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
|
||||
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("toggles a user's role through the confirm modal", async () => {
|
||||
m(admin.setUserRole).mockResolvedValue(undefined);
|
||||
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'));
|
||||
});
|
||||
|
||||
it('deactivates a user through the confirm modal', async () => {
|
||||
m(admin.setUserActive).mockResolvedValue(undefined);
|
||||
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));
|
||||
});
|
||||
|
||||
it('saves OIDC settings from the OIDC form', async () => {
|
||||
m(admin.saveOidc).mockResolvedValue(undefined);
|
||||
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' }
|
||||
});
|
||||
await fireEvent.submit(await screen.findByTestId('admin-oidc-form'));
|
||||
await waitFor(() => expect(admin.saveOidc).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('sends an SMTP test email', async () => {
|
||||
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
|
||||
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' }
|
||||
});
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-send-btn'));
|
||||
await waitFor(() => expect(admin.sendSmtpTest).toHaveBeenCalledWith('to@x.test'));
|
||||
});
|
||||
|
||||
it('saves storage settings and starts a migration', async () => {
|
||||
m(admin.saveStorage).mockResolvedValue(undefined);
|
||||
m(admin.migrationAction).mockResolvedValue(undefined);
|
||||
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'));
|
||||
await waitFor(() => expect(admin.migrationAction).toHaveBeenCalledWith('start'));
|
||||
});
|
||||
@@ -123,11 +123,12 @@
|
||||
<h1>{t('device.title', 'Device verification')}</h1>
|
||||
|
||||
{#if step === 'code'}
|
||||
<form onsubmit={lookup}>
|
||||
<form data-testid="device-code-form" onsubmit={lookup}>
|
||||
<label class="device__field">
|
||||
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
|
||||
<input
|
||||
bind:this={codeInput}
|
||||
data-testid="device-code-input"
|
||||
value={code}
|
||||
oninput={onCodeInput}
|
||||
autocomplete="off"
|
||||
@@ -137,7 +138,9 @@
|
||||
maxlength={FULL_CODE_LENGTH}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
|
||||
<button type="submit" data-testid="device-continue-btn" disabled={!code}
|
||||
>{t('device.continue', 'Continue')}</button
|
||||
>
|
||||
</form>
|
||||
{:else if step === 'loading'}
|
||||
<p>{t('common.loading', 'Loading…')}</p>
|
||||
@@ -149,10 +152,20 @@
|
||||
<dd>{info?.scopes || 'all'}</dd>
|
||||
</dl>
|
||||
<div class="device__actions">
|
||||
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}>
|
||||
<button
|
||||
class="device__deny"
|
||||
data-testid="device-deny-btn"
|
||||
disabled={busy}
|
||||
onclick={() => decide('deny')}
|
||||
>
|
||||
{t('device.deny', 'Deny')}
|
||||
</button>
|
||||
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}>
|
||||
<button
|
||||
class="device__approve"
|
||||
data-testid="device-approve-btn"
|
||||
disabled={busy}
|
||||
onclick={() => decide('approve')}
|
||||
>
|
||||
{t('device.approve', 'Approve')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -164,7 +177,9 @@
|
||||
<p>{t('device.denied', 'Device access denied.')}</p>
|
||||
{:else if step === 'error'}
|
||||
<p class="device__error" role="alert">{errorText}</p>
|
||||
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button>
|
||||
<button data-testid="device-retry-btn" onclick={backToCode}
|
||||
>{t('common.retry', 'Try again')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { pageState } = vi.hoisted(() => ({
|
||||
pageState: { url: new URL('http://localhost/device') }
|
||||
}));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/device', () => {
|
||||
class DeviceLookupFailure extends Error {}
|
||||
return {
|
||||
DeviceLookupFailure,
|
||||
decideDevice: vi.fn(),
|
||||
lookupDeviceCode: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
import { decideDevice, lookupDeviceCode } from '$lib/api/endpoints/device';
|
||||
import DevicePage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
const info = { user_code: 'WXYZ-1234', client_name: 'CLI', scopes: ['files'] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/device');
|
||||
});
|
||||
|
||||
it('looks up a typed code and shows the review step', async () => {
|
||||
m(lookupDeviceCode).mockResolvedValue(info);
|
||||
render(DevicePage);
|
||||
await screen.findByTestId('device-code-form');
|
||||
await fireEvent.input(screen.getByTestId('device-code-input'), {
|
||||
target: { value: 'WXYZ-1234' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await waitFor(() => expect(lookupDeviceCode).toHaveBeenCalledWith('WXYZ-1234'));
|
||||
await screen.findByTestId('device-approve-btn');
|
||||
});
|
||||
|
||||
it('approves a device after lookup', async () => {
|
||||
m(lookupDeviceCode).mockResolvedValue(info);
|
||||
m(decideDevice).mockResolvedValue(undefined);
|
||||
render(DevicePage);
|
||||
await fireEvent.input(await screen.findByTestId('device-code-input'), {
|
||||
target: { value: 'WXYZ-1234' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await fireEvent.click(await screen.findByTestId('device-approve-btn'));
|
||||
await waitFor(() => expect(decideDevice).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows an error when lookup fails', async () => {
|
||||
m(lookupDeviceCode).mockRejectedValue(new Error('nope'));
|
||||
render(DevicePage);
|
||||
await fireEvent.input(await screen.findByTestId('device-code-input'), {
|
||||
target: { value: 'BAD' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await screen.findByTestId('device-retry-btn');
|
||||
});
|
||||
@@ -300,17 +300,23 @@
|
||||
onselectionchange={(ids) => (selectedIds = ids)}
|
||||
>
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||
<Button icon="download" data-testid="favorites-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="favorites-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="favorites-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({
|
||||
fetchFavoritesPage: vi.fn(),
|
||||
removeFavorite: vi.fn(),
|
||||
resolveOwnerName: vi.fn(async () => 'me'),
|
||||
sizeBucket: () => 'Small',
|
||||
typeLabel: () => 'File'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
renameFile: vi.fn(),
|
||||
deleteFile: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
|
||||
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import FavoritesPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withOneFile() {
|
||||
m(fetchFavoritesPage).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
resource_type: 'file',
|
||||
favorited_at: '2024-01-01T00:00:00Z',
|
||||
resource: {
|
||||
category: 'Image',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id: 'f1',
|
||||
mime_type: 'image/png',
|
||||
modified_at: 0,
|
||||
name: 'photo.png',
|
||||
owner_id: 'me',
|
||||
folder_id: 'root',
|
||||
path: '/photo.png',
|
||||
size: 10,
|
||||
size_formatted: '10 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
}
|
||||
}
|
||||
],
|
||||
next_cursor: null
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders favorites returned by the API', async () => {
|
||||
withOneFile();
|
||||
render(FavoritesPage);
|
||||
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('photo.png')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no favorites', async () => {
|
||||
m(fetchFavoritesPage).mockResolvedValue({ items: [], next_cursor: null });
|
||||
render(FavoritesPage);
|
||||
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('unfavorites a row via the star button', async () => {
|
||||
withOneFile();
|
||||
m(removeFavorite).mockResolvedValue(undefined);
|
||||
render(FavoritesPage);
|
||||
await screen.findByText('photo.png');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-favorite-f1-btn'));
|
||||
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
|
||||
});
|
||||
|
||||
it('batch-deletes selected favorites after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(FavoritesPage);
|
||||
await screen.findByText('photo.png');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
});
|
||||
@@ -1455,13 +1455,21 @@
|
||||
>
|
||||
<div class="page-sticky-header">
|
||||
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. -->
|
||||
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="files-upload-file-input"
|
||||
onchange={onUpload}
|
||||
/>
|
||||
<input
|
||||
bind:this={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
webkitdirectory
|
||||
data-testid="files-upload-folder-input"
|
||||
onchange={onUploadFolder}
|
||||
/>
|
||||
|
||||
@@ -1474,12 +1482,13 @@
|
||||
>
|
||||
{#snippet start()}
|
||||
{#if selectedCount > 0}
|
||||
<div class="action-buttons batch-selection-bar">
|
||||
<div class="action-buttons batch-selection-bar" data-testid="files-batch-bar">
|
||||
<div class="list-header-checkbox">
|
||||
<button
|
||||
class="batch-bar-close"
|
||||
title={t('files.cancel_selection', 'Cancel selection')}
|
||||
aria-label={t('files.cancel_selection', 'Cancel selection')}
|
||||
data-testid="files-batch-cancel-btn"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1493,22 +1502,34 @@
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.add_favorites', 'Add to favorites')}
|
||||
data-testid="files-batch-favorite-btn"
|
||||
onclick={() => void batchFavorites()}
|
||||
>
|
||||
<Icon name="star" />
|
||||
<span>{t('files.add_favorites', 'Add to favorites')}</span>
|
||||
</button>
|
||||
<button class="batch-btn" title={t('files.move', 'Move')} onclick={batchMove}>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid="files-batch-move-btn"
|
||||
onclick={batchMove}
|
||||
>
|
||||
<Icon name="arrows-alt" />
|
||||
<span>{t('files.move', 'Move')}</span>
|
||||
</button>
|
||||
<button class="batch-btn" title={t('files.copy', 'Copy')} onclick={batchCopy}>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.copy', 'Copy')}
|
||||
data-testid="files-batch-copy-btn"
|
||||
onclick={batchCopy}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
<span>{t('files.copy', 'Copy')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid="files-batch-download-btn"
|
||||
onclick={() => void batchDownload()}
|
||||
>
|
||||
<Icon name="download" />
|
||||
@@ -1517,6 +1538,7 @@
|
||||
<button
|
||||
class="batch-btn batch-btn-danger"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid="files-batch-delete-btn"
|
||||
onclick={batchDelete}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -1527,9 +1549,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown">
|
||||
<div class="upload-dropdown" data-testid="files-upload-dropdown">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="files-upload-btn"
|
||||
onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
|
||||
disabled={uploading}
|
||||
aria-haspopup="true"
|
||||
@@ -1544,9 +1567,10 @@
|
||||
<Icon name="caret-down" class="upload-caret" />
|
||||
</button>
|
||||
{#if uploadMenuOpen}
|
||||
<div class="upload-dropdown-menu">
|
||||
<div class="upload-dropdown-menu" data-testid="files-upload-menu">
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-files-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
fileInput?.click();
|
||||
@@ -1557,6 +1581,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-folder-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
folderInput?.click();
|
||||
@@ -1568,7 +1593,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick={onNewFolder}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="files-new-folder-btn"
|
||||
onclick={onNewFolder}
|
||||
>
|
||||
<Icon name="folder-plus" class="icon-mr" />
|
||||
<span>{t('actions.new_folder', 'New folder')}</span>
|
||||
</button>
|
||||
@@ -1593,6 +1622,7 @@
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||
data-testid={i === 0 ? 'files-breadcrumb-home-link' : `files-breadcrumb-${c.id}`}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}
|
||||
>
|
||||
@@ -1655,6 +1685,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('files.select_all', 'Select all')}
|
||||
data-testid="files-select-all-checkbox"
|
||||
checked={selectedCount > 0 && selectedCount === totalCount}
|
||||
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
||||
onchange={toggleSelectAll}
|
||||
@@ -1668,6 +1699,7 @@
|
||||
class="list-header-sort"
|
||||
class:is-active={sortField === col.f}
|
||||
data-sort-field={col.f}
|
||||
data-testid={`files-sort-${col.f}-btn`}
|
||||
onclick={() => toggleSort(col.f as SortField)}
|
||||
>
|
||||
{col.l}
|
||||
@@ -1700,6 +1732,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={folder.name}
|
||||
data-testid={folder.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
|
||||
ondragover={(e) => {
|
||||
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
|
||||
@@ -1723,6 +1757,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(folder.id)}
|
||||
aria-label={folder.name}
|
||||
data-testid={`files-folder-checkbox-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(folder.id);
|
||||
@@ -1760,6 +1795,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(folder.id)}
|
||||
data-testid={`files-folder-favorite-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('folder', folder.id);
|
||||
@@ -1768,6 +1804,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-folder-share-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('folder', folder.id, folder.name);
|
||||
@@ -1776,6 +1813,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-folder-move-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('folder', folder.id, folder.name);
|
||||
@@ -1784,6 +1822,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-folder-rename-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('folder', folder.id, folder.name);
|
||||
@@ -1792,6 +1831,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-folder-delete-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('folder', folder.id, folder.name);
|
||||
@@ -1802,6 +1842,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-folder-more-${folder.id}`}
|
||||
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1817,6 +1858,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={file.name}
|
||||
data-testid={file.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
|
||||
ondblclick={() => openFile(file)}
|
||||
onclick={(e) => {
|
||||
@@ -1830,6 +1873,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(file.id)}
|
||||
aria-label={file.name}
|
||||
data-testid={`files-file-checkbox-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(file.id);
|
||||
@@ -1881,6 +1925,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(file.id)}
|
||||
data-testid={`files-file-favorite-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('file', file.id);
|
||||
@@ -1889,6 +1934,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-file-share-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('file', file.id, file.name);
|
||||
@@ -1897,6 +1943,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-file-move-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('file', file.id, file.name);
|
||||
@@ -1908,11 +1955,13 @@
|
||||
rel="external"
|
||||
download
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid={`files-file-download-${file.id}`}
|
||||
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
||||
>
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-file-rename-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('file', file.id, file.name);
|
||||
@@ -1921,6 +1970,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-file-delete-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('file', file.id, file.name);
|
||||
@@ -1931,6 +1981,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-file-more-${file.id}`}
|
||||
onclick={(e) => openContext(e, 'file', file.id, file.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1977,14 +2028,22 @@
|
||||
<div
|
||||
class="ctx-scrim"
|
||||
role="presentation"
|
||||
data-testid="files-context-menu-scrim"
|
||||
onclick={closeContext}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
></div>
|
||||
<div class="ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
|
||||
<div
|
||||
class="ctx-menu"
|
||||
style:left="{ctxX}px"
|
||||
style:top="{ctxY}px"
|
||||
role="menu"
|
||||
data-testid="files-context-menu"
|
||||
>
|
||||
{#if ctxTarget.kind === 'folder'}
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-folder-open-item"
|
||||
onclick={() => {
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
@@ -1994,6 +2053,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-download-zip-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2004,6 +2064,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-file-open-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2014,6 +2075,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2023,6 +2085,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-new-tab-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2036,11 +2099,13 @@
|
||||
href={fileDownloadUrl(ctxTarget.id)}
|
||||
rel="external"
|
||||
download
|
||||
data-testid="files-ctx-download-item"
|
||||
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
||||
>
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-open-parent-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2051,6 +2116,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-add-playlist-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2062,6 +2128,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-share-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2071,6 +2138,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-move-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2080,6 +2148,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-copy-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2089,6 +2158,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-favorite-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2103,6 +2173,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-rename-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2112,6 +2183,7 @@
|
||||
<button
|
||||
class="ctx-item ctx-item--danger"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-delete-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
|
||||
params: { path: string };
|
||||
url: URL;
|
||||
},
|
||||
session: {
|
||||
user: { id: 'me', username: 'admin', is_external: false },
|
||||
isExternalUser: false,
|
||||
loadHomeFolder: vi.fn(async () => 'home'),
|
||||
refresh: vi.fn(async () => {})
|
||||
},
|
||||
ui: { notify: vi.fn() },
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ tryDeltaUpload: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/wopi', () => ({
|
||||
canEditWithWopi: () => false,
|
||||
getEditorUrlWithFallback: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/music', () => ({
|
||||
addTracks: vi.fn(),
|
||||
createPlaylist: vi.fn(),
|
||||
listPlaylists: vi.fn(async () => [])
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
deleteFile: vi.fn(),
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb',
|
||||
moveFile: vi.fn(),
|
||||
renameFile: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
uploadFileWithProgress: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({
|
||||
cacheFolder: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
fetchFolderListing: vi.fn(),
|
||||
folderZipUrl: () => '/zip',
|
||||
getCachedFolder: () => undefined,
|
||||
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
||||
getFolderName: () => undefined,
|
||||
invalidateFolderCache: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
rememberFolderName: vi.fn(),
|
||||
renameFolder: vi.fn()
|
||||
}));
|
||||
|
||||
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import FilesPage from './[...path]/+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withListing() {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fileItem(id: string, name: string) {
|
||||
return {
|
||||
category: 'Document',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + name,
|
||||
size: 4,
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
};
|
||||
}
|
||||
function folderItem(id: string, name: string) {
|
||||
return {
|
||||
category: 'Folder',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-folder',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
parent_id: 'home',
|
||||
path: '/' + name,
|
||||
etag: 'e'
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// A concrete folder in the path: bare `/files` now canonicalizes to
|
||||
// `/files/<drive-root>` via goto (see the external-user test), so the
|
||||
// listing-oriented tests target a folder directly.
|
||||
pageState.params.path = 'home';
|
||||
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
||||
filesStore.viewMode = 'list';
|
||||
});
|
||||
|
||||
it('loads the home folder listing on mount and renders its contents', async () => {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
});
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
|
||||
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
|
||||
// surrounding chrome rendered rather than the windowed rows themselves.
|
||||
await screen.findByTestId('files-new-folder-btn');
|
||||
});
|
||||
|
||||
it('shows an error when the listing fails with no cache', async () => {
|
||||
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('redirects external users away from the home folder', async () => {
|
||||
session.isExternalUser = true;
|
||||
pageState.params.path = '';
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
|
||||
session.isExternalUser = false;
|
||||
});
|
||||
|
||||
it('creates a new folder in the current directory', async () => {
|
||||
withListing();
|
||||
promptDialog.mockResolvedValue('Reports');
|
||||
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
|
||||
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
|
||||
});
|
||||
|
||||
it('batch-deletes the whole selection after confirmation', async () => {
|
||||
withListing();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFolder).mockResolvedValue(undefined);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
});
|
||||
|
||||
it('batch-favorites the selection via the favorites batch endpoint', async () => {
|
||||
withListing();
|
||||
m(apiFetch).mockResolvedValue({ ok: true });
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'/api/favorites/batch',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -231,7 +231,8 @@
|
||||
<main class="groups">
|
||||
<header class="groups__head">
|
||||
<h1>{t('nav.groups', 'Groups')}</h1>
|
||||
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button
|
||||
<button class="btn btn--primary" data-testid="groups-create-btn" onclick={onCreate}
|
||||
>{t('groups.create', 'Create group')}</button
|
||||
>
|
||||
</header>
|
||||
|
||||
@@ -247,7 +248,11 @@
|
||||
{@const description = groupDescription(g)}
|
||||
<li class="group">
|
||||
<div class="group__row">
|
||||
<button class="group__name" onclick={() => expand(g)}>
|
||||
<button
|
||||
class="group__name"
|
||||
data-testid={`groups-expand-${g.id}`}
|
||||
onclick={() => expand(g)}
|
||||
>
|
||||
<span class="avatar"><Icon name={groupIconName(g)} /></span>
|
||||
<span class="group__text">
|
||||
<span class="group__title">
|
||||
@@ -264,10 +269,16 @@
|
||||
</button>
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<div class="group__actions">
|
||||
<button class="link-btn" onclick={() => onRename(g)}
|
||||
>{t('common.rename', 'Rename')}</button
|
||||
<button
|
||||
class="link-btn"
|
||||
data-testid={`groups-rename-${g.id}`}
|
||||
onclick={() => onRename(g)}>{t('common.rename', 'Rename')}</button
|
||||
>
|
||||
<button
|
||||
class="link-btn link-btn--danger"
|
||||
data-testid={`groups-delete-${g.id}`}
|
||||
onclick={() => onDelete(g)}
|
||||
>
|
||||
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +286,7 @@
|
||||
</div>
|
||||
|
||||
{#if expandedId === g.id}
|
||||
<div class="members">
|
||||
<div class="members" data-testid={`groups-members-panel-${g.id}`}>
|
||||
<div class="members__head">
|
||||
<h2>{t('groups.members', 'Members')}</h2>
|
||||
</div>
|
||||
@@ -284,6 +295,7 @@
|
||||
<div class="add-member">
|
||||
<input
|
||||
class="add-member__input"
|
||||
data-testid="groups-member-add-input"
|
||||
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
|
||||
bind:value={addQuery}
|
||||
oninput={onAddQuery}
|
||||
@@ -291,10 +303,14 @@
|
||||
{#if addBusy}
|
||||
<p class="muted">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if addResults.length > 0}
|
||||
<ul class="add-member__results">
|
||||
<ul class="add-member__results" data-testid="groups-member-add-results">
|
||||
{#each addResults as r (r.type + r.id)}
|
||||
<li>
|
||||
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
|
||||
<button
|
||||
class="add-member__opt"
|
||||
data-testid={`groups-member-add-opt-${r.type}-${r.id}`}
|
||||
onclick={() => pickMember(g, r)}
|
||||
>
|
||||
<span class="avatar avatar--sm">
|
||||
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
|
||||
</span>
|
||||
@@ -338,6 +354,7 @@
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<button
|
||||
class="link-btn link-btn--danger"
|
||||
data-testid={`groups-member-remove-${m.kind}-${m.id}`}
|
||||
onclick={() => onRemoveMember(g.id, m)}
|
||||
>
|
||||
{t('common.remove', 'Remove')}
|
||||
@@ -354,7 +371,12 @@
|
||||
</ul>
|
||||
|
||||
{#if hasMore}
|
||||
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
|
||||
<button
|
||||
class="btn load-more"
|
||||
data-testid="groups-load-more-btn"
|
||||
disabled={loadingMore}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, promptDialog } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, promptDialog: vi.fn() }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog }));
|
||||
vi.mock('$lib/api/endpoints/groups', () => ({
|
||||
INTERNAL_GROUP_ID: '00000000-0000-0000-0000-000000000001',
|
||||
createGroup: vi.fn(),
|
||||
deleteGroup: vi.fn(),
|
||||
addGroupMember: vi.fn(),
|
||||
addUserMember: vi.fn(),
|
||||
groupDescription: (g: { description?: string | null }) => g.description ?? null,
|
||||
groupDisplayName: (g: { name: string }) => g.name,
|
||||
groupIconName: () => 'fa-users',
|
||||
listGroupsPage: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
removeGroupMember: vi.fn(),
|
||||
removeUserMember: vi.fn(),
|
||||
renameGroup: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
ensureResolvers: vi.fn(),
|
||||
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
|
||||
searchRecipients: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
import { listGroupsPage, createGroup, listMembers } from '$lib/api/endpoints/groups';
|
||||
import GroupsPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listMembers).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders groups returned by the API', async () => {
|
||||
m(listGroupsPage).mockResolvedValue({
|
||||
items: [{ id: 'g1', name: 'Engineers', member_count: 2 }],
|
||||
total: 1
|
||||
});
|
||||
render(GroupsPage);
|
||||
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('Engineers')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('creates a new group', async () => {
|
||||
m(listGroupsPage).mockResolvedValue({ items: [], total: 0 });
|
||||
m(createGroup).mockResolvedValue(undefined);
|
||||
promptDialog.mockResolvedValue('New Team');
|
||||
render(GroupsPage);
|
||||
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
|
||||
await fireEvent.click(screen.getByTestId('groups-create-btn'));
|
||||
await waitFor(() => expect(createGroup).toHaveBeenCalledWith('New Team'));
|
||||
});
|
||||
@@ -271,7 +271,7 @@
|
||||
{#if mode === 'login'}
|
||||
{#if passwordLoginEnabled}
|
||||
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
|
||||
<form class="auth-form" onsubmit={onLogin} novalidate>
|
||||
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="login-username">
|
||||
{t('auth.username', 'Username or email')}
|
||||
@@ -280,6 +280,7 @@
|
||||
<input
|
||||
id="login-username"
|
||||
class="auth-input"
|
||||
data-testid="login-username-input"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
@@ -296,6 +297,7 @@
|
||||
<input
|
||||
id="login-password"
|
||||
class="auth-input"
|
||||
data-testid="login-password-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
onkeydown={onPwKey}
|
||||
@@ -308,6 +310,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={showPassword}
|
||||
data-testid="login-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
></button>
|
||||
@@ -317,12 +320,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}>
|
||||
<button
|
||||
class="auth-magic-toggle"
|
||||
data-testid="login-magic-toggle-btn"
|
||||
onclick={() => (magicOpen = !magicOpen)}
|
||||
>
|
||||
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
|
||||
</button>
|
||||
{#if magicOpen}
|
||||
@@ -333,7 +346,7 @@
|
||||
"No password? Enter your email and we'll send you a one-time sign-in link."
|
||||
)}
|
||||
</p>
|
||||
<form class="auth-form" onsubmit={onMagicLink}>
|
||||
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="magic-email">
|
||||
{t('auth.magic_email_label', 'Email address')}
|
||||
@@ -342,6 +355,7 @@
|
||||
<input
|
||||
id="magic-email"
|
||||
class="auth-input"
|
||||
data-testid="login-magic-email-input"
|
||||
type="email"
|
||||
bind:value={magicEmail}
|
||||
autocomplete="email"
|
||||
@@ -349,7 +363,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}>
|
||||
<button
|
||||
class="auth-button auth-button-secondary"
|
||||
type="submit"
|
||||
data-testid="login-magic-send-btn"
|
||||
disabled={busy}
|
||||
>
|
||||
{t('auth.magic_send', 'Send link')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -371,7 +390,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint} rel="external">
|
||||
<a
|
||||
class="auth-button auth-button-oidc"
|
||||
data-testid="login-oidc-btn"
|
||||
href={oidc.authorize_endpoint}
|
||||
rel="external"
|
||||
>
|
||||
{t(
|
||||
'auth.sso_login_provider',
|
||||
{ provider: oidc.provider_name ?? 'SSO' },
|
||||
@@ -383,7 +407,11 @@
|
||||
{#if passwordLoginEnabled}
|
||||
<div class="auth-toggle">
|
||||
{t('auth.no_account', 'No account?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'register')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-to-register-btn"
|
||||
onclick={() => (mode = 'register')}
|
||||
>
|
||||
{t('auth.register', 'Create one')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -392,7 +420,11 @@
|
||||
{#if setupAvailable}
|
||||
<div class="auth-toggle">
|
||||
{t('auth.admin_setup', 'First time?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-to-setup-btn"
|
||||
onclick={() => (mode = 'setup')}
|
||||
>
|
||||
{t('auth.setup', 'Set up administrator')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -402,12 +434,13 @@
|
||||
{regError}
|
||||
</div>{/if}
|
||||
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
|
||||
<form class="auth-form" onsubmit={onRegister} novalidate>
|
||||
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
class="auth-input"
|
||||
data-testid="login-register-username-input"
|
||||
bind:value={regUsername}
|
||||
required
|
||||
disabled={busy}
|
||||
@@ -418,6 +451,7 @@
|
||||
<input
|
||||
id="reg-email"
|
||||
class="auth-input"
|
||||
data-testid="login-register-email-input"
|
||||
type="email"
|
||||
bind:value={regEmail}
|
||||
required
|
||||
@@ -430,6 +464,7 @@
|
||||
<input
|
||||
id="reg-password"
|
||||
class="auth-input"
|
||||
data-testid="login-register-password-input"
|
||||
type={regShowPassword ? 'text' : 'password'}
|
||||
bind:value={regPassword}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -442,6 +477,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowPassword}
|
||||
data-testid="login-register-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowPassword = !regShowPassword)}
|
||||
></button>
|
||||
@@ -458,6 +494,7 @@
|
||||
<input
|
||||
id="reg-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-register-confirm-input"
|
||||
type={regShowConfirm ? 'text' : 'password'}
|
||||
bind:value={regConfirm}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -470,6 +507,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowConfirm}
|
||||
data-testid="login-register-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowConfirm = !regShowConfirm)}
|
||||
></button>
|
||||
@@ -484,13 +522,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-register-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{t('auth.register', 'Create account')}
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-toggle">
|
||||
{t('auth.have_account', 'Already have an account?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-register-to-login-btn"
|
||||
onclick={() => (mode = 'login')}
|
||||
>
|
||||
{t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -515,13 +563,20 @@
|
||||
</div>{/if}
|
||||
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
|
||||
|
||||
<form class="auth-form" onsubmit={onSetup} novalidate>
|
||||
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="setup-username">
|
||||
{t('auth.admin_username', 'Administrator username')}
|
||||
</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--user">
|
||||
<input id="setup-username" class="auth-input" type="text" value="admin" readonly />
|
||||
<input
|
||||
id="setup-username"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-username-input"
|
||||
type="text"
|
||||
value="admin"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -533,6 +588,7 @@
|
||||
<input
|
||||
id="setup-email"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-email-input"
|
||||
type="email"
|
||||
bind:value={setupEmail}
|
||||
autocomplete="email"
|
||||
@@ -550,6 +606,7 @@
|
||||
<input
|
||||
id="setup-password"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-password-input"
|
||||
type={setupShowPassword ? 'text' : 'password'}
|
||||
bind:value={setupPassword}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -563,6 +620,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowPassword}
|
||||
data-testid="login-setup-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowPassword = !setupShowPassword)}
|
||||
></button>
|
||||
@@ -580,6 +638,7 @@
|
||||
<input
|
||||
id="setup-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-confirm-input"
|
||||
type={setupShowConfirm ? 'text' : 'password'}
|
||||
bind:value={setupConfirm}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -592,6 +651,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowConfirm}
|
||||
data-testid="login-setup-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowConfirm = !setupShowConfirm)}
|
||||
></button>
|
||||
@@ -609,14 +669,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-setup-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{t('auth.create_admin', 'Create administrator')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-toggle">
|
||||
{t('auth.back_to_login', 'Already configured?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-setup-to-login-btn"
|
||||
onclick={() => (mode = 'login')}
|
||||
>
|
||||
{t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -626,6 +696,7 @@
|
||||
<div class="auth-lang">
|
||||
<select
|
||||
aria-label={t('settings.language', 'Language')}
|
||||
data-testid="login-language-select"
|
||||
value={i18n.locale}
|
||||
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: { user: null } as { user: unknown }
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({
|
||||
exchangeOidcCode: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
getOidcProviders: vi.fn(),
|
||||
getAuthStatus: vi.fn(),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
sendMagicLink: vi.fn(),
|
||||
setupAdmin: vi.fn()
|
||||
}));
|
||||
|
||||
import * as auth from '$lib/api/endpoints/auth';
|
||||
import LoginPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/login');
|
||||
session.user = null;
|
||||
m(auth.fetchMe).mockResolvedValue(null);
|
||||
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
|
||||
});
|
||||
|
||||
it('logs in and redirects', async () => {
|
||||
m(auth.login).mockResolvedValue({ user: { id: '1' } });
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
|
||||
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'pw' } });
|
||||
await fireEvent.click(screen.getByTestId('login-submit-btn'));
|
||||
await waitFor(() => expect(auth.login).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('exchanges an oidc code on mount and redirects', async () => {
|
||||
pageState.url = new URL('http://localhost/login?oidc_code=abc');
|
||||
m(auth.exchangeOidcCode).mockResolvedValue({ id: '1' });
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(auth.exchangeOidcCode).toHaveBeenCalledWith('abc'));
|
||||
await waitFor(() => expect(goto).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('skips the form when already authenticated', async () => {
|
||||
m(auth.fetchMe).mockResolvedValue({ id: '1' });
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('enters setup mode on a fresh install', async () => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-setup-form');
|
||||
});
|
||||
|
||||
it('sends a magic link', async () => {
|
||||
m(auth.sendMagicLink).mockResolvedValue('sent');
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
|
||||
target: { value: 'a@b.test' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
|
||||
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
|
||||
});
|
||||
|
||||
it('registers a new account', async () => {
|
||||
m(auth.register).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
|
||||
target: { value: 'u' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-email-input'), {
|
||||
target: { value: 'u@b.test' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
|
||||
await waitFor(() => expect(auth.register).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows an error message when login fails', async () => {
|
||||
m(auth.login).mockRejectedValue(new Error('bad credentials'));
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
|
||||
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'wrong' } });
|
||||
await fireEvent.click(screen.getByTestId('login-submit-btn'));
|
||||
await waitFor(() => expect(screen.getByText('bad credentials')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('rejects a registration with mismatched passwords without calling the API', async () => {
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
|
||||
target: { value: 'u' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
|
||||
target: { value: 'Different1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
|
||||
expect(auth.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the first administrator in setup mode', async () => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
|
||||
m(auth.setupAdmin).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-setup-form');
|
||||
await fireEvent.input(screen.getByTestId('login-setup-email-input'), {
|
||||
target: { value: 'admin@x.test' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-setup-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-setup-confirm-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-setup-submit-btn'));
|
||||
await waitFor(() =>
|
||||
expect(auth.setupAdmin).toHaveBeenCalledWith('admin@x.test', 'TestPassword1!')
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an SSO sign-in link when an OIDC provider is configured', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
authorize_endpoint: 'https://idp.test/auth',
|
||||
provider_name: 'Acme SSO',
|
||||
password_login_enabled: true
|
||||
});
|
||||
render(LoginPage);
|
||||
const sso = await screen.findByTestId('login-oidc-btn');
|
||||
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
|
||||
});
|
||||
@@ -512,7 +512,11 @@
|
||||
<p class="music-empty-state-desc">
|
||||
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick={onCreate}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="music-create-playlist-empty-btn"
|
||||
onclick={onCreate}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>{t('music.create_playlist', 'Create playlist')}</span>
|
||||
</button>
|
||||
@@ -526,6 +530,7 @@
|
||||
class="music-sidebar-add-btn"
|
||||
title={t('music.create_playlist', 'Create playlist')}
|
||||
aria-label={t('music.create_playlist', 'Create playlist')}
|
||||
data-testid="music-create-playlist-btn"
|
||||
onclick={onCreate}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
@@ -538,6 +543,7 @@
|
||||
<div
|
||||
class="music-playlist-item"
|
||||
class:active={current?.id === p.id}
|
||||
data-testid={p.name}
|
||||
onclick={() => select(p)}
|
||||
>
|
||||
<div class="music-playlist-icon"><Icon name="music" /></div>
|
||||
@@ -561,6 +567,7 @@
|
||||
class="music-playlist-cover"
|
||||
title={t('music.set_cover', 'Set cover')}
|
||||
aria-label={t('music.set_cover', 'Set cover')}
|
||||
data-testid="music-set-cover-btn"
|
||||
onclick={pickCover}
|
||||
>
|
||||
{#if coverUrl(current)}
|
||||
@@ -585,12 +592,18 @@
|
||||
</div>
|
||||
|
||||
<div class="music-playlist-actions">
|
||||
<button class="btn btn-secondary" onclick={playAll} disabled={tracks.length === 0}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-play-all-btn"
|
||||
onclick={playAll}
|
||||
disabled={tracks.length === 0}
|
||||
>
|
||||
<Icon name="play" />
|
||||
<span>{t('music.play_all', 'Play all')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-shuffle-play-btn"
|
||||
onclick={shufflePlay}
|
||||
disabled={tracks.length === 0}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
@@ -598,12 +611,17 @@
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={openAdd}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-add-tracks-btn"
|
||||
onclick={openAdd}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>{t('music.add_tracks', 'Add tracks')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-rename-playlist-btn"
|
||||
onclick={onRenamePlaylist}
|
||||
title={t('common.rename', 'Rename')}
|
||||
aria-label={t('common.rename', 'Rename')}
|
||||
@@ -612,6 +630,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-edit-description-btn"
|
||||
onclick={onEditDescription}
|
||||
title={t('music.edit_description', 'Edit description')}
|
||||
aria-label={t('music.edit_description', 'Edit description')}
|
||||
@@ -620,6 +639,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-manage-shares-btn"
|
||||
onclick={openShares}
|
||||
title={t('music.manage_shares', 'Manage shares')}
|
||||
aria-label={t('music.manage_shares', 'Manage shares')}
|
||||
@@ -629,6 +649,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
class:active={current.is_public}
|
||||
data-testid="music-toggle-public-btn"
|
||||
onclick={onTogglePublic}
|
||||
title={current.is_public
|
||||
? t('music.make_private', 'Make private')
|
||||
@@ -641,6 +662,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-delete-playlist-btn"
|
||||
onclick={() => onDelete(current!)}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
@@ -673,6 +695,7 @@
|
||||
<div
|
||||
class="music-track"
|
||||
class:playing={currentTrack?.id === track.id && playing}
|
||||
data-testid={trackLabel(track)}
|
||||
draggable="true"
|
||||
ondblclick={() => playFromTracks(i)}
|
||||
ondragstart={() => onDragStart(i)}
|
||||
@@ -687,6 +710,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
data-testid={`music-track-play-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (currentTrack?.id === track.id) togglePlay();
|
||||
@@ -714,6 +738,7 @@
|
||||
class="music-track-remove-btn"
|
||||
title={t('common.remove', 'Remove')}
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-track-remove-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveTrack(track);
|
||||
@@ -763,6 +788,7 @@
|
||||
class:active={shuffle}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
aria-label={t('music.shuffle', 'Shuffle')}
|
||||
data-testid="music-player-shuffle-btn"
|
||||
onclick={() => (shuffle = !shuffle)}
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
@@ -771,6 +797,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.prev', 'Previous')}
|
||||
aria-label={t('music.prev', 'Previous')}
|
||||
data-testid="music-player-prev-btn"
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon name="backward" />
|
||||
@@ -779,6 +806,7 @@
|
||||
class="player-btn player-btn-main"
|
||||
title={t('music.play', 'Play')}
|
||||
aria-label={t('music.play', 'Play')}
|
||||
data-testid="music-player-play-btn"
|
||||
onclick={togglePlay}
|
||||
>
|
||||
<Icon name={playing ? 'pause' : 'play'} />
|
||||
@@ -787,6 +815,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.next', 'Next')}
|
||||
aria-label={t('music.next', 'Next')}
|
||||
data-testid="music-player-next-btn"
|
||||
onclick={next}
|
||||
>
|
||||
<Icon name="forward" />
|
||||
@@ -797,6 +826,7 @@
|
||||
class:repeat-one={repeat === 'one'}
|
||||
title={t('music.repeat', 'Repeat')}
|
||||
aria-label={t('music.repeat', 'Repeat')}
|
||||
data-testid="music-player-repeat-btn"
|
||||
onclick={cycleRepeat}
|
||||
>
|
||||
<Icon name="repeat" />
|
||||
@@ -813,6 +843,7 @@
|
||||
value={currentTime}
|
||||
oninput={seek}
|
||||
aria-label={t('music.seek', 'Seek')}
|
||||
data-testid="music-player-seek-input"
|
||||
/>
|
||||
<span class="player-time player-time-total">{fmtTime(duration)}</span>
|
||||
</div>
|
||||
@@ -824,6 +855,7 @@
|
||||
class:active={queueOpen}
|
||||
title={t('music.queue', 'Queue')}
|
||||
aria-label={t('music.queue', 'Queue')}
|
||||
data-testid="music-player-queue-toggle-btn"
|
||||
onclick={() => (queueOpen = !queueOpen)}
|
||||
>
|
||||
<Icon name="list" />
|
||||
@@ -832,6 +864,7 @@
|
||||
class="player-btn player-btn-small"
|
||||
title={t('music.mute', 'Mute')}
|
||||
aria-label={t('music.mute', 'Mute')}
|
||||
data-testid="music-player-mute-btn"
|
||||
onclick={toggleMute}
|
||||
>
|
||||
<Icon name={volumeIcon} />
|
||||
@@ -846,17 +879,19 @@
|
||||
value={muted ? 0 : volume}
|
||||
oninput={setVolume}
|
||||
aria-label={t('music.volume', 'Volume')}
|
||||
data-testid="music-player-volume-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if queueOpen}
|
||||
<div class="player-queue">
|
||||
<div class="player-queue" data-testid="music-queue-panel">
|
||||
<div class="player-queue-header">
|
||||
<h3>{t('music.queue', 'Queue')}</h3>
|
||||
<button
|
||||
class="player-btn player-btn-small"
|
||||
data-testid="music-queue-close-btn"
|
||||
onclick={() => (queueOpen = false)}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
@@ -876,6 +911,7 @@
|
||||
<div
|
||||
class="player-queue-item"
|
||||
class:active={i === currentIndex}
|
||||
data-testid={trackLabel(qt)}
|
||||
onclick={() => jumpQueue(i)}
|
||||
>
|
||||
<span class="queue-item-num">{i + 1}</span>
|
||||
@@ -887,6 +923,7 @@
|
||||
<button
|
||||
class="queue-item-remove"
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-queue-remove-${qt.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFromQueue(i);
|
||||
@@ -917,6 +954,7 @@
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
data-testid="music-cover-input"
|
||||
onchange={onCoverChosen}
|
||||
/>
|
||||
|
||||
@@ -925,6 +963,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-picker-overlay active"
|
||||
data-testid="music-add-tracks-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) addOpen = false;
|
||||
}}
|
||||
@@ -935,6 +974,7 @@
|
||||
<button
|
||||
class="music-picker-close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-add-tracks-close-btn"
|
||||
onclick={() => (addOpen = false)}>×</button
|
||||
>
|
||||
</div>
|
||||
@@ -948,6 +988,7 @@
|
||||
oninput={onAddQueryInput}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
data-testid="music-add-tracks-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-picker-list">
|
||||
@@ -968,6 +1009,7 @@
|
||||
type="checkbox"
|
||||
checked={addSelected.has(f.id)}
|
||||
onchange={() => addSelected.toggle(f.id)}
|
||||
data-testid={f.name}
|
||||
/>
|
||||
<Icon name="file-audio" />
|
||||
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
||||
@@ -980,10 +1022,19 @@
|
||||
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
|
||||
</span>
|
||||
<div class="music-picker-actions">
|
||||
<button class="btn btn-secondary" onclick={() => (addOpen = false)}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-add-tracks-cancel-btn"
|
||||
onclick={() => (addOpen = false)}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button class="btn btn-primary" disabled={addSelected.size === 0} onclick={confirmAdd}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
disabled={addSelected.size === 0}
|
||||
data-testid="music-add-tracks-confirm-btn"
|
||||
onclick={confirmAdd}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{t('music.add_selected', 'Add selected')}
|
||||
</button>
|
||||
@@ -998,6 +1049,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-shares-overlay"
|
||||
data-testid="music-shares-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) sharesOpen = false;
|
||||
}}
|
||||
@@ -1008,6 +1060,7 @@
|
||||
<button
|
||||
class="music-shares-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-shares-close-btn"
|
||||
onclick={() => (sharesOpen = false)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1029,6 +1082,7 @@
|
||||
class="music-share-remove-btn"
|
||||
title={t('music.remove_share', 'Remove')}
|
||||
aria-label={t('music.remove_share', 'Remove')}
|
||||
data-testid={`music-share-remove-${s.user_id}`}
|
||||
onclick={() => onRemoveShare(s.user_id)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1044,12 +1098,22 @@
|
||||
placeholder={t('music.share_with_user', 'User ID or email')}
|
||||
bind:value={shareUser}
|
||||
autocomplete="off"
|
||||
data-testid="music-share-user-input"
|
||||
/>
|
||||
<label class="music-shares-write-label">
|
||||
<input type="checkbox" bind:checked={shareCanWrite} />
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={shareCanWrite}
|
||||
data-testid="music-share-can-write-checkbox"
|
||||
/>
|
||||
{t('music.can_write', 'Can edit')}
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" disabled={!shareUser.trim()} onclick={onAddShare}>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
disabled={!shareUser.trim()}
|
||||
data-testid="music-share-add-btn"
|
||||
onclick={onAddShare}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{t('music.share', 'Share')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, promptDialog, confirmDialog } = vi.hoisted(() => ({
|
||||
ui: { notify: vi.fn() },
|
||||
promptDialog: vi.fn(),
|
||||
confirmDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog, confirmDialog }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/music', () => ({
|
||||
addTracks: vi.fn(),
|
||||
createPlaylist: vi.fn(),
|
||||
deletePlaylist: vi.fn(),
|
||||
listPlaylists: vi.fn(),
|
||||
listShares: vi.fn(async () => []),
|
||||
listTracks: vi.fn(async () => []),
|
||||
removeShare: vi.fn(),
|
||||
removeTrack: vi.fn(),
|
||||
renamePlaylist: vi.fn(),
|
||||
reorderTracks: vi.fn(),
|
||||
sharePlaylist: vi.fn(),
|
||||
updatePlaylist: vi.fn(),
|
||||
uploadCoverImage: vi.fn()
|
||||
}));
|
||||
|
||||
import {
|
||||
listPlaylists,
|
||||
listTracks,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist
|
||||
} from '$lib/api/endpoints/music';
|
||||
import MusicPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
const playlist = {
|
||||
id: 'p1',
|
||||
name: 'Roadtrip',
|
||||
description: 'songs',
|
||||
owner_id: 'me',
|
||||
is_public: false,
|
||||
cover_file_id: null,
|
||||
track_count: 1,
|
||||
total_duration_secs: 180,
|
||||
created_at: 0,
|
||||
updated_at: 0
|
||||
};
|
||||
const track = {
|
||||
id: 't1',
|
||||
playlist_id: 'p1',
|
||||
file_id: 'f1',
|
||||
position: 0,
|
||||
added_at: 0,
|
||||
file_name: 'song.mp3',
|
||||
file_size: 100,
|
||||
mime_type: 'audio/mpeg',
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
duration_secs: 180
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listTracks).mockResolvedValue([track]);
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no playlists', async () => {
|
||||
m(listPlaylists).mockResolvedValue([]);
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
|
||||
await screen.findByTestId('music-create-playlist-empty-btn');
|
||||
});
|
||||
|
||||
it('selects the first playlist and loads its tracks', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listTracks).toHaveBeenCalledWith('p1'));
|
||||
await waitFor(() => expect(screen.getAllByText('Roadtrip').length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('creates a playlist from the header button', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
promptDialog.mockResolvedValue('Chill');
|
||||
m(createPlaylist).mockResolvedValue({ ...playlist, id: 'p2', name: 'Chill' });
|
||||
render(MusicPage);
|
||||
await screen.findByTestId('music-create-playlist-btn');
|
||||
await fireEvent.click(screen.getByTestId('music-create-playlist-btn'));
|
||||
await waitFor(() => expect(createPlaylist).toHaveBeenCalledWith('Chill'));
|
||||
});
|
||||
|
||||
it('surfaces a load error', async () => {
|
||||
m(listPlaylists).mockRejectedValue(new Error('boom'));
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('boom')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renames the current playlist', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
promptDialog.mockResolvedValue('Renamed');
|
||||
m(renamePlaylist).mockResolvedValue(undefined);
|
||||
render(MusicPage);
|
||||
await fireEvent.click(await screen.findByTestId('music-rename-playlist-btn'));
|
||||
await waitFor(() => expect(renamePlaylist).toHaveBeenCalledWith('p1', 'Renamed'));
|
||||
});
|
||||
|
||||
it('deletes the current playlist after confirmation', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deletePlaylist).mockResolvedValue(undefined);
|
||||
render(MusicPage);
|
||||
await fireEvent.click(await screen.findByTestId('music-delete-playlist-btn'));
|
||||
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
|
||||
});
|
||||
@@ -70,7 +70,12 @@
|
||||
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
|
||||
<h1>{view.title}</h1>
|
||||
<p>{view.message}</p>
|
||||
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nc-status__action"
|
||||
data-testid="nextcloud-error-action-btn"
|
||||
onclick={onAction}>{view.actionLabel}</button
|
||||
>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -52,7 +52,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if passwordLoginEnabled}
|
||||
<form class="auth-form" method="post" action={formAction}>
|
||||
<form
|
||||
class="auth-form"
|
||||
data-testid="nextcloud-login-form"
|
||||
method="post"
|
||||
action={formAction}
|
||||
>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
|
||||
>
|
||||
@@ -60,6 +65,7 @@
|
||||
<input
|
||||
id="nc-user"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-user-input"
|
||||
name="user"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -73,6 +79,7 @@
|
||||
<input
|
||||
id="nc-password"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-password-input"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@@ -80,7 +87,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button>
|
||||
<button class="auth-button" data-testid="nextcloud-login-grant-btn" type="submit"
|
||||
>{t('nextcloud.grant', 'Grant access')}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -89,7 +98,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`} rel="external">
|
||||
<a
|
||||
class="auth-button auth-button-sso"
|
||||
data-testid="nextcloud-login-sso-link"
|
||||
href={`/login/v2/flow/${token}/oidc`}
|
||||
rel="external"
|
||||
>
|
||||
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
|
||||
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
|
||||
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
|
||||
<button type="button" class="nc-status__action" onclick={closeWindow}>
|
||||
<button
|
||||
type="button"
|
||||
class="nc-status__action"
|
||||
data-testid="nextcloud-success-close-btn"
|
||||
onclick={closeWindow}
|
||||
>
|
||||
{t('nextcloud.close_window', 'Close Window')}
|
||||
</button>
|
||||
</main>
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
class:active={tab === 'moments'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'moments'}
|
||||
data-testid="photos-tab-moments"
|
||||
onclick={() => (tab = 'moments')}
|
||||
>
|
||||
{t('photos.tab_moments', 'Moments')}
|
||||
@@ -337,6 +338,7 @@
|
||||
class:active={tab === 'places'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'places'}
|
||||
data-testid="photos-tab-places"
|
||||
onclick={() => (tab = 'places')}
|
||||
>
|
||||
{t('photos.tab_places', 'Places')}
|
||||
@@ -347,6 +349,7 @@
|
||||
class:active={tab === 'people'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'people'}
|
||||
data-testid="photos-tab-people"
|
||||
onclick={() => (tab = 'people')}
|
||||
>
|
||||
{t('photos.tab_people', 'People')}
|
||||
@@ -370,6 +373,7 @@
|
||||
class:active={layoutMode === 'square'}
|
||||
title={t('photos.layout_square', 'Grid')}
|
||||
aria-label={t('photos.layout_square', 'Grid')}
|
||||
data-testid="photos-layout-square-btn"
|
||||
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
|
||||
>
|
||||
<button
|
||||
@@ -377,18 +381,25 @@
|
||||
class:active={layoutMode === 'justified'}
|
||||
title={t('photos.layout_justified', 'Justified')}
|
||||
aria-label={t('photos.layout_justified', 'Justified')}
|
||||
data-testid="photos-layout-justified-btn"
|
||||
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected.size > 0}
|
||||
<div class="batch-bar">
|
||||
<div class="batch-bar" data-testid="photos-batch-bar">
|
||||
<span>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span>
|
||||
<div class="batch-bar__actions">
|
||||
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
|
||||
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
|
||||
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
||||
<Button data-testid="photos-batch-download-btn" onclick={downloadSelected}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button data-testid="photos-batch-clear-btn" onclick={() => selected.clear()}
|
||||
>{t('common.clear', 'Clear')}</Button
|
||||
>
|
||||
<Button data-testid="photos-batch-delete-btn" variant="danger" onclick={trashSelected}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -449,7 +460,11 @@
|
||||
|
||||
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
|
||||
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
|
||||
<button class="photo-tile__open" onclick={() => onTileClick(photo)}>
|
||||
<button
|
||||
class="photo-tile__open"
|
||||
data-testid={`photo-tile-${photo.id}`}
|
||||
onclick={() => onTileClick(photo)}
|
||||
>
|
||||
<!-- Always-present placeholder: the thumbnail <img> overlays it and, when
|
||||
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
|
||||
this default rather than the browser's broken-image glyph. -->
|
||||
@@ -471,6 +486,7 @@
|
||||
class="photo-tile__check"
|
||||
class:on={selected.has(photo.id)}
|
||||
aria-label={t('common.select', 'Select')}
|
||||
data-testid={`photo-tile-check-${photo.id}`}
|
||||
onclick={() => selected.toggle(photo.id)}
|
||||
>
|
||||
<Icon name="check" />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, confirmDialog } = vi.hoisted(() => ({
|
||||
ui: { notify: vi.fn() },
|
||||
confirmDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog }));
|
||||
vi.mock('$lib/api/endpoints/photos', () => ({
|
||||
batchTrash: vi.fn(),
|
||||
fetchPhotos: vi.fn(),
|
||||
uploadThumbnail: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb'
|
||||
}));
|
||||
|
||||
import { fetchPhotos } from '$lib/api/endpoints/photos';
|
||||
import { peopleEnabled } from '$lib/api/endpoints/people';
|
||||
import PhotosPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function photo(id: string) {
|
||||
return {
|
||||
category: 'Image',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-image',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: 0,
|
||||
name: id + '.jpg',
|
||||
owner_id: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + id + '.jpg',
|
||||
size: 100,
|
||||
size_formatted: '100 B',
|
||||
sort_date: 1_700_000_000,
|
||||
etag: 'e',
|
||||
content_hash: 'h',
|
||||
width: 100,
|
||||
height: 100
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(peopleEnabled).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('loads the first page of photos on mount', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [photo('a')], nextCursor: null });
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
|
||||
expect(m(fetchPhotos).mock.calls[0][0]).toBe(60);
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no photos', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId('photos-tab-moments')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('surfaces a load error', async () => {
|
||||
m(fetchPhotos).mockRejectedValue(new Error('photos boom'));
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(screen.getByText('photos boom')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('hides the People tab when face detection is disabled', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
m(peopleEnabled).mockResolvedValue(false);
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(peopleEnabled).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('photos-tab-people')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the People tab when face detection is enabled', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
m(peopleEnabled).mockResolvedValue(true);
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(screen.getByTestId('photos-tab-people')).toBeTruthy());
|
||||
});
|
||||
@@ -318,6 +318,7 @@
|
||||
{#if canEditImage}
|
||||
<button
|
||||
class="btn btn-secondary avatar-edit-btn"
|
||||
data-testid="profile-avatar-edit-btn"
|
||||
title={t('profile.edit_photo', 'Edit photo')}
|
||||
onclick={openAvatarEdit}
|
||||
>
|
||||
@@ -327,10 +328,11 @@
|
||||
</div>
|
||||
|
||||
{#if canEditImage && avatarEditOpen}
|
||||
<div class="avatar-edit">
|
||||
<div class="avatar-edit" data-testid="profile-avatar-edit-panel">
|
||||
<div class="avatar-tabs">
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-url-tab"
|
||||
class:avatar-tab--active={avatarTab === 'url'}
|
||||
onclick={() => (avatarTab = 'url')}
|
||||
>
|
||||
@@ -338,6 +340,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-upload-tab"
|
||||
class:avatar-tab--active={avatarTab === 'upload'}
|
||||
onclick={() => (avatarTab = 'upload')}
|
||||
>
|
||||
@@ -346,7 +349,12 @@
|
||||
</div>
|
||||
|
||||
{#if avatarTab === 'url'}
|
||||
<input type="url" bind:value={avatarUrl} placeholder="https://example.com/photo.jpg" />
|
||||
<input
|
||||
type="url"
|
||||
data-testid="profile-avatar-url-input"
|
||||
bind:value={avatarUrl}
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
/>
|
||||
<small class="muted">
|
||||
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
|
||||
</small>
|
||||
@@ -356,6 +364,7 @@
|
||||
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
|
||||
<input
|
||||
type="file"
|
||||
data-testid="profile-avatar-file-input"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onchange={onAvatarFile}
|
||||
@@ -373,19 +382,30 @@
|
||||
{/if}
|
||||
|
||||
<div class="avatar-edit-actions">
|
||||
<button class="btn btn-primary" disabled={avatarBusy} onclick={saveAvatar}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="profile-avatar-save-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={saveAvatar}
|
||||
>
|
||||
{t('profile.photo_save', 'Save')}
|
||||
</button>
|
||||
{#if session.user.image}
|
||||
<button
|
||||
class="btn link-btn link-btn--danger"
|
||||
data-testid="profile-avatar-remove-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={() => commitAvatar(null)}
|
||||
>
|
||||
{t('profile.photo_remove', 'Remove photo')}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-secondary" disabled={avatarBusy} onclick={closeAvatarEdit}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="profile-avatar-cancel-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={closeAvatarEdit}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -463,10 +483,11 @@
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={saveProfile}>
|
||||
<form data-testid="profile-edit-form" onsubmit={saveProfile}>
|
||||
<label>
|
||||
<span>{t('profile.username', 'Username')}</span>
|
||||
<input
|
||||
data-testid="profile-username-input"
|
||||
bind:value={username}
|
||||
maxlength="64"
|
||||
autocomplete="username"
|
||||
@@ -483,26 +504,42 @@
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.given_name', 'First name')}</span>
|
||||
<input bind:value={givenName} maxlength="128" autocomplete="given-name" />
|
||||
<input
|
||||
data-testid="profile-given-name-input"
|
||||
bind:value={givenName}
|
||||
maxlength="128"
|
||||
autocomplete="given-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.family_name', 'Last name')}</span>
|
||||
<input bind:value={familyName} maxlength="128" autocomplete="family-name" />
|
||||
<input
|
||||
data-testid="profile-family-name-input"
|
||||
bind:value={familyName}
|
||||
maxlength="128"
|
||||
autocomplete="family-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.language', 'Language')}</span>
|
||||
<select bind:value={preferredLocale}>
|
||||
<option value="">{t('profile.language_auto', 'Automatic')}</option>
|
||||
<select data-testid="profile-language-select" bind:value={preferredLocale}>
|
||||
<option value="" data-testid="profile-language-auto-option"
|
||||
>{t('profile.language_auto', 'Automatic')}</option
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as loc (loc)}
|
||||
<option value={loc}>{loc}</option>
|
||||
<option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={notifyOnShare} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="profile-notify-on-share-checkbox"
|
||||
bind:checked={notifyOnShare}
|
||||
/>
|
||||
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
|
||||
</label>
|
||||
<button type="submit" disabled={savingProfile}
|
||||
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
|
||||
>{t('profile.save_profile', 'Save changes')}</button
|
||||
>
|
||||
</form>
|
||||
@@ -522,11 +559,17 @@
|
||||
|
||||
<div class="app-pw-create">
|
||||
<input
|
||||
data-testid="profile-app-pw-label-input"
|
||||
bind:value={newLabel}
|
||||
maxlength="128"
|
||||
placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={creatingPw} onclick={createPw}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="profile-app-pw-generate-btn"
|
||||
disabled={creatingPw}
|
||||
onclick={createPw}
|
||||
>
|
||||
<Icon name="user-plus" />
|
||||
{t('profile.generate', 'Generate')}
|
||||
</button>
|
||||
@@ -542,6 +585,7 @@
|
||||
<code>{generated.password}</code>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid="profile-app-pw-copy-btn"
|
||||
title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
|
||||
onclick={copyGenerated}
|
||||
>
|
||||
@@ -587,6 +631,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -602,7 +647,11 @@
|
||||
|
||||
{#if autoPasswords.length > 0}
|
||||
<div class="app-pw-auto">
|
||||
<button class="app-pw-auto__toggle" onclick={() => (autoExpanded = !autoExpanded)}>
|
||||
<button
|
||||
class="app-pw-auto__toggle"
|
||||
data-testid="profile-app-pw-auto-toggle-btn"
|
||||
onclick={() => (autoExpanded = !autoExpanded)}
|
||||
>
|
||||
<Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} />
|
||||
<span>{t('profile.client_sessions', 'Client sessions')}</span>
|
||||
<span class="badge badge--count">{autoPasswords.length}</span>
|
||||
@@ -637,6 +686,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -656,22 +706,39 @@
|
||||
|
||||
<!-- Change password -->
|
||||
{#if showPasswordCard}
|
||||
<form class="card" onsubmit={savePassword}>
|
||||
<form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
|
||||
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
|
||||
<label>
|
||||
<span>{t('profile.current_password', 'Current Password')}</span>
|
||||
<input type="password" bind:value={currentPw} autocomplete="current-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-current-password-input"
|
||||
bind:value={currentPw}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.new_password', 'New Password')}</span>
|
||||
<input type="password" bind:value={newPw} minlength="8" autocomplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-new-password-input"
|
||||
bind:value={newPw}
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.confirm_password', 'Confirm New Password')}</span>
|
||||
<input type="password" bind:value={confirmPw} minlength="8" autocomplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-confirm-password-input"
|
||||
bind:value={confirmPw}
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={savingPassword}>
|
||||
<button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
|
||||
{t('profile.update_password', 'Update Password')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: {
|
||||
loaded: true,
|
||||
load: vi.fn(),
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
}
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ getOidcProviders: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/profile', () => ({
|
||||
changePassword: vi.fn(),
|
||||
createAppPassword: vi.fn(),
|
||||
isAutoAppPassword: () => false,
|
||||
listAppPasswords: vi.fn(),
|
||||
revokeAppPassword: vi.fn(),
|
||||
updateAvatar: vi.fn(),
|
||||
updateProfile: vi.fn()
|
||||
}));
|
||||
|
||||
import * as profile from '$lib/api/endpoints/profile';
|
||||
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import ProfilePage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the shared session each test (handlers may mutate session.user).
|
||||
session.loaded = true;
|
||||
session.user = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
};
|
||||
m(profile.listAppPasswords).mockResolvedValue([]);
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
|
||||
});
|
||||
|
||||
it('renders and saves the profile form', async () => {
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-edit-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-given-name-input'), {
|
||||
target: { value: 'New' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-save-btn'));
|
||||
await waitFor(() => expect(profile.updateProfile).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('generates an app password', async () => {
|
||||
m(profile.createAppPassword).mockResolvedValue({ id: 'ap1', secret: 'xyz', label: 'tok' });
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-app-pw-label-input');
|
||||
await fireEvent.input(screen.getByTestId('profile-app-pw-label-input'), {
|
||||
target: { value: 'tok' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-app-pw-generate-btn'));
|
||||
await waitFor(() => expect(profile.createAppPassword).toHaveBeenCalledWith('tok'));
|
||||
});
|
||||
|
||||
it('rejects a mismatched password change without calling the API', async () => {
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-password-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
|
||||
target: { value: 'old' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
|
||||
target: { value: 'new1' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
|
||||
target: { value: 'new2' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
|
||||
expect(profile.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes the password when the confirmation matches', async () => {
|
||||
m(profile.changePassword).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-password-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
|
||||
target: { value: 'OldPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
|
||||
target: { value: 'NewPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
|
||||
target: { value: 'NewPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
|
||||
await waitFor(() => expect(profile.changePassword).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('revokes an existing app password after confirmation', async () => {
|
||||
m(profile.listAppPasswords).mockResolvedValue([
|
||||
{ id: 'ap1', label: 'CLI token', created_at: '2024-01-01T00:00:00Z' }
|
||||
]);
|
||||
m(confirmDialog).mockResolvedValue(true);
|
||||
m(profile.revokeAppPassword).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await fireEvent.click(await screen.findByTestId('profile-app-pw-revoke-ap1'));
|
||||
await waitFor(() => expect(profile.revokeAppPassword).toHaveBeenCalledWith('ap1'));
|
||||
});
|
||||
@@ -341,21 +341,29 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
<Button icon="broom" onclick={clearAll}>{t('recent.clear', 'Clear recent')}</Button>
|
||||
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
|
||||
>{t('recent.clear', 'Clear recent')}</Button
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||
<Button icon="download" data-testid="recent-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="recent-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="recent-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recent', () => ({ clearRecent: vi.fn(), fetchRecentPage: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({
|
||||
addFavorite: vi.fn(),
|
||||
dateBucket: () => 'Today',
|
||||
fetchFavoritesPage: vi.fn(async () => ({ items: [], next_cursor: null })),
|
||||
removeFavorite: vi.fn(),
|
||||
resolveOwnerName: vi.fn(async () => 'me'),
|
||||
sizeBucket: () => 'Small',
|
||||
typeLabel: () => 'File'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
renameFile: vi.fn(),
|
||||
deleteFile: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
|
||||
import { fetchRecentPage, clearRecent } from '$lib/api/endpoints/recent';
|
||||
import { addFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import RecentPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withOneFile() {
|
||||
m(fetchRecentPage).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
resource_type: 'file',
|
||||
accessed_at: '2024-01-01T00:00:00Z',
|
||||
resource: {
|
||||
category: 'Document',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id: 'r1',
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name: 'notes.txt',
|
||||
owner_id: 'me',
|
||||
folder_id: 'root',
|
||||
path: '/notes.txt',
|
||||
size: 4,
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
}
|
||||
}
|
||||
],
|
||||
next_cursor: null
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders recent items returned by the API', async () => {
|
||||
withOneFile();
|
||||
render(RecentPage);
|
||||
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('notes.txt')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('clears recent activity after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(clearRecent).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await fireEvent.click(await screen.findByTestId('recent-clear-btn'));
|
||||
await waitFor(() => expect(clearRecent).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('favorites a recent row via the star button', async () => {
|
||||
withOneFile();
|
||||
m(addFavorite).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await screen.findByText('notes.txt');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-favorite-r1-btn'));
|
||||
await waitFor(() => expect(addFavorite).toHaveBeenCalledWith('file', 'r1'));
|
||||
});
|
||||
|
||||
it('batch-deletes selected recent items after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await screen.findByText('notes.txt');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1'));
|
||||
});
|
||||
|
||||
it('renders an empty state when there is no recent activity', async () => {
|
||||
m(fetchRecentPage).mockResolvedValue({ items: [], next_cursor: null });
|
||||
render(RecentPage);
|
||||
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
|
||||
});
|
||||
@@ -258,23 +258,32 @@
|
||||
<p>{t('share.expired', 'This share link is no longer available.')}</p>
|
||||
</div>
|
||||
{:else if view === 'password'}
|
||||
<form class="share__pw" onsubmit={submitPassword}>
|
||||
<form class="share__pw" data-testid="public-share-password-form" onsubmit={submitPassword}>
|
||||
<h1>{t('share.password_title', 'Password required')}</h1>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="public-share-password-input"
|
||||
bind:value={pwInput}
|
||||
placeholder={t('share.password', 'Password')}
|
||||
disabled={busy}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
|
||||
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button>
|
||||
<button type="submit" data-testid="public-share-unlock-btn" disabled={busy}
|
||||
>{t('share.unlock', 'Unlock')}</button
|
||||
>
|
||||
</form>
|
||||
{:else if view === 'file'}
|
||||
<div class="share__center">
|
||||
<Icon name="file" class="share__big-icon" />
|
||||
<h1>{meta?.item_name}</h1>
|
||||
<a class="share__btn" href={shareDownloadUrl(token)} download rel="external">
|
||||
<a
|
||||
class="share__btn"
|
||||
data-testid="public-share-download-btn"
|
||||
href={shareDownloadUrl(token)}
|
||||
download
|
||||
rel="external"
|
||||
>
|
||||
{t('share.download', 'Download')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -286,7 +295,11 @@
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb__current">{c.name}</span>
|
||||
{:else}
|
||||
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
|
||||
<button
|
||||
class="breadcrumb__link"
|
||||
data-testid={`public-share-breadcrumb-${i}`}
|
||||
onclick={() => gotoCrumb(i)}>{c.name}</button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -296,6 +309,7 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
class:active={viewMode === 'grid'}
|
||||
data-testid="public-share-view-grid-btn"
|
||||
title={t('files.grid', 'Grid')}
|
||||
onclick={() => setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
@@ -303,11 +317,18 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
class:active={viewMode === 'list'}
|
||||
data-testid="public-share-view-list-btn"
|
||||
title={t('files.list', 'List')}
|
||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||
>
|
||||
</div>
|
||||
<a class="share__btn" href={shareZipUrl(token, folderId)} download rel="external">
|
||||
<a
|
||||
class="share__btn"
|
||||
data-testid="public-share-download-zip-btn"
|
||||
href={shareZipUrl(token, folderId)}
|
||||
download
|
||||
rel="external"
|
||||
>
|
||||
<Icon name="file-archive" />
|
||||
{t('share.download_zip', 'Download ZIP')}
|
||||
</a>
|
||||
@@ -323,7 +344,11 @@
|
||||
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
||||
{#each listing.folders as f (f.id)}
|
||||
<li>
|
||||
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
|
||||
<button
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}
|
||||
>
|
||||
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</button>
|
||||
@@ -341,6 +366,7 @@
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
|
||||
>
|
||||
<span class="card__thumb">
|
||||
@@ -369,6 +395,7 @@
|
||||
<li>
|
||||
<a
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
href={shareFileUrl(token, f.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
@@ -390,6 +417,7 @@
|
||||
<div
|
||||
class="lb"
|
||||
role="dialog"
|
||||
data-testid="public-share-lightbox-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.name}
|
||||
tabindex="-1"
|
||||
@@ -397,11 +425,13 @@
|
||||
>
|
||||
<button
|
||||
class="lb__close"
|
||||
data-testid="public-share-lightbox-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={() => (lightbox = -1)}>×</button
|
||||
>
|
||||
<button
|
||||
class="lb__nav lb__nav--prev"
|
||||
data-testid="public-share-lightbox-prev-btn"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={lightbox === 0}
|
||||
onclick={(e) => {
|
||||
@@ -417,6 +447,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="lb__nav lb__nav--next"
|
||||
data-testid="public-share-lightbox-next-btn"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={lightbox === mediaFiles.length - 1}
|
||||
onclick={(e) => {
|
||||
|
||||
@@ -201,12 +201,18 @@
|
||||
<div class="search-controls">
|
||||
{#if filesStore.currentFolder}
|
||||
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
|
||||
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'all'}
|
||||
data-testid="search-scope-all-btn"
|
||||
onclick={() => (scope = 'all')}
|
||||
>
|
||||
{t('search.everywhere', 'Everywhere')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
data-testid="search-scope-folder-btn"
|
||||
onclick={() => (scope = 'folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
@@ -217,28 +223,40 @@
|
||||
class="sort-select"
|
||||
bind:value={typeFilter}
|
||||
aria-label={t('search.type_label', 'Type')}
|
||||
data-testid="search-type-filter-select"
|
||||
>
|
||||
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sizeFilter}
|
||||
aria-label={t('search.size_label', 'Size')}
|
||||
data-testid="search-size-filter-select"
|
||||
>
|
||||
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={dateFilter}
|
||||
aria-label={t('search.date_label', 'Date')}
|
||||
data-testid="search-date-filter-select"
|
||||
>
|
||||
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
|
||||
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sortBy}
|
||||
aria-label={t('search.sort_by', 'Sort by')}
|
||||
data-testid="search-sort-select"
|
||||
>
|
||||
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" onclick={clearFilters}>
|
||||
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
@@ -281,6 +299,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.folder.name}
|
||||
data-testid={e.folder.name}
|
||||
onclick={() => openFolder(e.folder)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||
>
|
||||
@@ -297,6 +317,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.file.name}
|
||||
data-testid={e.file.name}
|
||||
onclick={() => openFile(e.file)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/search?q=report') }
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import SearchPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/search?q=report');
|
||||
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
|
||||
});
|
||||
|
||||
it('runs a search from the q query parameter on mount', async () => {
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
|
||||
});
|
||||
|
||||
it('does not search when there is no query', async () => {
|
||||
pageState.url = new URL('http://localhost/search');
|
||||
render(SearchPage);
|
||||
// Give the reactive effect a tick to (not) fire.
|
||||
await Promise.resolve();
|
||||
expect(searchFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a search error', async () => {
|
||||
m(searchFiles).mockRejectedValue(new Error('search boom'));
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
|
||||
});
|
||||
@@ -391,11 +391,19 @@
|
||||
<header class="ms-lane__header">
|
||||
{#if lane.header.kind === 'resource'}
|
||||
{@const laneItem = lane.header.item}
|
||||
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}>
|
||||
<button
|
||||
class="ms-lane__resource"
|
||||
data-testid={`shared-lane-open-${laneItem.resource.id}`}
|
||||
onclick={() => openResource(laneItem)}
|
||||
>
|
||||
<Icon name={resourceIcon(laneItem)} />
|
||||
<span class="ms-lane__name">{laneItem.resource.name}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}>
|
||||
<button
|
||||
class="btn btn-secondary ms-lane__edit"
|
||||
data-testid={`shared-edit-sharing-${laneItem.resource.id}`}
|
||||
onclick={() => editSharing(laneItem)}
|
||||
>
|
||||
<Icon name="pencil-alt" />
|
||||
{t('myshares.editSharing', 'Edit sharing')}
|
||||
</button>
|
||||
@@ -427,7 +435,11 @@
|
||||
<!-- Identity -->
|
||||
<span class="ms-row__identity">
|
||||
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
|
||||
<button class="ms-link-btn" onclick={() => openResource(item)}>
|
||||
<button
|
||||
class="ms-link-btn"
|
||||
data-testid={`shared-row-open-${grant.grant_id}`}
|
||||
onclick={() => openResource(item)}
|
||||
>
|
||||
<Icon name={resourceIcon(item)} />
|
||||
<span class="ms-row__name">{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -443,6 +455,7 @@
|
||||
<button
|
||||
class="ms-chip ms-chip--link"
|
||||
class:ms-chip--locked={grant.has_password}
|
||||
data-testid={`shared-copy-link-${grant.grant_id}`}
|
||||
onclick={() => copyLink(grant)}
|
||||
title={t('share.copyLink', 'Copy link')}
|
||||
>
|
||||
@@ -451,7 +464,11 @@
|
||||
</button>
|
||||
{#if groupBy === 'sharedWith'}
|
||||
<span class="ms-arrow">→</span>
|
||||
<button class="ms-link-btn" onclick={() => openResource(item)}>
|
||||
<button
|
||||
class="ms-link-btn"
|
||||
data-testid={`shared-link-open-${grant.grant_id}`}
|
||||
onclick={() => openResource(item)}
|
||||
>
|
||||
<Icon name={resourceIcon(item)} />
|
||||
<span>{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -480,6 +497,7 @@
|
||||
aria-label={t('myshares.manageAccess', 'Manage access')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuFor === grant.grant_id}
|
||||
data-testid={`shared-kebab-${grant.grant_id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu(grant.grant_id);
|
||||
@@ -490,11 +508,17 @@
|
||||
class="ms-menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
data-testid={`shared-menu-${grant.grant_id}`}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
|
||||
>
|
||||
{#if grant.subject_type === 'user' || grant.subject_type === 'group'}
|
||||
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}>
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-notify-${grant.grant_id}`}
|
||||
onclick={() => notify(grant)}
|
||||
>
|
||||
<Icon name="paper-plane" />
|
||||
{grant.subject_type === 'group'
|
||||
? t('myshares.notifyGroupMembers', 'Notify group members')
|
||||
@@ -508,6 +532,7 @@
|
||||
class="ms-menu__item"
|
||||
class:ms-menu__item--current={grant.role === r.v}
|
||||
role="menuitem"
|
||||
data-testid={`shared-role-${r.v}-${grant.grant_id}`}
|
||||
onclick={() => changeRole(grant, item, r.v)}
|
||||
>
|
||||
<Icon name={grant.role === r.v ? 'check' : r.icon} />
|
||||
@@ -520,6 +545,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -529,13 +555,19 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-remove-access-${grant.grant_id}`}
|
||||
onclick={() => removeAccess(grant)}
|
||||
>
|
||||
<Icon name="user-xmark" />
|
||||
{t('myshares.removeAccess', 'Remove access')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}>
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-menu-copy-link-${grant.grant_id}`}
|
||||
onclick={() => copyLink(grant)}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
{t('myshares.copyLink', 'Copy link')}
|
||||
</button>
|
||||
@@ -545,6 +577,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-link-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -553,6 +586,7 @@
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-edit-password-${grant.grant_id}`}
|
||||
onclick={() => editLinkPassword(grant)}
|
||||
>
|
||||
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
|
||||
@@ -564,6 +598,7 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-delete-link-${grant.grant_id}`}
|
||||
onclick={() => deleteLink(grant)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -580,7 +615,12 @@
|
||||
{/each}
|
||||
|
||||
{#if cursor}
|
||||
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}>
|
||||
<button
|
||||
class="btn btn-secondary ms-more"
|
||||
data-testid="shared-load-more-btn"
|
||||
onclick={() => load(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, goto } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, goto: vi.fn() }));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/utils/errors', () => ({
|
||||
errorMessage: (e: Error) => e.message,
|
||||
errorToast: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/grants', () => ({
|
||||
displayRole: (r: string) => r,
|
||||
expiryToIso: (v: string | null) => v,
|
||||
fetchMyShares: vi.fn(),
|
||||
notifyGrantRecipient: vi.fn(async () => ({ outcomes: [] })),
|
||||
revokeGrant: vi.fn(),
|
||||
updateGrantRole: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
ensureResolvers: vi.fn(),
|
||||
resolveLabel: (_t: string, id: string) => id
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/shares', () => ({
|
||||
copyShareLink: vi.fn(),
|
||||
deleteShare: vi.fn(),
|
||||
getShareById: vi.fn(),
|
||||
updateShare: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { fetchMyShares, updateGrantRole, revokeGrant } from '$lib/api/endpoints/grants';
|
||||
import { ensureResolvers } from '$lib/api/endpoints/recipients';
|
||||
import SharedPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function grantItem() {
|
||||
return {
|
||||
resource_type: 'folder' as const,
|
||||
resource: {
|
||||
category: 'Folder',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-folder',
|
||||
icon_special_class: '',
|
||||
id: 'r1',
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name: 'Docs',
|
||||
owner_id: 'me',
|
||||
parent_id: null,
|
||||
path: '/Docs',
|
||||
etag: 'e'
|
||||
},
|
||||
first_shared_at: '2024-01-01T00:00:00Z',
|
||||
grants: [
|
||||
{
|
||||
grant_id: 'g1',
|
||||
subject_type: 'user' as const,
|
||||
subject_id: 'u1',
|
||||
subject_display: 'Bob',
|
||||
role: 'viewer' as const,
|
||||
granted_at: '2024-01-01T00:00:00Z',
|
||||
expires_at: null,
|
||||
has_password: false,
|
||||
is_external: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(ensureResolvers).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders a swimlane for each shared resource', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
render(SharedPage);
|
||||
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
|
||||
await screen.findByTestId('shared-kebab-g1');
|
||||
});
|
||||
|
||||
it('changes a grant role from the kebab menu', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
m(updateGrantRole).mockResolvedValue(undefined);
|
||||
render(SharedPage);
|
||||
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
|
||||
await fireEvent.click(await screen.findByTestId('shared-role-editor-g1'));
|
||||
await waitFor(() =>
|
||||
expect(updateGrantRole).toHaveBeenCalledWith(
|
||||
{ type: 'user', id: 'u1' },
|
||||
{ type: 'folder', id: 'r1' },
|
||||
'editor',
|
||||
null
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes access from the kebab menu', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
m(revokeGrant).mockResolvedValue(undefined);
|
||||
render(SharedPage);
|
||||
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
|
||||
await fireEvent.click(await screen.findByTestId('shared-remove-access-g1'));
|
||||
await waitFor(() => expect(revokeGrant).toHaveBeenCalledWith('g1'));
|
||||
});
|
||||
|
||||
it('surfaces a load error without crashing', async () => {
|
||||
m(fetchMyShares).mockRejectedValue(new Error('boom'));
|
||||
render(SharedPage);
|
||||
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
|
||||
});
|
||||
@@ -174,7 +174,7 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
<button class="btn btn-danger" onclick={purgeAll}>
|
||||
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
|
||||
<Icon name="trash" />
|
||||
{t('trash.empty_action', 'Empty trash')}
|
||||
</button>
|
||||
@@ -188,11 +188,17 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet actions(entry)}
|
||||
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`trash-restore-btn-${entry.id}`}
|
||||
title={t('trash.restore', 'Restore')}
|
||||
onclick={() => restore(entry)}
|
||||
>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid={`trash-delete-btn-${entry.id}`}
|
||||
title={t('trash.delete', 'Delete permanently')}
|
||||
onclick={() => purge(entry)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user