Files
Oxicloud/frontend/src/lib/api/endpoints/folders.test.ts
T
Claude afbc0ba515 perf(files): resolve breadcrumbs from a name cache, not N getFolder calls
Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.

Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.

`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.

The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).

Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:12:20 +00:00

143 lines
4.7 KiB
TypeScript

import { describe, 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 type { FolderItem } from '$lib/api/types';
import {
fetchFolderListing,
getCachedFolder,
cacheFolder,
invalidateFolderCache,
getFolder,
getFolderName,
rememberFolderName,
type FolderListing
} from './folders';
type RawListing = {
folders?: unknown[];
files?: unknown[];
favorite_ids?: string[];
shared_ids?: string[];
};
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response {
return {
status: opts.status,
ok: opts.status >= 200 && opts.status < 300,
json: async () => opts.body ?? {},
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) }
} as unknown as Response;
}
const emptyListing = (): FolderListing => ({
folders: [],
files: [],
favoriteIds: [],
sharedIds: []
});
const initHeaders = (call: number): Record<string, string> =>
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
beforeEach(() => {
vi.clearAllMocks();
invalidateFolderCache();
});
describe('fetchFolderListing (conditional)', () => {
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => {
vi.mocked(apiFetch).mockResolvedValue(
fakeRes({
status: 200,
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] },
etag: '"v1"'
})
);
const r = await fetchFolderListing('f1');
expect(r.status).toBe(200);
expect(r.etag).toBe('"v1"');
expect(r.listing?.favoriteIds).toEqual(['a']);
expect(r.listing?.sharedIds).toEqual(['b']);
expect(initHeaders(0)['If-None-Match']).toBeUndefined();
// No cache-busting query param — the URL must be stable for revalidation.
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing');
});
it('sends If-None-Match and surfaces a 304 with no body', async () => {
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 }));
const r = await fetchFolderListing('f1', { etag: '"v1"' });
expect(r.status).toBe(304);
expect(r.listing).toBeUndefined();
expect(initHeaders(0)['If-None-Match']).toBe('"v1"');
});
it('throws a 403 carrying its status', async () => {
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 403 }));
await expect(fetchFolderListing('f1')).rejects.toMatchObject({ status: 403 });
});
});
describe('folder listing cache (LRU + invalidation)', () => {
it('stores and retrieves a listing + its ETag', () => {
cacheFolder('a', emptyListing(), '"1"');
expect(getCachedFolder('a')?.etag).toBe('"1"');
expect(getCachedFolder('missing')).toBeUndefined();
});
it('evicts the least-recently-used entry past the cap', () => {
for (let i = 0; i < 45; i++) cacheFolder(`f${i}`, emptyListing());
expect(getCachedFolder('f0')).toBeUndefined(); // evicted (cap is 40)
expect(getCachedFolder('f44')).toBeDefined();
});
it('a read bumps recency so the touched entry survives eviction', () => {
for (let i = 0; i < 40; i++) cacheFolder(`f${i}`, emptyListing());
getCachedFolder('f0'); // bump f0 to most-recent
cacheFolder('extra', emptyListing()); // forces one eviction
expect(getCachedFolder('f0')).toBeDefined();
expect(getCachedFolder('f1')).toBeUndefined(); // f1 was now the oldest
});
it('invalidates a single folder, or the whole cache', () => {
cacheFolder('a', emptyListing());
cacheFolder('b', emptyListing());
invalidateFolderCache('a');
expect(getCachedFolder('a')).toBeUndefined();
expect(getCachedFolder('b')).toBeDefined();
invalidateFolderCache();
expect(getCachedFolder('b')).toBeUndefined();
});
});
describe('folder name cache (breadcrumbs)', () => {
const folder = (id: string, name: string): FolderItem => ({ id, name }) as unknown as FolderItem;
it("learns its children's names from a cached listing", () => {
cacheFolder('nc-parent', {
folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')],
files: [],
favoriteIds: [],
sharedIds: []
});
expect(getFolderName('nc-a')).toBe('Alpha');
expect(getFolderName('nc-b')).toBe('Beta');
expect(getFolderName('nc-unknown')).toBeUndefined();
});
it('records the name fetched by getFolder', async () => {
vi.mocked(apiJson).mockResolvedValue(folder('gf-1', 'Reports') as never);
const f = await getFolder('gf-1');
expect(f.name).toBe('Reports');
expect(getFolderName('gf-1')).toBe('Reports');
});
it('rememberFolderName overwrites a stale name (e.g. after a rename)', () => {
rememberFolderName('rn-1', 'Old');
expect(getFolderName('rn-1')).toBe('Old');
rememberFolderName('rn-1', 'New');
expect(getFolderName('rn-1')).toBe('New');
});
});