Files
reader/web/test/api.test.ts
T
CodeBuddy d0a475919d feat(web): H5 客户端,精排 epub 渲染 + 多格式阅读
用户的三个问题在这里落地:

1. 「H5 是一个完整的客户端」——同一份构建产物既由服务端托管(`/` 直接
   就是完整阅读器),也可被 Android 外壳打进 assets 复用,两种宿主只差
   一个 serverUrl。

2. 「纯 H5 时 epub 等资源是否需要 iframe 或 shadow DOM」——用 shadow DOM,
   但不是为了隔离出版方样式(那做不到也不该做:书必须能给自己排版)。
   它挡的是反方向:客户端自身的 CSS 不会漏进书里。iframe 只在 PDF 那一处
   用,因为 PDF 需要自己的浏览上下文,理由写在 render/container.ts 里。

3. 「Android 端 epub 用 H5 渲染,其他类型用原生」——按 kind 分流,
   capabilities() 明确声明:reflowable 走 WebView,paged/document 在
   Android 交回原生。理由写进 reader-view.ts:WebView 每页一个合成层,
   300MB 的画册在一台中端机上就是 OOM。

服务端同步配合的两处:
- 资源流式下发 + HTTP Range
- epub 按包内路径寻址、items/manifest 分窗

前端无框架:渲染热路径是命令式 DOM 操作,引框架只会把"最小覆盖 CSS"
的策略搞复杂;最终产物 35KB(gzip 10.7KB)。
2026-09-16 14:45:04 +08:00

151 lines
5.4 KiB
TypeScript

/**
* API client tests.
*
* Two behaviours here cause real failures rather than cosmetic ones: the refresh
* token rotation, and the concurrency limiter.
*/
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { ApiClient, ApiError } from '../src/net/api.ts';
interface Call {
url: string;
init: RequestInit;
}
let calls: Call[] = [];
let responder: (url: string, init: RequestInit) => Response;
const originalFetch = globalThis.fetch;
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
beforeEach(() => {
calls = [];
responder = () => json({ ok: true });
globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => {
const url = String(input);
calls.push({ url, init });
return responder(url, init);
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function client(): ApiClient {
let stored: string | null = null;
return new ApiClient({
baseUrl: 'http://nas:8080',
storage: {
read: () => (stored ? (JSON.parse(stored) as never) : null),
write: (session) => {
stored = session ? JSON.stringify(session) : null;
},
},
});
}
describe('api client', () => {
test('a 401 is retried once after a refresh, and the new token is used', async () => {
const api = client();
responder = (url) => {
if (url.includes('/auth/login')) {
return json({ accessToken: 'old', refreshToken: 'r1', accessTokenExpiresAt: 0, refreshTokenExpiresAt: 0 });
}
if (url.includes('/auth/refresh')) {
return json({ accessToken: 'new', refreshToken: 'r2', accessTokenExpiresAt: 1, refreshTokenExpiresAt: 1 });
}
const auth = (calls.at(-1)!.init.headers as Record<string, string>)['authorization'];
return auth === 'Bearer new' ? json({ items: [] }) : json({ error: { code: 'TOKEN_EXPIRED', message: 'x' } }, 401);
};
await api.login('reader', 'password');
const shelf = await api.shelf();
assert.deepEqual(shelf.items, []);
// Rotation: the server revokes the token it was given, so the client must
// keep the one it got back.
assert.equal(api.currentSession?.refreshToken, 'r2');
});
test('concurrent 401s share one refresh instead of revoking each other', async () => {
// The server rotates refresh tokens on use, so four parallel page fetches that
// each refreshed independently would invalidate three of their own tokens and
// sign the reader out in the middle of a comic.
const api = client();
let refreshes = 0;
responder = (url) => {
if (url.includes('/auth/login')) {
return json({ accessToken: 'old', refreshToken: 'r1', accessTokenExpiresAt: 0, refreshTokenExpiresAt: 0 });
}
if (url.includes('/auth/refresh')) {
refreshes += 1;
return json({ accessToken: 'new', refreshToken: `r${refreshes + 1}`, accessTokenExpiresAt: 1, refreshTokenExpiresAt: 1 });
}
const auth = (calls.at(-1)!.init.headers as Record<string, string>)['authorization'];
return auth === 'Bearer new' ? new Response('page', { status: 200 }) : json({ error: { code: 'TOKEN_EXPIRED', message: 'x' } }, 401);
};
await api.login('reader', 'password');
await Promise.all([
api.asset('b', 'page:0'),
api.asset('b', 'page:1'),
api.asset('b', 'page:2'),
api.asset('b', 'page:3'),
]);
assert.equal(refreshes, 1, 'all four requests must share a single refresh');
});
test('a failed refresh signs the reader out rather than looping', async () => {
const api = client();
let signedOut = false;
const auth = new ApiClient({
storage: {
read: () => ({ accessToken: 'x', refreshToken: 'y', accessTokenExpiresAt: 0, refreshTokenExpiresAt: 0 }),
write: () => undefined,
},
onAuthLost: () => {
signedOut = true;
},
});
responder = () => json({ error: { code: 'REFRESH_INVALID', message: 'x' } }, 401);
await assert.rejects(() => auth.shelf(), (error: unknown) => error instanceof ApiError);
assert.equal(signedOut, true);
void api;
});
test('an error code is preserved, so the UI can explain it', async () => {
const api = client();
responder = () => json({ error: { code: 'REGISTRATION_DISABLED', message: 'nope' } }, 400);
await assert.rejects(
() => api.register('a', 'password123'),
(error: unknown) => error instanceof ApiError && error.code === 'REGISTRATION_DISABLED',
);
});
test('a non-JSON error body still produces a usable error', async () => {
// A reverse proxy's HTML error page is the common case; failing to parse it
// must not turn a 502 into a crash.
const api = client();
responder = () => new Response('<html>502</html>', { status: 502 });
await assert.rejects(
() => api.instance(),
(error: unknown) => error instanceof ApiError && error.status === 502,
);
});
test('refs are sent through untouched', async () => {
// `ref` is opaque: the client must not decode, re-encode or normalise it, or
// a chapter path containing a slash breaks.
const api = client();
responder = () => new Response('x', { status: 200 });
await api.asset('book1', 'xhtml:OEBPS/text/ch 1.xhtml');
const url = calls.at(-1)!.url;
assert.match(url, /ref=xhtml%3AOEBPS%2Ftext%2Fch%201\.xhtml/);
});
});