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:
Bradley Nelson
2026-06-21 20:03:32 -06:00
parent 0c40c69f9b
commit e3823ce470
162 changed files with 13213 additions and 554 deletions
+291 -61
View File
@@ -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}
+212
View File
@@ -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'));
});
+20 -5
View File
@@ -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>
+60
View File
@@ -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');
});
+9 -3
View File
@@ -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();
+195
View File
@@ -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' })
)
);
});
+31 -9
View File
@@ -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}
+56
View File
@@ -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'));
});
+86 -15
View File
@@ -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)}
>
+157
View File
@@ -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');
});
+72 -8
View File
@@ -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)}>&times;</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>
+119
View File
@@ -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>
+21 -5
View File
@@ -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" />
+88
View File
@@ -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());
});
+86 -19
View File
@@ -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>
+128
View File
@@ -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'));
});
+12 -4
View File
@@ -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>
+105
View File
@@ -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());
});
+37 -6
View File
@@ -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) => {
+29 -7
View File
@@ -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)}
>
+43
View 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());
});
+47 -7
View File
@@ -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}
+112
View File
@@ -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());
});
+8 -2
View File
@@ -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)}
>