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);
});