122 lines
5.7 KiB
TypeScript
122 lines
5.7 KiB
TypeScript
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 = '<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\)/);
|
||
});
|
||
|
||
/**
|
||
* 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');
|
||
});
|
||
});
|