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 = '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\)/); }); 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'); }); });