Files
feuerwerk/tests/core/http-store.test.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00

100 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { makeProject } from '../helpers';
const FILE = `${'a'.repeat(64)}.png`;
/** What nginx answers when it refuses a request body larger than its 1 MB limit. */
const NGINX_413 = '<html><head><title>413 Request Entity Too Large</title></head><body></body></html>';
/** Replaces `fetch` with one that always answers the same way; the mock records the request. */
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('HttpProjectStore', () => {
it('reads a JSON body and keeps the server message on failure', async () => {
stubFetch(JSON.stringify([{ id: 'p', name: 'P', revision: 1, updatedAt: 'now' }]), { status: 200 });
await expect(new HttpProjectStore('/api').list()).resolves.toHaveLength(1);
stubFetch(JSON.stringify({ error: 'Project not found' }), { status: 404 });
await expect(new HttpProjectStore('/api').load('p')).rejects.toThrow(/Project not found \(404\)/);
});
it('explains a non-JSON error body by its status instead of calling it invalid JSON', async () => {
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
const { project } = makeProject();
await expect(new HttpProjectStore('/api').save(project, 1)).rejects.toThrow(/too large \(413\)/i);
});
it('still reports a successful response that is not JSON as a defect', async () => {
stubFetch('<html>hello</html>', { status: 200 });
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
});
it('turns a 409 into a StaleRevisionError', async () => {
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
const { project } = makeProject();
await expect(new HttpProjectStore('/api').save(project, 3)).rejects.toBeInstanceOf(StaleRevisionError);
});
});
describe('HttpFileStore', () => {
it('uploads the raw bytes and returns what the server stored', async () => {
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
const stored = await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
const [url, init] = mock.mock.calls[0]!;
expect(url).toBe('/api?r=textures');
expect(init?.method).toBe('POST');
// The body *is* the image: no multipart wrapper, no JSON.
expect(init?.body).toBe(bytes);
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
});
it('sends the file name beside the route, and omits it when there is none', async () => {
const answer = JSON.stringify({ file: FILE, name: 'hero idle.png', width: 2, height: 3, bytes: 4 });
const bytes = new Blob([new Uint8Array(4)]);
let mock = stubFetch(answer, { status: 201 });
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png', 'hero idle.png');
// The body stays the raw image, so the name travels in the query – encoded, not as a path.
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png');
mock = stubFetch(answer, { status: 201 });
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures');
});
it('reports the nginx size limit as a size problem', async () => {
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
const bytes = new Blob([new Uint8Array(8)]);
await expect(new HttpFileStore('/api', 'textures').upload(bytes, 'image/png')).rejects.toThrow(
/too large \(413\)/i,
);
});
it('lists and deletes', async () => {
stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), {
status: 200,
});
await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
]);
const mock = stubFetch(null, { status: 204 });
await new HttpFileStore('/api', 'textures').delete(FILE);
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
});
});