Fix authenticated media flows and upload preflight handling
This commit is contained in:
85
frontend/src/lib/api.test.ts
Normal file
85
frontend/src/lib/api.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import { downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob } from './api.ts';
|
||||
|
||||
/**
|
||||
* Throws when a test condition is false.
|
||||
*/
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that async functions reject with an expected message fragment.
|
||||
*/
|
||||
async function assertRejects(action: () => Promise<unknown>, expectedMessage: string): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
assert(message.includes(expectedMessage), `Expected error containing "${expectedMessage}" but received "${message}"`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Expected rejection containing "${expectedMessage}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and download flows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
const requestUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
requestUrls.push(typeof input === 'string' ? input : input.toString());
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const thumbnail = await getDocumentThumbnailBlob('doc-1');
|
||||
const preview = await getDocumentPreviewBlob('doc-1');
|
||||
|
||||
assert(await thumbnail.text() === 'preview-bytes', 'Thumbnail blob bytes mismatch');
|
||||
assert(await preview.text() === 'preview-bytes', 'Preview blob bytes mismatch');
|
||||
assert(
|
||||
requestUrls[0] === 'http://localhost:8000/api/v1/documents/doc-1/thumbnail',
|
||||
`Unexpected thumbnail URL ${requestUrls[0]}`,
|
||||
);
|
||||
assert(
|
||||
requestUrls[1] === 'http://localhost:8000/api/v1/documents/doc-1/preview',
|
||||
`Unexpected preview URL ${requestUrls[1]}`,
|
||||
);
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('file-bytes', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="invoice.pdf"',
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const fileResult = await downloadDocumentFile('doc-2');
|
||||
assert(fileResult.filename === 'invoice.pdf', `Unexpected download filename ${fileResult.filename}`);
|
||||
assert((await fileResult.blob.text()) === 'file-bytes', 'Original download bytes mismatch');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('# markdown', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const markdownResult = await downloadDocumentContentMarkdown('doc-3');
|
||||
assert(markdownResult.filename === 'document-content.md', `Unexpected markdown filename ${markdownResult.filename}`);
|
||||
assert((await markdownResult.blob.text()) === '# markdown', 'Markdown bytes mismatch');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('forbidden', { status: 401 });
|
||||
}) as typeof fetch;
|
||||
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
await runApiTests();
|
||||
@@ -16,12 +16,12 @@ import type {
|
||||
/**
|
||||
* Resolves backend base URL from environment with localhost fallback.
|
||||
*/
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
const API_BASE = import.meta.env?.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
|
||||
/**
|
||||
* Optional bearer token used for authenticated backend routes.
|
||||
*/
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN?.trim();
|
||||
const API_TOKEN = import.meta.env?.VITE_API_TOKEN?.trim();
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
@@ -78,6 +78,22 @@ function responseFilename(response: Response, fallback: string): string {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser file download for blob payloads and releases temporary object URLs.
|
||||
*/
|
||||
export function downloadBlobFile(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads documents from the backend list endpoint.
|
||||
*/
|
||||
@@ -376,6 +392,60 @@ export function contentMarkdownUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/content-md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads preview bytes for one document using centralized auth headers.
|
||||
*/
|
||||
export async function getDocumentPreviewBlob(documentId: string): Promise<Blob> {
|
||||
const response = await apiRequest(previewUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document preview');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads thumbnail bytes for one document using centralized auth headers.
|
||||
*/
|
||||
export async function getDocumentThumbnailBlob(documentId: string): Promise<Blob> {
|
||||
const response = await apiRequest(thumbnailUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document thumbnail');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the original document payload with backend-provided filename fallback.
|
||||
*/
|
||||
export async function downloadDocumentFile(documentId: string): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await apiRequest(downloadUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download document');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
filename: responseFilename(response, 'document-download'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads extracted markdown content for one document with backend-provided filename fallback.
|
||||
*/
|
||||
export async function downloadDocumentContentMarkdown(
|
||||
documentId: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await apiRequest(contentMarkdownUrl(documentId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download document markdown');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
filename: responseFilename(response, 'document-content.md'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports extracted content markdown files for selected documents or path filters.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user