import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { pluginsOf } from '../../src/core/store/projectPlugins';
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 = '
413 Request Entity Too Large';
/** 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(() => 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('hello', { status: 200 });
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
});
/**
* The editor loads the project's plugins in `prepare` (#119). What they register – node types,
* asset kinds – has to be there before the JSON is read, or the deserializer drops it as unknown.
*/
it('runs prepare on the raw JSON and waits for it before deserializing', async () => {
stubFetch(JSON.stringify({ revision: 4, project: { id: 'p', name: 'P', plugins: ['demo.js'] } }), {
status: 200,
});
let seen: unknown;
const loaded = await new HttpProjectStore('/api').load('p', async (raw) => {
seen = raw;
await Promise.resolve();
// Whatever prepare does has to be part of what is read: this stands for the asset kinds and
// node types a plugin registers while it is being activated.
(raw as { assets: unknown[] }).assets = [{ id: 's', type: 'scene', name: 'Level' }];
});
expect(pluginsOf(seen)).toEqual(['demo.js']);
expect(loaded.project.plugins).toEqual(['demo.js']);
expect(loaded.project.assets.map((a) => a.name)).toEqual(['Level']);
});
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');
});
});