mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
67ef7f29a5
Replace the hardcoded English in the TTS and narration components with message keys, label every icon-only control, and add settings.common.nav.tts, which the settings sidebar rendered as a raw key. Stop requiring PODCAST_ENCRYPTION_KEY while APP_FEATURES.podcasts is off, and skip podcast routes and permissions in the authorization matrix for the same reason. Pass the direct-download settle() helper the SETTLE_MS budget the file already defines: vi.waitFor's one-second default cannot cover six DNS-resolved redirect hops on a cold CI resolver.
659 lines
28 KiB
TypeScript
659 lines
28 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
/**
|
|
* The fetcher pins the connection to the address that passed policy, and that path goes through
|
|
* undici's fetch rather than the global one. What is under test here is the service, not the
|
|
* dispatcher, so undici is pointed back at whatever stub each test installed.
|
|
*/
|
|
vi.mock('undici', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('undici')>()),
|
|
fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
|
|
}));
|
|
|
|
import { Agent } from 'undici';
|
|
|
|
import { DirectDownloadService, stagedDirectFileName } from './direct-download.service';
|
|
import type { BookRequestDownloadRow } from '../../../db/schema';
|
|
|
|
const HASH = 'a'.repeat(40);
|
|
/** Real time a case may spend waiting on event-loop turns, and a test budget that outlasts it. */
|
|
const SETTLE_MS = 5_000;
|
|
const FAKE_CLOCK = { timeout: 30_000 };
|
|
|
|
describe('DirectDownloadService', () => {
|
|
let appDataPath: string;
|
|
let service: DirectDownloadService;
|
|
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
|
let downloads: { update: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(async () => {
|
|
appDataPath = await mkdtemp(join(tmpdir(), 'bookorbit-http-'));
|
|
downloads = { update: vi.fn().mockResolvedValue(undefined) };
|
|
service = new DirectDownloadService({ appDataPath } as never, downloads as never);
|
|
fetchMock = vi.fn<typeof fetch>();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await rm(appDataPath, { recursive: true, force: true });
|
|
});
|
|
|
|
function fileResponse(body: string, headers: Record<string, string> = {}): Response {
|
|
return new Response(body, { headers: { 'content-type': 'application/epub+zip', ...headers } });
|
|
}
|
|
|
|
/**
|
|
* Waits for the fire-and-forget download, which `add` deliberately does not await.
|
|
*
|
|
* The budget has to be passed: `vi.waitFor` defaults to one second, and a redirect chain pays a
|
|
* real `dns.lookup` per hop through `ensureSafeUrl`, so six hops on a slow resolver outlast it
|
|
* and the case fails in CI while passing on a warm cache.
|
|
*/
|
|
async function settle() {
|
|
return vi.waitFor(
|
|
async () => {
|
|
const [status] = await service.status([HASH]);
|
|
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
|
|
return status;
|
|
},
|
|
{ timeout: SETTLE_MS },
|
|
);
|
|
}
|
|
|
|
it('writes the file into its staging directory and reports where it landed', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
const status = await settle();
|
|
|
|
expect(status.state).toBe('completed');
|
|
expect(status.progressPercent).toBe(100);
|
|
expect(status.contentPath).toBe(join(appDataPath, 'request-downloads', HASH, 'book.epub'));
|
|
await expect(readFile(status.contentPath!, 'utf8')).resolves.toBe('a real epub would go here');
|
|
});
|
|
|
|
it('persists validators and resumes a partial file only from the requested byte', async () => {
|
|
const directory = join(appDataPath, 'request-downloads', HASH);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, 'book.epub'), 'partial');
|
|
fetchMock.mockResolvedValue(
|
|
new Response(' tail', {
|
|
status: 206,
|
|
headers: {
|
|
'content-type': 'application/epub+zip',
|
|
'content-range': 'bytes 7-11/12',
|
|
etag: '"edition-1"',
|
|
},
|
|
}),
|
|
);
|
|
|
|
const resumed = await service.resume({
|
|
id: 44,
|
|
source: 'direct_url',
|
|
clientKey: HASH,
|
|
directUrl: 'https://archive.org/download/x/book.epub',
|
|
directFileName: 'book.epub',
|
|
directEtag: '"edition-1"',
|
|
directLastModified: null,
|
|
totalBytes: 12,
|
|
status: 'downloading',
|
|
} as BookRequestDownloadRow);
|
|
|
|
expect(resumed).toBe(true);
|
|
expect((await settle()).state).toBe('completed');
|
|
await expect(readFile(join(directory, 'book.epub'), 'utf8')).resolves.toBe('partial tail');
|
|
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Range: 'bytes=7-', 'If-Range': '"edition-1"' });
|
|
expect(downloads.update).toHaveBeenCalledWith(44, expect.objectContaining({ directEtag: '"edition-1"', totalBytes: 12 }));
|
|
});
|
|
|
|
it('fails rather than appending when a source ignores the validated range request', async () => {
|
|
const directory = join(appDataPath, 'request-downloads', HASH);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, 'book.epub'), 'partial');
|
|
fetchMock.mockResolvedValue(fileResponse('a different whole file', { etag: '"edition-2"' }));
|
|
|
|
await service.resume({
|
|
id: 45,
|
|
source: 'direct_url',
|
|
clientKey: HASH,
|
|
directUrl: 'https://archive.org/download/x/book.epub',
|
|
directFileName: 'book.epub',
|
|
directEtag: '"edition-1"',
|
|
directLastModified: null,
|
|
totalBytes: 12,
|
|
status: 'downloading',
|
|
} as BookRequestDownloadRow);
|
|
|
|
const status = await settle();
|
|
expect(status.state).toBe('failed');
|
|
expect(status.errorMessage).toContain('does not support resuming');
|
|
});
|
|
|
|
it('accepts a completed partial file only when a 416 confirms its exact saved size', async () => {
|
|
const directory = join(appDataPath, 'request-downloads', HASH);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, 'book.epub'), 'complete');
|
|
fetchMock.mockResolvedValue(new Response('', { status: 416, headers: { 'content-range': 'bytes */8' } }));
|
|
|
|
await service.resume({
|
|
id: 46,
|
|
source: 'direct_url',
|
|
clientKey: HASH,
|
|
directUrl: 'https://archive.org/download/x/book.epub',
|
|
directFileName: 'book.epub',
|
|
directEtag: '"edition-1"',
|
|
directLastModified: null,
|
|
totalBytes: 8,
|
|
status: 'downloading',
|
|
} as BookRequestDownloadRow);
|
|
|
|
await expect(settle()).resolves.toMatchObject({ state: 'completed', downloadedBytes: 8, totalBytes: 8 });
|
|
await expect(readFile(join(directory, 'book.epub'), 'utf8')).resolves.toBe('complete');
|
|
});
|
|
|
|
it('fails a resumed response that ends before its declared total', async () => {
|
|
const directory = join(appDataPath, 'request-downloads', HASH);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, 'book.epub'), 'partial');
|
|
fetchMock.mockResolvedValue(
|
|
new Response('x', {
|
|
status: 206,
|
|
headers: { 'content-type': 'application/epub+zip', 'content-range': 'bytes 7-11/12', etag: '"edition-1"' },
|
|
}),
|
|
);
|
|
|
|
await service.resume({
|
|
id: 47,
|
|
source: 'direct_url',
|
|
clientKey: HASH,
|
|
directUrl: 'https://archive.org/download/x/book.epub',
|
|
directFileName: 'book.epub',
|
|
directEtag: '"edition-1"',
|
|
directLastModified: null,
|
|
totalBytes: 12,
|
|
status: 'downloading',
|
|
} as BookRequestDownloadRow);
|
|
|
|
await expect(settle()).resolves.toMatchObject({ state: 'failed', errorMessage: 'That source ended after 8 of 12 bytes' });
|
|
});
|
|
|
|
it('refuses a partial file with no strong validator before making a request', async () => {
|
|
const directory = join(appDataPath, 'request-downloads', HASH);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, 'book.epub'), 'partial');
|
|
|
|
await expect(
|
|
service.resume({
|
|
id: 48,
|
|
source: 'direct_url',
|
|
clientKey: HASH,
|
|
directUrl: 'https://archive.org/download/x/book.epub',
|
|
directFileName: 'book.epub',
|
|
directEtag: null,
|
|
directLastModified: null,
|
|
totalBytes: 12,
|
|
status: 'downloading',
|
|
} as BookRequestDownloadRow),
|
|
).resolves.toBe(false);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* The importer classifies by extension alone, so a name the source did not put one on is a file
|
|
* it finds no book in. Inspection reported this release ready, which is a promise the staged
|
|
* name has to keep rather than one the download breaks.
|
|
*/
|
|
it('stages a nameless file under the format the source declared', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/get', fileName: 'download', format: 'epub', clientKey: HASH });
|
|
|
|
expect((await settle()).contentPath).toBe(join(appDataPath, 'request-downloads', HASH, 'download.epub'));
|
|
});
|
|
|
|
/**
|
|
* A content-length that is not a whole number of kilobytes used to be fatal: the transfer budget
|
|
* was derived from it, and `AbortSignal.timeout` rejects a fractional delay outright. This
|
|
* failed against the live archive on a 187712 byte EPUB while every mocked test passed.
|
|
*/
|
|
it('accepts a content-length that does not divide evenly', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('x'.repeat(187712), { 'content-length': '187712' }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
|
|
expect((await settle()).state).toBe('completed');
|
|
});
|
|
|
|
/**
|
|
* Timers and the clock are faked; `setImmediate` is not. Staging the file and resolving the host
|
|
* are real I/O that only completes on real event-loop turns, so faking everything would stop the
|
|
* download before it started.
|
|
*/
|
|
function withFakeClock<T>(body: () => Promise<T>): Promise<T> {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
return body().finally(() => vi.useRealTimers());
|
|
}
|
|
|
|
/** Bounded on the real clock, which `Date` being faked is exactly why this cannot use `Date`. */
|
|
function realElapsedMs(from: bigint): number {
|
|
return Number(process.hrtime.bigint() - from) / 1e6;
|
|
}
|
|
|
|
/** Real event-loop turns, so the fs and DNS work `add` kicks off can actually finish. */
|
|
async function until(predicate: () => boolean, ms = SETTLE_MS): Promise<void> {
|
|
const started = process.hrtime.bigint();
|
|
while (!predicate() && realElapsedMs(started) < ms) await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
/**
|
|
* Moves `Date` on without faking the timers the transfer itself needed to be real, and puts it
|
|
* back afterwards so the offset cannot leak into the next case.
|
|
*/
|
|
async function atLaterTime<T>(offsetMs: number, body: () => Promise<T>): Promise<T> {
|
|
vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + offsetMs });
|
|
try {
|
|
return await body();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Real turns until the transfer has taken the bytes just pushed. A fixed pause was enough here
|
|
* and timed out on a loaded runner, where the clock could pass the idle timeout before the
|
|
* watchdog these cases advance had been armed at all, leaving a transfer nothing would end.
|
|
*/
|
|
async function untilDownloaded(bytes: number, ms = SETTLE_MS): Promise<void> {
|
|
const started = process.hrtime.bigint();
|
|
while (realElapsedMs(started) < ms) {
|
|
const [status] = await service.status([HASH]);
|
|
if (status.downloadedBytes >= bytes) return;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
/** What `settle` does, without the faked `setTimeout` that `vi.waitFor` would need. */
|
|
async function settleOnRealTurns() {
|
|
const started = process.hrtime.bigint();
|
|
while (realElapsedMs(started) < SETTLE_MS) {
|
|
const [status] = await service.status([HASH]);
|
|
if (status.state === 'completed' || status.state === 'failed') return status;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
return { state: 'never settled', errorMessage: null } as never;
|
|
}
|
|
|
|
/** A body the test feeds by hand, so a transfer can be spread across as much clock as it likes. */
|
|
function trickleResponse() {
|
|
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(source) {
|
|
controller = source;
|
|
},
|
|
});
|
|
return {
|
|
response: new Response(body, { headers: { 'content-type': 'application/epub+zip' } }),
|
|
push: (text: string) => controller.enqueue(new TextEncoder().encode(text)),
|
|
close: () => controller.close(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The regression that made every LibriVox grab fail. The connect deadline was set with
|
|
* `AbortSignal.timeout`, whose signal stays attached to the response body, so a 239 MB zip
|
|
* streaming at 6 MB/s was aborted at exactly 30 seconds with three quarters of it written.
|
|
* Gutenberg's one-megabyte EPUBs land long before that, which is why nothing caught it.
|
|
*/
|
|
it('lets a download outlive the deadline for reaching the server', FAKE_CLOCK, async () => {
|
|
await withFakeClock(async () => {
|
|
let handed: AbortSignal | undefined;
|
|
fetchMock.mockImplementation((_url, init) => {
|
|
handed = init?.signal ?? undefined;
|
|
return Promise.resolve(fileResponse('a real epub would go here'));
|
|
});
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await until(() => handed !== undefined);
|
|
// Well past the connect deadline, which by then has nothing left to say about the transfer.
|
|
vi.advanceTimersByTime(90_000);
|
|
|
|
await expect(settleOnRealTurns()).resolves.toMatchObject({ state: 'completed' });
|
|
expect(handed!.aborted).toBe(false);
|
|
});
|
|
});
|
|
|
|
/** The other half: dropping the deadline once headers arrive must not drop it before they do. */
|
|
it('still gives up on a server that never sends its headers', FAKE_CLOCK, async () => {
|
|
await withFakeClock(async () => {
|
|
let asked = false;
|
|
fetchMock.mockImplementation((_url, init) => {
|
|
asked = true;
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error));
|
|
});
|
|
});
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await until(() => asked);
|
|
vi.advanceTimersByTime(31_000);
|
|
|
|
await expect(settleOnRealTurns()).resolves.toMatchObject({ state: 'failed' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The second half of the same defect. The body had a budget that scaled with the file size but
|
|
* still assumed a floor on throughput: about 1 MB/s, so a 239 MB audiobook needed 5.4 Mbit/s and
|
|
* anything slower spent six minutes downloading before being told the line was too slow. A slow
|
|
* transfer is not a failed one, and nothing here bounds how long an honest one may take.
|
|
*/
|
|
it('finishes a slow transfer that keeps sending, however long it takes', FAKE_CLOCK, async () => {
|
|
await withFakeClock(async () => {
|
|
const trickle = trickleResponse();
|
|
fetchMock.mockResolvedValue(trickle.response);
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
// Ten minutes of transfer, for a file the old size-scaled budget allowed two.
|
|
const chunk = 'another chunk of the audiobook';
|
|
for (let minute = 0; minute < 10; minute++) {
|
|
trickle.push(chunk);
|
|
await untilDownloaded(chunk.length * (minute + 1));
|
|
vi.advanceTimersByTime(60_000);
|
|
}
|
|
trickle.close();
|
|
|
|
await expect(settleOnRealTurns()).resolves.toMatchObject({ state: 'completed' });
|
|
});
|
|
});
|
|
|
|
/** Slow is not stalled, and the difference is whether anything is still arriving. */
|
|
it('gives up on a transfer that stops sending altogether', FAKE_CLOCK, async () => {
|
|
await withFakeClock(async () => {
|
|
const trickle = trickleResponse();
|
|
fetchMock.mockResolvedValue(trickle.response);
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
const chunk = 'the only chunk that ever arrives';
|
|
trickle.push(chunk);
|
|
await untilDownloaded(chunk.length);
|
|
vi.advanceTimersByTime(130_000);
|
|
|
|
await expect(settleOnRealTurns()).resolves.toMatchObject({ state: 'failed', errorMessage: expect.stringMatching(/sent nothing/) });
|
|
});
|
|
});
|
|
|
|
/** An open library answers a wrong path with a courtesy page and a 200, not a 404. */
|
|
it('fails a response that is a web page rather than a file', async () => {
|
|
fetchMock.mockResolvedValue(new Response('<html>not found</html>', { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
const status = await settle();
|
|
|
|
expect(status.state).toBe('failed');
|
|
expect(status.errorMessage).toMatch(/web page/);
|
|
});
|
|
|
|
/**
|
|
* Which is exactly what a file host does when it reads `node` in the agent header, so the cause
|
|
* arrives as the failure above and names nothing. Saying who we are is what gets us served.
|
|
*/
|
|
it('names itself rather than letting Node announce the request as node', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
|
expect((init.headers as Record<string, string>)['User-Agent']).toBe('BookOrbit');
|
|
});
|
|
|
|
it('fails a file that declares itself past the size cap', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('x', { 'content-length': String(64 * 1024 * 1024 * 1024) }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', clientKey: HASH });
|
|
|
|
expect((await settle()).state).toBe('failed');
|
|
});
|
|
|
|
it('fails an empty file rather than importing nothing', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse(''));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
|
|
expect((await settle()).state).toBe('failed');
|
|
});
|
|
|
|
/**
|
|
* `redirect: 'follow'` would let a redirect to a private address through, which is exactly what
|
|
* the containment check exists to stop, so each hop is checked in turn.
|
|
*/
|
|
it('refuses a redirect to a private address', async () => {
|
|
fetchMock.mockResolvedValueOnce(new Response('', { status: 302, headers: { location: 'http://127.0.0.1:8080/secret' } }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
|
|
expect((await settle()).state).toBe('failed');
|
|
});
|
|
|
|
it('gives up rather than following redirects forever', async () => {
|
|
fetchMock.mockResolvedValue(new Response('', { status: 302, headers: { location: 'https://archive.org/again' } }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
const status = await settle();
|
|
|
|
expect(status.state).toBe('failed');
|
|
expect(status.errorMessage).toMatch(/redirected more than/);
|
|
});
|
|
|
|
/** The filename comes from an external source, so it must not be able to choose a path. */
|
|
it('keeps a traversing filename inside the download directory', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('payload'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/b', fileName: '../../escaped.epub', clientKey: HASH });
|
|
const status = await settle();
|
|
|
|
expect(status.state).toBe('completed');
|
|
expect(status.contentPath!.startsWith(join(appDataPath, 'request-downloads', HASH))).toBe(true);
|
|
});
|
|
|
|
/**
|
|
* A release URL comes from an indexer, so this is the caller that cannot live with a name that
|
|
* resolves publicly to be approved and privately to be connected to. The refusal itself is
|
|
* proven in `safe-fetch.test.ts`; what matters here is that this path asks for it.
|
|
*/
|
|
it('connects only to the address that passed policy', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
expect(fetchMock.mock.calls[0][1]).toMatchObject({ dispatcher: expect.any(Agent) });
|
|
});
|
|
|
|
/**
|
|
* Reported the way a download client reports a torrent it does not hold: by leaving it out. The
|
|
* poll loop reads an unmapped `unknown` as `downloading`, so answering with one left a transfer a
|
|
* restart interrupted sitting at "downloading 0%" until the twelve-hour stall timeout.
|
|
*/
|
|
it('leaves a hash it has never seen out of the answer rather than reporting a state for it', async () => {
|
|
await expect(service.status(['b'.repeat(40)])).resolves.toEqual([]);
|
|
});
|
|
|
|
it('deletes only the staged copy, the library hardlink being a separate one', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('payload'));
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
await service.remove(HASH, { deleteFiles: true });
|
|
|
|
await expect(stat(join(appDataPath, 'request-downloads', HASH))).rejects.toThrow();
|
|
await expect(service.status([HASH])).resolves.toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* Nothing collects a failed attempt. It is no longer polled, `cleanupStagedDirectDownload` runs
|
|
* only after a successful import, and `removeLatestForRequest` only on cancel or delete - so
|
|
* whatever the failure wrote sits under its own hash directory until the disk fills. The size
|
|
* ceiling is the worst of them: it is checked after the chunk is appended, so tripping it means
|
|
* roughly eight gigabytes are already written.
|
|
*/
|
|
describe('staging after a failure', () => {
|
|
async function stagingExists(): Promise<boolean> {
|
|
return stat(join(appDataPath, 'request-downloads', HASH)).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
}
|
|
|
|
it('leaves nothing behind when the server answers with a page rather than a file', async () => {
|
|
fetchMock.mockResolvedValue(new Response('<html>not found</html>', { headers: { 'content-type': 'text/html' } }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
expect(await stagingExists()).toBe(false);
|
|
});
|
|
|
|
it('leaves nothing behind when the file declares itself past the size cap', async () => {
|
|
fetchMock.mockResolvedValue(fileResponse('x', { 'content-length': String(64 * 1024 * 1024 * 1024) }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', clientKey: HASH });
|
|
await settle();
|
|
|
|
expect(await stagingExists()).toBe(false);
|
|
});
|
|
|
|
it('leaves nothing behind when the connection fails outright', async () => {
|
|
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
expect(await stagingExists()).toBe(false);
|
|
});
|
|
|
|
it('leaves nothing behind when the stream breaks partway through', async () => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('the first half'));
|
|
controller.error(new Error('the connection dropped'));
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/epub+zip' } }));
|
|
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
expect(await stagingExists()).toBe(false);
|
|
});
|
|
|
|
/** Read once by the poll loop and then never again, so the entry must not outlive the process. */
|
|
it('drops a terminal entry once nothing could still be reading it', async () => {
|
|
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
expect((await settle()).state).toBe('failed');
|
|
|
|
const reported = await atLaterTime(11 * 60 * 1000, () => service.status([HASH]));
|
|
|
|
expect(reported).toEqual([]);
|
|
});
|
|
|
|
/** Dropping it too early would replace the reason it failed with "the client has never heard of it". */
|
|
it('keeps a terminal entry readable while the poll loop could still want it', async () => {
|
|
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await settle();
|
|
|
|
const state = await atLaterTime(60 * 1000, async () => (await service.status([HASH]))[0].state);
|
|
|
|
expect(state).toBe('failed');
|
|
});
|
|
});
|
|
|
|
/** Startup reaps directories only after resumable attempts have claimed the ones they still need. */
|
|
describe('reapStaging', () => {
|
|
const OTHER = 'c'.repeat(40);
|
|
|
|
async function stage(hash: string): Promise<void> {
|
|
await mkdir(join(appDataPath, 'request-downloads', hash), { recursive: true });
|
|
}
|
|
|
|
it('removes a directory no live attempt is behind and keeps the ones that are', async () => {
|
|
await stage(HASH);
|
|
await stage(OTHER);
|
|
|
|
const reaped = await service.reapStaging(new Set([OTHER]));
|
|
|
|
expect(reaped).toBe(1);
|
|
await expect(stat(join(appDataPath, 'request-downloads', HASH))).rejects.toThrow();
|
|
await expect(stat(join(appDataPath, 'request-downloads', OTHER))).resolves.toBeDefined();
|
|
});
|
|
|
|
/** A transfer this process is running has no row saying so until the grab commits. */
|
|
it('never reaps a directory this process is downloading into', async () => {
|
|
const trickle = trickleResponse();
|
|
fetchMock.mockResolvedValue(trickle.response);
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
|
|
|
expect(await service.reapStaging(new Set())).toBe(0);
|
|
await expect(stat(join(appDataPath, 'request-downloads', HASH))).resolves.toBeDefined();
|
|
|
|
trickle.push('payload');
|
|
trickle.close();
|
|
await settle();
|
|
});
|
|
|
|
it('says nothing was reaped when the root does not exist yet', async () => {
|
|
await expect(service.reapStaging(new Set())).resolves.toBe(0);
|
|
});
|
|
});
|
|
|
|
it('aborts an active transfer before deleting its partial staging file', async () => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('partial'));
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/epub+zip', 'content-length': '1000' } }));
|
|
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
|
await vi.waitFor(async () => {
|
|
expect((await service.status([HASH]))[0].downloadedBytes).toBeGreaterThan(0);
|
|
});
|
|
|
|
await service.remove(HASH, { deleteFiles: true });
|
|
|
|
await expect(service.status([HASH])).resolves.toEqual([]);
|
|
await expect(stat(join(appDataPath, 'request-downloads', HASH))).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The rule the staged name follows, which inspection reads too: the two have to agree or a release
|
|
* is accepted, downloaded in full, and then refused for a condition inspection called safe.
|
|
*/
|
|
describe('stagedDirectFileName', () => {
|
|
it('gives a nameless file the declared extension', () => {
|
|
expect(stagedDirectFileName('download', 'epub')).toBe('download.epub');
|
|
expect(stagedDirectFileName('', 'epub')).toBe('download.epub');
|
|
expect(stagedDirectFileName(null, 'M4B')).toBe('download.m4b');
|
|
expect(stagedDirectFileName('download', '.epub')).toBe('download.epub');
|
|
});
|
|
|
|
/** A `.zip` holding an epub is an archive the importer extracts; `book.zip.epub` is not. */
|
|
it('leaves a name that already carries an extension alone', () => {
|
|
expect(stagedDirectFileName('book.zip', 'epub')).toBe('book.zip');
|
|
expect(stagedDirectFileName('book.epub', 'epub')).toBe('book.epub');
|
|
});
|
|
|
|
/** Nothing to promise: an unsupported format is refused at inspection rather than dressed up. */
|
|
it('adds nothing for a format the importer does not support', () => {
|
|
expect(stagedDirectFileName('download', 'html')).toBe('download');
|
|
expect(stagedDirectFileName(null, 'html')).toBe('download.bin');
|
|
expect(stagedDirectFileName(undefined, undefined)).toBe('download.bin');
|
|
});
|
|
});
|