textmachine/frontend/src/api/upload.test.ts

254 lines
10 KiB
TypeScript

// 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();
});