diff --git a/frontend/src/lib/api/endpoints/folders.test.ts b/frontend/src/lib/api/endpoints/folders.test.ts index 68d61943..621a9850 100644 --- a/frontend/src/lib/api/endpoints/folders.test.ts +++ b/frontend/src/lib/api/endpoints/folders.test.ts @@ -15,19 +15,15 @@ import { type FolderListing } from './folders'; -type RawListing = { - folders?: unknown[]; - files?: unknown[]; - favorite_ids?: string[]; - shared_ids?: string[]; -}; +type ResourceItem = { resource_type: 'file' | 'folder'; resource: { id: string; name?: string } }; +type ResourcePage = { items?: ResourceItem[]; next_cursor?: string }; -function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response { +function fakeRes(opts: { status: number; body?: ResourcePage }): 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) } + headers: { get: () => null } } as unknown as Response; } @@ -38,39 +34,50 @@ const emptyListing = (): FolderListing => ({ sharedIds: [] }); -const initHeaders = (call: number): Record => - (vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record; - beforeEach(() => { vi.clearAllMocks(); invalidateFolderCache(); }); -describe('fetchFolderListing (conditional)', () => { - it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => { +describe('fetchFolderListing (cursor-paginated /resources)', () => { + it('splits one page of resources into folders + files', async () => { vi.mocked(apiFetch).mockResolvedValue( fakeRes({ status: 200, - body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] }, - etag: '"v1"' + body: { + items: [ + { resource_type: 'folder', resource: { id: 'd1', name: 'Docs' } }, + { resource_type: 'file', resource: { id: 'x1', name: 'a.txt' } } + ] + } }) ); 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'); + expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']); + expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']); + expect(r.listing?.favoriteIds).toEqual([]); + expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources'); }); - 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('follows next_cursor across pages', async () => { + vi.mocked(apiFetch) + .mockResolvedValueOnce( + fakeRes({ + status: 200, + body: { items: [{ resource_type: 'file', resource: { id: 'p1' } }], next_cursor: 'c2' } + }) + ) + .mockResolvedValueOnce( + fakeRes({ + status: 200, + body: { items: [{ resource_type: 'file', resource: { id: 'p2' } }] } + }) + ); + const r = await fetchFolderListing('f1'); + expect(r.listing?.files.map((f) => f.id)).toEqual(['p1', 'p2']); + expect(vi.mocked(apiFetch)).toHaveBeenCalledTimes(2); + expect(vi.mocked(apiFetch).mock.calls[1][0]).toContain('cursor=c2'); }); it('throws a 403 carrying its status', async () => { diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 3880c6c7..2bc8adee 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -1,7 +1,7 @@ /** Folder endpoints — ported from filesModel.js + fileOperations.js. */ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { FileItem, FolderItem } from '$lib/api/types'; +import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; const NO_CACHE: RequestInit = { @@ -88,21 +88,6 @@ export function getFolderName(id: string): string | undefined { return folderNames.get(id); } -function parseListing(raw: unknown): FolderListing { - const o = (raw ?? {}) as { - folders?: FolderItem[]; - files?: FileItem[]; - favorite_ids?: string[]; - shared_ids?: string[]; - }; - return { - folders: Array.isArray(o.folders) ? o.folders : [], - files: Array.isArray(o.files) ? o.files : [], - favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [], - sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : [] - }; -} - export async function getFolder(id: string): Promise { const folder = await apiJson(`/api/folders/${id}`, NO_CACHE); rememberFolderName(folder.id, folder.name); @@ -110,32 +95,46 @@ export async function getFolder(id: string): Promise { } /** - * Fetch a folder listing, optionally conditionally. With `etag` set it sends - * `If-None-Match`; the server replies 304 (empty body) when nothing changed — - * the ETag covers folders + files + favorite/share badges — so the caller can - * keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of - * the way; revalidation is driven entirely by our own ETag. + * Fetch a folder's complete listing (sub-folders + files), rebuilt from the + * cursor-paginated `/api/folders/{id}/resources` feed — the old combined + * `/listing` route was removed. We page through to the end (folders sort first + * under `order_by=name`) and split the mixed resource items back into + * `folders` / `files`. + * + * That feed carries no whole-listing ETag, so the 304 conditional fast-path is + * gone: `opts.etag` is accepted for call-site compatibility but ignored, and the + * in-memory `folderCache` is what the views revalidate against. Favorite/share + * badge sets aren't part of this feed either, so they come back empty for now. */ export async function fetchFolderListing( folderId: string, opts: { etag?: string; forceRefresh?: boolean } = {} ): Promise { - const headers: Record = {}; - if (opts.etag) headers['If-None-Match'] = opts.etag; - let url = `/api/folders/${folderId}/listing`; - if (opts.forceRefresh) { - url += '?force_refresh=true'; - headers['X-Force-Refresh'] = 'true'; - } - const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers }); - if (res.status === 304) return { status: 304 }; - if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); - if (!res.ok) throw new Error(`listing failed: ${res.status}`); - return { - status: 200, - listing: parseListing(await res.json()), - etag: res.headers.get('ETag') ?? undefined - }; + const folders: FolderItem[] = []; + const files: FileItem[] = []; + let cursor: string | undefined; + do { + const params = new URLSearchParams({ order_by: 'name', limit: '200' }); + if (opts.forceRefresh) params.set('force_refresh', 'true'); + if (cursor) params.set('cursor', cursor); + const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); + if (!res.ok) throw new Error(`listing failed: ${res.status}`); + const page = (await res.json()) as { + items?: { resource_type: ItemType; resource: FolderItem | FileItem }[]; + next_cursor?: string; + }; + for (const it of page.items ?? []) { + if (it.resource_type === 'folder') folders.push(it.resource as FolderItem); + else files.push(it.resource as FileItem); + } + cursor = page.next_cursor; + } while (cursor); + + return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } }; } /** Non-conditional listing fetch (e.g. the move-dialog folder tree). */