254 lines
10 KiB
TypeScript
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();
|
|
});
|