mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
import { createStores } from '../../src/store/idb.ts';
|
|
import { ApiError, errorForStatus } from '../../src/api/errors.ts';
|
|
import type { KeyValueStore, BlobStore, Platform, HttpRequest, HttpResponse } from '../../src/core/platform.ts';
|
|
|
|
/** In-memory key/value store, matching the interface the client depends on. */
|
|
export class MemoryKv implements KeyValueStore {
|
|
private readonly map = new Map<string, string>();
|
|
async get(key: string): Promise<string | null> {
|
|
return this.map.has(key) ? this.map.get(key)! : null;
|
|
}
|
|
async set(key: string, value: string): Promise<void> {
|
|
this.map.set(key, value);
|
|
}
|
|
async remove(key: string): Promise<void> {
|
|
this.map.delete(key);
|
|
}
|
|
}
|
|
|
|
export class MemoryBlobs implements BlobStore {
|
|
private readonly map = new Map<string, Uint8Array>();
|
|
async get(key: string): Promise<Uint8Array | null> {
|
|
return this.map.get(key) ?? null;
|
|
}
|
|
async put(key: string, bytes: Uint8Array): Promise<void> {
|
|
this.map.set(key, bytes);
|
|
}
|
|
async remove(key: string): Promise<void> {
|
|
this.map.delete(key);
|
|
}
|
|
async list(): Promise<string[]> {
|
|
return [...this.map.keys()];
|
|
}
|
|
async usage(): Promise<number> {
|
|
let total = 0;
|
|
for (const bytes of this.map.values()) total += bytes.byteLength;
|
|
return total;
|
|
}
|
|
}
|
|
|
|
export interface RecordedRequest {
|
|
url: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
/** String for the JSON calls, `FormData` for an upload. */
|
|
body?: string | FormData;
|
|
}
|
|
|
|
/** The recorded body as text, for the assertions that read JSON. */
|
|
export function bodyText(request: RecordedRequest | undefined): string {
|
|
const body = request?.body;
|
|
if (body === undefined) return '';
|
|
if (typeof body === 'string') return body;
|
|
// A `FormData` has no single text form; the upload tests read it field by
|
|
// field instead, which is what the server does.
|
|
return '';
|
|
}
|
|
|
|
export type Responder = (request: RecordedRequest) => HttpResponse | Promise<HttpResponse>;
|
|
|
|
/** Sentinel for "the server answered 200 with a body that is not JSON". */
|
|
export const NON_JSON_BODY = Symbol('non-json-body');
|
|
|
|
/**
|
|
* A transport double that records every request.
|
|
*
|
|
* Used to assert on the *shape* of client-server traffic — that a refresh is
|
|
* de-duplicated, that a stale offline batch is still sent — which is where the
|
|
* subtle bugs in sync live, and which the server's own tests cannot see.
|
|
*/
|
|
export class FakeTransport {
|
|
readonly requests: RecordedRequest[] = [];
|
|
private responder: Responder = () => ({ status: 200, headers: {}, json: {} });
|
|
|
|
respondWith(responder: Responder): void {
|
|
this.responder = responder;
|
|
}
|
|
|
|
json(payload: unknown, status = 200): void {
|
|
this.respondWith(() => ({ status, headers: {}, json: payload }));
|
|
}
|
|
|
|
/** A 200 whose body is not JSON, which is what a proxy or portal returns. */
|
|
nonJson(status = 200): void {
|
|
this.respondWith(() => {
|
|
throw new ApiError('offline', 'server returned a non-JSON response', 'BAD_GATEWAY', status);
|
|
});
|
|
}
|
|
|
|
failWith(error: Error): void {
|
|
this.respondWith(() => {
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mirrors `FetchTransport`'s contract: a non-2xx response is a thrown
|
|
* `ApiError`, and a thrown `TypeError` becomes an `offline` error.
|
|
*
|
|
* Reproducing that normalisation is not incidental. The client's retry, refresh
|
|
* and offline logic is all keyed off `ApiError.kind`, so a double that hands
|
|
* back raw failure responses would let all of it go untested.
|
|
*/
|
|
async send(request: HttpRequest): Promise<HttpResponse> {
|
|
const recorded: RecordedRequest = {
|
|
url: request.url,
|
|
method: request.method,
|
|
headers: request.headers,
|
|
...(request.body !== undefined ? { body: request.body } : {}),
|
|
};
|
|
this.requests.push(recorded);
|
|
|
|
let response: HttpResponse;
|
|
try {
|
|
response = await this.responder(recorded);
|
|
} catch (err) {
|
|
if (err instanceof ApiError) throw err;
|
|
throw new ApiError('offline', err instanceof Error ? err.message : 'network error');
|
|
}
|
|
|
|
if (response.status >= 200 && response.status < 300) return response;
|
|
|
|
const body = response.json as { error?: { code?: string; message?: string } } | undefined;
|
|
const code = body?.error?.code ?? '';
|
|
const message = body?.error?.message ?? `request failed with status ${response.status}`;
|
|
throw new ApiError(errorForStatus(response.status), message, code, response.status);
|
|
}
|
|
|
|
countMatching(predicate: (request: RecordedRequest) => boolean): number {
|
|
return this.requests.filter(predicate).length;
|
|
}
|
|
|
|
/**
|
|
* A library folder: the file listing, and no books in it.
|
|
*
|
|
* The library screen asks two endpoints — `/library/browse` for the rows and
|
|
* `/books` for the books in the same folder — because it has two pages. A suite
|
|
* that is about the *file* page has no opinion about the other one, but the other
|
|
* one still has to answer: a transport that replies `{}` to `/books` makes the
|
|
* screen read `undefined.items`, which surfaces as "books is not iterable" against
|
|
* whichever assertion the test was actually making.
|
|
*
|
|
* Answering `{ items: [] }` to everything *else* is what keeps a suite about one
|
|
* endpoint from having to enumerate the others.
|
|
*/
|
|
respondWithBoth(payload: unknown, status = 200): void {
|
|
this.respondWith((request) => {
|
|
const json = request.url.startsWith('/api/v1/books')
|
|
? { items: [], total: 0, page: 1, pageSize: 60 }
|
|
: payload;
|
|
return { status, headers: {}, json };
|
|
});
|
|
}
|
|
}
|
|
|
|
export interface TestPlatform extends Platform {
|
|
kv: MemoryKv;
|
|
blobs: MemoryBlobs;
|
|
setOnline(next: boolean): void;
|
|
}
|
|
|
|
export function makePlatform(
|
|
transport: FakeTransport,
|
|
kv = new MemoryKv(),
|
|
blobs = new MemoryBlobs(),
|
|
): TestPlatform {
|
|
const listeners = new Set<(state: 'online' | 'offline') => void>();
|
|
let online = true;
|
|
return {
|
|
name: 'web',
|
|
deviceLabel: 'test-device',
|
|
transport,
|
|
kv,
|
|
blobs,
|
|
async connectivity() {
|
|
return online ? 'online' : 'offline';
|
|
},
|
|
onConnectivityChange(listener) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
setOnline(next: boolean) {
|
|
online = next;
|
|
for (const listener of listeners) listener(next ? 'online' : 'offline');
|
|
},
|
|
};
|
|
}
|
|
|
|
export { createStores };
|