50eca0627f
Benchmark-gated round (benches/ROUND12.md; every change ships with a BEFORE/AFTER harness + equivalence gates, one candidate rejected by its own bench): DB / query shapes (bench_round12_queries): - NC sharee search: username-only projection instead of the 21-column row (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index). - Password login: delete the redundant full-row update_user — create_session already stamps last_login_at in its own txn (4.45x per login). - Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login now compares profile state in memory and issues ZERO queries when nothing changed (was: full 17-column rewrite per login). - Refresh rotation: revoke+insert+stamp fused into one transaction via new rotate_session port method (1.18x). - WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file + check(Update) overlapped with tokio::join!, original result precedence (cold 1.34x). - Upload quota gate: user-envelope + drive-cap checks fused into ONE round-trip (check_upload_quotas) — the NC chunked PUT pays this per chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep error shapes byte-identical. CPU / allocs (bench_round12_micro): - sized_json: pre-sized listing serialization replacing axum Json's 128 B seed + doubling-realloc chain on files/folder-resources/photos/search responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical). - Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated byte-identical incl. 304s). - Media capture-metadata: single-read extraction — nom-exif now parses the buffer kamadak already read (zero-copy Bytes) and videos open once with a kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x cold cache; extraction outputs gated identical incl. the MIME-mislabel track fallback). - Chunked-upload session ops: owner gate folded into the operation's own DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs, 1.28x per chunk). Blob cache (bench_blob_cache_index + round-3 regression guard): - CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache with byte weigher. The mutex serialized every cached chunk read and scaled NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are lock-free (2.17x at K=2). Byte budget now enforced by moka (manual current_size + collect_evictions machinery deleted); eviction listener unlinks size-evicted files only (Replaced entries keep their file — gated). Single-flight miss gate unchanged (16 concurrent misses -> 1 fetch re-verified via the round-3 harness). - put_blob now populates the cache BEFORE the inner backend consumes the source file (the old order failed 100% of the time — local renames, S3/Azure delete the source — so the first read after a whole-file put re-downloaded from the remote); inner-put failure invalidates the entry. Frontend (vitest gates): - List-view thumbnails request the 150px icon rendition instead of 400px preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per thumbnail across list views); grid keeps preview. Rejected by its own bench (kept as evidence in bench_round12_micro §2): - Single-pass compression predicate: the monomorphized And-chain already costs ~4.6 ns / 0 allocs total; the fused node measured within noise. New migration: 20260719000000_users_search_trgm.sql (trgm indexes). Deferred with prepared design: grouped file/grid view virtualization (single-VirtualRows flatten, the photos pattern) — next round's headline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
import { it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
|
|
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
|
|
goto: vi.fn(),
|
|
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
|
|
params: { path: string };
|
|
url: URL;
|
|
},
|
|
session: {
|
|
user: { id: 'me', username: 'admin', is_external: false },
|
|
isExternalUser: false,
|
|
loadHomeFolder: vi.fn(async () => 'home'),
|
|
refresh: vi.fn(async () => {})
|
|
},
|
|
ui: { notify: vi.fn() },
|
|
confirmDialog: vi.fn(),
|
|
promptDialog: vi.fn()
|
|
}));
|
|
vi.mock('$app/navigation', () => ({ goto }));
|
|
vi.mock('$app/state', () => ({ page: pageState }));
|
|
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
|
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
|
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
|
|
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
|
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ tryDeltaUpload: vi.fn() }));
|
|
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
|
|
vi.mock('$lib/api/endpoints/wopi', () => ({
|
|
canEditWithWopi: () => false,
|
|
getEditorUrlWithFallback: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/music', () => ({
|
|
addTracks: vi.fn(),
|
|
createPlaylist: vi.fn(),
|
|
listPlaylists: vi.fn(async () => [])
|
|
}));
|
|
vi.mock('$lib/api/endpoints/files', () => ({
|
|
deleteFile: vi.fn(),
|
|
fileDownloadUrl: () => '/dl',
|
|
fileThumbnailUrl: () => '/thumb',
|
|
thumbSizeForView: () => 'preview' as const,
|
|
moveFile: vi.fn(),
|
|
renameFile: vi.fn(),
|
|
uploadFile: vi.fn(),
|
|
uploadFileWithProgress: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/folders', () => ({
|
|
cacheFolder: vi.fn(),
|
|
createFolder: vi.fn(),
|
|
deleteFolder: vi.fn(),
|
|
fetchFolderListing: vi.fn(),
|
|
folderZipUrl: () => '/zip',
|
|
getCachedFolder: () => undefined,
|
|
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
|
getFolderName: () => undefined,
|
|
invalidateFolderCache: vi.fn(),
|
|
moveFolder: vi.fn(),
|
|
rememberFolderName: vi.fn(),
|
|
renameFolder: vi.fn()
|
|
}));
|
|
|
|
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
|
import { deleteFile } from '$lib/api/endpoints/files';
|
|
import { apiFetch } from '$lib/api/client';
|
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
|
import FilesPage from './[...path]/+page.svelte';
|
|
|
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
|
|
|
function withListing() {
|
|
m(fetchFolderListing).mockResolvedValue({
|
|
status: 200,
|
|
etag: 'v1',
|
|
listing: {
|
|
folders: [folderItem('sub1', 'Sub')],
|
|
files: [fileItem('f1', 'hello.txt')],
|
|
favoriteIds: [],
|
|
sharedIds: []
|
|
}
|
|
});
|
|
}
|
|
|
|
function fileItem(id: string, name: string) {
|
|
return {
|
|
category: 'Document',
|
|
created_at: 0,
|
|
icon_class: 'fa-file',
|
|
icon_special_class: '',
|
|
id,
|
|
mime_type: 'text/plain',
|
|
modified_at: 0,
|
|
name,
|
|
created_by: 'me',
|
|
updated_by: 'me',
|
|
folder_id: 'home',
|
|
path: '/' + name,
|
|
size: 4,
|
|
size_formatted: '4 B',
|
|
sort_date: 0,
|
|
etag: 'e',
|
|
content_hash: 'h'
|
|
};
|
|
}
|
|
function folderItem(id: string, name: string) {
|
|
return {
|
|
category: 'Folder',
|
|
created_at: 0,
|
|
icon_class: 'fa-folder',
|
|
icon_special_class: '',
|
|
id,
|
|
is_root: false,
|
|
modified_at: 0,
|
|
name,
|
|
created_by: 'me',
|
|
updated_by: 'me',
|
|
parent_id: 'home',
|
|
path: '/' + name,
|
|
etag: 'e'
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// A concrete folder in the path: bare `/files` now canonicalizes to
|
|
// `/files/<drive-root>` via goto (see the external-user test), so the
|
|
// listing-oriented tests target a folder directly.
|
|
pageState.params.path = 'home';
|
|
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
|
filesStore.viewMode = 'list';
|
|
});
|
|
|
|
it('loads the home folder listing on mount and renders its contents', async () => {
|
|
m(fetchFolderListing).mockResolvedValue({
|
|
status: 200,
|
|
etag: 'v1',
|
|
listing: {
|
|
folders: [folderItem('sub1', 'Sub')],
|
|
files: [fileItem('f1', 'hello.txt')],
|
|
favoriteIds: [],
|
|
sharedIds: []
|
|
}
|
|
});
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
|
|
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
|
|
// surrounding chrome rendered rather than the windowed rows themselves.
|
|
await screen.findByTestId('files-new-folder-btn');
|
|
});
|
|
|
|
it('shows an error when the listing fails with no cache', async () => {
|
|
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
|
|
});
|
|
|
|
it('redirects external users away from the home folder', async () => {
|
|
session.isExternalUser = true;
|
|
pageState.params.path = '';
|
|
render(FilesPage);
|
|
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
|
|
session.isExternalUser = false;
|
|
});
|
|
|
|
it('creates a new folder in the current directory', async () => {
|
|
withListing();
|
|
promptDialog.mockResolvedValue('Reports');
|
|
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
|
|
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
|
|
});
|
|
|
|
it('batch-deletes the whole selection after confirmation', async () => {
|
|
withListing();
|
|
confirmDialog.mockResolvedValue(true);
|
|
m(deleteFolder).mockResolvedValue(undefined);
|
|
m(deleteFile).mockResolvedValue(undefined);
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
|
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
|
|
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
|
|
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
|
});
|
|
|
|
it('batch-favorites the selection via the favorites batch endpoint', async () => {
|
|
withListing();
|
|
m(apiFetch).mockResolvedValue({ ok: true });
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
|
|
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
|
|
await waitFor(() =>
|
|
expect(apiFetch).toHaveBeenCalledWith(
|
|
'/api/favorites/batch',
|
|
expect.objectContaining({ method: 'POST' })
|
|
)
|
|
);
|
|
});
|