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
} 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<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 () => {
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 () => {
+36 -37
View File
@@ -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<FolderItem> {
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
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
* `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<FolderListingResult> {
const headers: Record<string, string> = {};
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). */