test(e2e): Playwright + Vitest coverage harness and test instrumentation

Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
This commit is contained in:
Bradley Nelson
2026-06-21 20:03:32 -06:00
parent 0c40c69f9b
commit e3823ce470
162 changed files with 13213 additions and 554 deletions
+13
View File
@@ -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);
});
+3 -1
View File
@@ -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);
});
+69 -12
View File
@@ -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'));
});
+4 -1
View File
@@ -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');
});
+15 -2
View File
@@ -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();
});
+17 -2
View File
@@ -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}
+6 -2
View File
@@ -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}
+34
View File
@@ -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();
});
+62 -40
View File
@@ -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();
+233 -178
View File
@@ -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();
});
+2 -1
View File
@@ -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)}
>
+25 -4
View File
@@ -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();
});
+68
View File
@@ -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();
});
+43
View File
@@ -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);
});
+29
View File
@@ -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');
});
});
+34
View File
@@ -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('');
});
});
+30
View File
@@ -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');
});
});
+35
View File
@@ -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)
});
});
+20
View File
@@ -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');
});
});
+39
View File
@@ -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/);
});
});