fix(frontend): migrate folder listing from removed /listing to /resources

The legacy-frontend removal dropped the deprecated /api/folders/{id}/listing
route, but folders.ts still called it, so every folder view 404'd
("listing failed: 404"). Complete the migration: fetchFolderListing now pages
through the cursor-paginated /api/folders/{id}/resources feed and rebuilds the
combined {folders, files} listing the views expect.

- Pages through next_cursor (limit 200) and splits mixed resource items by
  resource_type. 403 still throws; the 304/ETag fast-path is gone (that feed has
  no whole-listing ETag) so the in-memory folderCache is the only revalidation.
- Favorite/share badge sets aren't carried by /resources, so they come back
  empty for now (no star / share badge until wired from /favorites + /shares).
- folders.test.ts updated to the paginated shape.

npm run check: 0 errors. 58 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-21 10:47:21 +02:00
parent 5bf81e4653
commit 0cfa1212ff
2 changed files with 70 additions and 64 deletions
+34 -27
View File
@@ -15,19 +15,15 @@ import {
type FolderListing type FolderListing
} from './folders'; } from './folders';
type RawListing = { type ResourceItem = { resource_type: 'file' | 'folder'; resource: { id: string; name?: string } };
folders?: unknown[]; type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
files?: unknown[];
favorite_ids?: string[];
shared_ids?: string[];
};
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response { function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
return { return {
status: opts.status, status: opts.status,
ok: opts.status >= 200 && opts.status < 300, ok: opts.status >= 200 && opts.status < 300,
json: async () => opts.body ?? {}, json: async () => opts.body ?? {},
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) } headers: { get: () => null }
} as unknown as Response; } as unknown as Response;
} }
@@ -38,39 +34,50 @@ const emptyListing = (): FolderListing => ({
sharedIds: [] sharedIds: []
}); });
const initHeaders = (call: number): Record<string, string> =>
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
invalidateFolderCache(); invalidateFolderCache();
}); });
describe('fetchFolderListing (conditional)', () => { describe('fetchFolderListing (cursor-paginated /resources)', () => {
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => { it('splits one page of resources into folders + files', async () => {
vi.mocked(apiFetch).mockResolvedValue( vi.mocked(apiFetch).mockResolvedValue(
fakeRes({ fakeRes({
status: 200, status: 200,
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] }, body: {
etag: '"v1"' items: [
{ resource_type: 'folder', resource: { id: 'd1', name: 'Docs' } },
{ resource_type: 'file', resource: { id: 'x1', name: 'a.txt' } }
]
}
}) })
); );
const r = await fetchFolderListing('f1'); const r = await fetchFolderListing('f1');
expect(r.status).toBe(200); expect(r.status).toBe(200);
expect(r.etag).toBe('"v1"'); expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
expect(r.listing?.favoriteIds).toEqual(['a']); expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
expect(r.listing?.sharedIds).toEqual(['b']); expect(r.listing?.favoriteIds).toEqual([]);
expect(initHeaders(0)['If-None-Match']).toBeUndefined(); expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
// 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 () => { it('follows next_cursor across pages', async () => {
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 })); vi.mocked(apiFetch)
const r = await fetchFolderListing('f1', { etag: '"v1"' }); .mockResolvedValueOnce(
expect(r.status).toBe(304); fakeRes({
expect(r.listing).toBeUndefined(); status: 200,
expect(initHeaders(0)['If-None-Match']).toBe('"v1"'); 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 () => { it('throws a 403 carrying its status', async () => {
+33 -34
View File
@@ -1,7 +1,7 @@
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */ /** Folder endpoints — ported from filesModel.js + fileOperations.js. */
import { apiFetch, apiJson } from '$lib/api/client'; import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; 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 JSON_HEADERS = { 'Content-Type': 'application/json' };
const NO_CACHE: RequestInit = { const NO_CACHE: RequestInit = {
@@ -88,21 +88,6 @@ export function getFolderName(id: string): string | undefined {
return folderNames.get(id); 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<FolderItem> { export async function getFolder(id: string): Promise<FolderItem> {
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE); const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
rememberFolderName(folder.id, folder.name); rememberFolderName(folder.id, folder.name);
@@ -110,32 +95,46 @@ export async function getFolder(id: string): Promise<FolderItem> {
} }
/** /**
* Fetch a folder listing, optionally conditionally. With `etag` set it sends * Fetch a folder's complete listing (sub-folders + files), rebuilt from the
* `If-None-Match`; the server replies 304 (empty body) when nothing changed — * cursor-paginated `/api/folders/{id}/resources` feed — the old combined
* the ETag covers folders + files + favorite/share badges — so the caller can * `/listing` route was removed. We page through to the end (folders sort first
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of * under `order_by=name`) and split the mixed resource items back into
* the way; revalidation is driven entirely by our own ETag. * `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( export async function fetchFolderListing(
folderId: string, folderId: string,
opts: { etag?: string; forceRefresh?: boolean } = {} opts: { etag?: string; forceRefresh?: boolean } = {}
): Promise<FolderListingResult> { ): Promise<FolderListingResult> {
const headers: Record<string, string> = {}; const folders: FolderItem[] = [];
if (opts.etag) headers['If-None-Match'] = opts.etag; const files: FileItem[] = [];
let url = `/api/folders/${folderId}/listing`; let cursor: string | undefined;
if (opts.forceRefresh) { do {
url += '?force_refresh=true'; const params = new URLSearchParams({ order_by: 'name', limit: '200' });
headers['X-Force-Refresh'] = 'true'; if (opts.forceRefresh) params.set('force_refresh', 'true');
} if (cursor) params.set('cursor', cursor);
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers }); const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
if (res.status === 304) return { status: 304 }; credentials: 'same-origin',
cache: 'no-store'
});
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`); if (!res.ok) throw new Error(`listing failed: ${res.status}`);
return { const page = (await res.json()) as {
status: 200, items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
listing: parseListing(await res.json()), next_cursor?: string;
etag: res.headers.get('ETag') ?? undefined
}; };
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). */ /** Non-conditional listing fetch (e.g. the move-dialog folder tree). */