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:
@@ -5,13 +5,20 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
%sveltekit.head%
|
||||
<!--
|
||||
Anti-FOUC theme init — runs synchronously before first paint.
|
||||
Ported from static/js/core/theme-init.js. Keeps the legacy
|
||||
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
|
||||
so existing users keep their preference across the migration.
|
||||
|
||||
Placed AFTER %sveltekit.head% so it follows the CSP <meta> SvelteKit
|
||||
injects there, hence it IS governed by that policy. svelte.config.js
|
||||
parses this file, finds this <script> by id="theme-init", and adds its
|
||||
SHA-256 to script-src automatically — so it's allowed inline (zero extra
|
||||
request, no 'unsafe-inline') and the hash stays in sync if you edit it.
|
||||
-->
|
||||
<script>
|
||||
<script id="theme-init">
|
||||
(function () {
|
||||
try {
|
||||
var s = localStorage.getItem('oxicloud_theme');
|
||||
@@ -64,7 +71,6 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div id="app-splash" role="status" aria-label="Loading">
|
||||
|
||||
@@ -128,3 +128,16 @@ describe('createApiFetch — 401 refresh/retry parity', () => {
|
||||
expect(rawFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiError + apiJson', () => {
|
||||
it('ApiError carries status, statusText, and a descriptive message', async () => {
|
||||
const { ApiError } = await import('./client');
|
||||
const e = new ApiError(404, 'Not Found', '/api/files/x');
|
||||
expect(e.status).toBe(404);
|
||||
expect(e.statusText).toBe('Not Found');
|
||||
expect(e.name).toBe('ApiError');
|
||||
expect(e.message).toContain('404');
|
||||
expect(e.message).toContain('/api/files/x');
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({ 'x-csrf-token': 't' }) }));
|
||||
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as admin from './admin';
|
||||
|
||||
const okRes = (body: unknown = {}) =>
|
||||
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
|
||||
const errRes = (status = 400, body: unknown = { message: 'nope' }) =>
|
||||
({ ok: false, status, json: async () => body }) as unknown as Response;
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue(okRes());
|
||||
jsonMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('admin mutate-based endpoints', () => {
|
||||
it('resolve on success and call the expected URL/method', async () => {
|
||||
await admin.createUser({
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
email: null,
|
||||
role: 'user',
|
||||
quota_bytes: 0
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/admin/users',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
await admin.setUserRole('1', 'admin');
|
||||
await admin.setUserActive('1', false);
|
||||
await admin.setUserQuota('1', 100);
|
||||
await admin.resetUserPassword('1', 'newpw');
|
||||
await admin.deleteUser('1');
|
||||
await admin.setRegistrationEnabled(true);
|
||||
await admin.saveOidc({ enabled: true });
|
||||
await admin.saveStorage({ backend: 'local' });
|
||||
await admin.savePluginRetention('id', { retention_days: 30, max_bytes: 100 });
|
||||
await admin.clearPluginLogs('id');
|
||||
await admin.setPluginEnabled('id', true);
|
||||
await admin.deletePlugin('id');
|
||||
await admin.migrationAction('start');
|
||||
await admin.migrationAction('pause');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throw the server message on failure', async () => {
|
||||
fetchMock.mockResolvedValue(errRes(409, { message: 'conflict' }));
|
||||
await expect(admin.deleteUser('1')).rejects.toThrow('conflict');
|
||||
});
|
||||
|
||||
it('throw a generic message when the error body has none', async () => {
|
||||
fetchMock.mockResolvedValue(errRes(500, {}));
|
||||
await expect(admin.setUserActive('1', true)).rejects.toThrow(/failed: 500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin read endpoints', () => {
|
||||
it('call apiJson for the listing/settings reads', async () => {
|
||||
await admin.listUsers(25, 0);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/admin/users?limit=25&offset=0'),
|
||||
expect.anything()
|
||||
);
|
||||
await admin.getDashboard();
|
||||
await admin.getSmtpInfo();
|
||||
await admin.getOidcSettings();
|
||||
await admin.getStorageSettings();
|
||||
await admin.getMigration();
|
||||
await admin.listPlugins();
|
||||
await admin.getPluginLogs('id', { limit: 50, offset: 0 });
|
||||
expect(jsonMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getPluginRetention returns null when the request is not ok', async () => {
|
||||
fetchMock.mockResolvedValueOnce(errRes(404, {}));
|
||||
await expect(admin.getPluginRetention('id')).resolves.toBeNull();
|
||||
fetchMock.mockResolvedValueOnce(okRes({ max_age_days: 7, max_entries: 50 }));
|
||||
await expect(admin.getPluginRetention('id')).resolves.toMatchObject({ max_age_days: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin test/probe endpoints', () => {
|
||||
it('sendSmtpTest maps 503 to an unconfigured message', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 503, json: async () => ({}) } as unknown as Response);
|
||||
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('sendSmtpTest returns the parsed result otherwise', async () => {
|
||||
fetchMock.mockResolvedValue(okRes({ success: true }));
|
||||
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
it('testOidc / testStorage return parsed results', async () => {
|
||||
fetchMock.mockResolvedValue(okRes({ success: true }));
|
||||
await expect(admin.testOidc('https://idp')).resolves.toBeTruthy();
|
||||
fetchMock.mockResolvedValue(okRes({ connected: true }));
|
||||
await expect(admin.testStorage({ backend: 's3' })).resolves.toMatchObject({ connected: true });
|
||||
});
|
||||
|
||||
it('verifyMigration fills defaults and throws on error', async () => {
|
||||
fetchMock.mockResolvedValue(okRes({ passed: true }));
|
||||
await expect(admin.verifyMigration(10)).resolves.toMatchObject({
|
||||
passed: true,
|
||||
sample_checked: 0
|
||||
});
|
||||
fetchMock.mockResolvedValue(errRes(500, {}));
|
||||
await expect(admin.verifyMigration()).rejects.toThrow(/verify failed/);
|
||||
});
|
||||
|
||||
it('installPlugin posts a FormData bundle', async () => {
|
||||
fetchMock.mockResolvedValue(okRes({ id: 'com.example.hello' }));
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'p.zip', { type: 'application/zip' });
|
||||
await admin.installPlugin(file);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/admin/plugins',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as auth from './auth';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
|
||||
const okRes = { ok: true, status: 200, json: async () => ({}) };
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue(okRes);
|
||||
j.mockResolvedValue({});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it('exercises the auth endpoints (success paths)', async () => {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
await auth.tryRefresh().catch(() => {});
|
||||
await auth.login('u', 'p').catch(() => {});
|
||||
await auth.getOidcProviders().catch(() => {});
|
||||
await auth.getAuthStatus().catch(() => {});
|
||||
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
||||
await auth.exchangeOidcCode('code').catch(() => {});
|
||||
await auth.register('u', 'e@x.test', 'p').catch(() => {});
|
||||
await auth.sendMagicLink('e@x.test').catch(() => {});
|
||||
await auth.logout().catch(() => {});
|
||||
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(fc + f.mock.calls.length).toBeGreaterThan(3);
|
||||
});
|
||||
it('fetchMe returns null when the probe is not ok', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
||||
);
|
||||
await expect(auth.fetchMe()).resolves.toBeNull();
|
||||
});
|
||||
it('tryRefresh returns false when the refresh fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
||||
);
|
||||
await expect(auth.tryRefresh()).resolves.toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { copyFiles, copyFolders } from './batch';
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('batch copy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
});
|
||||
|
||||
it('short-circuits on empty input', async () => {
|
||||
await copyFiles([], null);
|
||||
await copyFolders([], 'x');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts copy requests for files and folders', async () => {
|
||||
await copyFiles(['a'], 't');
|
||||
await copyFolders(['b'], null);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/batch/files/copy',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the server error/message on failure', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'bad' }) });
|
||||
await expect(copyFiles(['a'], 't')).rejects.toThrow('bad');
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
await expect(copyFolders(['b'], 't')).rejects.toThrow(/failed: 500/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfToken: () => 'tok' }));
|
||||
|
||||
// vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them.
|
||||
const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({
|
||||
@@ -13,7 +15,96 @@ vi.mock('$lib/api/endpoints/files', () => ({
|
||||
dedupCheckBatch: batchMock
|
||||
}));
|
||||
|
||||
import { DELTA_UPLOAD_MIN_SIZE, instantUploadOwned, resolveOwnedHashes } from './deltaUpload';
|
||||
import {
|
||||
DELTA_UPLOAD_MIN_SIZE,
|
||||
instantUploadOwned,
|
||||
resolveOwnedHashes,
|
||||
tryDeltaUpload
|
||||
} from './deltaUpload';
|
||||
|
||||
describe('tryDeltaUpload (delta worker)', () => {
|
||||
type Handler = ((e: { data: unknown }) => void) | null;
|
||||
let lastWorker: FakeWorker | null = null;
|
||||
|
||||
class FakeWorker {
|
||||
onmessage: Handler = null;
|
||||
onerror: (() => void) | null = null;
|
||||
posted: unknown[] = [];
|
||||
terminated = false;
|
||||
constructor() {
|
||||
// Capture the instance the code-under-test creates so the test can drive its events.
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
lastWorker = this;
|
||||
}
|
||||
postMessage(msg: unknown) {
|
||||
this.posted.push(msg);
|
||||
}
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
emit(data: unknown) {
|
||||
this.onmessage?.({ data });
|
||||
}
|
||||
}
|
||||
|
||||
function bigFile(): File {
|
||||
const f = new File(['x'], 'big.bin');
|
||||
Object.defineProperty(f, 'size', { value: DELTA_UPLOAD_MIN_SIZE + 1 });
|
||||
return f;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lastWorker = null;
|
||||
vi.stubGlobal('Worker', FakeWorker as unknown as typeof Worker);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('skips delta for a small file', async () => {
|
||||
const small = new File(['x'], 's.txt');
|
||||
expect(await tryDeltaUpload(small, 'folder')).toBeNull();
|
||||
});
|
||||
|
||||
it('skips delta when there is no folder', async () => {
|
||||
expect(await tryDeltaUpload(bigFile(), null)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves ok on a 201 done message and reports progress', async () => {
|
||||
const onProgress = vi.fn();
|
||||
const p = tryDeltaUpload(bigFile(), 'folder', onProgress);
|
||||
await Promise.resolve();
|
||||
expect(lastWorker).toBeTruthy();
|
||||
lastWorker!.emit({ type: 'progress', reusedBytes: 50, uploadedBytes: 50, totalBytes: 100 });
|
||||
expect(onProgress).toHaveBeenCalledWith(99);
|
||||
lastWorker!.emit({ type: 'done', status: 201, body: { message: 'ok' } });
|
||||
const res = await p;
|
||||
expect(res?.ok).toBe(true);
|
||||
expect(res?.savedBytes).toBe(50);
|
||||
expect(lastWorker!.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to null on a fallback message', async () => {
|
||||
const p = tryDeltaUpload(bigFile(), 'folder');
|
||||
await Promise.resolve();
|
||||
lastWorker!.emit({ type: 'fallback', reason: 'no wasm' });
|
||||
expect(await p).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a quota error on a 507 done message', async () => {
|
||||
const p = tryDeltaUpload(bigFile(), 'folder');
|
||||
await Promise.resolve();
|
||||
lastWorker!.emit({ type: 'done', status: 507, body: { error: 'over quota' } });
|
||||
const res = await p;
|
||||
expect(res?.isQuotaError).toBe(true);
|
||||
expect(res?.errorMsg).toBe('over quota');
|
||||
});
|
||||
|
||||
it('resolves null on a worker error', async () => {
|
||||
const p = tryDeltaUpload(bigFile(), 'folder');
|
||||
await Promise.resolve();
|
||||
lastWorker!.onerror?.();
|
||||
expect(await p).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File;
|
||||
const hashOf = (name: string) => name.padEnd(64, '0');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { lookupDeviceCode, decideDevice } from './device';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
});
|
||||
it('looks up and decides device codes', async () => {
|
||||
await lookupDeviceCode('ABCD').catch(() => {});
|
||||
await decideDevice('ABCD', 'approve').catch(() => {});
|
||||
await decideDevice('ABCD', 'deny').catch(() => {});
|
||||
expect(f).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { dateBucket, sizeBucket, typeLabel, addFavorite, removeFavorite } from './favorites';
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('dateBucket', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('buckets by recency and falls back to the year', () => {
|
||||
expect(dateBucket(Date.now())).toBeTruthy();
|
||||
expect(dateBucket(Date.now() - 3 * 86_400_000)).toBeTruthy();
|
||||
expect(dateBucket(Date.now() - 20 * 86_400_000)).toBeTruthy();
|
||||
expect(dateBucket('2019-06-15')).toBe('2019'); // mid-year is timezone-safe
|
||||
});
|
||||
it('returns null for null/invalid', () => {
|
||||
expect(dateBucket(null)).toBeNull();
|
||||
expect(dateBucket(undefined)).toBeNull();
|
||||
expect(dateBucket('not a date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sizeBucket', () => {
|
||||
it('maps byte ranges to distinct labels', () => {
|
||||
const labels = [
|
||||
sizeBucket(null),
|
||||
sizeBucket(0),
|
||||
sizeBucket(500),
|
||||
sizeBucket(50 * 1_048_576),
|
||||
sizeBucket(500 * 1_048_576),
|
||||
sizeBucket(2 * 1_073_741_824),
|
||||
sizeBucket(10 * 1_073_741_824)
|
||||
];
|
||||
expect(new Set(labels).size).toBe(labels.length); // all distinct
|
||||
labels.forEach((l) => expect(l).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
describe('typeLabel', () => {
|
||||
it('maps known categories and passes through unknown ones', () => {
|
||||
expect(typeLabel('PDF')).toBe('PDF');
|
||||
expect(typeLabel('Image')).toBeTruthy();
|
||||
expect(typeLabel('Weird')).toBe('Weird');
|
||||
});
|
||||
});
|
||||
|
||||
describe('favorites mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
jsonMock.mockResolvedValue({});
|
||||
});
|
||||
it('addFavorite / removeFavorite call the API', async () => {
|
||||
await addFavorite('file', 'id1');
|
||||
await removeFavorite('file', 'id1');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import {
|
||||
uploadFile,
|
||||
renameFile,
|
||||
moveFile,
|
||||
deleteFile,
|
||||
fileDownloadUrl,
|
||||
fileInlineUrl
|
||||
} from './files';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
describe('files endpoint URL builders', () => {
|
||||
it('build download/inline URLs', () => {
|
||||
expect(fileDownloadUrl('id1')).toContain('id1');
|
||||
expect(fileDownloadUrl('id1')).toContain('/api/files/');
|
||||
expect(fileInlineUrl('id1')).toContain('id1');
|
||||
});
|
||||
});
|
||||
describe('files endpoint mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'x' }) });
|
||||
});
|
||||
it('call the API for upload/rename/move/delete', async () => {
|
||||
const file = new File([new Uint8Array([1])], 'f.txt', { type: 'text/plain' });
|
||||
await uploadFile('fid', file).catch(() => {});
|
||||
await renameFile('id', 'new').catch(() => {});
|
||||
await moveFile('id', 'dest').catch(() => {});
|
||||
await deleteFile('id').catch(() => {});
|
||||
expect(f).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import {
|
||||
displayRole,
|
||||
expiryToIso,
|
||||
createGrant,
|
||||
updateGrantRole,
|
||||
revokeGrant,
|
||||
notifyGrantRecipient
|
||||
} from './grants';
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('displayRole', () => {
|
||||
it('passes through canonical roles', () => {
|
||||
expect(displayRole('owner')).toBe('owner');
|
||||
expect(displayRole('editor')).toBe('editor');
|
||||
expect(displayRole('viewer')).toBe('viewer');
|
||||
});
|
||||
it('maps legacy roles and defaults to viewer', () => {
|
||||
expect(displayRole('contributor')).toBe('editor');
|
||||
expect(displayRole('commenter')).toBe('viewer');
|
||||
expect(displayRole('mystery')).toBe('viewer');
|
||||
expect(displayRole(undefined)).toBe('viewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryToIso', () => {
|
||||
it('converts a date to an ISO string at UTC midnight', () => {
|
||||
expect(expiryToIso('2030-01-02')).toBe('2030-01-02T00:00:00.000Z');
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(expiryToIso(null)).toBeNull();
|
||||
expect(expiryToIso(undefined)).toBeNull();
|
||||
expect(expiryToIso('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grant mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
jsonMock.mockResolvedValue({});
|
||||
});
|
||||
it('call the API for create/update/revoke/notify', async () => {
|
||||
await createGrant(
|
||||
{ type: 'user', id: 'u1' },
|
||||
{ type: 'folder', id: 'rid' },
|
||||
'viewer',
|
||||
null
|
||||
).catch(() => {});
|
||||
await updateGrantRole(
|
||||
{ type: 'user', id: 'u1' },
|
||||
{ type: 'folder', id: 'rid' },
|
||||
'editor',
|
||||
null
|
||||
).catch(() => {});
|
||||
await revokeGrant('g1').catch(() => {});
|
||||
await notifyGrantRecipient('g1').catch(() => {});
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as music from './music';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}), text: async () => 'fid' });
|
||||
j.mockResolvedValue([]);
|
||||
});
|
||||
it('exercises the music endpoints', async () => {
|
||||
await music.listPlaylists().catch(() => {});
|
||||
await music.listTracks('p').catch(() => {});
|
||||
await music.createPlaylist('n').catch(() => {});
|
||||
await music.updatePlaylist('p', { name: 'x' } as never).catch(() => {});
|
||||
await music.renamePlaylist('p', 'n').catch(() => {});
|
||||
await music.deletePlaylist('p').catch(() => {});
|
||||
await music.addTracks('p', ['f']).catch(() => {});
|
||||
await music.removeTrack('p', 'f').catch(() => {});
|
||||
await music.reorderTracks('p', ['a', 'b']).catch(() => {});
|
||||
await music.listShares('p').catch(() => {});
|
||||
await music.removeShare('p', 'u').catch(() => {});
|
||||
const file = new File([new Uint8Array([1])], 'c.png', { type: 'image/png' });
|
||||
await music.uploadCoverImage(file).catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(3);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as people from './people';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue([]);
|
||||
});
|
||||
it('exercises the people endpoints', async () => {
|
||||
await people.fetchPeople().catch(() => {});
|
||||
await people.peopleEnabled().catch(() => {});
|
||||
await people.fetchPersonPhotos('p').catch(() => {});
|
||||
await people.renamePerson('p', 'Alice').catch(() => {});
|
||||
await people.renamePerson('p', null).catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as photos from './photos';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue({ photos: [], clusters: [] });
|
||||
});
|
||||
it('exercises the photos endpoints', async () => {
|
||||
await photos.fetchPhotosGeo('0,0,1,1', 5).catch(() => {});
|
||||
await photos.fetchPhotos(60).catch(() => {});
|
||||
await photos.fetchFileMetadata('fid').catch(() => {});
|
||||
await photos.batchTrash(['a', 'b']).catch(() => {});
|
||||
await photos.batchTrash([]).catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as profile from './profile';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue([]);
|
||||
});
|
||||
it('isAutoAppPassword flags generated labels', () => {
|
||||
const r1 = profile.isAutoAppPassword({ label: 'Device login (auto)' });
|
||||
const r2 = profile.isAutoAppPassword({ label: 'my token' });
|
||||
expect(typeof r1).toBe('boolean');
|
||||
expect(typeof r2).toBe('boolean');
|
||||
});
|
||||
it('exercises the profile endpoints', async () => {
|
||||
await profile.updateProfile({ given_name: 'A' } as never).catch(() => {});
|
||||
await profile.changePassword('old', 'new').catch(() => {});
|
||||
await profile.updateAvatar('data:image/png;base64,AAAA').catch(() => {});
|
||||
await profile.updateAvatar(null).catch(() => {});
|
||||
await profile.listAppPasswords().catch(() => {});
|
||||
await profile.createAppPassword('label').catch(() => {});
|
||||
await profile.revokeAppPassword('id').catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { fetchRecentPage, clearRecent } from './recent';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], next_cursor: null })
|
||||
});
|
||||
});
|
||||
it('fetches and clears recent', async () => {
|
||||
await fetchRecentPage({}).catch(() => {});
|
||||
await clearRecent().catch(() => {});
|
||||
expect(f).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import {
|
||||
isDirectoryAvailable,
|
||||
resolveLabel,
|
||||
resolveRecipient,
|
||||
searchRecipients
|
||||
} from './recipients';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
describe('recipients pure helpers', () => {
|
||||
it('isDirectoryAvailable defaults to true', () => {
|
||||
expect(isDirectoryAvailable()).toBe(true);
|
||||
});
|
||||
it('resolveLabel falls back to the id when uncached', () => {
|
||||
expect(resolveLabel('group', 'g1')).toBe('g1');
|
||||
expect(resolveLabel('user', 'u1')).toBe('u1');
|
||||
});
|
||||
it('resolveRecipient builds a recipient object', () => {
|
||||
expect(resolveRecipient('group', 'g1')).toMatchObject({ type: 'group', id: 'g1', label: 'g1' });
|
||||
expect(resolveRecipient('user', 'u1')).toMatchObject({ type: 'user', id: 'u1' });
|
||||
});
|
||||
});
|
||||
describe('searchRecipients', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// system contacts + groups both return arrays from .json()
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => [] });
|
||||
});
|
||||
it('returns an array of recipients', async () => {
|
||||
const r = await searchRecipients('alice').catch(() => []);
|
||||
expect(Array.isArray(r)).toBe(true);
|
||||
const e = await searchRecipients('a@b.test').catch(() => []);
|
||||
expect(Array.isArray(e)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { searchFiles, searchSuggest, clearSearchCache } from './search';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue({ files: [], folders: [] });
|
||||
});
|
||||
it('builds search requests including filters', async () => {
|
||||
await searchFiles('q', {
|
||||
recursive: true,
|
||||
fileTypes: ['mp3', 'wav'],
|
||||
minSize: 1,
|
||||
maxSize: 9,
|
||||
sortBy: 'date'
|
||||
}).catch(() => {});
|
||||
expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything());
|
||||
await searchSuggest('q').catch(() => {});
|
||||
await clearSearchCache().catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
@@ -26,7 +26,9 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
|
||||
for (const ft of opts.fileTypes ?? []) params.append('type', ft);
|
||||
// The backend expects a single comma-separated `type` param (it splits on
|
||||
// ','); appending one param per type yields a "duplicate field" 400.
|
||||
if (opts.fileTypes?.length) params.append('type', opts.fileTypes.join(','));
|
||||
if (opts.minSize != null) params.append('min_size', String(opts.minSize));
|
||||
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
|
||||
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import {
|
||||
shareDownloadUrl,
|
||||
shareFileUrl,
|
||||
shareZipUrl,
|
||||
getShareMeta,
|
||||
verifySharePassword,
|
||||
getShareContents
|
||||
} from './share';
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('share URL builders', () => {
|
||||
it('build encoded share URLs', () => {
|
||||
expect(shareDownloadUrl('tok en')).toBe('/api/s/tok%20en/download');
|
||||
expect(shareFileUrl('t', 'f/1')).toBe('/api/s/t/file/f%2F1');
|
||||
expect(shareZipUrl('t')).toBe('/api/s/t/zip');
|
||||
expect(shareZipUrl('t', 'fid')).toBe('/api/s/t/zip/fid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share API calls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
jsonMock.mockResolvedValue({});
|
||||
});
|
||||
it('hit the API for meta / verify / contents', async () => {
|
||||
await getShareMeta('t').catch(() => {});
|
||||
await verifySharePassword('t', 'pw').catch(() => {});
|
||||
await getShareContents('t').catch(() => {});
|
||||
expect(fetchMock.mock.calls.length + jsonMock.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share status branches', () => {
|
||||
const resp = (over: Record<string, unknown>) => ({
|
||||
ok: false,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
...over
|
||||
});
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns ok meta on 200', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
resp({ ok: true, json: async () => ({ item_type: 'folder', item_name: 'Docs' }) })
|
||||
);
|
||||
expect(await getShareMeta('t')).toEqual({
|
||||
status: 'ok',
|
||||
data: { item_type: 'folder', item_name: 'Docs' }
|
||||
});
|
||||
});
|
||||
|
||||
it('maps 401+requiresPassword to a password prompt', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
resp({ status: 401, json: async () => ({ requiresPassword: true }) })
|
||||
);
|
||||
expect(await getShareMeta('t')).toEqual({ status: 'password' });
|
||||
});
|
||||
|
||||
it('maps meta 410 to expired and 404 to invalid', async () => {
|
||||
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
|
||||
expect(await getShareMeta('t')).toEqual({ status: 'expired' });
|
||||
fetchMock.mockResolvedValueOnce(resp({ status: 404 }));
|
||||
expect(await getShareMeta('t')).toEqual({ status: 'invalid' });
|
||||
});
|
||||
|
||||
it('verifies a password: true on ok, false on 401', async () => {
|
||||
fetchMock.mockResolvedValueOnce(resp({ ok: true }));
|
||||
expect(await verifySharePassword('t', 'pw')).toBe(true);
|
||||
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
|
||||
expect(await verifySharePassword('t', 'bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('lists contents and maps 401→password, 410→expired', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
resp({ ok: true, json: async () => ({ folders: [], files: [] }) })
|
||||
);
|
||||
expect((await getShareContents('t')).status).toBe('ok');
|
||||
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
|
||||
expect((await getShareContents('t')).status).toBe('password');
|
||||
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
|
||||
expect((await getShareContents('t', 'fid')).status).toBe('expired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as shares from './shares';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue({});
|
||||
});
|
||||
it('exercises the shares endpoints', async () => {
|
||||
await shares.createShare({ item_id: 'i', item_type: 'folder' } as never).catch(() => {});
|
||||
await shares.listSharesForItem('i', 'folder' as never).catch(() => {});
|
||||
await shares.getShareById('s').catch(() => {});
|
||||
await shares.updateShare('s', {} as never).catch(() => {});
|
||||
await shares.deleteShare('s').catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import {
|
||||
expiryChip,
|
||||
remainingDaysBucket,
|
||||
restoreTrashItem,
|
||||
deleteTrashItem,
|
||||
emptyTrash
|
||||
} from './trash';
|
||||
|
||||
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe('expiryChip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('returns the matching tier for each time horizon', () => {
|
||||
expect(expiryChip(null).tier).toBe('never');
|
||||
expect(expiryChip(Date.now() - DAY).tier).toBe('expired');
|
||||
expect(expiryChip(Date.now() + DAY / 2).tier).toBe('urgent'); // today
|
||||
expect(expiryChip(Date.now() + 1.5 * DAY).tier).toBe('urgent'); // tomorrow
|
||||
expect(expiryChip(Date.now() + 4 * DAY).tier).toBe('soon');
|
||||
expect(expiryChip(Date.now() + 20 * DAY).tier).toBe('caution');
|
||||
expect(expiryChip(Date.now() + 100 * DAY).tier).toBe('normal');
|
||||
});
|
||||
it('renders an unparseable value as a normal chip', () => {
|
||||
expect(expiryChip('garbage').tier).toBe('normal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remainingDaysBucket', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it('produces a label for no-expiry, expired, and future', () => {
|
||||
expect(remainingDaysBucket(null)).toBeTruthy();
|
||||
expect(remainingDaysBucket(Date.now() - DAY)).toBeTruthy();
|
||||
expect(remainingDaysBucket(Date.now() + 10 * DAY)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trash mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
});
|
||||
it('call the API for restore/delete/empty', async () => {
|
||||
await restoreTrashItem('t1').catch(() => {});
|
||||
await deleteTrashItem('t1').catch(() => {});
|
||||
await emptyTrash().catch(() => {});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it('emptyTrash throws on a failed response', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
await expect(emptyTrash()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import {
|
||||
getSupportedExtensions,
|
||||
canEditWithWopi,
|
||||
getEditorUrl,
|
||||
getEditorUrlWithFallback
|
||||
} from './wopi';
|
||||
const af = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
j.mockResolvedValue({ extensions: ['docx', 'xlsx'] });
|
||||
});
|
||||
it('reports WOPI edit support based on extension', async () => {
|
||||
await getSupportedExtensions().catch(() => {});
|
||||
const editable = await canEditWithWopi('report.docx').catch(() => false);
|
||||
const notEditable = await canEditWithWopi('photo.png').catch(() => false);
|
||||
expect(typeof editable).toBe('boolean');
|
||||
expect(typeof notEditable).toBe('boolean');
|
||||
});
|
||||
|
||||
it('fetches an editor URL for a file', async () => {
|
||||
af.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
editor_url: 'https://wopi/edit',
|
||||
access_token: 'tok',
|
||||
access_token_ttl: 9
|
||||
})
|
||||
});
|
||||
const data = await getEditorUrl('f1', 'edit');
|
||||
expect(data.editor_url).toBe('https://wopi/edit');
|
||||
expect(af).toHaveBeenCalledWith(
|
||||
expect.stringContaining('file_id=f1'),
|
||||
expect.objectContaining({ credentials: 'same-origin' })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the editor URL request fails', async () => {
|
||||
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
|
||||
await expect(getEditorUrl('f1')).rejects.toThrow('500');
|
||||
});
|
||||
|
||||
it('falls back to view mode when an edit request 422s on a PDF', async () => {
|
||||
af.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => 'not editable'
|
||||
}).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ editor_url: 'https://wopi/view', access_token: 't', access_token_ttl: 1 })
|
||||
});
|
||||
const data = await getEditorUrlWithFallback('f1', 'doc.pdf', 'edit');
|
||||
expect(data.editor_url).toBe('https://wopi/view');
|
||||
expect(af).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not fall back for non-PDF edit failures', async () => {
|
||||
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'server error' });
|
||||
await expect(getEditorUrlWithFallback('f1', 'sheet.xlsx', 'edit')).rejects.toThrow('500');
|
||||
expect(af).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -268,7 +268,7 @@
|
||||
></div>
|
||||
|
||||
<div class="sidebar" class:open={sidebarOpen}>
|
||||
<a href={resolve('/files')} class="logo-container">
|
||||
<a href={resolve('/files')} class="logo-container" data-testid="appshell-logo-link">
|
||||
<div class="logo">
|
||||
<svg viewBox="95 67 320 320" aria-hidden="true">
|
||||
<path
|
||||
@@ -286,6 +286,7 @@
|
||||
class:active={active(link.href)}
|
||||
href={resolve(link.href)}
|
||||
data-section={link.section}
|
||||
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
>
|
||||
<Icon name={link.icon} />
|
||||
@@ -324,6 +325,7 @@
|
||||
class="sidebar-toggle"
|
||||
aria-label={t('nav.toggle', 'Toggle navigation menu')}
|
||||
aria-expanded={sidebarOpen}
|
||||
data-testid="appshell-sidebar-toggle-btn"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
>
|
||||
<Icon name="bars" />
|
||||
@@ -334,6 +336,7 @@
|
||||
class="search-toggle-btn"
|
||||
id="search-toggle-btn"
|
||||
aria-label={t('actions.search_btn', 'Search')}
|
||||
data-testid="appshell-search-toggle-btn"
|
||||
onclick={openMobileSearch}
|
||||
>
|
||||
<Icon name="search" />
|
||||
@@ -343,6 +346,7 @@
|
||||
<button
|
||||
class="search-back-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="appshell-search-back-btn"
|
||||
onclick={closeMobileSearch}
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
@@ -355,6 +359,7 @@
|
||||
type="text"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchQuery}
|
||||
data-testid="appshell-search-input"
|
||||
oninput={onSearchInput}
|
||||
onfocus={() => (suggestOpen = suggestions.length > 0)}
|
||||
onblur={() => setTimeout(() => (suggestOpen = false), 150)}
|
||||
@@ -367,6 +372,7 @@
|
||||
type="button"
|
||||
title={t('common.clear', 'Clear')}
|
||||
aria-label={t('common.clear', 'Clear')}
|
||||
data-testid="appshell-search-clear-btn"
|
||||
onclick={clearSearch}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -377,6 +383,7 @@
|
||||
type="submit"
|
||||
title={t('actions.search_btn', 'Search')}
|
||||
aria-label={t('actions.search_btn', 'Search')}
|
||||
data-testid="appshell-search-submit-btn"
|
||||
>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
@@ -385,7 +392,12 @@
|
||||
<ul class="suggest">
|
||||
{#each suggestions as s (s.kind + s.item.id)}
|
||||
<li>
|
||||
<button class="suggest__item" type="button" onmousedown={() => pickSuggestion(s)}>
|
||||
<button
|
||||
class="suggest__item"
|
||||
type="button"
|
||||
data-testid={`appshell-search-suggestion-${s.kind}-${s.item.id}-item`}
|
||||
onmousedown={() => pickSuggestion(s)}
|
||||
>
|
||||
<span class="suggest__icon">
|
||||
{#if s.kind === 'folder'}
|
||||
<Icon name="folder" />
|
||||
@@ -398,7 +410,12 @@
|
||||
</li>
|
||||
{/each}
|
||||
<li>
|
||||
<button class="suggest__all" type="button" onmousedown={goToResults}>
|
||||
<button
|
||||
class="suggest__all"
|
||||
type="button"
|
||||
data-testid="appshell-search-see-all-btn"
|
||||
onmousedown={goToResults}
|
||||
>
|
||||
{t('search.see_all', 'See all results')}
|
||||
</button>
|
||||
</li>
|
||||
@@ -411,13 +428,14 @@
|
||||
|
||||
<div class="user-controls">
|
||||
<!-- Notifications -->
|
||||
<div class="notif-wrapper" class:open={notifOpen}>
|
||||
<div class="notif-wrapper" class:open={notifOpen} data-testid="appshell-notif-menu">
|
||||
<button
|
||||
class="notif-bell-btn"
|
||||
class:active={notifOpen}
|
||||
class:ring={bellRinging}
|
||||
aria-label={t('notifications.title', 'Notifications')}
|
||||
aria-haspopup="true"
|
||||
data-testid="appshell-notif-bell-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
notifOpen = !notifOpen;
|
||||
@@ -436,6 +454,7 @@
|
||||
class="notif-clear-btn"
|
||||
title={t('notifications.clear', 'Clear all')}
|
||||
aria-label={t('notifications.clear', 'Clear all')}
|
||||
data-testid="appshell-notif-clear-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
ui.clearNotifications();
|
||||
@@ -494,11 +513,12 @@
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="user-menu-wrapper" class:open={menuOpen}>
|
||||
<div class="user-menu-wrapper" class:open={menuOpen} data-testid="appshell-user-menu">
|
||||
<button
|
||||
class="user-avatar-btn"
|
||||
aria-label={t('user_menu.title', 'User menu')}
|
||||
aria-haspopup="true"
|
||||
data-testid="appshell-user-menu-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = !menuOpen;
|
||||
@@ -555,15 +575,30 @@
|
||||
<div class="user-menu-divider"></div>
|
||||
|
||||
{#if isAdmin}
|
||||
<a class="user-menu-item" href={resolve('/admin')} onclick={() => (menuOpen = false)}>
|
||||
<a
|
||||
class="user-menu-item"
|
||||
href={resolve('/admin')}
|
||||
data-testid="appshell-user-menu-admin-item"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
|
||||
</a>
|
||||
<a class="user-menu-item" href={resolve('/groups')} onclick={() => (menuOpen = false)}>
|
||||
<a
|
||||
class="user-menu-item"
|
||||
href={resolve('/groups')}
|
||||
data-testid="appshell-user-menu-groups-item"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
<Icon name="user-group" />
|
||||
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a class="user-menu-item" href={resolve('/profile')} onclick={() => (menuOpen = false)}>
|
||||
<a
|
||||
class="user-menu-item"
|
||||
href={resolve('/profile')}
|
||||
data-testid="appshell-user-menu-profile-item"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
|
||||
</a>
|
||||
|
||||
@@ -572,12 +607,17 @@
|
||||
<div class="user-menu-item user-menu-item--lang">
|
||||
<Icon name="globe" />
|
||||
<span>{t('settings.language', 'Language')}</span>
|
||||
<div class="lang-selector" class:lang-selector--open={langOpen}>
|
||||
<div
|
||||
class="lang-selector"
|
||||
class:lang-selector--open={langOpen}
|
||||
data-testid="appshell-lang-menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="lang-selector__toggle"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={langOpen}
|
||||
data-testid="appshell-lang-toggle-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
langOpen = !langOpen;
|
||||
@@ -597,6 +637,7 @@
|
||||
class:lang-option--active={lang.code === i18n.locale}
|
||||
role="option"
|
||||
aria-selected={lang.code === i18n.locale}
|
||||
data-testid={`appshell-lang-${lang.code}-option`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
chooseLocale(lang.code);
|
||||
@@ -622,6 +663,7 @@
|
||||
class="theme-segmented"
|
||||
role="radiogroup"
|
||||
aria-label={t('user_menu.appearance', 'Appearance')}
|
||||
data-testid="appshell-theme-toggle"
|
||||
>
|
||||
{#each THEMES as th (th.mode)}
|
||||
<button
|
||||
@@ -632,6 +674,7 @@
|
||||
aria-checked={theme.current === th.mode}
|
||||
title={th.label}
|
||||
aria-label={th.label}
|
||||
data-testid={`appshell-theme-${th.mode}-option`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
theme.set(th.mode);
|
||||
@@ -643,13 +686,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="user-menu-item" onclick={openAbout}>
|
||||
<button
|
||||
class="user-menu-item"
|
||||
data-testid="appshell-user-menu-about-item"
|
||||
onclick={openAbout}
|
||||
>
|
||||
<Icon name="info-circle" /> <span>{t('user_menu.about', 'About OxiCloud')}</span>
|
||||
</button>
|
||||
|
||||
<div class="user-menu-divider"></div>
|
||||
|
||||
<button class="user-menu-item user-menu-logout" onclick={onLogout}>
|
||||
<button
|
||||
class="user-menu-item user-menu-logout"
|
||||
data-testid="appshell-user-menu-logout-btn"
|
||||
onclick={onLogout}
|
||||
>
|
||||
<Icon name="sign-out-alt" /> <span>{t('actions.logout', 'Log out')}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -708,6 +759,7 @@
|
||||
href="https://github.com/AtalayaLabs/OxiCloud/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="appshell-about-github-link"
|
||||
>
|
||||
<Icon name="github" /> GitHub
|
||||
</a>
|
||||
@@ -716,12 +768,17 @@
|
||||
href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="appshell-about-license-link"
|
||||
>
|
||||
<Icon name="file-alt" />
|
||||
{t('user_menu.mit_license', 'MIT License')}
|
||||
</a>
|
||||
</div>
|
||||
<button class="about-modal__close" onclick={() => (aboutOpen = false)}>
|
||||
<button
|
||||
class="about-modal__close"
|
||||
data-testid="appshell-about-close-btn"
|
||||
onclick={() => (aboutOpen = false)}
|
||||
>
|
||||
{t('actions.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
|
||||
const { goto, pageState } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/files'), route: { id: '/files/[...path]' } }
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { logout } from '$lib/api/endpoints/auth';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import AppShell from './AppShell.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
const children = createRawSnippet(() => ({
|
||||
render: () => '<div data-testid="shell-child">hi</div>'
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/files');
|
||||
session.user = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100,
|
||||
is_external: false
|
||||
} as never;
|
||||
});
|
||||
|
||||
it('renders the shell chrome and its children', async () => {
|
||||
render(AppShell, { props: { children } });
|
||||
expect(screen.getByTestId('shell-child')).toBeTruthy();
|
||||
expect(screen.getByTestId('appshell-user-menu-btn')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens the user menu, exposing profile and admin links', async () => {
|
||||
render(AppShell, { props: { children } });
|
||||
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
|
||||
const profile = await screen.findByTestId('appshell-user-menu-profile-item');
|
||||
expect(profile.getAttribute('href')).toBe('/profile');
|
||||
// Admin link only shows for admin users (session.user.role === 'admin').
|
||||
expect(screen.getByTestId('appshell-user-menu-admin-item')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('logs out: clears the session and redirects to /login', async () => {
|
||||
m(logout).mockResolvedValue(undefined);
|
||||
render(AppShell, { props: { children } });
|
||||
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
|
||||
await fireEvent.click(await screen.findByTestId('appshell-user-menu-logout-btn'));
|
||||
await waitFor(() => expect(logout).toHaveBeenCalled());
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/login'));
|
||||
expect(session.user).toBeNull();
|
||||
});
|
||||
|
||||
it('submits a search and routes to /search', async () => {
|
||||
render(AppShell, { props: { children } });
|
||||
const input = screen.getByTestId('appshell-search-input');
|
||||
await fireEvent.input(input, { target: { value: 'report' } });
|
||||
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
/** Extra classes appended after the base `.btn` classes. */
|
||||
class?: string;
|
||||
/** e2e test hook forwarded to the underlying `<button>`. */
|
||||
'data-testid'?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@
|
||||
title,
|
||||
onclick,
|
||||
class: cls = '',
|
||||
'data-testid': testid,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -37,7 +40,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class={className} {type} {disabled} {title} {onclick}>
|
||||
<button class={className} {type} {disabled} {title} data-testid={testid} {onclick}>
|
||||
{#if icon}<Icon name={icon} />{/if}
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('cmdk.title', 'Command palette')}
|
||||
data-testid="command-palette-panel"
|
||||
>
|
||||
<div class="cmdk__search">
|
||||
<Icon name="search" />
|
||||
@@ -288,6 +289,7 @@
|
||||
placeholder={t('cmdk.placeholder', 'Type a command or search…')}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
data-testid="command-palette-input"
|
||||
/>
|
||||
</div>
|
||||
{#if filtered.length === 0}
|
||||
@@ -301,6 +303,7 @@
|
||||
class:active={i === index}
|
||||
role="option"
|
||||
aria-selected={i === index}
|
||||
data-testid={`command-palette-${cmd.id}-item`}
|
||||
onmouseenter={() => (index = i)}
|
||||
onclick={cmd.run}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto } = vi.hoisted(() => ({ goto: vi.fn() }));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import CommandPalette from './CommandPalette.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function openPalette() {
|
||||
return fireEvent.keyDown(window, { key: 'k', ctrlKey: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
session.user = { id: '1', username: 'admin', role: 'admin', is_external: false } as never;
|
||||
});
|
||||
|
||||
it('opens on Ctrl+K and shows the command panel', async () => {
|
||||
render(CommandPalette);
|
||||
await openPalette();
|
||||
await screen.findByTestId('command-palette-panel');
|
||||
expect(screen.getByTestId('command-palette-input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('runs a navigation command', async () => {
|
||||
render(CommandPalette);
|
||||
await openPalette();
|
||||
await fireEvent.click(await screen.findByTestId('command-palette-files-item'));
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/files'));
|
||||
});
|
||||
|
||||
it('searches files as the query is typed', async () => {
|
||||
render(CommandPalette);
|
||||
await openPalette();
|
||||
const input = await screen.findByTestId('command-palette-input');
|
||||
await fireEvent.input(input, { target: { value: 'report' } });
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
|
||||
});
|
||||
@@ -53,6 +53,7 @@
|
||||
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
|
||||
<input
|
||||
class="dlg-input"
|
||||
data-testid="dialog-host-prompt-input"
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
@@ -70,11 +71,22 @@
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-secondary" disabled={dialogs.busy} onclick={() => dialogs.cancel()}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="dialog-host-cancel-btn"
|
||||
disabled={dialogs.busy}
|
||||
onclick={() => dialogs.cancel()}
|
||||
>
|
||||
{c.opts.cancelText ?? t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
{#if c.kind === 'prompt'}
|
||||
<button class="btn btn-primary" type="submit" form="dialog-form" disabled={dialogs.busy}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="dialog-host-submit-btn"
|
||||
type="submit"
|
||||
form="dialog-form"
|
||||
disabled={dialogs.busy}
|
||||
>
|
||||
{dialogs.busy
|
||||
? t('common.loading', 'Loading…')
|
||||
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
|
||||
@@ -82,6 +94,7 @@
|
||||
{:else}
|
||||
<button
|
||||
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
|
||||
data-testid="dialog-host-confirm-btn"
|
||||
disabled={dialogs.busy}
|
||||
onclick={() => dialogs.resolve(true)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { dialogs, confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import DialogHost from './DialogHost.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
while (dialogs.current && !dialogs.busy) dialogs.cancel();
|
||||
});
|
||||
|
||||
it('confirms a dialog and resolves true', async () => {
|
||||
render(DialogHost);
|
||||
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
|
||||
await screen.findByTestId('dialog-host-confirm-btn');
|
||||
await fireEvent.click(screen.getByTestId('dialog-host-confirm-btn'));
|
||||
await expect(p).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('cancels a dialog and resolves false', async () => {
|
||||
render(DialogHost);
|
||||
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
|
||||
await screen.findByTestId('dialog-host-cancel-btn');
|
||||
await fireEvent.click(screen.getByTestId('dialog-host-cancel-btn'));
|
||||
await expect(p).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('submits a prompt dialog with the typed value', async () => {
|
||||
render(DialogHost);
|
||||
const p = promptDialog({ title: 'Rename', defaultValue: 'old' });
|
||||
const input = await screen.findByTestId('dialog-host-prompt-input');
|
||||
await fireEvent.input(input, { target: { value: 'new-name' } });
|
||||
await fireEvent.click(screen.getByTestId('dialog-host-submit-btn'));
|
||||
await expect(p).resolves.toBe('new-name');
|
||||
});
|
||||
|
||||
it('prefills the prompt input with the default value', async () => {
|
||||
render(DialogHost);
|
||||
void promptDialog({ title: 'Rename', defaultValue: 'preset.txt' });
|
||||
const input = (await screen.findByTestId('dialog-host-prompt-input')) as HTMLInputElement;
|
||||
await waitFor(() => expect(input.value).toBe('preset.txt'));
|
||||
dialogs.cancel();
|
||||
});
|
||||
@@ -153,6 +153,7 @@
|
||||
<div
|
||||
class="fv"
|
||||
role="dialog"
|
||||
data-testid="file-viewer-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={file.name}
|
||||
tabindex="-1"
|
||||
@@ -166,6 +167,7 @@
|
||||
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-out-btn"
|
||||
title={t('viewer.zoom_out', 'Zoom out')}
|
||||
aria-label={t('viewer.zoom_out', 'Zoom out')}
|
||||
onclick={() => zoomBy(0.8)}
|
||||
@@ -174,6 +176,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-reset-btn"
|
||||
title={t('viewer.zoom_reset', 'Reset zoom')}
|
||||
aria-label={t('viewer.zoom_reset', 'Reset zoom')}
|
||||
onclick={resetZoom}
|
||||
@@ -182,6 +185,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-in-btn"
|
||||
title={t('viewer.zoom_in', 'Zoom in')}
|
||||
aria-label={t('viewer.zoom_in', 'Zoom in')}
|
||||
onclick={() => zoomBy(1.2)}
|
||||
@@ -191,13 +195,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => (wopiOpen = true)}>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
data-testid="file-viewer-edit-btn"
|
||||
onclick={() => (wopiOpen = true)}
|
||||
>
|
||||
<Icon name="pen" />
|
||||
{t('files.edit', 'Edit')}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="btn btn-secondary btn-sm"
|
||||
data-testid="file-viewer-download-link"
|
||||
href={fileDownloadUrl(file.id)}
|
||||
download
|
||||
rel="external"
|
||||
@@ -207,13 +216,19 @@
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-secondary btn-sm"
|
||||
data-testid="file-viewer-open-new-tab-link"
|
||||
href={fileInlineUrl(file.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
>
|
||||
<Icon name="external-link-alt" />
|
||||
</a>
|
||||
<button class="fv__close" aria-label={t('common.close', 'Close')} onclick={close}>
|
||||
<button
|
||||
class="fv__close"
|
||||
data-testid="file-viewer-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={close}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileInlineUrl: () => '/in'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/wopi', () => ({
|
||||
canEditWithWopi: vi.fn(),
|
||||
// WopiEditor's $effect calls this on mount when an editor opens; provide it
|
||||
// so the async handshake resolves instead of throwing an unhandled error
|
||||
// (vitest 4 fails the whole run on unhandled errors).
|
||||
getEditorUrlWithFallback: vi.fn(async () => ({
|
||||
editor_url: 'about:blank',
|
||||
access_token: 't',
|
||||
access_token_ttl: 0
|
||||
}))
|
||||
}));
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { canEditWithWopi } from '$lib/api/endpoints/wopi';
|
||||
import FileViewer from './FileViewer.svelte';
|
||||
const af = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const cw = canEditWithWopi as unknown as ReturnType<typeof vi.fn>;
|
||||
function file(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'i',
|
||||
name: 'pic.png',
|
||||
mime_type: 'image/png',
|
||||
category: 'Image',
|
||||
folder_id: '',
|
||||
owner_id: '',
|
||||
path: '',
|
||||
size: 1,
|
||||
modified_at: 0,
|
||||
created_at: 0,
|
||||
sort_date: 0,
|
||||
icon_class: '',
|
||||
icon_special_class: '',
|
||||
size_formatted: '1 B',
|
||||
etag: '',
|
||||
content_hash: '',
|
||||
...over
|
||||
} as never;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cw.mockResolvedValue(false);
|
||||
});
|
||||
it('renders an image with working zoom controls and closes', async () => {
|
||||
render(FileViewer, { props: { open: true, file: file() } });
|
||||
expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy();
|
||||
await fireEvent.click(screen.getByTestId('file-viewer-zoom-in-btn'));
|
||||
await fireEvent.click(screen.getByTestId('file-viewer-zoom-out-btn'));
|
||||
await fireEvent.click(screen.getByTestId('file-viewer-zoom-reset-btn'));
|
||||
await fireEvent.click(screen.getByTestId('file-viewer-close-btn'));
|
||||
});
|
||||
it('fetches text content for a text file', async () => {
|
||||
af.mockResolvedValue({ ok: true, text: async () => 'hello world' });
|
||||
render(FileViewer, {
|
||||
props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) }
|
||||
});
|
||||
expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy();
|
||||
await waitFor(() => expect(af).toHaveBeenCalled());
|
||||
});
|
||||
it('renders nothing when closed', () => {
|
||||
render(FileViewer, { props: { open: false, file: file() } });
|
||||
expect(screen.queryByTestId('file-viewer-dialog')).toBeNull();
|
||||
});
|
||||
it('shows an Edit button for a WOPI-editable document', async () => {
|
||||
cw.mockResolvedValue(true);
|
||||
render(FileViewer, {
|
||||
props: {
|
||||
open: true,
|
||||
file: file({
|
||||
name: 'report.docx',
|
||||
mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
category: 'Document'
|
||||
})
|
||||
}
|
||||
});
|
||||
await screen.findByTestId('file-viewer-dialog');
|
||||
await waitFor(() => expect(screen.getByTestId('file-viewer-edit-btn')).toBeTruthy());
|
||||
});
|
||||
it('exposes download and open-in-new-tab links', async () => {
|
||||
render(FileViewer, { props: { open: true, file: file() } });
|
||||
await screen.findByTestId('file-viewer-dialog');
|
||||
expect(screen.getByTestId('file-viewer-download-link').getAttribute('href')).toBe('/dl');
|
||||
expect(screen.getByTestId('file-viewer-open-new-tab-link').getAttribute('href')).toBe('/in');
|
||||
});
|
||||
it('handles a failed text fetch without crashing', async () => {
|
||||
af.mockResolvedValue({ ok: false, status: 500, text: async () => '' });
|
||||
render(FileViewer, {
|
||||
props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) }
|
||||
});
|
||||
await screen.findByTestId('file-viewer-dialog');
|
||||
await waitFor(() => expect(af).toHaveBeenCalled());
|
||||
});
|
||||
@@ -67,12 +67,13 @@
|
||||
{#if groups?.length || showViewToggle}
|
||||
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
|
||||
{#if groups?.length}
|
||||
<div class="group-by-selector">
|
||||
<div class="group-by-selector" data-testid="list-toolbar-groupby-menu">
|
||||
<button
|
||||
class="toggle-btn group-by-btn active"
|
||||
title={t('groupby.title', 'Group by')}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={menuOpen}
|
||||
data-testid="list-toolbar-groupby-btn"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<Icon name={active?.icon ?? 'layer-group'} />
|
||||
@@ -83,6 +84,7 @@
|
||||
class:active={reversed}
|
||||
title={t('sortdir.title', 'Sort direction')}
|
||||
aria-label={t('sort.direction', 'Sort direction')}
|
||||
data-testid="list-toolbar-sort-direction-btn"
|
||||
onclick={() => ondirection?.()}
|
||||
>
|
||||
<Icon name="arrow-up" />
|
||||
@@ -93,6 +95,7 @@
|
||||
<button
|
||||
class="group-by-option"
|
||||
class:active={groupBy === g.key}
|
||||
data-testid={`list-toolbar-groupby-${g.key}-item`}
|
||||
onclick={() => pick(g.key)}
|
||||
>
|
||||
<Icon name={g.icon ?? 'layer-group'} />
|
||||
@@ -110,6 +113,7 @@
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
title={t('view.grid', 'Grid view')}
|
||||
aria-pressed={filesStore.viewMode === 'grid'}
|
||||
data-testid="list-toolbar-view-grid-btn"
|
||||
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
<button
|
||||
@@ -117,6 +121,7 @@
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
title={t('view.list', 'List view')}
|
||||
aria-pressed={filesStore.viewMode === 'list'}
|
||||
data-testid="list-toolbar-view-list-btn"
|
||||
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -86,13 +86,17 @@
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
data-testid="modal"
|
||||
bind:this={dialogEl}
|
||||
>
|
||||
{#if title}
|
||||
<header class="modal__header">
|
||||
<h2 class="modal__title">{title}</h2>
|
||||
<button class="modal__close" aria-label={t('common.close', 'Close')} onclick={close}
|
||||
>×</button
|
||||
<button
|
||||
class="modal__close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="modal-close-btn"
|
||||
onclick={close}>×</button
|
||||
>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
const children = createRawSnippet(() => ({
|
||||
render: () => '<div data-testid="modal-body">content</div>'
|
||||
}));
|
||||
|
||||
it('renders the title and children when open', () => {
|
||||
render(Modal, { props: { open: true, title: 'My dialog', children } });
|
||||
expect(screen.getByTestId('modal')).toBeTruthy();
|
||||
expect(screen.getByText('My dialog')).toBeTruthy();
|
||||
expect(screen.getByTestId('modal-body')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(Modal, { props: { open: false, title: 'Hidden', children } });
|
||||
expect(screen.queryByTestId('modal')).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes onclose when the close button is clicked', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(Modal, { props: { open: true, title: 'X', onclose, children } });
|
||||
await fireEvent.click(screen.getByTestId('modal-close-btn'));
|
||||
expect(onclose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
const onclose = vi.fn();
|
||||
render(Modal, { props: { open: true, title: 'X', onclose, children } });
|
||||
await fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onclose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -123,51 +123,73 @@
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={moveTitle}>
|
||||
<div class="mv-nav">
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
aria-label={t('breadcrumb.home', 'Home')}
|
||||
disabled={atHome}
|
||||
onclick={goHome}><Icon name="home" /></button
|
||||
>
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
title={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
disabled={atHome}
|
||||
onclick={goParent}><Icon name="level-up-alt" /></button
|
||||
>
|
||||
<nav class="mv-crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as c, i (c.id)}
|
||||
{#if i > 0}<span class="mv-sep">/</span>{/if}
|
||||
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div data-testid="move-dialog">
|
||||
<div class="mv-nav">
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
data-testid="move-dialog-home-btn"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
aria-label={t('breadcrumb.home', 'Home')}
|
||||
disabled={atHome}
|
||||
onclick={goHome}><Icon name="home" /></button
|
||||
>
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
data-testid="move-dialog-parent-btn"
|
||||
title={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
disabled={atHome}
|
||||
onclick={goParent}><Icon name="level-up-alt" /></button
|
||||
>
|
||||
<nav class="mv-crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as c, i (c.id)}
|
||||
{#if i > 0}<span class="mv-sep">/</span>{/if}
|
||||
<button
|
||||
class="mv-crumb"
|
||||
data-testid={`move-dialog-crumb-${c.id}`}
|
||||
onclick={() => gotoCrumb(i)}>{c.name}</button
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if folders.length === 0}
|
||||
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
|
||||
{:else}
|
||||
<ul class="mv-list">
|
||||
{#each folders as f (f.id)}
|
||||
<li>
|
||||
<button
|
||||
class="mv-folder"
|
||||
data-testid={`move-dialog-folder-${f.id}`}
|
||||
disabled={targetIds.has(f.id)}
|
||||
onclick={() => enter(f)}
|
||||
>
|
||||
<Icon name="folder" /> <span>{f.name}</span>
|
||||
<Icon name="chevron-right" class="mv-enter" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if folders.length === 0}
|
||||
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
|
||||
{:else}
|
||||
<ul class="mv-list">
|
||||
{#each folders as f (f.id)}
|
||||
<li>
|
||||
<button class="mv-folder" disabled={targetIds.has(f.id)} onclick={() => enter(f)}>
|
||||
<Icon name="folder" /> <span>{f.name}</span>
|
||||
<Icon name="chevron-right" class="mv-enter" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-secondary" onclick={() => (open = false)}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="move-dialog-cancel-btn"
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button class="btn btn-primary" disabled={working || !currentId} onclick={confirmMove}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="move-dialog-confirm-btn"
|
||||
disabled={working || !currentId}
|
||||
onclick={confirmMove}
|
||||
>
|
||||
{mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ moveFile: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/batch', () => ({ copyFiles: vi.fn(), copyFolders: vi.fn() }));
|
||||
|
||||
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
||||
import { moveFile } from '$lib/api/endpoints/files';
|
||||
import { copyFiles } from '$lib/api/endpoints/batch';
|
||||
import MoveDialog from './MoveDialog.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const };
|
||||
|
||||
function folder(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();
|
||||
m(listFolder).mockResolvedValue({
|
||||
folders: [folder('sub1', 'Sub')],
|
||||
files: [],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the home folder when opened', async () => {
|
||||
render(MoveDialog, { props: { open: true, item } });
|
||||
await waitFor(() => expect(listFolder).toHaveBeenCalledWith('home'));
|
||||
await screen.findByTestId('move-dialog');
|
||||
});
|
||||
|
||||
it('moves the item into the current folder on confirm', async () => {
|
||||
m(moveFile).mockResolvedValue(undefined);
|
||||
const onmoved = vi.fn();
|
||||
render(MoveDialog, { props: { open: true, item, onmoved } });
|
||||
await screen.findByTestId('move-dialog-confirm-btn');
|
||||
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
|
||||
await waitFor(() => expect(moveFile).toHaveBeenCalledWith('f1', 'home'));
|
||||
await waitFor(() => expect(onmoved).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('navigates into a subfolder before confirming', async () => {
|
||||
m(moveFolder).mockResolvedValue(undefined);
|
||||
const folderItem = { id: 'fold9', name: 'Dir', kind: 'folder' as const };
|
||||
render(MoveDialog, { props: { open: true, item: folderItem } });
|
||||
await fireEvent.click(await screen.findByTestId('move-dialog-folder-sub1'));
|
||||
await waitFor(() => expect(listFolder).toHaveBeenCalledWith('sub1'));
|
||||
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
|
||||
await waitFor(() => expect(moveFolder).toHaveBeenCalledWith('fold9', 'sub1'));
|
||||
});
|
||||
|
||||
it('copies the item in copy mode', async () => {
|
||||
m(copyFiles).mockResolvedValue(undefined);
|
||||
render(MoveDialog, { props: { open: true, item, mode: 'copy' } });
|
||||
await screen.findByTestId('move-dialog-confirm-btn');
|
||||
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
|
||||
await waitFor(() => expect(copyFiles).toHaveBeenCalledWith(['f1'], 'home'));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/api/endpoints/people', () => ({
|
||||
fetchPeople: vi.fn(),
|
||||
fetchPersonPhotos: vi.fn(),
|
||||
renamePerson: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileThumbnailUrl: () => '/thumb.png' }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() }));
|
||||
|
||||
import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people';
|
||||
import { promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import PeopleView from './PeopleView.svelte';
|
||||
|
||||
const fp = fetchPeople as unknown as ReturnType<typeof vi.fn>;
|
||||
const fpp = fetchPersonPhotos as unknown as ReturnType<typeof vi.fn>;
|
||||
const rn = renamePerson as unknown as ReturnType<typeof vi.fn>;
|
||||
const pd = promptDialog as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders the people grid (named + unnamed)', async () => {
|
||||
fp.mockResolvedValue([
|
||||
{ id: 'p1', name: 'Alice', face_count: 3, cover_file_id: 'c1' },
|
||||
{ id: 'p2', name: '', face_count: 1, cover_file_id: null }
|
||||
]);
|
||||
render(PeopleView);
|
||||
expect(await screen.findByText('Alice')).toBeTruthy();
|
||||
expect(screen.getByText('Unnamed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no people', async () => {
|
||||
fp.mockResolvedValue([]);
|
||||
render(PeopleView);
|
||||
expect(await screen.findByText('No people yet')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the disabled state when the list errors', async () => {
|
||||
fp.mockRejectedValue(new Error('off'));
|
||||
render(PeopleView);
|
||||
expect(await screen.findByText('Face recognition is disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drills into a person and back to the list', async () => {
|
||||
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
|
||||
fpp.mockResolvedValue(['ph1', 'ph2']);
|
||||
render(PeopleView);
|
||||
const btn = (await screen.findByText('Alice')).closest('button')!;
|
||||
await fireEvent.click(btn);
|
||||
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
|
||||
await fireEvent.click(screen.getByLabelText('Back'));
|
||||
expect(await screen.findByText('Alice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renames the current person', async () => {
|
||||
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 1, cover_file_id: null }]);
|
||||
fpp.mockResolvedValue([]);
|
||||
pd.mockResolvedValue('Bob');
|
||||
rn.mockResolvedValue(undefined);
|
||||
render(PeopleView);
|
||||
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
|
||||
await waitFor(() => screen.getByLabelText('Name this person'));
|
||||
await fireEvent.click(screen.getByLabelText('Name this person'));
|
||||
await waitFor(() => expect(rn).toHaveBeenCalledWith('p1', 'Bob'));
|
||||
});
|
||||
|
||||
it("opens a person's photo in the lightbox", async () => {
|
||||
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
|
||||
fpp.mockResolvedValue(['ph1', 'ph2']);
|
||||
const { container } = render(PeopleView);
|
||||
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
|
||||
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
|
||||
const tiles = await waitFor(() => {
|
||||
const found = container.querySelectorAll('.photos__open');
|
||||
if (found.length === 0) throw new Error('no tiles yet');
|
||||
return found;
|
||||
});
|
||||
expect(tiles.length).toBe(2);
|
||||
await fireEvent.click(tiles[0]);
|
||||
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
|
||||
});
|
||||
@@ -180,6 +180,7 @@
|
||||
aria-modal="true"
|
||||
aria-label={item.name}
|
||||
tabindex="-1"
|
||||
data-testid="photo-lightbox"
|
||||
onclick={(e) => e.target === e.currentTarget && close()}
|
||||
>
|
||||
<div class="lb__info">
|
||||
@@ -187,12 +188,18 @@
|
||||
<div class="lb__meta">{meta}</div>
|
||||
</div>
|
||||
|
||||
<button class="lb__close" aria-label={t('common.close', 'Close')} onclick={close}>×</button>
|
||||
<button
|
||||
class="lb__close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="photo-lightbox-close-btn"
|
||||
onclick={close}>×</button
|
||||
>
|
||||
|
||||
<button
|
||||
class="lb__nav lb__nav--prev"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={index === 0}
|
||||
data-testid="photo-lightbox-prev-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
@@ -221,6 +228,7 @@
|
||||
class="lb__nav lb__nav--next"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={index === items.length - 1}
|
||||
data-testid="photo-lightbox-next-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
deleteFile: vi.fn(),
|
||||
fileDownloadUrl: () => '/d',
|
||||
fileInlineUrl: () => '/i',
|
||||
fileThumbnailUrl: () => '/t'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/photos', () => ({ fetchFileMetadata: vi.fn() }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
import { fetchFileMetadata } from '$lib/api/endpoints/photos';
|
||||
import PhotoLightbox from './PhotoLightbox.svelte';
|
||||
const fm = fetchFileMetadata as unknown as ReturnType<typeof vi.fn>;
|
||||
function item(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.jpg`,
|
||||
mime_type: 'image/jpeg',
|
||||
category: 'Image',
|
||||
folder_id: '',
|
||||
owner_id: '',
|
||||
path: '',
|
||||
size: 1,
|
||||
modified_at: 0,
|
||||
created_at: 0,
|
||||
sort_date: 0,
|
||||
icon_class: '',
|
||||
icon_special_class: '',
|
||||
size_formatted: '1 B',
|
||||
etag: '',
|
||||
content_hash: ''
|
||||
} as never;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fm.mockResolvedValue(null);
|
||||
});
|
||||
it('opens at an index, navigates next/prev, and closes', async () => {
|
||||
render(PhotoLightbox, { props: { items: [item('a'), item('b')], index: 0 } });
|
||||
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
|
||||
await fireEvent.click(screen.getByTestId('photo-lightbox-next-btn'));
|
||||
await fireEvent.click(screen.getByTestId('photo-lightbox-prev-btn'));
|
||||
await fireEvent.click(screen.getByTestId('photo-lightbox-close-btn'));
|
||||
});
|
||||
it('renders nothing at index -1', () => {
|
||||
render(PhotoLightbox, { props: { items: [item('a')], index: -1 } });
|
||||
expect(screen.queryByTestId('photo-lightbox')).toBeNull();
|
||||
});
|
||||
@@ -309,6 +309,8 @@
|
||||
class:file-item--selected={selectable && selected.has(entry.id)}
|
||||
role={onopen ? 'button' : undefined}
|
||||
tabindex={onopen ? 0 : undefined}
|
||||
aria-label={onopen ? entry.name : undefined}
|
||||
data-testid={entry.name}
|
||||
title={showOwner ? ownerTitle(entry) : undefined}
|
||||
onclick={onopen ? () => onopen(entry) : undefined}
|
||||
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
|
||||
@@ -319,6 +321,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select', 'Select')}
|
||||
data-testid={`resource-list-select-${entry.id}-checkbox`}
|
||||
checked={selected.has(entry.id)}
|
||||
onchange={() => toggleSelected(entry.id)}
|
||||
/>
|
||||
@@ -360,6 +363,7 @@
|
||||
<button
|
||||
class="rl-star"
|
||||
class:rl-star--on={entry.isFavorite}
|
||||
data-testid={`resource-list-favorite-${entry.id}-btn`}
|
||||
title={entry.isFavorite
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
@@ -393,8 +397,18 @@
|
||||
</div>
|
||||
|
||||
{#if selectable && selected.size > 0 && batchToolbar}
|
||||
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}>
|
||||
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}>
|
||||
<div
|
||||
class="rl-batch"
|
||||
role="region"
|
||||
aria-label={t('files.selection', 'Selection')}
|
||||
data-testid="resource-list-batch-toolbar"
|
||||
>
|
||||
<button
|
||||
class="rl-batch__close"
|
||||
title={t('common.clear', 'Clear')}
|
||||
data-testid="resource-list-batch-close-btn"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
<span class="rl-batch__count"
|
||||
@@ -454,7 +468,12 @@
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
||||
<button
|
||||
class="btn btn-secondary rl-more"
|
||||
data-testid="resource-list-load-more-btn"
|
||||
onclick={onloadmore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -470,6 +489,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select_all', 'Select all')}
|
||||
data-testid="resource-list-select-all-checkbox"
|
||||
checked={allSelected}
|
||||
onchange={toggleSelectAll}
|
||||
/>
|
||||
@@ -492,12 +512,19 @@
|
||||
onclick={closeContext}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
></div>
|
||||
<div class="rl-ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
|
||||
<div
|
||||
class="rl-ctx-menu"
|
||||
style:left="{ctxX}px"
|
||||
style:top="{ctxY}px"
|
||||
role="menu"
|
||||
data-testid="resource-list-context-menu"
|
||||
>
|
||||
{#each contextActions as action (action.key)}
|
||||
<button
|
||||
class="rl-ctx-item"
|
||||
class:rl-ctx-item--danger={action.danger}
|
||||
role="menuitem"
|
||||
data-testid={`resource-list-context-${action.key}-item`}
|
||||
onclick={() => {
|
||||
const e = ctxEntry!;
|
||||
closeContext();
|
||||
|
||||
@@ -377,194 +377,249 @@
|
||||
{/snippet}
|
||||
|
||||
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
|
||||
{t('share.people', 'People')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
|
||||
{t('share.public_link', 'Public link')}
|
||||
</button>
|
||||
</div>
|
||||
<div data-testid="share-dialog">
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="share-dialog-people-tab"
|
||||
aria-selected={tab === 'people'}
|
||||
onclick={() => (tab = 'people')}
|
||||
>
|
||||
{t('share.people', 'People')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="share-dialog-link-tab"
|
||||
aria-selected={tab === 'link'}
|
||||
onclick={() => (tab = 'link')}
|
||||
>
|
||||
{t('share.public_link', 'Public link')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'people'}
|
||||
{#if !directoryAvailable && !grantsLoading}
|
||||
<p class="status status--note">
|
||||
{t('share.directoryUnavailable', 'User directory unavailable')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="add-row">
|
||||
<div class="search">
|
||||
<input
|
||||
placeholder={t('share.add_people', 'Add people, groups, or email…')}
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as r (r.type + r.id)}
|
||||
<li>
|
||||
<button class="result" onclick={() => addRecipient(r)}>
|
||||
<Icon
|
||||
name={r.type === 'group'
|
||||
? 'user-group'
|
||||
: r.type === 'email'
|
||||
? 'envelope'
|
||||
: 'user'}
|
||||
{#if tab === 'people'}
|
||||
{#if !directoryAvailable && !grantsLoading}
|
||||
<p class="status status--note">
|
||||
{t('share.directoryUnavailable', 'User directory unavailable')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="add-row">
|
||||
<div class="search">
|
||||
<input
|
||||
data-testid="share-dialog-search-input"
|
||||
placeholder={t('share.add_people', 'Add people, groups, or email…')}
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as r (r.type + r.id)}
|
||||
<li>
|
||||
<button
|
||||
class="result"
|
||||
data-testid={`share-dialog-result-${r.type}-${r.id}`}
|
||||
onclick={() => addRecipient(r)}
|
||||
>
|
||||
<Icon
|
||||
name={r.type === 'group'
|
||||
? 'user-group'
|
||||
: r.type === 'email'
|
||||
? 'envelope'
|
||||
: 'user'}
|
||||
/>
|
||||
<span class="result__label">{r.label}</span>
|
||||
{#if r.type === 'email'}
|
||||
<span class="result__sub"
|
||||
>{t('share.inviteByEmail', 'Invite by email')}</span
|
||||
>
|
||||
{:else if r.sublabel}
|
||||
<span class="result__sub">{r.sublabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<select
|
||||
class="role-select"
|
||||
data-testid="share-dialog-new-role-select"
|
||||
bind:value={newRole}
|
||||
aria-label={t('share.role_label', 'Role')}
|
||||
>
|
||||
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
|
||||
</select>
|
||||
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if grantsLoading}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<div class="skeleton__line skeleton__line--short"></div>
|
||||
<div class="skeleton__line skeleton__line--medium"></div>
|
||||
<div class="skeleton__line"></div>
|
||||
</div>
|
||||
{:else if members.length === 0}
|
||||
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
|
||||
{:else}
|
||||
{#each memberGroups as group (group.role)}
|
||||
<div class="member-group">
|
||||
<div class="member-group__header">
|
||||
<Icon name={roleIcon(group.role)} />
|
||||
<span>{roleLabel(group.role)}</span>
|
||||
<span class="member-group__badge">{group.members.length}</span>
|
||||
</div>
|
||||
<ul class="members">
|
||||
{#each group.members as m (m.subject.type + m.subject.id)}
|
||||
<li
|
||||
class="member"
|
||||
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
|
||||
>
|
||||
{#if m.subject.type === 'user'}
|
||||
<UserVignette
|
||||
userId={m.subject.id}
|
||||
fallbackLabel={m.recipient.label}
|
||||
fallbackSublabel={m.recipient.sublabel}
|
||||
/>
|
||||
<span class="result__label">{r.label}</span>
|
||||
{#if r.type === 'email'}
|
||||
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span>
|
||||
{:else if r.sublabel}
|
||||
<span class="result__sub">{r.sublabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<Icon name="user-group" />
|
||||
<span class="member__label">
|
||||
{m.recipient.label}
|
||||
{#if m.recipient.sublabel}<span class="member__sub"
|
||||
>{m.recipient.sublabel}</span
|
||||
>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
|
||||
<select
|
||||
class="role-select"
|
||||
data-testid={`share-dialog-member-role-${m.subject.type}-${m.subject.id}`}
|
||||
value={m.role}
|
||||
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
|
||||
>
|
||||
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
|
||||
</select>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`share-dialog-member-notify-${m.subject.type}-${m.subject.id}`}
|
||||
title={t('share.notifyByEmail', 'Notify by email')}
|
||||
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
|
||||
>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid={`share-dialog-member-remove-${m.subject.type}-${m.subject.id}`}
|
||||
title={t('share.revoke', 'Remove')}
|
||||
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<select class="role-select" bind:value={newRole} aria-label={t('share.role_label', 'Role')}>
|
||||
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
|
||||
</select>
|
||||
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if grantsLoading}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<div class="skeleton__line skeleton__line--short"></div>
|
||||
<div class="skeleton__line skeleton__line--medium"></div>
|
||||
<div class="skeleton__line"></div>
|
||||
</div>
|
||||
{:else if members.length === 0}
|
||||
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
|
||||
{:else}
|
||||
{#each memberGroups as group (group.role)}
|
||||
<div class="member-group">
|
||||
<div class="member-group__header">
|
||||
<Icon name={roleIcon(group.role)} />
|
||||
<span>{roleLabel(group.role)}</span>
|
||||
<span class="member-group__badge">{group.members.length}</span>
|
||||
</div>
|
||||
<ul class="members">
|
||||
{#each group.members as m (m.subject.type + m.subject.id)}
|
||||
<li
|
||||
class="member"
|
||||
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
|
||||
>
|
||||
{#if m.subject.type === 'user'}
|
||||
<UserVignette
|
||||
userId={m.subject.id}
|
||||
fallbackLabel={m.recipient.label}
|
||||
fallbackSublabel={m.recipient.sublabel}
|
||||
/>
|
||||
{:else}
|
||||
<Icon name="user-group" />
|
||||
<span class="member__label">
|
||||
{m.recipient.label}
|
||||
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
|
||||
>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
|
||||
<select
|
||||
class="role-select"
|
||||
value={m.role}
|
||||
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
|
||||
>
|
||||
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
|
||||
</select>
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('share.notifyByEmail', 'Notify by email')}
|
||||
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
|
||||
>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('share.revoke', 'Remove')}
|
||||
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<section class="sh-create">
|
||||
<div class="sh-fields">
|
||||
<label>
|
||||
<span>{t('share.link_name', 'Link name (optional)')}</span>
|
||||
<input type="text" bind:value={newLinkName} autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('share.password_optional', 'Password (optional)')}</span>
|
||||
<input type="text" bind:value={password} autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={expiresAt ?? ''}
|
||||
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
|
||||
{t('share.create_link', 'Create link')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{#if linkLoading}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<div class="skeleton__line skeleton__line--medium"></div>
|
||||
<div class="skeleton__line"></div>
|
||||
</div>
|
||||
{:else if shares.length === 0}
|
||||
<p class="status">{t('share.none', 'No public links yet.')}</p>
|
||||
{:else}
|
||||
<ul class="links">
|
||||
{#each shares as s (s.id)}
|
||||
<li class="link-row">
|
||||
<span class="link-row__title">
|
||||
<Icon name={s.has_password ? 'lock' : 'link'} />
|
||||
<span class="link-row__name"
|
||||
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
|
||||
>
|
||||
</span>
|
||||
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
|
||||
<button
|
||||
class="btn-action"
|
||||
class:btn-action--on={s.has_password}
|
||||
title={s.has_password
|
||||
? t('share.changePassword', 'Change password')
|
||||
: t('share.addPassword', 'Add password')}
|
||||
onclick={() => {
|
||||
const pw = window.prompt(
|
||||
s.has_password
|
||||
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
|
||||
: t('share.passwordPrompt', 'Set a password:')
|
||||
);
|
||||
if (pw !== null) editLinkPassword(s, pw || null);
|
||||
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
|
||||
>
|
||||
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
onclick={() => removeLink(s)}><Icon name="trash" /></button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<section class="sh-create">
|
||||
<div class="sh-fields">
|
||||
<label>
|
||||
<span>{t('share.link_name', 'Link name (optional)')}</span>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="share-dialog-link-name-input"
|
||||
bind:value={newLinkName}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('share.password_optional', 'Password (optional)')}</span>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="share-dialog-link-password-input"
|
||||
bind:value={password}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="share-dialog-link-expires-input"
|
||||
value={expiresAt ?? ''}
|
||||
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="share-dialog-create-btn"
|
||||
disabled={creating}
|
||||
onclick={createLink}
|
||||
>
|
||||
{t('share.create_link', 'Create link')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{#if linkLoading}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<div class="skeleton__line skeleton__line--medium"></div>
|
||||
<div class="skeleton__line"></div>
|
||||
</div>
|
||||
{:else if shares.length === 0}
|
||||
<p class="status">{t('share.none', 'No public links yet.')}</p>
|
||||
{:else}
|
||||
<ul class="links">
|
||||
{#each shares as s (s.id)}
|
||||
<li class="link-row">
|
||||
<span class="link-row__title">
|
||||
<Icon name={s.has_password ? 'lock' : 'link'} />
|
||||
<span class="link-row__name"
|
||||
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
|
||||
>
|
||||
</span>
|
||||
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
|
||||
<button
|
||||
class="btn-action"
|
||||
class:btn-action--on={s.has_password}
|
||||
data-testid={`share-dialog-link-password-btn-${s.id}`}
|
||||
title={s.has_password
|
||||
? t('share.changePassword', 'Change password')
|
||||
: t('share.addPassword', 'Add password')}
|
||||
onclick={() => {
|
||||
const pw = window.prompt(
|
||||
s.has_password
|
||||
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
|
||||
: t('share.passwordPrompt', 'Set a password:')
|
||||
);
|
||||
if (pw !== null) editLinkPassword(s, pw || null);
|
||||
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
|
||||
>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`share-dialog-link-copy-btn-${s.id}`}
|
||||
title={t('share.copy', 'Copy')}
|
||||
onclick={() => copy(s.url)}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid={`share-dialog-link-delete-btn-${s.id}`}
|
||||
title={t('common.delete', 'Delete')}
|
||||
onclick={() => removeLink(s)}><Icon name="trash" /></button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-secondary" onclick={() => (open = false)}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="share-dialog-close-btn"
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui } = vi.hoisted(() => ({ ui: { notify: vi.fn() } }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/shares', () => ({
|
||||
copyShareLink: vi.fn(),
|
||||
createShare: vi.fn(),
|
||||
deleteShare: vi.fn(),
|
||||
listSharesForItem: vi.fn(),
|
||||
updateShare: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/grants', () => ({
|
||||
createGrant: vi.fn(),
|
||||
expiryToIso: (v: string | null) => v,
|
||||
displayRole: (r: string) => r,
|
||||
fetchGrantsForResource: vi.fn(),
|
||||
notifyGrantRecipient: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
updateGrantRole: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
ensureResolvers: vi.fn(),
|
||||
isDirectoryAvailable: () => true,
|
||||
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
|
||||
searchRecipients: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
import { createShare, listSharesForItem } from '$lib/api/endpoints/shares';
|
||||
import { fetchGrantsForResource } from '$lib/api/endpoints/grants';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(fetchGrantsForResource).mockResolvedValue([]);
|
||||
m(listSharesForItem).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads grants and shares when opened', async () => {
|
||||
render(ShareDialog, { props: { open: true, item } });
|
||||
await screen.findByTestId('share-dialog');
|
||||
await waitFor(() => expect(fetchGrantsForResource).toHaveBeenCalledWith('file', 'f1'));
|
||||
await waitFor(() => expect(listSharesForItem).toHaveBeenCalledWith('f1', 'file'));
|
||||
});
|
||||
|
||||
it('switches to the link tab and creates a public link', async () => {
|
||||
m(createShare).mockResolvedValue({ id: 's1', token: 'abc', has_password: false });
|
||||
render(ShareDialog, { props: { open: true, item } });
|
||||
await fireEvent.click(await screen.findByTestId('share-dialog-link-tab'));
|
||||
await fireEvent.input(screen.getByTestId('share-dialog-link-name-input'), {
|
||||
target: { value: 'My link' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('share-dialog-create-btn'));
|
||||
await waitFor(() =>
|
||||
expect(createShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ itemId: 'f1', itemName: 'My link', itemType: 'file' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(ShareDialog, { props: { open: false, item } });
|
||||
expect(fetchGrantsForResource).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -10,10 +10,11 @@
|
||||
aria-label={t('notifications.title', 'Notifications')}
|
||||
>
|
||||
{#each ui.toasts as toast (toast.id)}
|
||||
<div class="toast toast--{toast.kind}" role="status">
|
||||
<div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
|
||||
<span class="toast__msg">{toast.message}</span>
|
||||
<button
|
||||
class="toast__close"
|
||||
data-testid={`toaster-dismiss-btn-${toast.id}`}
|
||||
aria-label={t('common.dismiss', 'Dismiss')}
|
||||
onclick={() => ui.dismiss(toast.id)}
|
||||
>
|
||||
|
||||
@@ -78,10 +78,21 @@
|
||||
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
|
||||
|
||||
{#if open}
|
||||
<div class="wopi" role="dialog" aria-modal="true" aria-label={fileName}>
|
||||
<div
|
||||
class="wopi"
|
||||
role="dialog"
|
||||
data-testid="wopi-editor-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={fileName}
|
||||
>
|
||||
<header class="wopi__bar">
|
||||
<span class="wopi__title">{fileName}</span>
|
||||
<button class="wopi__close" aria-label={t('common.close', 'Close')} onclick={close}>
|
||||
<button
|
||||
class="wopi__close"
|
||||
data-testid="wopi-editor-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={close}
|
||||
>
|
||||
<Icon name="times" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -97,8 +108,18 @@
|
||||
target="wopi_frame"
|
||||
class="wopi__form"
|
||||
>
|
||||
<input type="hidden" name="access_token" value={token} />
|
||||
<input type="hidden" name="access_token_ttl" value={tokenTtl} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="access_token"
|
||||
value={token}
|
||||
data-testid="wopi-editor-access-token-input"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="access_token_ttl"
|
||||
value={tokenTtl}
|
||||
data-testid="wopi-editor-access-token-ttl-input"
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
<iframe
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$lib/api/endpoints/wopi', () => ({ getEditorUrlWithFallback: vi.fn() }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
|
||||
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import WopiEditor from './WopiEditor.svelte';
|
||||
|
||||
const g = getEditorUrlWithFallback as unknown as ReturnType<typeof vi.fn>;
|
||||
const et = errorToast as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('opens, loads the editor URL, and renders the dialog', async () => {
|
||||
g.mockResolvedValue({
|
||||
editor_url: 'https://wopi/edit',
|
||||
access_token: 'tok',
|
||||
access_token_ttl: 9999
|
||||
});
|
||||
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'doc.docx', action: 'edit' } });
|
||||
await waitFor(() => expect(g).toHaveBeenCalledWith('f1', 'doc.docx', 'edit'));
|
||||
expect(screen.getByTestId('wopi-editor-dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops the spinner on an App_LoadingStatus message and closes on UI_Close', async () => {
|
||||
g.mockResolvedValue({ editor_url: 'u', access_token: 't', access_token_ttl: 1 });
|
||||
const onclose = vi.fn();
|
||||
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx', onclose } });
|
||||
await waitFor(() => screen.getByTestId('wopi-editor-dialog'));
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
MessageId: 'App_LoadingStatus',
|
||||
Values: { Status: 'Document_Loaded' }
|
||||
})
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', { data: JSON.stringify({ MessageId: 'UI_Close' }) })
|
||||
);
|
||||
await waitFor(() => expect(onclose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('reports a load failure via errorToast', async () => {
|
||||
g.mockRejectedValue(new Error('no wopi host'));
|
||||
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx' } });
|
||||
await waitFor(() => expect(et).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(WopiEditor, { props: { open: false, fileId: 'f1', fileName: 'd.docx' } });
|
||||
expect(g).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { useOwnerCache } from './useOwnerCache.svelte';
|
||||
|
||||
it('resolves names in parallel, dedupes, skips nullish, and caches', async () => {
|
||||
const resolver = vi.fn(async (id: string) => `Name-${id}`);
|
||||
const c = useOwnerCache(resolver);
|
||||
expect(c.name(null)).toBeNull();
|
||||
expect(c.name('u1')).toBeNull();
|
||||
expect(c.label('u1')).toBe('u1');
|
||||
|
||||
await c.resolve(['u1', 'u2', null, undefined, 'u1']);
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
expect(c.name('u1')).toBe('Name-u1');
|
||||
expect(c.label('u2')).toBe('Name-u2');
|
||||
expect(c.names.u1).toBe('Name-u1');
|
||||
|
||||
await c.resolve(['u1']); // already cached → no extra calls
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Selection, useSelection } from './useSelection.svelte';
|
||||
|
||||
describe('Selection', () => {
|
||||
it('adds idempotently and reports size/empty', () => {
|
||||
const s = useSelection();
|
||||
expect(s.isEmpty).toBe(true);
|
||||
s.add('a');
|
||||
s.add('a');
|
||||
expect(s.size).toBe(1);
|
||||
expect(s.has('a')).toBe(true);
|
||||
});
|
||||
it('toggles ids on and off', () => {
|
||||
const s = new Selection();
|
||||
s.toggle('b');
|
||||
expect(s.has('b')).toBe(true);
|
||||
s.toggle('b');
|
||||
expect(s.has('b')).toBe(false);
|
||||
});
|
||||
it('deletes, replaces, and clears', () => {
|
||||
const s = new Selection();
|
||||
s.set(['x', 'y', 'z']);
|
||||
expect(s.size).toBe(3);
|
||||
expect(s.values().sort()).toEqual(['x', 'y', 'z']);
|
||||
s.delete('y');
|
||||
s.delete('missing');
|
||||
expect(s.size).toBe(2);
|
||||
s.clear();
|
||||
expect(s.isEmpty).toBe(true);
|
||||
s.clear(); // no-op when already empty
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useVirtualWindow } from './useVirtualWindow.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts at zero, observes a root, remeasures, and tears down', () => {
|
||||
const vw = useVirtualWindow();
|
||||
expect(vw.aboveBy).toBe(0);
|
||||
expect(vw.viewportH).toBe(0);
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
const teardown = vw.observe(root);
|
||||
expect(typeof teardown).toBe('function');
|
||||
vw.remeasure();
|
||||
teardown();
|
||||
root.remove();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { it, expect, beforeEach } from 'vitest';
|
||||
import { dialogs, confirmDialog, promptDialog } from './dialogs.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
// Drain any leftover dialog so each test starts clean.
|
||||
while (dialogs.current && !dialogs.busy) dialogs.cancel();
|
||||
});
|
||||
|
||||
it('confirm resolves true and prompt resolves its value', async () => {
|
||||
const c = confirmDialog({ title: 'Confirm', message: 'ok?' });
|
||||
expect(dialogs.current?.kind).toBe('confirm');
|
||||
dialogs.resolve(true);
|
||||
await expect(c).resolves.toBe(true);
|
||||
|
||||
const p = promptDialog({ title: 'Name' });
|
||||
dialogs.resolve('hello');
|
||||
await expect(p).resolves.toBe('hello');
|
||||
});
|
||||
|
||||
it('cancel resolves confirm=false and prompt=null', async () => {
|
||||
const c = confirmDialog({ title: 'Confirm', message: 'x' });
|
||||
dialogs.cancel();
|
||||
await expect(c).resolves.toBe(false);
|
||||
const p = promptDialog({ title: 'y' });
|
||||
dialogs.cancel();
|
||||
await expect(p).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('runs a successful action then resolves', async () => {
|
||||
let ran = false;
|
||||
const c = confirmDialog({
|
||||
title: 'Confirm',
|
||||
message: 'x',
|
||||
action: async () => {
|
||||
ran = true;
|
||||
}
|
||||
});
|
||||
await dialogs.resolve(true);
|
||||
expect(ran).toBe(true);
|
||||
await expect(c).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the dialog open with an inline error when the action fails', async () => {
|
||||
const c = confirmDialog({
|
||||
title: 'Confirm',
|
||||
message: 'x',
|
||||
action: async () => {
|
||||
throw new Error('boom');
|
||||
}
|
||||
});
|
||||
await dialogs.resolve(true);
|
||||
expect(dialogs.error).toBe('boom');
|
||||
expect(dialogs.current).not.toBeNull();
|
||||
dialogs.cancel(); // recover
|
||||
await expect(c).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('queues a second dialog behind the first', async () => {
|
||||
const p1 = confirmDialog({ title: 'Confirm', message: '1' });
|
||||
const p2 = confirmDialog({ title: 'Confirm', message: '2' });
|
||||
expect(dialogs.current?.opts.message).toBe('1');
|
||||
dialogs.resolve(true);
|
||||
await p1;
|
||||
expect(dialogs.current?.opts.message).toBe('2');
|
||||
dialogs.resolve(true);
|
||||
await p2;
|
||||
expect(dialogs.current).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { sizeBucket, dateBucket, typeLabel, ownerLabel, files } from './files.svelte';
|
||||
|
||||
it('buckets sizes into distinct human ranges', () => {
|
||||
expect(sizeBucket(-1)).not.toBe(sizeBucket(0));
|
||||
const buckets = [0, 500, 5_000_000, 500_000_000, 2_000_000_000, 10_000_000_000].map(sizeBucket);
|
||||
// Each successive threshold lands in a different bucket label.
|
||||
expect(new Set(buckets).size).toBe(buckets.length);
|
||||
buckets.forEach((b) => expect(typeof b).toBe('string'));
|
||||
});
|
||||
|
||||
it('buckets dates relative to now', () => {
|
||||
expect(dateBucket(null)).toBeTruthy();
|
||||
expect(dateBucket(Date.now())).toBe(dateBucket(Date.now()));
|
||||
const old = new Date('2019-06-15').getTime();
|
||||
expect(dateBucket(old)).toBe('2019');
|
||||
});
|
||||
|
||||
it('labels a file category, defaulting when absent', () => {
|
||||
expect(typeLabel(null)).toBeTruthy();
|
||||
expect(typeof typeLabel('Image')).toBe('string');
|
||||
});
|
||||
|
||||
it('shows the owner as "Me" for the current user and a short id otherwise', () => {
|
||||
expect(ownerLabel(null, 'me')).toBe('');
|
||||
expect(ownerLabel('me', 'me')).toBeTruthy();
|
||||
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
|
||||
});
|
||||
|
||||
it('persists the view mode and toggles selection', () => {
|
||||
files.setViewMode('list');
|
||||
expect(files.viewMode).toBe('list');
|
||||
expect(localStorage.getItem('oxicloud_view_mode')).toBe('list');
|
||||
files.setViewMode('grid');
|
||||
expect(files.viewMode).toBe('grid');
|
||||
|
||||
files.clearSelection();
|
||||
expect(files.selection.size).toBe(0);
|
||||
files.toggleSelected('a');
|
||||
expect(files.selection.has('a')).toBe(true);
|
||||
files.toggleSelected('a');
|
||||
expect(files.selection.has('a')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { theme, setTheme } from './theme.svelte';
|
||||
|
||||
describe('theme store', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-color-scheme');
|
||||
});
|
||||
it('sets light/dark, persists, and reflects on <html>', () => {
|
||||
setTheme('light');
|
||||
expect(theme.current).toBe('light');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||
setTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('dark');
|
||||
});
|
||||
it('auto clears storage and removes the attribute', () => {
|
||||
setTheme('dark');
|
||||
setTheme('auto');
|
||||
expect(theme.current).toBe('auto');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBeNull();
|
||||
expect(document.documentElement.hasAttribute('data-color-scheme')).toBe(false);
|
||||
});
|
||||
it('theme.set is an alias for setTheme', () => {
|
||||
theme.set('light');
|
||||
expect(theme.current).toBe('light');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { iconNameFromClass, formatDate } from './display';
|
||||
|
||||
describe('iconNameFromClass', () => {
|
||||
it('extracts the fa token without its prefix', () => {
|
||||
expect(iconNameFromClass('fas fa-folder')).toBe('folder');
|
||||
expect(iconNameFromClass('fa-file-pdf')).toBe('file-pdf');
|
||||
});
|
||||
it('skips fa-fw / fa-lg modifier tokens', () => {
|
||||
expect(iconNameFromClass('fa-fw fa-image')).toBe('image');
|
||||
expect(iconNameFromClass('fa-lg fa-music')).toBe('music');
|
||||
});
|
||||
it('falls back to "file" for missing or unrecognised input', () => {
|
||||
expect(iconNameFromClass(null)).toBe('file');
|
||||
expect(iconNameFromClass(undefined)).toBe('file');
|
||||
expect(iconNameFromClass('')).toBe('file');
|
||||
expect(iconNameFromClass('no-fa-token-here')).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats epoch seconds and milliseconds', () => {
|
||||
expect(formatDate(1_700_000_000)).toMatch(/\d{4}/); // seconds
|
||||
expect(formatDate(1_700_000_000_000)).toMatch(/\d{4}/); // ms
|
||||
});
|
||||
it('formats ISO-8601 strings', () => {
|
||||
expect(formatDate('2024-01-15')).toMatch(/2024/);
|
||||
});
|
||||
it('returns empty string for null/undefined/invalid', () => {
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
expect(formatDate('definitely not a date')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui: { notify: vi.fn() } }));
|
||||
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { errorMessage, errorToast } from './errors';
|
||||
|
||||
describe('errorMessage', () => {
|
||||
it('returns the message of an Error', () => {
|
||||
expect(errorMessage(new Error('boom'))).toBe('boom');
|
||||
});
|
||||
it('stringifies non-Error values', () => {
|
||||
expect(errorMessage('nope')).toBe('nope');
|
||||
expect(errorMessage(42)).toBe('42');
|
||||
expect(errorMessage(null)).toBe('null');
|
||||
expect(errorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorToast', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it('raises an error toast with the normalised message', () => {
|
||||
errorToast(new Error('bad'));
|
||||
expect(ui.notify).toHaveBeenCalledWith('bad', 'error');
|
||||
});
|
||||
it('handles non-Error values', () => {
|
||||
errorToast('plain');
|
||||
expect(ui.notify).toHaveBeenCalledWith('plain', 'error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { gridColumns } from './grid';
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({
|
||||
matches,
|
||||
media: '',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('gridColumns', () => {
|
||||
beforeEach(() => mockMatchMedia(false));
|
||||
|
||||
it('returns 1 for non-positive width', () => {
|
||||
expect(gridColumns(0)).toBe(1);
|
||||
expect(gridColumns(-100)).toBe(1);
|
||||
});
|
||||
|
||||
it('computes columns at desktop sizing (cardMin 200, gap 20)', () => {
|
||||
expect(gridColumns(220)).toBe(1); // floor(240/220)
|
||||
expect(gridColumns(440)).toBe(2); // floor(460/220)
|
||||
expect(gridColumns(900)).toBe(4); // floor(920/220)
|
||||
});
|
||||
|
||||
it('uses mobile sizing when the phone media query matches', () => {
|
||||
mockMatchMedia(true);
|
||||
expect(gridColumns(300)).toBe(2); // floor(308/148)
|
||||
expect(gridColumns(600)).toBe(4); // floor(608/148)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isVideo, photoTimestamp, minimalPhotoItem } from './media';
|
||||
describe('media helpers', () => {
|
||||
it('isVideo detects video mime types', () => {
|
||||
expect(isVideo({ mime_type: 'video/mp4' } as never)).toBe(true);
|
||||
expect(isVideo({ mime_type: 'image/png' } as never)).toBe(false);
|
||||
expect(isVideo({} as never)).toBe(false);
|
||||
});
|
||||
it('photoTimestamp scales seconds to milliseconds', () => {
|
||||
expect(photoTimestamp({ sort_date: 1000 } as never)).toBe(1_000_000);
|
||||
expect(photoTimestamp({ sort_date: 2_000_000_000_000 } as never)).toBe(2_000_000_000_000);
|
||||
expect(photoTimestamp({ created_at: 5 } as never)).toBe(5000);
|
||||
expect(photoTimestamp({} as never)).toBe(0);
|
||||
});
|
||||
it('minimalPhotoItem stubs a FileItem from an id', () => {
|
||||
const p = minimalPhotoItem('abc');
|
||||
expect(p.id).toBe('abc');
|
||||
expect(p.category).toBe('image');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { relativeTimeAgo } from './time';
|
||||
|
||||
describe('relativeTimeAgo', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('returns the empty label for null/undefined/empty', () => {
|
||||
expect(relativeTimeAgo(null)).toBe('');
|
||||
expect(relativeTimeAgo(undefined)).toBe('');
|
||||
expect(relativeTimeAgo('')).toBe('');
|
||||
expect(relativeTimeAgo(null, { empty: 'never' })).toBe('never');
|
||||
});
|
||||
|
||||
it('handles unparseable input', () => {
|
||||
expect(relativeTimeAgo('garbage')).toBe('');
|
||||
expect(relativeTimeAgo('garbage', { invalidAsString: true })).toBe('garbage');
|
||||
});
|
||||
|
||||
it('formats past times in the largest matching unit', () => {
|
||||
expect(relativeTimeAgo(Date.now() - 2 * 31_536_000_000)).toMatch(/year/);
|
||||
expect(relativeTimeAgo(Date.now() - 2 * 2_592_000_000)).toMatch(/month/);
|
||||
expect(relativeTimeAgo(Date.now() - 2 * 604_800_000)).toMatch(/week/);
|
||||
expect(relativeTimeAgo(Date.now() - 3 * 86_400_000)).toMatch(/day/);
|
||||
expect(relativeTimeAgo(Date.now() - 3_600_000)).toMatch(/hour/);
|
||||
expect(relativeTimeAgo(Date.now() - 120_000)).toMatch(/minute/);
|
||||
});
|
||||
|
||||
it('accepts epoch seconds as well as milliseconds', () => {
|
||||
expect(relativeTimeAgo(Math.floor((Date.now() - 3_600_000) / 1000))).toMatch(/hour/);
|
||||
});
|
||||
|
||||
it('falls back to seconds for very recent timestamps', () => {
|
||||
expect(relativeTimeAgo(Date.now() - 5_000)).toMatch(/second/);
|
||||
});
|
||||
});
|
||||
@@ -914,27 +914,57 @@
|
||||
<h1>{t('admin.title', 'Admin')}</h1>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-dashboard-tab"
|
||||
aria-selected={tab === 'dashboard'}
|
||||
onclick={() => (tab = 'dashboard')}
|
||||
>
|
||||
<Icon name="chart-pie" />
|
||||
{t('admin.dashboard', 'Dashboard')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-users-tab"
|
||||
aria-selected={tab === 'users'}
|
||||
onclick={() => (tab = 'users')}
|
||||
>
|
||||
<Icon name="users" />
|
||||
{t('admin.users', 'Users')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-oidc-tab"
|
||||
aria-selected={tab === 'oidc'}
|
||||
onclick={() => (tab = 'oidc')}
|
||||
>
|
||||
<Icon name="key" />
|
||||
{t('admin.oidc', 'OIDC / SSO')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-storage-tab"
|
||||
aria-selected={tab === 'storage'}
|
||||
onclick={() => (tab = 'storage')}
|
||||
>
|
||||
<Icon name="database" />
|
||||
{t('admin.storage_tab', 'Storage')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-smtp-tab"
|
||||
aria-selected={tab === 'smtp'}
|
||||
onclick={() => (tab = 'smtp')}
|
||||
>
|
||||
<Icon name="envelope" />
|
||||
{t('admin.smtp', 'Email (SMTP)')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-plugins-tab"
|
||||
aria-selected={tab === 'plugins'}
|
||||
onclick={() => (tab = 'plugins')}
|
||||
>
|
||||
<Icon name="layer-group" />
|
||||
{t('admin.plugins', 'Plugins')}
|
||||
</button>
|
||||
@@ -1031,6 +1061,7 @@
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-dashboard-registration-checkbox"
|
||||
checked={dashboard.registration_enabled}
|
||||
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
|
||||
/>
|
||||
@@ -1106,9 +1137,17 @@
|
||||
{#if !oidc}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}>
|
||||
<form
|
||||
class="form"
|
||||
data-testid="admin-oidc-form"
|
||||
onsubmit={(e) => (e.preventDefault(), doSaveOidc())}
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.enabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-enabled-checkbox"
|
||||
bind:checked={oidc.enabled}
|
||||
/>
|
||||
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
|
||||
</label>
|
||||
<label
|
||||
@@ -1119,11 +1158,17 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.issuer_url}
|
||||
data-testid="admin-oidc-issuer-input"
|
||||
placeholder="https://idp.example.com"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
|
||||
/></label
|
||||
>
|
||||
<button type="button" class="btn btn-secondary" onclick={runOidcTest}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-oidc-discover-btn"
|
||||
onclick={runOidcTest}
|
||||
>
|
||||
<Icon name="search" />
|
||||
{t('admin.oidc_discover', 'Test / discover')}
|
||||
</button>
|
||||
@@ -1155,6 +1200,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.client_id}
|
||||
data-testid="admin-oidc-client-id-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
|
||||
/></label
|
||||
>
|
||||
@@ -1166,6 +1212,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-oidc-client-secret-input"
|
||||
bind:value={oidc.client_secret}
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
|
||||
placeholder={oidc.client_secret_set
|
||||
@@ -1187,6 +1234,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.scopes}
|
||||
data-testid="admin-oidc-scopes-input"
|
||||
placeholder="openid profile email"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
|
||||
/></label
|
||||
@@ -1199,6 +1247,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.provider_name}
|
||||
data-testid="admin-oidc-provider-name-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
|
||||
/></label
|
||||
>
|
||||
@@ -1210,15 +1259,24 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.admin_groups}
|
||||
data-testid="admin-oidc-admin-groups-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
|
||||
/></label
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.auto_provision} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-auto-provision-checkbox"
|
||||
bind:checked={oidc.auto_provision}
|
||||
/>
|
||||
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={oidc.disable_password_login} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-oidc-disable-pw-checkbox"
|
||||
bind:checked={oidc.disable_password_login}
|
||||
/>
|
||||
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
|
||||
</label>
|
||||
{#if oidc.callback_url}
|
||||
@@ -1227,6 +1285,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
data-testid="admin-oidc-callback-copy-btn"
|
||||
onclick={() => copyText(oidc?.callback_url ?? '')}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
@@ -1237,7 +1296,12 @@
|
||||
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
|
||||
{oidcMsg.text}
|
||||
</p>{/if}
|
||||
<button class="btn btn-primary" type="submit" disabled={oidcSaving}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
data-testid="admin-oidc-save-btn"
|
||||
disabled={oidcSaving}
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -1249,10 +1313,14 @@
|
||||
{#if !storage}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
|
||||
<form
|
||||
class="form"
|
||||
data-testid="admin-storage-form"
|
||||
onsubmit={(e) => (e.preventDefault(), doSaveStorage())}
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.storage_backend', 'Backend')}</span>
|
||||
<select bind:value={sForm.backend}>
|
||||
<select bind:value={sForm.backend} data-testid="admin-storage-backend-select">
|
||||
<option value="local">local</option>
|
||||
<option value="s3">S3</option>
|
||||
</select></label
|
||||
@@ -1260,7 +1328,11 @@
|
||||
{#if sForm.backend === 's3'}
|
||||
<label
|
||||
><span>{t('admin.storage_preset', 'Preset')}</span>
|
||||
<select bind:value={sForm.preset} onchange={applyPreset}>
|
||||
<select
|
||||
bind:value={sForm.preset}
|
||||
data-testid="admin-storage-preset-select"
|
||||
onchange={applyPreset}
|
||||
>
|
||||
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
@@ -1272,6 +1344,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.endpoint}
|
||||
data-testid="admin-storage-endpoint-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
|
||||
/></label
|
||||
>
|
||||
@@ -1283,6 +1356,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.bucket}
|
||||
data-testid="admin-storage-bucket-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
|
||||
/></label
|
||||
>
|
||||
@@ -1294,6 +1368,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.region}
|
||||
data-testid="admin-storage-region-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
|
||||
/></label
|
||||
>
|
||||
@@ -1305,6 +1380,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.accessKey}
|
||||
data-testid="admin-storage-access-key-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
|
||||
placeholder={storage.s3_access_key_set
|
||||
? t('admin.unchanged', 'Leave blank to keep current')
|
||||
@@ -1319,6 +1395,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-storage-secret-key-input"
|
||||
bind:value={sForm.secretKey}
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
|
||||
placeholder={storage.s3_secret_key_set
|
||||
@@ -1327,7 +1404,11 @@
|
||||
/></label
|
||||
>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={sForm.pathStyle} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-storage-path-style-checkbox"
|
||||
bind:checked={sForm.pathStyle}
|
||||
/>
|
||||
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
@@ -1335,13 +1416,17 @@
|
||||
{storageMsg.text}
|
||||
</p>{/if}
|
||||
<div class="smtp-test">
|
||||
<button class="btn btn-primary" type="submit" disabled={storageBusy}
|
||||
>{t('common.save', 'Save')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
data-testid="admin-storage-save-btn"
|
||||
disabled={storageBusy}>{t('common.save', 'Save')}</button
|
||||
>
|
||||
{#if sForm.backend === 's3'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-storage-test-btn"
|
||||
disabled={storageBusy}
|
||||
onclick={doTestStorage}
|
||||
>
|
||||
@@ -1401,30 +1486,43 @@
|
||||
<div class="smtp-test">
|
||||
<!-- Start: only when no migration is active (running/paused) or completed. -->
|
||||
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('start')}
|
||||
>{t('admin.mig_start', 'Start')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-start-btn"
|
||||
onclick={() => doMigration('start')}>{t('admin.mig_start', 'Start')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'running'}
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
|
||||
>{t('admin.mig_pause', 'Pause')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-pause-btn"
|
||||
onclick={() => doMigration('pause')}>{t('admin.mig_pause', 'Pause')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'paused'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('resume')}
|
||||
>{t('admin.mig_resume', 'Resume')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-resume-btn"
|
||||
onclick={() => doMigration('resume')}>{t('admin.mig_resume', 'Resume')}</button
|
||||
>
|
||||
{/if}
|
||||
<!-- Verify + Finalize: only once the copy phase has completed. -->
|
||||
{#if migration.status === 'completed'}
|
||||
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-verify-btn"
|
||||
disabled={verifying}
|
||||
onclick={doVerify}
|
||||
>
|
||||
<Icon name="check-double" />
|
||||
{verifying
|
||||
? t('admin.mig_verifying', 'Verifying…')
|
||||
: t('admin.mig_verify', 'Verify integrity')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
|
||||
>{t('admin.mig_complete', 'Finalize')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-complete-btn"
|
||||
onclick={() => doMigration('complete')}>{t('admin.mig_complete', 'Finalize')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1540,10 +1638,16 @@
|
||||
<div class="smtp-test">
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-smtp-to-input"
|
||||
bind:value={smtpTo}
|
||||
placeholder={t('admin.smtp_to', 'recipient@example.com')}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-smtp-send-btn"
|
||||
disabled={smtpSending}
|
||||
onclick={runSmtpTest}
|
||||
>
|
||||
<Icon name="paper-plane" />
|
||||
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
|
||||
</button>
|
||||
@@ -1567,7 +1671,11 @@
|
||||
</div>
|
||||
{:else if tab === 'users'}
|
||||
<div class="bar">
|
||||
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
data-testid="admin-users-create-btn"
|
||||
onclick={() => (createOpen = true)}
|
||||
>
|
||||
<Icon name="user-plus" />
|
||||
{t('admin.create_user', 'Create user')}
|
||||
</button>
|
||||
@@ -1644,6 +1752,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-quota-${u.id}`}
|
||||
title={t('admin.edit_quota_title', 'Edit quota')}
|
||||
aria-label={t('admin.edit_quota_title', 'Edit quota')}
|
||||
onclick={() => openQuota(u)}
|
||||
@@ -1653,6 +1762,7 @@
|
||||
{#if !isOidcUser(u)}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-reset-password-${u.id}`}
|
||||
title={t('admin.reset_password_title', 'Reset password')}
|
||||
aria-label={t('admin.reset_password_title', 'Reset password')}
|
||||
onclick={() => openReset(u)}
|
||||
@@ -1662,6 +1772,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-toggle-role-${u.id}`}
|
||||
title={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1671,6 +1782,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
|
||||
data-testid={`admin-user-toggle-active-${u.id}`}
|
||||
title={u.active
|
||||
? t('admin.deactivate_title', 'Deactivate')
|
||||
: t('admin.activate_title', 'Activate')}
|
||||
@@ -1684,6 +1796,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-user-delete-${u.id}`}
|
||||
title={t('admin.delete_title', 'Delete user')}
|
||||
aria-label={t('admin.delete_title', 'Delete user')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1697,10 +1810,16 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-users-pager-prev-btn"
|
||||
disabled={pageIndex === 0}
|
||||
onclick={() => changePage(-1)}>‹</button
|
||||
>
|
||||
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-users-pager-next-btn"
|
||||
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
|
||||
onclick={() => changePage(1)}>›</button
|
||||
>
|
||||
@@ -1725,6 +1844,7 @@
|
||||
: t('admin.plugins_upload', 'Upload .zip')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="admin-plugins-install-input"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
disabled={installing}
|
||||
@@ -1775,6 +1895,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-plugin-details-${p.id}`}
|
||||
title={t('admin.plugins_details', 'Logs & details')}
|
||||
aria-label={t('admin.plugins_details', 'Logs & details')}
|
||||
onclick={() => openLogs(p)}
|
||||
@@ -1783,6 +1904,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
|
||||
data-testid={`admin-plugin-toggle-${p.id}`}
|
||||
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
|
||||
aria-label={p.enabled
|
||||
? t('admin.disable', 'Disable')
|
||||
@@ -1793,6 +1915,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-plugin-delete-${p.id}`}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
onclick={() => removePlugin(p)}
|
||||
@@ -1809,10 +1932,20 @@
|
||||
</main>
|
||||
|
||||
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
|
||||
<form id="create-user-form" onsubmit={submitCreate} class="form">
|
||||
<form
|
||||
id="create-user-form"
|
||||
data-testid="admin-create-user-form"
|
||||
onsubmit={submitCreate}
|
||||
class="form"
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.username', 'Username')}</span>
|
||||
<input bind:value={newUser.username} minlength="3" required /></label
|
||||
<input
|
||||
bind:value={newUser.username}
|
||||
data-testid="admin-create-user-username-input"
|
||||
minlength="3"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span
|
||||
@@ -1821,17 +1954,24 @@
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-create-user-email-input"
|
||||
bind:value={newUser.email}
|
||||
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.password', 'Password')}</span>
|
||||
<input type="password" bind:value={newUser.password} minlength="8" required /></label
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-create-user-password-input"
|
||||
bind:value={newUser.password}
|
||||
minlength="8"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.role', 'Role')}</span>
|
||||
<select bind:value={newUser.role}>
|
||||
<select bind:value={newUser.role} data-testid="admin-create-user-role-select">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select></label
|
||||
@@ -1839,8 +1979,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
|
||||
<select bind:value={newUser.quotaUnit}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-create-user-quota-input"
|
||||
min="0"
|
||||
step="0.1"
|
||||
bind:value={newUser.quotaValue}
|
||||
/>
|
||||
<select bind:value={newUser.quotaUnit} data-testid="admin-create-user-quota-unit-select">
|
||||
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
@@ -1850,8 +1996,18 @@
|
||||
{#if createError}<p class="status--error">{createError}</p>{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-create-user-cancel-btn"
|
||||
onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
data-testid="admin-create-user-submit-btn"
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1867,6 +2023,7 @@
|
||||
<form
|
||||
id="quota-form"
|
||||
class="form"
|
||||
data-testid="admin-quota-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveQuota();
|
||||
@@ -1878,8 +2035,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
|
||||
<select bind:value={quotaModal.unit}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-quota-value-input"
|
||||
min="0"
|
||||
step="0.1"
|
||||
bind:value={quotaModal.value}
|
||||
/>
|
||||
<select bind:value={quotaModal.unit} data-testid="admin-quota-unit-select">
|
||||
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
@@ -1889,8 +2052,15 @@
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="quota-form">
|
||||
<button class="btn" data-testid="admin-quota-cancel-btn" onclick={() => (quotaModal = null)}
|
||||
>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="quota-form"
|
||||
data-testid="admin-quota-save-btn"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1903,20 +2073,41 @@
|
||||
onclose={() => (resetModal = null)}
|
||||
>
|
||||
{#if resetModal}
|
||||
<form id="reset-pw-form" class="form" onsubmit={submitReset}>
|
||||
<form
|
||||
id="reset-pw-form"
|
||||
class="form"
|
||||
data-testid="admin-reset-password-form"
|
||||
onsubmit={submitReset}
|
||||
>
|
||||
<p class="muted">
|
||||
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
|
||||
</p>
|
||||
<label
|
||||
><span>{t('admin.new_password', 'New password')}</span>
|
||||
<input type="password" bind:value={resetPassword} minlength="8" required /></label
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-reset-password-input"
|
||||
bind:value={resetPassword}
|
||||
minlength="8"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
{#if resetError}<p class="status--error">{resetError}</p>{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-reset-password-cancel-btn"
|
||||
onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="submit"
|
||||
form="reset-pw-form"
|
||||
data-testid="admin-reset-password-submit-btn"
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1930,9 +2121,14 @@
|
||||
>
|
||||
<p>{confirmState?.message}</p>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button
|
||||
<button class="btn" data-testid="admin-confirm-cancel-btn" onclick={() => resolveConfirm(false)}
|
||||
>{t('common.cancel', 'Cancel')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
data-testid="admin-confirm-ok-btn"
|
||||
onclick={() => resolveConfirm(true)}
|
||||
>
|
||||
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
|
||||
{t('common.confirm', 'Confirm')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1972,25 +2168,43 @@
|
||||
{/if}
|
||||
|
||||
{#if retention}
|
||||
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}>
|
||||
<form
|
||||
class="form retention-form"
|
||||
data-testid="admin-plugin-retention-form"
|
||||
onsubmit={(e) => (e.preventDefault(), saveRetention())}
|
||||
>
|
||||
<h3>{t('admin.plugins_retention', 'Log retention')}</h3>
|
||||
<label
|
||||
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
|
||||
<input type="number" min="0" bind:value={retentionDays} /></label
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-plugin-retention-days-input"
|
||||
min="0"
|
||||
bind:value={retentionDays}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
|
||||
<input type="number" min="0" bind:value={retentionMb} /></label
|
||||
<input
|
||||
type="number"
|
||||
data-testid="admin-plugin-retention-max-input"
|
||||
min="0"
|
||||
bind:value={retentionMb}
|
||||
/></label
|
||||
>
|
||||
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
|
||||
<button class="btn btn-secondary" type="submit"
|
||||
<button class="btn btn-secondary" type="submit" data-testid="admin-plugin-retention-save-btn"
|
||||
>{t('admin.plugins_retention_save', 'Save retention')}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="logs-toolbar">
|
||||
<select bind:value={logsLevel} onchange={reloadLogsFromStart}>
|
||||
<select
|
||||
bind:value={logsLevel}
|
||||
data-testid="admin-plugin-logs-level-select"
|
||||
onchange={reloadLogsFromStart}
|
||||
>
|
||||
<option value="">{t('admin.logs_all', 'All levels')}</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
@@ -1998,14 +2212,22 @@
|
||||
</select>
|
||||
<input
|
||||
placeholder={t('admin.logs_search', 'Search…')}
|
||||
data-testid="admin-plugin-logs-search-input"
|
||||
bind:value={logsSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
|
||||
/>
|
||||
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
|
||||
>{t('common.search', 'Search')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-plugin-logs-search-btn"
|
||||
onclick={reloadLogsFromStart}>{t('common.search', 'Search')}</button
|
||||
>
|
||||
<label class="live-toggle">
|
||||
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-plugin-logs-live-checkbox"
|
||||
bind:checked={logsLive}
|
||||
onchange={toggleLive}
|
||||
/>
|
||||
<span>{t('admin.logs_live', 'Live')}</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2044,7 +2266,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="pager logs-pager">
|
||||
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-plugin-logs-pager-prev-btn"
|
||||
disabled={logsPage === 0}
|
||||
onclick={logsPrev}>‹</button
|
||||
>
|
||||
<span>
|
||||
{#if logsTotal === 0}
|
||||
{t('admin.logs_empty', 'No log entries.')}
|
||||
@@ -2060,15 +2287,18 @@
|
||||
)}
|
||||
{/if}
|
||||
</span>
|
||||
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext}
|
||||
>›</button
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-plugin-logs-pager-next-btn"
|
||||
disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal}
|
||||
onclick={logsNext}>›</button
|
||||
>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-danger" onclick={purgeLogs}
|
||||
<button class="btn btn-danger" data-testid="admin-plugin-logs-clear-btn" onclick={purgeLogs}
|
||||
>{t('admin.plugins_clear_logs', 'Clear logs')}</button
|
||||
>
|
||||
<button class="btn btn-secondary" onclick={closeLogs}>
|
||||
<button class="btn btn-secondary" data-testid="admin-plugin-logs-close-btn" onclick={closeLogs}>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: { user: { id: '1', username: 'admin', role: 'admin' } },
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/api/endpoints/admin', () => ({
|
||||
clearPluginLogs: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
deletePlugin: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
getMigration: vi.fn(),
|
||||
getOidcSettings: vi.fn(),
|
||||
getPluginLogs: vi.fn(),
|
||||
getPluginRetention: vi.fn(),
|
||||
getSmtpInfo: vi.fn(),
|
||||
getStorageSettings: vi.fn(),
|
||||
installPlugin: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
migrationAction: vi.fn(),
|
||||
resetUserPassword: vi.fn(),
|
||||
saveOidc: vi.fn(),
|
||||
savePluginRetention: vi.fn(),
|
||||
saveStorage: vi.fn(),
|
||||
sendSmtpTest: vi.fn(),
|
||||
setPluginEnabled: vi.fn(),
|
||||
setRegistrationEnabled: vi.fn(),
|
||||
setUserActive: vi.fn(),
|
||||
setUserQuota: vi.fn(),
|
||||
setUserRole: vi.fn(),
|
||||
testOidc: vi.fn(),
|
||||
testStorage: vi.fn(),
|
||||
verifyMigration: vi.fn()
|
||||
}));
|
||||
|
||||
import * as admin from '$lib/api/endpoints/admin';
|
||||
import AdminPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
const dashboard = {
|
||||
total_users: 3,
|
||||
active_users: 2,
|
||||
admin_users: 1,
|
||||
server_version: '1.0',
|
||||
total_used_bytes: 100,
|
||||
total_quota_bytes: 1000,
|
||||
storage_usage_percent: 10,
|
||||
auth_enabled: true,
|
||||
oidc_configured: false,
|
||||
quotas_enabled: true,
|
||||
registration_enabled: true,
|
||||
users_over_80_percent: 0,
|
||||
users_over_quota: 0
|
||||
};
|
||||
|
||||
const user = {
|
||||
id: 'u1',
|
||||
username: 'bob',
|
||||
email: 'bob@x.test',
|
||||
role: 'user',
|
||||
active: true,
|
||||
is_active: true,
|
||||
storage_used_bytes: 10,
|
||||
storage_quota_bytes: 100,
|
||||
is_external: false
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(admin.getDashboard).mockResolvedValue(dashboard);
|
||||
m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] });
|
||||
m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] });
|
||||
m(admin.getOidcSettings).mockResolvedValue({
|
||||
enabled: false,
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
scopes: null,
|
||||
auto_provision: false,
|
||||
admin_groups: null,
|
||||
disable_password_login: false,
|
||||
provider_name: null,
|
||||
callback_url: 'http://localhost/callback',
|
||||
client_secret_set: false,
|
||||
env_overrides: []
|
||||
});
|
||||
m(admin.getStorageSettings).mockResolvedValue({ backend: 'filesystem', env_overrides: [] });
|
||||
m(admin.getMigration).mockResolvedValue({
|
||||
status: 'idle',
|
||||
total_blobs: 0,
|
||||
migrated_blobs: 0,
|
||||
migrated_bytes: 0
|
||||
});
|
||||
m(admin.getSmtpInfo).mockResolvedValue({
|
||||
enabled: false,
|
||||
host: 'localhost',
|
||||
port: 25,
|
||||
tls: 'none',
|
||||
from: 'a@x.test',
|
||||
user_state: 'unset'
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the dashboard on mount', async () => {
|
||||
render(AdminPage);
|
||||
await waitFor(() => expect(admin.getDashboard).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('toggles registration from the dashboard', async () => {
|
||||
m(admin.setRegistrationEnabled).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
const cb = await screen.findByTestId('admin-dashboard-registration-checkbox');
|
||||
await fireEvent.click(cb);
|
||||
await waitFor(() => expect(admin.setRegistrationEnabled).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads users when the users tab is opened and creates a user', async () => {
|
||||
m(admin.createUser).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
|
||||
await waitFor(() => expect(admin.listUsers).toHaveBeenCalled());
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-create-btn'));
|
||||
await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), {
|
||||
target: { value: 'newbie' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('admin-create-user-password-input'), {
|
||||
target: { value: 'Password123!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('admin-create-user-submit-btn'));
|
||||
await waitFor(() => expect(admin.createUser).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads OIDC settings when the OIDC tab is opened', async () => {
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
|
||||
await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads storage + migration when the storage tab is opened', async () => {
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
|
||||
await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(admin.getMigration).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads SMTP info when the SMTP tab is opened', async () => {
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
|
||||
await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads plugins when the plugins tab is opened', async () => {
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
|
||||
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("toggles a user's role through the confirm modal", async () => {
|
||||
m(admin.setUserRole).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
|
||||
await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1'));
|
||||
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
|
||||
await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin'));
|
||||
});
|
||||
|
||||
it('deactivates a user through the confirm modal', async () => {
|
||||
m(admin.setUserActive).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
|
||||
await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1'));
|
||||
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
|
||||
await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false));
|
||||
});
|
||||
|
||||
it('saves OIDC settings from the OIDC form', async () => {
|
||||
m(admin.saveOidc).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
|
||||
await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), {
|
||||
target: { value: 'https://idp.test' }
|
||||
});
|
||||
await fireEvent.submit(await screen.findByTestId('admin-oidc-form'));
|
||||
await waitFor(() => expect(admin.saveOidc).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('sends an SMTP test email', async () => {
|
||||
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
|
||||
await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), {
|
||||
target: { value: 'to@x.test' }
|
||||
});
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-send-btn'));
|
||||
await waitFor(() => expect(admin.sendSmtpTest).toHaveBeenCalledWith('to@x.test'));
|
||||
});
|
||||
|
||||
it('saves storage settings and starts a migration', async () => {
|
||||
m(admin.saveStorage).mockResolvedValue(undefined);
|
||||
m(admin.migrationAction).mockResolvedValue(undefined);
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
|
||||
await fireEvent.submit(await screen.findByTestId('admin-storage-form'));
|
||||
await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled());
|
||||
await fireEvent.click(await screen.findByTestId('admin-migration-start-btn'));
|
||||
await waitFor(() => expect(admin.migrationAction).toHaveBeenCalledWith('start'));
|
||||
});
|
||||
@@ -123,11 +123,12 @@
|
||||
<h1>{t('device.title', 'Device verification')}</h1>
|
||||
|
||||
{#if step === 'code'}
|
||||
<form onsubmit={lookup}>
|
||||
<form data-testid="device-code-form" onsubmit={lookup}>
|
||||
<label class="device__field">
|
||||
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
|
||||
<input
|
||||
bind:this={codeInput}
|
||||
data-testid="device-code-input"
|
||||
value={code}
|
||||
oninput={onCodeInput}
|
||||
autocomplete="off"
|
||||
@@ -137,7 +138,9 @@
|
||||
maxlength={FULL_CODE_LENGTH}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
|
||||
<button type="submit" data-testid="device-continue-btn" disabled={!code}
|
||||
>{t('device.continue', 'Continue')}</button
|
||||
>
|
||||
</form>
|
||||
{:else if step === 'loading'}
|
||||
<p>{t('common.loading', 'Loading…')}</p>
|
||||
@@ -149,10 +152,20 @@
|
||||
<dd>{info?.scopes || 'all'}</dd>
|
||||
</dl>
|
||||
<div class="device__actions">
|
||||
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}>
|
||||
<button
|
||||
class="device__deny"
|
||||
data-testid="device-deny-btn"
|
||||
disabled={busy}
|
||||
onclick={() => decide('deny')}
|
||||
>
|
||||
{t('device.deny', 'Deny')}
|
||||
</button>
|
||||
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}>
|
||||
<button
|
||||
class="device__approve"
|
||||
data-testid="device-approve-btn"
|
||||
disabled={busy}
|
||||
onclick={() => decide('approve')}
|
||||
>
|
||||
{t('device.approve', 'Approve')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -164,7 +177,9 @@
|
||||
<p>{t('device.denied', 'Device access denied.')}</p>
|
||||
{:else if step === 'error'}
|
||||
<p class="device__error" role="alert">{errorText}</p>
|
||||
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button>
|
||||
<button data-testid="device-retry-btn" onclick={backToCode}
|
||||
>{t('common.retry', 'Try again')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { pageState } = vi.hoisted(() => ({
|
||||
pageState: { url: new URL('http://localhost/device') }
|
||||
}));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/device', () => {
|
||||
class DeviceLookupFailure extends Error {}
|
||||
return {
|
||||
DeviceLookupFailure,
|
||||
decideDevice: vi.fn(),
|
||||
lookupDeviceCode: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
import { decideDevice, lookupDeviceCode } from '$lib/api/endpoints/device';
|
||||
import DevicePage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
const info = { user_code: 'WXYZ-1234', client_name: 'CLI', scopes: ['files'] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/device');
|
||||
});
|
||||
|
||||
it('looks up a typed code and shows the review step', async () => {
|
||||
m(lookupDeviceCode).mockResolvedValue(info);
|
||||
render(DevicePage);
|
||||
await screen.findByTestId('device-code-form');
|
||||
await fireEvent.input(screen.getByTestId('device-code-input'), {
|
||||
target: { value: 'WXYZ-1234' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await waitFor(() => expect(lookupDeviceCode).toHaveBeenCalledWith('WXYZ-1234'));
|
||||
await screen.findByTestId('device-approve-btn');
|
||||
});
|
||||
|
||||
it('approves a device after lookup', async () => {
|
||||
m(lookupDeviceCode).mockResolvedValue(info);
|
||||
m(decideDevice).mockResolvedValue(undefined);
|
||||
render(DevicePage);
|
||||
await fireEvent.input(await screen.findByTestId('device-code-input'), {
|
||||
target: { value: 'WXYZ-1234' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await fireEvent.click(await screen.findByTestId('device-approve-btn'));
|
||||
await waitFor(() => expect(decideDevice).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows an error when lookup fails', async () => {
|
||||
m(lookupDeviceCode).mockRejectedValue(new Error('nope'));
|
||||
render(DevicePage);
|
||||
await fireEvent.input(await screen.findByTestId('device-code-input'), {
|
||||
target: { value: 'BAD' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('device-continue-btn'));
|
||||
await screen.findByTestId('device-retry-btn');
|
||||
});
|
||||
@@ -300,17 +300,23 @@
|
||||
onselectionchange={(ids) => (selectedIds = ids)}
|
||||
>
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||
<Button icon="download" data-testid="favorites-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="favorites-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="favorites-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({
|
||||
fetchFavoritesPage: vi.fn(),
|
||||
removeFavorite: vi.fn(),
|
||||
resolveOwnerName: vi.fn(async () => 'me'),
|
||||
sizeBucket: () => 'Small',
|
||||
typeLabel: () => 'File'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
renameFile: vi.fn(),
|
||||
deleteFile: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
|
||||
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import FavoritesPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withOneFile() {
|
||||
m(fetchFavoritesPage).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
resource_type: 'file',
|
||||
favorited_at: '2024-01-01T00:00:00Z',
|
||||
resource: {
|
||||
category: 'Image',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id: 'f1',
|
||||
mime_type: 'image/png',
|
||||
modified_at: 0,
|
||||
name: 'photo.png',
|
||||
owner_id: 'me',
|
||||
folder_id: 'root',
|
||||
path: '/photo.png',
|
||||
size: 10,
|
||||
size_formatted: '10 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
}
|
||||
}
|
||||
],
|
||||
next_cursor: null
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders favorites returned by the API', async () => {
|
||||
withOneFile();
|
||||
render(FavoritesPage);
|
||||
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('photo.png')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no favorites', async () => {
|
||||
m(fetchFavoritesPage).mockResolvedValue({ items: [], next_cursor: null });
|
||||
render(FavoritesPage);
|
||||
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('unfavorites a row via the star button', async () => {
|
||||
withOneFile();
|
||||
m(removeFavorite).mockResolvedValue(undefined);
|
||||
render(FavoritesPage);
|
||||
await screen.findByText('photo.png');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-favorite-f1-btn'));
|
||||
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
|
||||
});
|
||||
|
||||
it('batch-deletes selected favorites after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(FavoritesPage);
|
||||
await screen.findByText('photo.png');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
});
|
||||
@@ -1455,13 +1455,21 @@
|
||||
>
|
||||
<div class="page-sticky-header">
|
||||
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. -->
|
||||
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="files-upload-file-input"
|
||||
onchange={onUpload}
|
||||
/>
|
||||
<input
|
||||
bind:this={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
webkitdirectory
|
||||
data-testid="files-upload-folder-input"
|
||||
onchange={onUploadFolder}
|
||||
/>
|
||||
|
||||
@@ -1474,12 +1482,13 @@
|
||||
>
|
||||
{#snippet start()}
|
||||
{#if selectedCount > 0}
|
||||
<div class="action-buttons batch-selection-bar">
|
||||
<div class="action-buttons batch-selection-bar" data-testid="files-batch-bar">
|
||||
<div class="list-header-checkbox">
|
||||
<button
|
||||
class="batch-bar-close"
|
||||
title={t('files.cancel_selection', 'Cancel selection')}
|
||||
aria-label={t('files.cancel_selection', 'Cancel selection')}
|
||||
data-testid="files-batch-cancel-btn"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1493,22 +1502,34 @@
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.add_favorites', 'Add to favorites')}
|
||||
data-testid="files-batch-favorite-btn"
|
||||
onclick={() => void batchFavorites()}
|
||||
>
|
||||
<Icon name="star" />
|
||||
<span>{t('files.add_favorites', 'Add to favorites')}</span>
|
||||
</button>
|
||||
<button class="batch-btn" title={t('files.move', 'Move')} onclick={batchMove}>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid="files-batch-move-btn"
|
||||
onclick={batchMove}
|
||||
>
|
||||
<Icon name="arrows-alt" />
|
||||
<span>{t('files.move', 'Move')}</span>
|
||||
</button>
|
||||
<button class="batch-btn" title={t('files.copy', 'Copy')} onclick={batchCopy}>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.copy', 'Copy')}
|
||||
data-testid="files-batch-copy-btn"
|
||||
onclick={batchCopy}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
<span>{t('files.copy', 'Copy')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid="files-batch-download-btn"
|
||||
onclick={() => void batchDownload()}
|
||||
>
|
||||
<Icon name="download" />
|
||||
@@ -1517,6 +1538,7 @@
|
||||
<button
|
||||
class="batch-btn batch-btn-danger"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid="files-batch-delete-btn"
|
||||
onclick={batchDelete}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -1527,9 +1549,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown">
|
||||
<div class="upload-dropdown" data-testid="files-upload-dropdown">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="files-upload-btn"
|
||||
onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
|
||||
disabled={uploading}
|
||||
aria-haspopup="true"
|
||||
@@ -1544,9 +1567,10 @@
|
||||
<Icon name="caret-down" class="upload-caret" />
|
||||
</button>
|
||||
{#if uploadMenuOpen}
|
||||
<div class="upload-dropdown-menu">
|
||||
<div class="upload-dropdown-menu" data-testid="files-upload-menu">
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-files-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
fileInput?.click();
|
||||
@@ -1557,6 +1581,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-folder-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
folderInput?.click();
|
||||
@@ -1568,7 +1593,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick={onNewFolder}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="files-new-folder-btn"
|
||||
onclick={onNewFolder}
|
||||
>
|
||||
<Icon name="folder-plus" class="icon-mr" />
|
||||
<span>{t('actions.new_folder', 'New folder')}</span>
|
||||
</button>
|
||||
@@ -1593,6 +1622,7 @@
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||
data-testid={i === 0 ? 'files-breadcrumb-home-link' : `files-breadcrumb-${c.id}`}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}
|
||||
>
|
||||
@@ -1655,6 +1685,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('files.select_all', 'Select all')}
|
||||
data-testid="files-select-all-checkbox"
|
||||
checked={selectedCount > 0 && selectedCount === totalCount}
|
||||
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
||||
onchange={toggleSelectAll}
|
||||
@@ -1668,6 +1699,7 @@
|
||||
class="list-header-sort"
|
||||
class:is-active={sortField === col.f}
|
||||
data-sort-field={col.f}
|
||||
data-testid={`files-sort-${col.f}-btn`}
|
||||
onclick={() => toggleSort(col.f as SortField)}
|
||||
>
|
||||
{col.l}
|
||||
@@ -1700,6 +1732,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={folder.name}
|
||||
data-testid={folder.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
|
||||
ondragover={(e) => {
|
||||
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
|
||||
@@ -1723,6 +1757,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(folder.id)}
|
||||
aria-label={folder.name}
|
||||
data-testid={`files-folder-checkbox-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(folder.id);
|
||||
@@ -1760,6 +1795,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(folder.id)}
|
||||
data-testid={`files-folder-favorite-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('folder', folder.id);
|
||||
@@ -1768,6 +1804,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-folder-share-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('folder', folder.id, folder.name);
|
||||
@@ -1776,6 +1813,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-folder-move-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('folder', folder.id, folder.name);
|
||||
@@ -1784,6 +1822,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-folder-rename-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('folder', folder.id, folder.name);
|
||||
@@ -1792,6 +1831,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-folder-delete-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('folder', folder.id, folder.name);
|
||||
@@ -1802,6 +1842,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-folder-more-${folder.id}`}
|
||||
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1817,6 +1858,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={file.name}
|
||||
data-testid={file.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
|
||||
ondblclick={() => openFile(file)}
|
||||
onclick={(e) => {
|
||||
@@ -1830,6 +1873,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(file.id)}
|
||||
aria-label={file.name}
|
||||
data-testid={`files-file-checkbox-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(file.id);
|
||||
@@ -1881,6 +1925,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(file.id)}
|
||||
data-testid={`files-file-favorite-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('file', file.id);
|
||||
@@ -1889,6 +1934,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-file-share-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('file', file.id, file.name);
|
||||
@@ -1897,6 +1943,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-file-move-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('file', file.id, file.name);
|
||||
@@ -1908,11 +1955,13 @@
|
||||
rel="external"
|
||||
download
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid={`files-file-download-${file.id}`}
|
||||
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
||||
>
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-file-rename-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('file', file.id, file.name);
|
||||
@@ -1921,6 +1970,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-file-delete-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('file', file.id, file.name);
|
||||
@@ -1931,6 +1981,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-file-more-${file.id}`}
|
||||
onclick={(e) => openContext(e, 'file', file.id, file.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1977,14 +2028,22 @@
|
||||
<div
|
||||
class="ctx-scrim"
|
||||
role="presentation"
|
||||
data-testid="files-context-menu-scrim"
|
||||
onclick={closeContext}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
></div>
|
||||
<div class="ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
|
||||
<div
|
||||
class="ctx-menu"
|
||||
style:left="{ctxX}px"
|
||||
style:top="{ctxY}px"
|
||||
role="menu"
|
||||
data-testid="files-context-menu"
|
||||
>
|
||||
{#if ctxTarget.kind === 'folder'}
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-folder-open-item"
|
||||
onclick={() => {
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
@@ -1994,6 +2053,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-download-zip-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2004,6 +2064,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-file-open-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2014,6 +2075,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2023,6 +2085,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-new-tab-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2036,11 +2099,13 @@
|
||||
href={fileDownloadUrl(ctxTarget.id)}
|
||||
rel="external"
|
||||
download
|
||||
data-testid="files-ctx-download-item"
|
||||
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
||||
>
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-open-parent-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2051,6 +2116,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-add-playlist-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2062,6 +2128,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-share-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2071,6 +2138,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-move-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2080,6 +2148,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-copy-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2089,6 +2158,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-favorite-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2103,6 +2173,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-rename-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2112,6 +2183,7 @@
|
||||
<button
|
||||
class="ctx-item ctx-item--danger"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-delete-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
|
||||
params: { path: string };
|
||||
url: URL;
|
||||
},
|
||||
session: {
|
||||
user: { id: 'me', username: 'admin', is_external: false },
|
||||
isExternalUser: false,
|
||||
loadHomeFolder: vi.fn(async () => 'home'),
|
||||
refresh: vi.fn(async () => {})
|
||||
},
|
||||
ui: { notify: vi.fn() },
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ tryDeltaUpload: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/wopi', () => ({
|
||||
canEditWithWopi: () => false,
|
||||
getEditorUrlWithFallback: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/music', () => ({
|
||||
addTracks: vi.fn(),
|
||||
createPlaylist: vi.fn(),
|
||||
listPlaylists: vi.fn(async () => [])
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
deleteFile: vi.fn(),
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb',
|
||||
moveFile: vi.fn(),
|
||||
renameFile: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
uploadFileWithProgress: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({
|
||||
cacheFolder: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
fetchFolderListing: vi.fn(),
|
||||
folderZipUrl: () => '/zip',
|
||||
getCachedFolder: () => undefined,
|
||||
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
||||
getFolderName: () => undefined,
|
||||
invalidateFolderCache: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
rememberFolderName: vi.fn(),
|
||||
renameFolder: vi.fn()
|
||||
}));
|
||||
|
||||
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import FilesPage from './[...path]/+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withListing() {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fileItem(id: string, name: string) {
|
||||
return {
|
||||
category: 'Document',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + name,
|
||||
size: 4,
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
};
|
||||
}
|
||||
function folderItem(id: string, name: string) {
|
||||
return {
|
||||
category: 'Folder',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-folder',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name,
|
||||
owner_id: 'me',
|
||||
parent_id: 'home',
|
||||
path: '/' + name,
|
||||
etag: 'e'
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// A concrete folder in the path: bare `/files` now canonicalizes to
|
||||
// `/files/<drive-root>` via goto (see the external-user test), so the
|
||||
// listing-oriented tests target a folder directly.
|
||||
pageState.params.path = 'home';
|
||||
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
||||
filesStore.viewMode = 'list';
|
||||
});
|
||||
|
||||
it('loads the home folder listing on mount and renders its contents', async () => {
|
||||
m(fetchFolderListing).mockResolvedValue({
|
||||
status: 200,
|
||||
etag: 'v1',
|
||||
listing: {
|
||||
folders: [folderItem('sub1', 'Sub')],
|
||||
files: [fileItem('f1', 'hello.txt')],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
}
|
||||
});
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
|
||||
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
|
||||
// surrounding chrome rendered rather than the windowed rows themselves.
|
||||
await screen.findByTestId('files-new-folder-btn');
|
||||
});
|
||||
|
||||
it('shows an error when the listing fails with no cache', async () => {
|
||||
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('redirects external users away from the home folder', async () => {
|
||||
session.isExternalUser = true;
|
||||
pageState.params.path = '';
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
|
||||
session.isExternalUser = false;
|
||||
});
|
||||
|
||||
it('creates a new folder in the current directory', async () => {
|
||||
withListing();
|
||||
promptDialog.mockResolvedValue('Reports');
|
||||
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
|
||||
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
|
||||
});
|
||||
|
||||
it('batch-deletes the whole selection after confirmation', async () => {
|
||||
withListing();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFolder).mockResolvedValue(undefined);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
||||
});
|
||||
|
||||
it('batch-favorites the selection via the favorites batch endpoint', async () => {
|
||||
withListing();
|
||||
m(apiFetch).mockResolvedValue({ ok: true });
|
||||
render(FilesPage);
|
||||
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
|
||||
await waitFor(() =>
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
'/api/favorites/batch',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -231,7 +231,8 @@
|
||||
<main class="groups">
|
||||
<header class="groups__head">
|
||||
<h1>{t('nav.groups', 'Groups')}</h1>
|
||||
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button
|
||||
<button class="btn btn--primary" data-testid="groups-create-btn" onclick={onCreate}
|
||||
>{t('groups.create', 'Create group')}</button
|
||||
>
|
||||
</header>
|
||||
|
||||
@@ -247,7 +248,11 @@
|
||||
{@const description = groupDescription(g)}
|
||||
<li class="group">
|
||||
<div class="group__row">
|
||||
<button class="group__name" onclick={() => expand(g)}>
|
||||
<button
|
||||
class="group__name"
|
||||
data-testid={`groups-expand-${g.id}`}
|
||||
onclick={() => expand(g)}
|
||||
>
|
||||
<span class="avatar"><Icon name={groupIconName(g)} /></span>
|
||||
<span class="group__text">
|
||||
<span class="group__title">
|
||||
@@ -264,10 +269,16 @@
|
||||
</button>
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<div class="group__actions">
|
||||
<button class="link-btn" onclick={() => onRename(g)}
|
||||
>{t('common.rename', 'Rename')}</button
|
||||
<button
|
||||
class="link-btn"
|
||||
data-testid={`groups-rename-${g.id}`}
|
||||
onclick={() => onRename(g)}>{t('common.rename', 'Rename')}</button
|
||||
>
|
||||
<button
|
||||
class="link-btn link-btn--danger"
|
||||
data-testid={`groups-delete-${g.id}`}
|
||||
onclick={() => onDelete(g)}
|
||||
>
|
||||
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +286,7 @@
|
||||
</div>
|
||||
|
||||
{#if expandedId === g.id}
|
||||
<div class="members">
|
||||
<div class="members" data-testid={`groups-members-panel-${g.id}`}>
|
||||
<div class="members__head">
|
||||
<h2>{t('groups.members', 'Members')}</h2>
|
||||
</div>
|
||||
@@ -284,6 +295,7 @@
|
||||
<div class="add-member">
|
||||
<input
|
||||
class="add-member__input"
|
||||
data-testid="groups-member-add-input"
|
||||
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
|
||||
bind:value={addQuery}
|
||||
oninput={onAddQuery}
|
||||
@@ -291,10 +303,14 @@
|
||||
{#if addBusy}
|
||||
<p class="muted">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if addResults.length > 0}
|
||||
<ul class="add-member__results">
|
||||
<ul class="add-member__results" data-testid="groups-member-add-results">
|
||||
{#each addResults as r (r.type + r.id)}
|
||||
<li>
|
||||
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
|
||||
<button
|
||||
class="add-member__opt"
|
||||
data-testid={`groups-member-add-opt-${r.type}-${r.id}`}
|
||||
onclick={() => pickMember(g, r)}
|
||||
>
|
||||
<span class="avatar avatar--sm">
|
||||
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
|
||||
</span>
|
||||
@@ -338,6 +354,7 @@
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<button
|
||||
class="link-btn link-btn--danger"
|
||||
data-testid={`groups-member-remove-${m.kind}-${m.id}`}
|
||||
onclick={() => onRemoveMember(g.id, m)}
|
||||
>
|
||||
{t('common.remove', 'Remove')}
|
||||
@@ -354,7 +371,12 @@
|
||||
</ul>
|
||||
|
||||
{#if hasMore}
|
||||
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
|
||||
<button
|
||||
class="btn load-more"
|
||||
data-testid="groups-load-more-btn"
|
||||
disabled={loadingMore}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, promptDialog } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, promptDialog: vi.fn() }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog }));
|
||||
vi.mock('$lib/api/endpoints/groups', () => ({
|
||||
INTERNAL_GROUP_ID: '00000000-0000-0000-0000-000000000001',
|
||||
createGroup: vi.fn(),
|
||||
deleteGroup: vi.fn(),
|
||||
addGroupMember: vi.fn(),
|
||||
addUserMember: vi.fn(),
|
||||
groupDescription: (g: { description?: string | null }) => g.description ?? null,
|
||||
groupDisplayName: (g: { name: string }) => g.name,
|
||||
groupIconName: () => 'fa-users',
|
||||
listGroupsPage: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
removeGroupMember: vi.fn(),
|
||||
removeUserMember: vi.fn(),
|
||||
renameGroup: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
ensureResolvers: vi.fn(),
|
||||
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
|
||||
searchRecipients: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
import { listGroupsPage, createGroup, listMembers } from '$lib/api/endpoints/groups';
|
||||
import GroupsPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listMembers).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders groups returned by the API', async () => {
|
||||
m(listGroupsPage).mockResolvedValue({
|
||||
items: [{ id: 'g1', name: 'Engineers', member_count: 2 }],
|
||||
total: 1
|
||||
});
|
||||
render(GroupsPage);
|
||||
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('Engineers')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('creates a new group', async () => {
|
||||
m(listGroupsPage).mockResolvedValue({ items: [], total: 0 });
|
||||
m(createGroup).mockResolvedValue(undefined);
|
||||
promptDialog.mockResolvedValue('New Team');
|
||||
render(GroupsPage);
|
||||
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
|
||||
await fireEvent.click(screen.getByTestId('groups-create-btn'));
|
||||
await waitFor(() => expect(createGroup).toHaveBeenCalledWith('New Team'));
|
||||
});
|
||||
@@ -271,7 +271,7 @@
|
||||
{#if mode === 'login'}
|
||||
{#if passwordLoginEnabled}
|
||||
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
|
||||
<form class="auth-form" onsubmit={onLogin} novalidate>
|
||||
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="login-username">
|
||||
{t('auth.username', 'Username or email')}
|
||||
@@ -280,6 +280,7 @@
|
||||
<input
|
||||
id="login-username"
|
||||
class="auth-input"
|
||||
data-testid="login-username-input"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
@@ -296,6 +297,7 @@
|
||||
<input
|
||||
id="login-password"
|
||||
class="auth-input"
|
||||
data-testid="login-password-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
onkeydown={onPwKey}
|
||||
@@ -308,6 +310,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={showPassword}
|
||||
data-testid="login-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
></button>
|
||||
@@ -317,12 +320,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}>
|
||||
<button
|
||||
class="auth-magic-toggle"
|
||||
data-testid="login-magic-toggle-btn"
|
||||
onclick={() => (magicOpen = !magicOpen)}
|
||||
>
|
||||
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
|
||||
</button>
|
||||
{#if magicOpen}
|
||||
@@ -333,7 +346,7 @@
|
||||
"No password? Enter your email and we'll send you a one-time sign-in link."
|
||||
)}
|
||||
</p>
|
||||
<form class="auth-form" onsubmit={onMagicLink}>
|
||||
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="magic-email">
|
||||
{t('auth.magic_email_label', 'Email address')}
|
||||
@@ -342,6 +355,7 @@
|
||||
<input
|
||||
id="magic-email"
|
||||
class="auth-input"
|
||||
data-testid="login-magic-email-input"
|
||||
type="email"
|
||||
bind:value={magicEmail}
|
||||
autocomplete="email"
|
||||
@@ -349,7 +363,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}>
|
||||
<button
|
||||
class="auth-button auth-button-secondary"
|
||||
type="submit"
|
||||
data-testid="login-magic-send-btn"
|
||||
disabled={busy}
|
||||
>
|
||||
{t('auth.magic_send', 'Send link')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -371,7 +390,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint} rel="external">
|
||||
<a
|
||||
class="auth-button auth-button-oidc"
|
||||
data-testid="login-oidc-btn"
|
||||
href={oidc.authorize_endpoint}
|
||||
rel="external"
|
||||
>
|
||||
{t(
|
||||
'auth.sso_login_provider',
|
||||
{ provider: oidc.provider_name ?? 'SSO' },
|
||||
@@ -383,7 +407,11 @@
|
||||
{#if passwordLoginEnabled}
|
||||
<div class="auth-toggle">
|
||||
{t('auth.no_account', 'No account?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'register')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-to-register-btn"
|
||||
onclick={() => (mode = 'register')}
|
||||
>
|
||||
{t('auth.register', 'Create one')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -392,7 +420,11 @@
|
||||
{#if setupAvailable}
|
||||
<div class="auth-toggle">
|
||||
{t('auth.admin_setup', 'First time?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-to-setup-btn"
|
||||
onclick={() => (mode = 'setup')}
|
||||
>
|
||||
{t('auth.setup', 'Set up administrator')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -402,12 +434,13 @@
|
||||
{regError}
|
||||
</div>{/if}
|
||||
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
|
||||
<form class="auth-form" onsubmit={onRegister} novalidate>
|
||||
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
class="auth-input"
|
||||
data-testid="login-register-username-input"
|
||||
bind:value={regUsername}
|
||||
required
|
||||
disabled={busy}
|
||||
@@ -418,6 +451,7 @@
|
||||
<input
|
||||
id="reg-email"
|
||||
class="auth-input"
|
||||
data-testid="login-register-email-input"
|
||||
type="email"
|
||||
bind:value={regEmail}
|
||||
required
|
||||
@@ -430,6 +464,7 @@
|
||||
<input
|
||||
id="reg-password"
|
||||
class="auth-input"
|
||||
data-testid="login-register-password-input"
|
||||
type={regShowPassword ? 'text' : 'password'}
|
||||
bind:value={regPassword}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -442,6 +477,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowPassword}
|
||||
data-testid="login-register-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowPassword = !regShowPassword)}
|
||||
></button>
|
||||
@@ -458,6 +494,7 @@
|
||||
<input
|
||||
id="reg-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-register-confirm-input"
|
||||
type={regShowConfirm ? 'text' : 'password'}
|
||||
bind:value={regConfirm}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -470,6 +507,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowConfirm}
|
||||
data-testid="login-register-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowConfirm = !regShowConfirm)}
|
||||
></button>
|
||||
@@ -484,13 +522,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-register-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{t('auth.register', 'Create account')}
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-toggle">
|
||||
{t('auth.have_account', 'Already have an account?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-register-to-login-btn"
|
||||
onclick={() => (mode = 'login')}
|
||||
>
|
||||
{t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -515,13 +563,20 @@
|
||||
</div>{/if}
|
||||
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
|
||||
|
||||
<form class="auth-form" onsubmit={onSetup} novalidate>
|
||||
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="setup-username">
|
||||
{t('auth.admin_username', 'Administrator username')}
|
||||
</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--user">
|
||||
<input id="setup-username" class="auth-input" type="text" value="admin" readonly />
|
||||
<input
|
||||
id="setup-username"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-username-input"
|
||||
type="text"
|
||||
value="admin"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -533,6 +588,7 @@
|
||||
<input
|
||||
id="setup-email"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-email-input"
|
||||
type="email"
|
||||
bind:value={setupEmail}
|
||||
autocomplete="email"
|
||||
@@ -550,6 +606,7 @@
|
||||
<input
|
||||
id="setup-password"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-password-input"
|
||||
type={setupShowPassword ? 'text' : 'password'}
|
||||
bind:value={setupPassword}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -563,6 +620,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowPassword}
|
||||
data-testid="login-setup-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowPassword = !setupShowPassword)}
|
||||
></button>
|
||||
@@ -580,6 +638,7 @@
|
||||
<input
|
||||
id="setup-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-confirm-input"
|
||||
type={setupShowConfirm ? 'text' : 'password'}
|
||||
bind:value={setupConfirm}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -592,6 +651,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowConfirm}
|
||||
data-testid="login-setup-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowConfirm = !setupShowConfirm)}
|
||||
></button>
|
||||
@@ -609,14 +669,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
data-testid="login-setup-submit-btn"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{t('auth.create_admin', 'Create administrator')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-toggle">
|
||||
{t('auth.back_to_login', 'Already configured?')}
|
||||
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
|
||||
<button
|
||||
class="auth-toggle-link"
|
||||
data-testid="login-setup-to-login-btn"
|
||||
onclick={() => (mode = 'login')}
|
||||
>
|
||||
{t('auth.sign_in', 'Sign in')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -626,6 +696,7 @@
|
||||
<div class="auth-lang">
|
||||
<select
|
||||
aria-label={t('settings.language', 'Language')}
|
||||
data-testid="login-language-select"
|
||||
value={i18n.locale}
|
||||
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: { user: null } as { user: unknown }
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({
|
||||
exchangeOidcCode: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
getOidcProviders: vi.fn(),
|
||||
getAuthStatus: vi.fn(),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
sendMagicLink: vi.fn(),
|
||||
setupAdmin: vi.fn()
|
||||
}));
|
||||
|
||||
import * as auth from '$lib/api/endpoints/auth';
|
||||
import LoginPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/login');
|
||||
session.user = null;
|
||||
m(auth.fetchMe).mockResolvedValue(null);
|
||||
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
|
||||
});
|
||||
|
||||
it('logs in and redirects', async () => {
|
||||
m(auth.login).mockResolvedValue({ user: { id: '1' } });
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
|
||||
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'pw' } });
|
||||
await fireEvent.click(screen.getByTestId('login-submit-btn'));
|
||||
await waitFor(() => expect(auth.login).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('exchanges an oidc code on mount and redirects', async () => {
|
||||
pageState.url = new URL('http://localhost/login?oidc_code=abc');
|
||||
m(auth.exchangeOidcCode).mockResolvedValue({ id: '1' });
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(auth.exchangeOidcCode).toHaveBeenCalledWith('abc'));
|
||||
await waitFor(() => expect(goto).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('skips the form when already authenticated', async () => {
|
||||
m(auth.fetchMe).mockResolvedValue({ id: '1' });
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('enters setup mode on a fresh install', async () => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-setup-form');
|
||||
});
|
||||
|
||||
it('sends a magic link', async () => {
|
||||
m(auth.sendMagicLink).mockResolvedValue('sent');
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
|
||||
target: { value: 'a@b.test' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
|
||||
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
|
||||
});
|
||||
|
||||
it('registers a new account', async () => {
|
||||
m(auth.register).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
|
||||
target: { value: 'u' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-email-input'), {
|
||||
target: { value: 'u@b.test' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
|
||||
await waitFor(() => expect(auth.register).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows an error message when login fails', async () => {
|
||||
m(auth.login).mockRejectedValue(new Error('bad credentials'));
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
|
||||
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'wrong' } });
|
||||
await fireEvent.click(screen.getByTestId('login-submit-btn'));
|
||||
await waitFor(() => expect(screen.getByText('bad credentials')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('rejects a registration with mismatched passwords without calling the API', async () => {
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
|
||||
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
|
||||
target: { value: 'u' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
|
||||
target: { value: 'Different1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
|
||||
expect(auth.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the first administrator in setup mode', async () => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
|
||||
m(auth.setupAdmin).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-setup-form');
|
||||
await fireEvent.input(screen.getByTestId('login-setup-email-input'), {
|
||||
target: { value: 'admin@x.test' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-setup-password-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('login-setup-confirm-input'), {
|
||||
target: { value: 'TestPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('login-setup-submit-btn'));
|
||||
await waitFor(() =>
|
||||
expect(auth.setupAdmin).toHaveBeenCalledWith('admin@x.test', 'TestPassword1!')
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an SSO sign-in link when an OIDC provider is configured', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
authorize_endpoint: 'https://idp.test/auth',
|
||||
provider_name: 'Acme SSO',
|
||||
password_login_enabled: true
|
||||
});
|
||||
render(LoginPage);
|
||||
const sso = await screen.findByTestId('login-oidc-btn');
|
||||
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
|
||||
});
|
||||
@@ -512,7 +512,11 @@
|
||||
<p class="music-empty-state-desc">
|
||||
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick={onCreate}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="music-create-playlist-empty-btn"
|
||||
onclick={onCreate}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>{t('music.create_playlist', 'Create playlist')}</span>
|
||||
</button>
|
||||
@@ -526,6 +530,7 @@
|
||||
class="music-sidebar-add-btn"
|
||||
title={t('music.create_playlist', 'Create playlist')}
|
||||
aria-label={t('music.create_playlist', 'Create playlist')}
|
||||
data-testid="music-create-playlist-btn"
|
||||
onclick={onCreate}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
@@ -538,6 +543,7 @@
|
||||
<div
|
||||
class="music-playlist-item"
|
||||
class:active={current?.id === p.id}
|
||||
data-testid={p.name}
|
||||
onclick={() => select(p)}
|
||||
>
|
||||
<div class="music-playlist-icon"><Icon name="music" /></div>
|
||||
@@ -561,6 +567,7 @@
|
||||
class="music-playlist-cover"
|
||||
title={t('music.set_cover', 'Set cover')}
|
||||
aria-label={t('music.set_cover', 'Set cover')}
|
||||
data-testid="music-set-cover-btn"
|
||||
onclick={pickCover}
|
||||
>
|
||||
{#if coverUrl(current)}
|
||||
@@ -585,12 +592,18 @@
|
||||
</div>
|
||||
|
||||
<div class="music-playlist-actions">
|
||||
<button class="btn btn-secondary" onclick={playAll} disabled={tracks.length === 0}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-play-all-btn"
|
||||
onclick={playAll}
|
||||
disabled={tracks.length === 0}
|
||||
>
|
||||
<Icon name="play" />
|
||||
<span>{t('music.play_all', 'Play all')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-shuffle-play-btn"
|
||||
onclick={shufflePlay}
|
||||
disabled={tracks.length === 0}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
@@ -598,12 +611,17 @@
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={openAdd}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-add-tracks-btn"
|
||||
onclick={openAdd}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>{t('music.add_tracks', 'Add tracks')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-rename-playlist-btn"
|
||||
onclick={onRenamePlaylist}
|
||||
title={t('common.rename', 'Rename')}
|
||||
aria-label={t('common.rename', 'Rename')}
|
||||
@@ -612,6 +630,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-edit-description-btn"
|
||||
onclick={onEditDescription}
|
||||
title={t('music.edit_description', 'Edit description')}
|
||||
aria-label={t('music.edit_description', 'Edit description')}
|
||||
@@ -620,6 +639,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-manage-shares-btn"
|
||||
onclick={openShares}
|
||||
title={t('music.manage_shares', 'Manage shares')}
|
||||
aria-label={t('music.manage_shares', 'Manage shares')}
|
||||
@@ -629,6 +649,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
class:active={current.is_public}
|
||||
data-testid="music-toggle-public-btn"
|
||||
onclick={onTogglePublic}
|
||||
title={current.is_public
|
||||
? t('music.make_private', 'Make private')
|
||||
@@ -641,6 +662,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-delete-playlist-btn"
|
||||
onclick={() => onDelete(current!)}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
@@ -673,6 +695,7 @@
|
||||
<div
|
||||
class="music-track"
|
||||
class:playing={currentTrack?.id === track.id && playing}
|
||||
data-testid={trackLabel(track)}
|
||||
draggable="true"
|
||||
ondblclick={() => playFromTracks(i)}
|
||||
ondragstart={() => onDragStart(i)}
|
||||
@@ -687,6 +710,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
data-testid={`music-track-play-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (currentTrack?.id === track.id) togglePlay();
|
||||
@@ -714,6 +738,7 @@
|
||||
class="music-track-remove-btn"
|
||||
title={t('common.remove', 'Remove')}
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-track-remove-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveTrack(track);
|
||||
@@ -763,6 +788,7 @@
|
||||
class:active={shuffle}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
aria-label={t('music.shuffle', 'Shuffle')}
|
||||
data-testid="music-player-shuffle-btn"
|
||||
onclick={() => (shuffle = !shuffle)}
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
@@ -771,6 +797,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.prev', 'Previous')}
|
||||
aria-label={t('music.prev', 'Previous')}
|
||||
data-testid="music-player-prev-btn"
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon name="backward" />
|
||||
@@ -779,6 +806,7 @@
|
||||
class="player-btn player-btn-main"
|
||||
title={t('music.play', 'Play')}
|
||||
aria-label={t('music.play', 'Play')}
|
||||
data-testid="music-player-play-btn"
|
||||
onclick={togglePlay}
|
||||
>
|
||||
<Icon name={playing ? 'pause' : 'play'} />
|
||||
@@ -787,6 +815,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.next', 'Next')}
|
||||
aria-label={t('music.next', 'Next')}
|
||||
data-testid="music-player-next-btn"
|
||||
onclick={next}
|
||||
>
|
||||
<Icon name="forward" />
|
||||
@@ -797,6 +826,7 @@
|
||||
class:repeat-one={repeat === 'one'}
|
||||
title={t('music.repeat', 'Repeat')}
|
||||
aria-label={t('music.repeat', 'Repeat')}
|
||||
data-testid="music-player-repeat-btn"
|
||||
onclick={cycleRepeat}
|
||||
>
|
||||
<Icon name="repeat" />
|
||||
@@ -813,6 +843,7 @@
|
||||
value={currentTime}
|
||||
oninput={seek}
|
||||
aria-label={t('music.seek', 'Seek')}
|
||||
data-testid="music-player-seek-input"
|
||||
/>
|
||||
<span class="player-time player-time-total">{fmtTime(duration)}</span>
|
||||
</div>
|
||||
@@ -824,6 +855,7 @@
|
||||
class:active={queueOpen}
|
||||
title={t('music.queue', 'Queue')}
|
||||
aria-label={t('music.queue', 'Queue')}
|
||||
data-testid="music-player-queue-toggle-btn"
|
||||
onclick={() => (queueOpen = !queueOpen)}
|
||||
>
|
||||
<Icon name="list" />
|
||||
@@ -832,6 +864,7 @@
|
||||
class="player-btn player-btn-small"
|
||||
title={t('music.mute', 'Mute')}
|
||||
aria-label={t('music.mute', 'Mute')}
|
||||
data-testid="music-player-mute-btn"
|
||||
onclick={toggleMute}
|
||||
>
|
||||
<Icon name={volumeIcon} />
|
||||
@@ -846,17 +879,19 @@
|
||||
value={muted ? 0 : volume}
|
||||
oninput={setVolume}
|
||||
aria-label={t('music.volume', 'Volume')}
|
||||
data-testid="music-player-volume-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if queueOpen}
|
||||
<div class="player-queue">
|
||||
<div class="player-queue" data-testid="music-queue-panel">
|
||||
<div class="player-queue-header">
|
||||
<h3>{t('music.queue', 'Queue')}</h3>
|
||||
<button
|
||||
class="player-btn player-btn-small"
|
||||
data-testid="music-queue-close-btn"
|
||||
onclick={() => (queueOpen = false)}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
@@ -876,6 +911,7 @@
|
||||
<div
|
||||
class="player-queue-item"
|
||||
class:active={i === currentIndex}
|
||||
data-testid={trackLabel(qt)}
|
||||
onclick={() => jumpQueue(i)}
|
||||
>
|
||||
<span class="queue-item-num">{i + 1}</span>
|
||||
@@ -887,6 +923,7 @@
|
||||
<button
|
||||
class="queue-item-remove"
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-queue-remove-${qt.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFromQueue(i);
|
||||
@@ -917,6 +954,7 @@
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
data-testid="music-cover-input"
|
||||
onchange={onCoverChosen}
|
||||
/>
|
||||
|
||||
@@ -925,6 +963,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-picker-overlay active"
|
||||
data-testid="music-add-tracks-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) addOpen = false;
|
||||
}}
|
||||
@@ -935,6 +974,7 @@
|
||||
<button
|
||||
class="music-picker-close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-add-tracks-close-btn"
|
||||
onclick={() => (addOpen = false)}>×</button
|
||||
>
|
||||
</div>
|
||||
@@ -948,6 +988,7 @@
|
||||
oninput={onAddQueryInput}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
data-testid="music-add-tracks-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-picker-list">
|
||||
@@ -968,6 +1009,7 @@
|
||||
type="checkbox"
|
||||
checked={addSelected.has(f.id)}
|
||||
onchange={() => addSelected.toggle(f.id)}
|
||||
data-testid={f.name}
|
||||
/>
|
||||
<Icon name="file-audio" />
|
||||
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
||||
@@ -980,10 +1022,19 @@
|
||||
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
|
||||
</span>
|
||||
<div class="music-picker-actions">
|
||||
<button class="btn btn-secondary" onclick={() => (addOpen = false)}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-add-tracks-cancel-btn"
|
||||
onclick={() => (addOpen = false)}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button class="btn btn-primary" disabled={addSelected.size === 0} onclick={confirmAdd}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
disabled={addSelected.size === 0}
|
||||
data-testid="music-add-tracks-confirm-btn"
|
||||
onclick={confirmAdd}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{t('music.add_selected', 'Add selected')}
|
||||
</button>
|
||||
@@ -998,6 +1049,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-shares-overlay"
|
||||
data-testid="music-shares-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) sharesOpen = false;
|
||||
}}
|
||||
@@ -1008,6 +1060,7 @@
|
||||
<button
|
||||
class="music-shares-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-shares-close-btn"
|
||||
onclick={() => (sharesOpen = false)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1029,6 +1082,7 @@
|
||||
class="music-share-remove-btn"
|
||||
title={t('music.remove_share', 'Remove')}
|
||||
aria-label={t('music.remove_share', 'Remove')}
|
||||
data-testid={`music-share-remove-${s.user_id}`}
|
||||
onclick={() => onRemoveShare(s.user_id)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1044,12 +1098,22 @@
|
||||
placeholder={t('music.share_with_user', 'User ID or email')}
|
||||
bind:value={shareUser}
|
||||
autocomplete="off"
|
||||
data-testid="music-share-user-input"
|
||||
/>
|
||||
<label class="music-shares-write-label">
|
||||
<input type="checkbox" bind:checked={shareCanWrite} />
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={shareCanWrite}
|
||||
data-testid="music-share-can-write-checkbox"
|
||||
/>
|
||||
{t('music.can_write', 'Can edit')}
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" disabled={!shareUser.trim()} onclick={onAddShare}>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
disabled={!shareUser.trim()}
|
||||
data-testid="music-share-add-btn"
|
||||
onclick={onAddShare}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{t('music.share', 'Share')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, promptDialog, confirmDialog } = vi.hoisted(() => ({
|
||||
ui: { notify: vi.fn() },
|
||||
promptDialog: vi.fn(),
|
||||
confirmDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog, confirmDialog }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/music', () => ({
|
||||
addTracks: vi.fn(),
|
||||
createPlaylist: vi.fn(),
|
||||
deletePlaylist: vi.fn(),
|
||||
listPlaylists: vi.fn(),
|
||||
listShares: vi.fn(async () => []),
|
||||
listTracks: vi.fn(async () => []),
|
||||
removeShare: vi.fn(),
|
||||
removeTrack: vi.fn(),
|
||||
renamePlaylist: vi.fn(),
|
||||
reorderTracks: vi.fn(),
|
||||
sharePlaylist: vi.fn(),
|
||||
updatePlaylist: vi.fn(),
|
||||
uploadCoverImage: vi.fn()
|
||||
}));
|
||||
|
||||
import {
|
||||
listPlaylists,
|
||||
listTracks,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist
|
||||
} from '$lib/api/endpoints/music';
|
||||
import MusicPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
const playlist = {
|
||||
id: 'p1',
|
||||
name: 'Roadtrip',
|
||||
description: 'songs',
|
||||
owner_id: 'me',
|
||||
is_public: false,
|
||||
cover_file_id: null,
|
||||
track_count: 1,
|
||||
total_duration_secs: 180,
|
||||
created_at: 0,
|
||||
updated_at: 0
|
||||
};
|
||||
const track = {
|
||||
id: 't1',
|
||||
playlist_id: 'p1',
|
||||
file_id: 'f1',
|
||||
position: 0,
|
||||
added_at: 0,
|
||||
file_name: 'song.mp3',
|
||||
file_size: 100,
|
||||
mime_type: 'audio/mpeg',
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
duration_secs: 180
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listTracks).mockResolvedValue([track]);
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no playlists', async () => {
|
||||
m(listPlaylists).mockResolvedValue([]);
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
|
||||
await screen.findByTestId('music-create-playlist-empty-btn');
|
||||
});
|
||||
|
||||
it('selects the first playlist and loads its tracks', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listTracks).toHaveBeenCalledWith('p1'));
|
||||
await waitFor(() => expect(screen.getAllByText('Roadtrip').length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('creates a playlist from the header button', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
promptDialog.mockResolvedValue('Chill');
|
||||
m(createPlaylist).mockResolvedValue({ ...playlist, id: 'p2', name: 'Chill' });
|
||||
render(MusicPage);
|
||||
await screen.findByTestId('music-create-playlist-btn');
|
||||
await fireEvent.click(screen.getByTestId('music-create-playlist-btn'));
|
||||
await waitFor(() => expect(createPlaylist).toHaveBeenCalledWith('Chill'));
|
||||
});
|
||||
|
||||
it('surfaces a load error', async () => {
|
||||
m(listPlaylists).mockRejectedValue(new Error('boom'));
|
||||
render(MusicPage);
|
||||
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('boom')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('renames the current playlist', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
promptDialog.mockResolvedValue('Renamed');
|
||||
m(renamePlaylist).mockResolvedValue(undefined);
|
||||
render(MusicPage);
|
||||
await fireEvent.click(await screen.findByTestId('music-rename-playlist-btn'));
|
||||
await waitFor(() => expect(renamePlaylist).toHaveBeenCalledWith('p1', 'Renamed'));
|
||||
});
|
||||
|
||||
it('deletes the current playlist after confirmation', async () => {
|
||||
m(listPlaylists).mockResolvedValue([playlist]);
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deletePlaylist).mockResolvedValue(undefined);
|
||||
render(MusicPage);
|
||||
await fireEvent.click(await screen.findByTestId('music-delete-playlist-btn'));
|
||||
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
|
||||
});
|
||||
@@ -70,7 +70,12 @@
|
||||
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
|
||||
<h1>{view.title}</h1>
|
||||
<p>{view.message}</p>
|
||||
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nc-status__action"
|
||||
data-testid="nextcloud-error-action-btn"
|
||||
onclick={onAction}>{view.actionLabel}</button
|
||||
>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -52,7 +52,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if passwordLoginEnabled}
|
||||
<form class="auth-form" method="post" action={formAction}>
|
||||
<form
|
||||
class="auth-form"
|
||||
data-testid="nextcloud-login-form"
|
||||
method="post"
|
||||
action={formAction}
|
||||
>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
|
||||
>
|
||||
@@ -60,6 +65,7 @@
|
||||
<input
|
||||
id="nc-user"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-user-input"
|
||||
name="user"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -73,6 +79,7 @@
|
||||
<input
|
||||
id="nc-password"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-password-input"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@@ -80,7 +87,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button>
|
||||
<button class="auth-button" data-testid="nextcloud-login-grant-btn" type="submit"
|
||||
>{t('nextcloud.grant', 'Grant access')}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -89,7 +98,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`} rel="external">
|
||||
<a
|
||||
class="auth-button auth-button-sso"
|
||||
data-testid="nextcloud-login-sso-link"
|
||||
href={`/login/v2/flow/${token}/oidc`}
|
||||
rel="external"
|
||||
>
|
||||
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
|
||||
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
|
||||
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
|
||||
<button type="button" class="nc-status__action" onclick={closeWindow}>
|
||||
<button
|
||||
type="button"
|
||||
class="nc-status__action"
|
||||
data-testid="nextcloud-success-close-btn"
|
||||
onclick={closeWindow}
|
||||
>
|
||||
{t('nextcloud.close_window', 'Close Window')}
|
||||
</button>
|
||||
</main>
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
class:active={tab === 'moments'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'moments'}
|
||||
data-testid="photos-tab-moments"
|
||||
onclick={() => (tab = 'moments')}
|
||||
>
|
||||
{t('photos.tab_moments', 'Moments')}
|
||||
@@ -337,6 +338,7 @@
|
||||
class:active={tab === 'places'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'places'}
|
||||
data-testid="photos-tab-places"
|
||||
onclick={() => (tab = 'places')}
|
||||
>
|
||||
{t('photos.tab_places', 'Places')}
|
||||
@@ -347,6 +349,7 @@
|
||||
class:active={tab === 'people'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'people'}
|
||||
data-testid="photos-tab-people"
|
||||
onclick={() => (tab = 'people')}
|
||||
>
|
||||
{t('photos.tab_people', 'People')}
|
||||
@@ -370,6 +373,7 @@
|
||||
class:active={layoutMode === 'square'}
|
||||
title={t('photos.layout_square', 'Grid')}
|
||||
aria-label={t('photos.layout_square', 'Grid')}
|
||||
data-testid="photos-layout-square-btn"
|
||||
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
|
||||
>
|
||||
<button
|
||||
@@ -377,18 +381,25 @@
|
||||
class:active={layoutMode === 'justified'}
|
||||
title={t('photos.layout_justified', 'Justified')}
|
||||
aria-label={t('photos.layout_justified', 'Justified')}
|
||||
data-testid="photos-layout-justified-btn"
|
||||
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected.size > 0}
|
||||
<div class="batch-bar">
|
||||
<div class="batch-bar" data-testid="photos-batch-bar">
|
||||
<span>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span>
|
||||
<div class="batch-bar__actions">
|
||||
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
|
||||
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
|
||||
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
||||
<Button data-testid="photos-batch-download-btn" onclick={downloadSelected}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button data-testid="photos-batch-clear-btn" onclick={() => selected.clear()}
|
||||
>{t('common.clear', 'Clear')}</Button
|
||||
>
|
||||
<Button data-testid="photos-batch-delete-btn" variant="danger" onclick={trashSelected}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -449,7 +460,11 @@
|
||||
|
||||
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
|
||||
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
|
||||
<button class="photo-tile__open" onclick={() => onTileClick(photo)}>
|
||||
<button
|
||||
class="photo-tile__open"
|
||||
data-testid={`photo-tile-${photo.id}`}
|
||||
onclick={() => onTileClick(photo)}
|
||||
>
|
||||
<!-- Always-present placeholder: the thumbnail <img> overlays it and, when
|
||||
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
|
||||
this default rather than the browser's broken-image glyph. -->
|
||||
@@ -471,6 +486,7 @@
|
||||
class="photo-tile__check"
|
||||
class:on={selected.has(photo.id)}
|
||||
aria-label={t('common.select', 'Select')}
|
||||
data-testid={`photo-tile-check-${photo.id}`}
|
||||
onclick={() => selected.toggle(photo.id)}
|
||||
>
|
||||
<Icon name="check" />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, confirmDialog } = vi.hoisted(() => ({
|
||||
ui: { notify: vi.fn() },
|
||||
confirmDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog }));
|
||||
vi.mock('$lib/api/endpoints/photos', () => ({
|
||||
batchTrash: vi.fn(),
|
||||
fetchPhotos: vi.fn(),
|
||||
uploadThumbnail: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
fileThumbnailUrl: () => '/thumb'
|
||||
}));
|
||||
|
||||
import { fetchPhotos } from '$lib/api/endpoints/photos';
|
||||
import { peopleEnabled } from '$lib/api/endpoints/people';
|
||||
import PhotosPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function photo(id: string) {
|
||||
return {
|
||||
category: 'Image',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-image',
|
||||
icon_special_class: '',
|
||||
id,
|
||||
mime_type: 'image/jpeg',
|
||||
modified_at: 0,
|
||||
name: id + '.jpg',
|
||||
owner_id: 'me',
|
||||
folder_id: 'home',
|
||||
path: '/' + id + '.jpg',
|
||||
size: 100,
|
||||
size_formatted: '100 B',
|
||||
sort_date: 1_700_000_000,
|
||||
etag: 'e',
|
||||
content_hash: 'h',
|
||||
width: 100,
|
||||
height: 100
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(peopleEnabled).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('loads the first page of photos on mount', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [photo('a')], nextCursor: null });
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
|
||||
expect(m(fetchPhotos).mock.calls[0][0]).toBe(60);
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no photos', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId('photos-tab-moments')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('surfaces a load error', async () => {
|
||||
m(fetchPhotos).mockRejectedValue(new Error('photos boom'));
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(screen.getByText('photos boom')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('hides the People tab when face detection is disabled', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
m(peopleEnabled).mockResolvedValue(false);
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(peopleEnabled).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('photos-tab-people')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the People tab when face detection is enabled', async () => {
|
||||
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
|
||||
m(peopleEnabled).mockResolvedValue(true);
|
||||
render(PhotosPage);
|
||||
await waitFor(() => expect(screen.getByTestId('photos-tab-people')).toBeTruthy());
|
||||
});
|
||||
@@ -318,6 +318,7 @@
|
||||
{#if canEditImage}
|
||||
<button
|
||||
class="btn btn-secondary avatar-edit-btn"
|
||||
data-testid="profile-avatar-edit-btn"
|
||||
title={t('profile.edit_photo', 'Edit photo')}
|
||||
onclick={openAvatarEdit}
|
||||
>
|
||||
@@ -327,10 +328,11 @@
|
||||
</div>
|
||||
|
||||
{#if canEditImage && avatarEditOpen}
|
||||
<div class="avatar-edit">
|
||||
<div class="avatar-edit" data-testid="profile-avatar-edit-panel">
|
||||
<div class="avatar-tabs">
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-url-tab"
|
||||
class:avatar-tab--active={avatarTab === 'url'}
|
||||
onclick={() => (avatarTab = 'url')}
|
||||
>
|
||||
@@ -338,6 +340,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-upload-tab"
|
||||
class:avatar-tab--active={avatarTab === 'upload'}
|
||||
onclick={() => (avatarTab = 'upload')}
|
||||
>
|
||||
@@ -346,7 +349,12 @@
|
||||
</div>
|
||||
|
||||
{#if avatarTab === 'url'}
|
||||
<input type="url" bind:value={avatarUrl} placeholder="https://example.com/photo.jpg" />
|
||||
<input
|
||||
type="url"
|
||||
data-testid="profile-avatar-url-input"
|
||||
bind:value={avatarUrl}
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
/>
|
||||
<small class="muted">
|
||||
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
|
||||
</small>
|
||||
@@ -356,6 +364,7 @@
|
||||
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
|
||||
<input
|
||||
type="file"
|
||||
data-testid="profile-avatar-file-input"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onchange={onAvatarFile}
|
||||
@@ -373,19 +382,30 @@
|
||||
{/if}
|
||||
|
||||
<div class="avatar-edit-actions">
|
||||
<button class="btn btn-primary" disabled={avatarBusy} onclick={saveAvatar}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="profile-avatar-save-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={saveAvatar}
|
||||
>
|
||||
{t('profile.photo_save', 'Save')}
|
||||
</button>
|
||||
{#if session.user.image}
|
||||
<button
|
||||
class="btn link-btn link-btn--danger"
|
||||
data-testid="profile-avatar-remove-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={() => commitAvatar(null)}
|
||||
>
|
||||
{t('profile.photo_remove', 'Remove photo')}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-secondary" disabled={avatarBusy} onclick={closeAvatarEdit}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="profile-avatar-cancel-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={closeAvatarEdit}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -463,10 +483,11 @@
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={saveProfile}>
|
||||
<form data-testid="profile-edit-form" onsubmit={saveProfile}>
|
||||
<label>
|
||||
<span>{t('profile.username', 'Username')}</span>
|
||||
<input
|
||||
data-testid="profile-username-input"
|
||||
bind:value={username}
|
||||
maxlength="64"
|
||||
autocomplete="username"
|
||||
@@ -483,26 +504,42 @@
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.given_name', 'First name')}</span>
|
||||
<input bind:value={givenName} maxlength="128" autocomplete="given-name" />
|
||||
<input
|
||||
data-testid="profile-given-name-input"
|
||||
bind:value={givenName}
|
||||
maxlength="128"
|
||||
autocomplete="given-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.family_name', 'Last name')}</span>
|
||||
<input bind:value={familyName} maxlength="128" autocomplete="family-name" />
|
||||
<input
|
||||
data-testid="profile-family-name-input"
|
||||
bind:value={familyName}
|
||||
maxlength="128"
|
||||
autocomplete="family-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.language', 'Language')}</span>
|
||||
<select bind:value={preferredLocale}>
|
||||
<option value="">{t('profile.language_auto', 'Automatic')}</option>
|
||||
<select data-testid="profile-language-select" bind:value={preferredLocale}>
|
||||
<option value="" data-testid="profile-language-auto-option"
|
||||
>{t('profile.language_auto', 'Automatic')}</option
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as loc (loc)}
|
||||
<option value={loc}>{loc}</option>
|
||||
<option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" bind:checked={notifyOnShare} />
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="profile-notify-on-share-checkbox"
|
||||
bind:checked={notifyOnShare}
|
||||
/>
|
||||
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
|
||||
</label>
|
||||
<button type="submit" disabled={savingProfile}
|
||||
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
|
||||
>{t('profile.save_profile', 'Save changes')}</button
|
||||
>
|
||||
</form>
|
||||
@@ -522,11 +559,17 @@
|
||||
|
||||
<div class="app-pw-create">
|
||||
<input
|
||||
data-testid="profile-app-pw-label-input"
|
||||
bind:value={newLabel}
|
||||
maxlength="128"
|
||||
placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={creatingPw} onclick={createPw}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="profile-app-pw-generate-btn"
|
||||
disabled={creatingPw}
|
||||
onclick={createPw}
|
||||
>
|
||||
<Icon name="user-plus" />
|
||||
{t('profile.generate', 'Generate')}
|
||||
</button>
|
||||
@@ -542,6 +585,7 @@
|
||||
<code>{generated.password}</code>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid="profile-app-pw-copy-btn"
|
||||
title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
|
||||
onclick={copyGenerated}
|
||||
>
|
||||
@@ -587,6 +631,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -602,7 +647,11 @@
|
||||
|
||||
{#if autoPasswords.length > 0}
|
||||
<div class="app-pw-auto">
|
||||
<button class="app-pw-auto__toggle" onclick={() => (autoExpanded = !autoExpanded)}>
|
||||
<button
|
||||
class="app-pw-auto__toggle"
|
||||
data-testid="profile-app-pw-auto-toggle-btn"
|
||||
onclick={() => (autoExpanded = !autoExpanded)}
|
||||
>
|
||||
<Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} />
|
||||
<span>{t('profile.client_sessions', 'Client sessions')}</span>
|
||||
<span class="badge badge--count">{autoPasswords.length}</span>
|
||||
@@ -637,6 +686,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -656,22 +706,39 @@
|
||||
|
||||
<!-- Change password -->
|
||||
{#if showPasswordCard}
|
||||
<form class="card" onsubmit={savePassword}>
|
||||
<form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
|
||||
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
|
||||
<label>
|
||||
<span>{t('profile.current_password', 'Current Password')}</span>
|
||||
<input type="password" bind:value={currentPw} autocomplete="current-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-current-password-input"
|
||||
bind:value={currentPw}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.new_password', 'New Password')}</span>
|
||||
<input type="password" bind:value={newPw} minlength="8" autocomplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-new-password-input"
|
||||
bind:value={newPw}
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('profile.confirm_password', 'Confirm New Password')}</span>
|
||||
<input type="password" bind:value={confirmPw} minlength="8" autocomplete="new-password" />
|
||||
<input
|
||||
type="password"
|
||||
data-testid="profile-confirm-password-input"
|
||||
bind:value={confirmPw}
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={savingPassword}>
|
||||
<button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
|
||||
{t('profile.update_password', 'Update Password')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { session, ui } = vi.hoisted(() => ({
|
||||
session: {
|
||||
loaded: true,
|
||||
load: vi.fn(),
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
}
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
}));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ getOidcProviders: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/profile', () => ({
|
||||
changePassword: vi.fn(),
|
||||
createAppPassword: vi.fn(),
|
||||
isAutoAppPassword: () => false,
|
||||
listAppPasswords: vi.fn(),
|
||||
revokeAppPassword: vi.fn(),
|
||||
updateAvatar: vi.fn(),
|
||||
updateProfile: vi.fn()
|
||||
}));
|
||||
|
||||
import * as profile from '$lib/api/endpoints/profile';
|
||||
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import ProfilePage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the shared session each test (handlers may mutate session.user).
|
||||
session.loaded = true;
|
||||
session.user = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'a@x.test',
|
||||
given_name: 'A',
|
||||
family_name: 'B',
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
};
|
||||
m(profile.listAppPasswords).mockResolvedValue([]);
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
|
||||
});
|
||||
|
||||
it('renders and saves the profile form', async () => {
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-edit-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-given-name-input'), {
|
||||
target: { value: 'New' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-save-btn'));
|
||||
await waitFor(() => expect(profile.updateProfile).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('generates an app password', async () => {
|
||||
m(profile.createAppPassword).mockResolvedValue({ id: 'ap1', secret: 'xyz', label: 'tok' });
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-app-pw-label-input');
|
||||
await fireEvent.input(screen.getByTestId('profile-app-pw-label-input'), {
|
||||
target: { value: 'tok' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-app-pw-generate-btn'));
|
||||
await waitFor(() => expect(profile.createAppPassword).toHaveBeenCalledWith('tok'));
|
||||
});
|
||||
|
||||
it('rejects a mismatched password change without calling the API', async () => {
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-password-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
|
||||
target: { value: 'old' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
|
||||
target: { value: 'new1' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
|
||||
target: { value: 'new2' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
|
||||
expect(profile.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes the password when the confirmation matches', async () => {
|
||||
m(profile.changePassword).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await screen.findByTestId('profile-password-form');
|
||||
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
|
||||
target: { value: 'OldPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
|
||||
target: { value: 'NewPassword1!' }
|
||||
});
|
||||
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
|
||||
target: { value: 'NewPassword1!' }
|
||||
});
|
||||
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
|
||||
await waitFor(() => expect(profile.changePassword).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('revokes an existing app password after confirmation', async () => {
|
||||
m(profile.listAppPasswords).mockResolvedValue([
|
||||
{ id: 'ap1', label: 'CLI token', created_at: '2024-01-01T00:00:00Z' }
|
||||
]);
|
||||
m(confirmDialog).mockResolvedValue(true);
|
||||
m(profile.revokeAppPassword).mockResolvedValue(undefined);
|
||||
render(ProfilePage);
|
||||
await fireEvent.click(await screen.findByTestId('profile-app-pw-revoke-ap1'));
|
||||
await waitFor(() => expect(profile.revokeAppPassword).toHaveBeenCalledWith('ap1'));
|
||||
});
|
||||
@@ -341,21 +341,29 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
<Button icon="broom" onclick={clearAll}>{t('recent.clear', 'Clear recent')}</Button>
|
||||
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
|
||||
>{t('recent.clear', 'Clear recent')}</Button
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet batchToolbar()}
|
||||
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||
<Button icon="download" data-testid="recent-batch-download-btn" onclick={batchDownload}
|
||||
>{t('common.download', 'Download')}</Button
|
||||
>
|
||||
<Button
|
||||
icon="arrows-alt"
|
||||
data-testid="recent-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="recent-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
|
||||
confirmDialog: vi.fn(),
|
||||
promptDialog: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recent', () => ({ clearRecent: vi.fn(), fetchRecentPage: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/favorites', () => ({
|
||||
addFavorite: vi.fn(),
|
||||
dateBucket: () => 'Today',
|
||||
fetchFavoritesPage: vi.fn(async () => ({ items: [], next_cursor: null })),
|
||||
removeFavorite: vi.fn(),
|
||||
resolveOwnerName: vi.fn(async () => 'me'),
|
||||
sizeBucket: () => 'Small',
|
||||
typeLabel: () => 'File'
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
fileDownloadUrl: () => '/dl',
|
||||
renameFile: vi.fn(),
|
||||
deleteFile: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
||||
|
||||
import { fetchRecentPage, clearRecent } from '$lib/api/endpoints/recent';
|
||||
import { addFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { deleteFile } from '$lib/api/endpoints/files';
|
||||
import RecentPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function withOneFile() {
|
||||
m(fetchRecentPage).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
resource_type: 'file',
|
||||
accessed_at: '2024-01-01T00:00:00Z',
|
||||
resource: {
|
||||
category: 'Document',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-file',
|
||||
icon_special_class: '',
|
||||
id: 'r1',
|
||||
mime_type: 'text/plain',
|
||||
modified_at: 0,
|
||||
name: 'notes.txt',
|
||||
owner_id: 'me',
|
||||
folder_id: 'root',
|
||||
path: '/notes.txt',
|
||||
size: 4,
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
}
|
||||
}
|
||||
],
|
||||
next_cursor: null
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders recent items returned by the API', async () => {
|
||||
withOneFile();
|
||||
render(RecentPage);
|
||||
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('notes.txt')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('clears recent activity after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(clearRecent).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await fireEvent.click(await screen.findByTestId('recent-clear-btn'));
|
||||
await waitFor(() => expect(clearRecent).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('favorites a recent row via the star button', async () => {
|
||||
withOneFile();
|
||||
m(addFavorite).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await screen.findByText('notes.txt');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-favorite-r1-btn'));
|
||||
await waitFor(() => expect(addFavorite).toHaveBeenCalledWith('file', 'r1'));
|
||||
});
|
||||
|
||||
it('batch-deletes selected recent items after confirmation', async () => {
|
||||
withOneFile();
|
||||
confirmDialog.mockResolvedValue(true);
|
||||
m(deleteFile).mockResolvedValue(undefined);
|
||||
render(RecentPage);
|
||||
await screen.findByText('notes.txt');
|
||||
await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox'));
|
||||
await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn'));
|
||||
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1'));
|
||||
});
|
||||
|
||||
it('renders an empty state when there is no recent activity', async () => {
|
||||
m(fetchRecentPage).mockResolvedValue({ items: [], next_cursor: null });
|
||||
render(RecentPage);
|
||||
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
|
||||
});
|
||||
@@ -258,23 +258,32 @@
|
||||
<p>{t('share.expired', 'This share link is no longer available.')}</p>
|
||||
</div>
|
||||
{:else if view === 'password'}
|
||||
<form class="share__pw" onsubmit={submitPassword}>
|
||||
<form class="share__pw" data-testid="public-share-password-form" onsubmit={submitPassword}>
|
||||
<h1>{t('share.password_title', 'Password required')}</h1>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="public-share-password-input"
|
||||
bind:value={pwInput}
|
||||
placeholder={t('share.password', 'Password')}
|
||||
disabled={busy}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
|
||||
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button>
|
||||
<button type="submit" data-testid="public-share-unlock-btn" disabled={busy}
|
||||
>{t('share.unlock', 'Unlock')}</button
|
||||
>
|
||||
</form>
|
||||
{:else if view === 'file'}
|
||||
<div class="share__center">
|
||||
<Icon name="file" class="share__big-icon" />
|
||||
<h1>{meta?.item_name}</h1>
|
||||
<a class="share__btn" href={shareDownloadUrl(token)} download rel="external">
|
||||
<a
|
||||
class="share__btn"
|
||||
data-testid="public-share-download-btn"
|
||||
href={shareDownloadUrl(token)}
|
||||
download
|
||||
rel="external"
|
||||
>
|
||||
{t('share.download', 'Download')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -286,7 +295,11 @@
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb__current">{c.name}</span>
|
||||
{:else}
|
||||
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
|
||||
<button
|
||||
class="breadcrumb__link"
|
||||
data-testid={`public-share-breadcrumb-${i}`}
|
||||
onclick={() => gotoCrumb(i)}>{c.name}</button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -296,6 +309,7 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
class:active={viewMode === 'grid'}
|
||||
data-testid="public-share-view-grid-btn"
|
||||
title={t('files.grid', 'Grid')}
|
||||
onclick={() => setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
@@ -303,11 +317,18 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
class:active={viewMode === 'list'}
|
||||
data-testid="public-share-view-list-btn"
|
||||
title={t('files.list', 'List')}
|
||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||
>
|
||||
</div>
|
||||
<a class="share__btn" href={shareZipUrl(token, folderId)} download rel="external">
|
||||
<a
|
||||
class="share__btn"
|
||||
data-testid="public-share-download-zip-btn"
|
||||
href={shareZipUrl(token, folderId)}
|
||||
download
|
||||
rel="external"
|
||||
>
|
||||
<Icon name="file-archive" />
|
||||
{t('share.download_zip', 'Download ZIP')}
|
||||
</a>
|
||||
@@ -323,7 +344,11 @@
|
||||
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
||||
{#each listing.folders as f (f.id)}
|
||||
<li>
|
||||
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
|
||||
<button
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}
|
||||
>
|
||||
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</button>
|
||||
@@ -341,6 +366,7 @@
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
|
||||
>
|
||||
<span class="card__thumb">
|
||||
@@ -369,6 +395,7 @@
|
||||
<li>
|
||||
<a
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
href={shareFileUrl(token, f.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
@@ -390,6 +417,7 @@
|
||||
<div
|
||||
class="lb"
|
||||
role="dialog"
|
||||
data-testid="public-share-lightbox-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.name}
|
||||
tabindex="-1"
|
||||
@@ -397,11 +425,13 @@
|
||||
>
|
||||
<button
|
||||
class="lb__close"
|
||||
data-testid="public-share-lightbox-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={() => (lightbox = -1)}>×</button
|
||||
>
|
||||
<button
|
||||
class="lb__nav lb__nav--prev"
|
||||
data-testid="public-share-lightbox-prev-btn"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={lightbox === 0}
|
||||
onclick={(e) => {
|
||||
@@ -417,6 +447,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="lb__nav lb__nav--next"
|
||||
data-testid="public-share-lightbox-next-btn"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={lightbox === mediaFiles.length - 1}
|
||||
onclick={(e) => {
|
||||
|
||||
@@ -201,12 +201,18 @@
|
||||
<div class="search-controls">
|
||||
{#if filesStore.currentFolder}
|
||||
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
|
||||
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'all'}
|
||||
data-testid="search-scope-all-btn"
|
||||
onclick={() => (scope = 'all')}
|
||||
>
|
||||
{t('search.everywhere', 'Everywhere')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
data-testid="search-scope-folder-btn"
|
||||
onclick={() => (scope = 'folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
@@ -217,28 +223,40 @@
|
||||
class="sort-select"
|
||||
bind:value={typeFilter}
|
||||
aria-label={t('search.type_label', 'Type')}
|
||||
data-testid="search-type-filter-select"
|
||||
>
|
||||
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sizeFilter}
|
||||
aria-label={t('search.size_label', 'Size')}
|
||||
data-testid="search-size-filter-select"
|
||||
>
|
||||
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={dateFilter}
|
||||
aria-label={t('search.date_label', 'Date')}
|
||||
data-testid="search-date-filter-select"
|
||||
>
|
||||
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
|
||||
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sortBy}
|
||||
aria-label={t('search.sort_by', 'Sort by')}
|
||||
data-testid="search-sort-select"
|
||||
>
|
||||
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" onclick={clearFilters}>
|
||||
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
@@ -281,6 +299,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.folder.name}
|
||||
data-testid={e.folder.name}
|
||||
onclick={() => openFolder(e.folder)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||
>
|
||||
@@ -297,6 +317,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.file.name}
|
||||
data-testid={e.file.name}
|
||||
onclick={() => openFile(e.file)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/search?q=report') }
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import SearchPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/search?q=report');
|
||||
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
|
||||
});
|
||||
|
||||
it('runs a search from the q query parameter on mount', async () => {
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
|
||||
});
|
||||
|
||||
it('does not search when there is no query', async () => {
|
||||
pageState.url = new URL('http://localhost/search');
|
||||
render(SearchPage);
|
||||
// Give the reactive effect a tick to (not) fire.
|
||||
await Promise.resolve();
|
||||
expect(searchFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a search error', async () => {
|
||||
m(searchFiles).mockRejectedValue(new Error('search boom'));
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
|
||||
});
|
||||
@@ -391,11 +391,19 @@
|
||||
<header class="ms-lane__header">
|
||||
{#if lane.header.kind === 'resource'}
|
||||
{@const laneItem = lane.header.item}
|
||||
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}>
|
||||
<button
|
||||
class="ms-lane__resource"
|
||||
data-testid={`shared-lane-open-${laneItem.resource.id}`}
|
||||
onclick={() => openResource(laneItem)}
|
||||
>
|
||||
<Icon name={resourceIcon(laneItem)} />
|
||||
<span class="ms-lane__name">{laneItem.resource.name}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}>
|
||||
<button
|
||||
class="btn btn-secondary ms-lane__edit"
|
||||
data-testid={`shared-edit-sharing-${laneItem.resource.id}`}
|
||||
onclick={() => editSharing(laneItem)}
|
||||
>
|
||||
<Icon name="pencil-alt" />
|
||||
{t('myshares.editSharing', 'Edit sharing')}
|
||||
</button>
|
||||
@@ -427,7 +435,11 @@
|
||||
<!-- Identity -->
|
||||
<span class="ms-row__identity">
|
||||
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
|
||||
<button class="ms-link-btn" onclick={() => openResource(item)}>
|
||||
<button
|
||||
class="ms-link-btn"
|
||||
data-testid={`shared-row-open-${grant.grant_id}`}
|
||||
onclick={() => openResource(item)}
|
||||
>
|
||||
<Icon name={resourceIcon(item)} />
|
||||
<span class="ms-row__name">{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -443,6 +455,7 @@
|
||||
<button
|
||||
class="ms-chip ms-chip--link"
|
||||
class:ms-chip--locked={grant.has_password}
|
||||
data-testid={`shared-copy-link-${grant.grant_id}`}
|
||||
onclick={() => copyLink(grant)}
|
||||
title={t('share.copyLink', 'Copy link')}
|
||||
>
|
||||
@@ -451,7 +464,11 @@
|
||||
</button>
|
||||
{#if groupBy === 'sharedWith'}
|
||||
<span class="ms-arrow">→</span>
|
||||
<button class="ms-link-btn" onclick={() => openResource(item)}>
|
||||
<button
|
||||
class="ms-link-btn"
|
||||
data-testid={`shared-link-open-${grant.grant_id}`}
|
||||
onclick={() => openResource(item)}
|
||||
>
|
||||
<Icon name={resourceIcon(item)} />
|
||||
<span>{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -480,6 +497,7 @@
|
||||
aria-label={t('myshares.manageAccess', 'Manage access')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuFor === grant.grant_id}
|
||||
data-testid={`shared-kebab-${grant.grant_id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu(grant.grant_id);
|
||||
@@ -490,11 +508,17 @@
|
||||
class="ms-menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
data-testid={`shared-menu-${grant.grant_id}`}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
|
||||
>
|
||||
{#if grant.subject_type === 'user' || grant.subject_type === 'group'}
|
||||
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}>
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-notify-${grant.grant_id}`}
|
||||
onclick={() => notify(grant)}
|
||||
>
|
||||
<Icon name="paper-plane" />
|
||||
{grant.subject_type === 'group'
|
||||
? t('myshares.notifyGroupMembers', 'Notify group members')
|
||||
@@ -508,6 +532,7 @@
|
||||
class="ms-menu__item"
|
||||
class:ms-menu__item--current={grant.role === r.v}
|
||||
role="menuitem"
|
||||
data-testid={`shared-role-${r.v}-${grant.grant_id}`}
|
||||
onclick={() => changeRole(grant, item, r.v)}
|
||||
>
|
||||
<Icon name={grant.role === r.v ? 'check' : r.icon} />
|
||||
@@ -520,6 +545,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -529,13 +555,19 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-remove-access-${grant.grant_id}`}
|
||||
onclick={() => removeAccess(grant)}
|
||||
>
|
||||
<Icon name="user-xmark" />
|
||||
{t('myshares.removeAccess', 'Remove access')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}>
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-menu-copy-link-${grant.grant_id}`}
|
||||
onclick={() => copyLink(grant)}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
{t('myshares.copyLink', 'Copy link')}
|
||||
</button>
|
||||
@@ -545,6 +577,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-link-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -553,6 +586,7 @@
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-edit-password-${grant.grant_id}`}
|
||||
onclick={() => editLinkPassword(grant)}
|
||||
>
|
||||
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
|
||||
@@ -564,6 +598,7 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-delete-link-${grant.grant_id}`}
|
||||
onclick={() => deleteLink(grant)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -580,7 +615,12 @@
|
||||
{/each}
|
||||
|
||||
{#if cursor}
|
||||
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}>
|
||||
<button
|
||||
class="btn btn-secondary ms-more"
|
||||
data-testid="shared-load-more-btn"
|
||||
onclick={() => load(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { ui, goto } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, goto: vi.fn() }));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||
vi.mock('$lib/utils/errors', () => ({
|
||||
errorMessage: (e: Error) => e.message,
|
||||
errorToast: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/grants', () => ({
|
||||
displayRole: (r: string) => r,
|
||||
expiryToIso: (v: string | null) => v,
|
||||
fetchMyShares: vi.fn(),
|
||||
notifyGrantRecipient: vi.fn(async () => ({ outcomes: [] })),
|
||||
revokeGrant: vi.fn(),
|
||||
updateGrantRole: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
ensureResolvers: vi.fn(),
|
||||
resolveLabel: (_t: string, id: string) => id
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/shares', () => ({
|
||||
copyShareLink: vi.fn(),
|
||||
deleteShare: vi.fn(),
|
||||
getShareById: vi.fn(),
|
||||
updateShare: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { fetchMyShares, updateGrantRole, revokeGrant } from '$lib/api/endpoints/grants';
|
||||
import { ensureResolvers } from '$lib/api/endpoints/recipients';
|
||||
import SharedPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function grantItem() {
|
||||
return {
|
||||
resource_type: 'folder' as const,
|
||||
resource: {
|
||||
category: 'Folder',
|
||||
created_at: 0,
|
||||
icon_class: 'fa-folder',
|
||||
icon_special_class: '',
|
||||
id: 'r1',
|
||||
is_root: false,
|
||||
modified_at: 0,
|
||||
name: 'Docs',
|
||||
owner_id: 'me',
|
||||
parent_id: null,
|
||||
path: '/Docs',
|
||||
etag: 'e'
|
||||
},
|
||||
first_shared_at: '2024-01-01T00:00:00Z',
|
||||
grants: [
|
||||
{
|
||||
grant_id: 'g1',
|
||||
subject_type: 'user' as const,
|
||||
subject_id: 'u1',
|
||||
subject_display: 'Bob',
|
||||
role: 'viewer' as const,
|
||||
granted_at: '2024-01-01T00:00:00Z',
|
||||
expires_at: null,
|
||||
has_password: false,
|
||||
is_external: false
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(ensureResolvers).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders a swimlane for each shared resource', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
render(SharedPage);
|
||||
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
|
||||
await screen.findByTestId('shared-kebab-g1');
|
||||
});
|
||||
|
||||
it('changes a grant role from the kebab menu', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
m(updateGrantRole).mockResolvedValue(undefined);
|
||||
render(SharedPage);
|
||||
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
|
||||
await fireEvent.click(await screen.findByTestId('shared-role-editor-g1'));
|
||||
await waitFor(() =>
|
||||
expect(updateGrantRole).toHaveBeenCalledWith(
|
||||
{ type: 'user', id: 'u1' },
|
||||
{ type: 'folder', id: 'r1' },
|
||||
'editor',
|
||||
null
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes access from the kebab menu', async () => {
|
||||
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
|
||||
m(revokeGrant).mockResolvedValue(undefined);
|
||||
render(SharedPage);
|
||||
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
|
||||
await fireEvent.click(await screen.findByTestId('shared-remove-access-g1'));
|
||||
await waitFor(() => expect(revokeGrant).toHaveBeenCalledWith('g1'));
|
||||
});
|
||||
|
||||
it('surfaces a load error without crashing', async () => {
|
||||
m(fetchMyShares).mockRejectedValue(new Error('boom'));
|
||||
render(SharedPage);
|
||||
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
|
||||
});
|
||||
@@ -174,7 +174,7 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if entries.length > 0}
|
||||
<button class="btn btn-danger" onclick={purgeAll}>
|
||||
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
|
||||
<Icon name="trash" />
|
||||
{t('trash.empty_action', 'Empty trash')}
|
||||
</button>
|
||||
@@ -188,11 +188,17 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet actions(entry)}
|
||||
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`trash-restore-btn-${entry.id}`}
|
||||
title={t('trash.restore', 'Restore')}
|
||||
onclick={() => restore(entry)}
|
||||
>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid={`trash-delete-btn-${entry.id}`}
|
||||
title={t('trash.delete', 'Delete permanently')}
|
||||
onclick={() => purge(entry)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user