// The upload, against the mock network. Its own file because what is tested here is a TRANSPORT and // not a read: the order of the parts on the wire, the numbers the browser reports while a body goes // out, and the way every class of refusal comes back. // // ⚠ The progress half is tested HERE and not by a scene, and that is a measurement rather than a // preference: in a browser the mock network is a service worker, and Chromium reports no upload // progress for a request a worker answers (probe: `loadstart` with the right total, then nothing). // Under this runner the mock intercepts `XMLHttpRequest` itself and does emit the events, so the // wiring is provable — while the scene can only check what the browser world is able to show. import { HttpResponse, http } from 'msw'; import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest'; import { ApiError } from './client'; import { book } from '../mock/book'; import { rewind } from '../mock/live'; import { clearIntake, intakeLimits, settleIntake } from '../mock/intake'; import { handlersFor } from '../mock/handlers'; import { readBook, readChapters, readLibrary } from './queries'; import { uploadBook } from './upload'; import type { UploadProgress } from './upload'; const server = setupServer(); beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); }); afterAll(() => { server.close(); }); beforeEach(() => { clearIntake(); // The moving state of the fixture is shared by the worlds (one module, one live counter), and a // run started here would otherwise hand its totals to the next file's showcase book. rewind(); server.use(...handlersFor('intake')); }); afterEach(() => { server.resetHandlers(); }); const bookFile = (name = 'gu-zhen-ren.txt', size = 4096) => new File([new Uint8Array(size)], name, { type: 'text/plain' }); test('the book is accepted and comes back as `parsing`, never as `uploading`', async () => { const accepted = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru', title: book.title, }); // PD-180: by the time an answer can be read the last byte is in, so the state it carries is the // one AFTER the upload. A client that drew `uploading` from this answer would draw a state the // book has already left. expect(accepted.status).toBe('parsing'); expect(accepted.title).toBe(book.title); expect(accepted.character_count).toBe(4096); // The chapters are not there yet, and the count says so rather than promising a tree. expect(accepted.chapter_count).toBe(0); }); test('an empty title leaves the naming to the parse', async () => { const accepted = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru', title: '', }); // Today that means the name of the uploaded file (0.2.3): the form sends no `title` part at all, // and what the platform does with its absence is the platform's. expect(accepted.title).toBe('gu-zhen-ren'); }); test('the file is the LAST part of the form, and every field precedes it', async () => { // The rule the whole intake stands on (PD-172): the platform reads the form as a stream and // writes the book's row before the body of the file, so a field behind the file is never read. // What the two tests below add is the other side of it — what the platform does with a part that // arrived too late, required or not. let parts: string[] = []; server.use( http.post('*/v0/books', async ({ request }) => { parts = [...(await request.formData()).keys()]; return HttpResponse.json({}, { status: 500 }); }), ); await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru', title: book.title, genre: book.genre, }).catch(() => undefined); expect(parts).toEqual(['title', 'source_lang', 'target_lang', 'genre', 'file']); }); test('a REQUIRED field behind the file is the same as a field never sent', async () => { // The other side of the rule the client obeys, and it is stated exactly as the platform behaves: // the reader stops at the file, so the languages behind it were never seen — and "late" and // "never" are one answer, 400. const form = new FormData(); form.append('file', bookFile()); form.append('source_lang', 'zh'); form.append('target_lang', 'ru'); const answer = await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: form, }); expect(answer.status).toBe(400); }); test('an OPTIONAL field behind the file is lost, and the book is created without it', async () => { // ⚠ Not a refusal. The assignment of this pack said "a field after the file = 400" and the // platform does not do that: it stops reading at the file, so `genre` behind it is dropped on the // floor and the upload succeeds. A fixture that refused this would refuse a legal request. const form = new FormData(); form.append('source_lang', 'zh'); form.append('target_lang', 'ru'); form.append('file', bookFile()); form.append('genre', book.genre ?? ''); const answer = await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: form, }); expect(answer.status).toBe(201); expect(((await answer.json()) as { genre?: string }).genre).toBe(''); }); test('more parts than the form allows, or a field longer than a kilobyte, are refused', async () => { const many = new FormData(); for (let part = 0; part < intakeLimits.maxParts; part += 1) many.append(`x${String(part)}`, '1'); many.append('file', bookFile()); expect( (await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: many })) .status, ).toBe(400); const long = new FormData(); long.append('genre', 'x'.repeat(intakeLimits.maxFieldBytes + 1)); long.append('source_lang', 'zh'); long.append('target_lang', 'ru'); long.append('file', bookFile()); expect( (await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: long })) .status, ).toBe(400); }); test('the numbers of the body going out reach the caller', async () => { const seen: UploadProgress[] = []; await uploadBook( { file: bookFile('gu-zhen-ren.txt', 4096), sourceLang: 'zh', targetLang: 'ru' }, { onProgress: (progress) => seen.push(progress) }, ); expect(seen.length).toBeGreaterThan(0); const last = seen.at(-1); // The total is the whole multipart body — bigger than the file by its envelope — and the share a // screen draws is `sent / total`, which is why both halves have to arrive. expect(last?.total).toBeGreaterThanOrEqual(4096); expect(last?.sent).toBe(last?.total); }); test('a body over the cap is refused with 413 and a product phrase', async () => { const failure = await uploadBook({ file: bookFile('big.txt', intakeLimits.maxBodyBytes + 1), sourceLang: 'zh', targetLang: 'ru', }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(ApiError); expect((failure as ApiError).status).toBe(413); // The phrase is the platform's and reaches the screen untouched; what the client adds is the // ADVICE, which is different for every class and is not on the wire at all. // ⚠ Asserted as a NON-EMPTY string and not as `not.toBe('')`: with no problem body at all the // title is `undefined`, which is also "not an empty string" — the check passed on the very case // it exists to catch (found by the adversarial review). expect(typeof (failure as ApiError).problem?.title).toBe('string'); expect((failure as ApiError).problem?.title).not.toBe(''); }); test.each([ ['refuse-400.txt', 400], ['refuse-404.txt', 404], ['refuse-408.txt', 408], ])('%s is answered %i', async (name, status) => { const failure = await uploadBook({ file: bookFile(name), sourceLang: 'zh', targetLang: 'ru', }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(ApiError); expect((failure as ApiError).status).toBe(status); }); test('an upload the user cancelled is not a failure of anything', async () => { const abort = new AbortController(); abort.abort(); // ⚠ The signal is checked BEFORE the request is opened, and this test covers only that half. A // cancellation mid-body is the browser's own (`xhr.abort()`), and the mock network cannot be made // to model it: under this runner it intercepts the request the moment it is sent, so an abort a // tick later changes nothing. Named rather than faked green. await expect( uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }, { signal: abort.signal }), ).rejects.toMatchObject({ name: 'UploadAborted' }); }); test('a parsed book gains its chapters; a rejected one gains a machine reason', async () => { const good = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }); const bad = await uploadBook({ file: bookFile('reject-source.txt'), sourceLang: 'zh', targetLang: 'ru', }); settleIntake(); const parsed = await readBook(good.id); expect(parsed.book.status).toBe('not_started'); expect((await readChapters(good.id)).chapters.length).toBeGreaterThan(0); const rejected = await readBook(bad.id); expect(rejected.book.status).toBe('rejected'); expect(rejected.book.reject_reason).toBe('source_unreadable'); // Both books are in the library, and both are visible: a rejected book keeps its row so that the // person can see the upload did not make it. expect((await readLibrary()).books).toHaveLength(2); }); test('a reason this build does not know is narrowed to "not known", never trusted', async () => { const accepted = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }); server.use( http.get('*/v0/books/:bookId', () => HttpResponse.json({ revision: 1, book: { ...accepted, status: 'rejected', reject_reason: 'a_reason_from_a_future_contract' }, run: null, }), ), ); // Narrowed to `null` — which the screen shows exactly as it shows an ABSENT reason, because the // contract says the two are one fact: the reason is not known (0.2.3). expect((await readBook(accepted.id)).book.reject_reason).toBeNull(); });