mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
用户的三个问题在这里落地: 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)。
151 lines
5.4 KiB
TypeScript
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/);
|
|
});
|
|
});
|