fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image

This commit is contained in:
2026-09-07 21:24:33 +08:00
parent 95317a0108
commit 961de429f9
45 changed files with 7 additions and 3 deletions
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { setToken } from "../src/api/client";
import { __resetImageCache, fetchObjectUrl } from "../src/lib/authImage";
beforeEach(() => {
__resetImageCache();
setToken("T");
(URL as any).createObjectURL = vi.fn(() => "blob:gen");
});
afterEach(() => vi.unstubAllGlobals());
it("blob → objectURL;同 URL 只发一次请求", async () => {
const f = vi.fn(async (_url: any, init: any) => {
expect(init.headers.Authorization).toBe("Bearer T");
return new Response(new Blob(["img"]), { status: 200 });
});
vi.stubGlobal("fetch", f);
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(f).toHaveBeenCalledTimes(1);
});
it("失败不入缓存,重试重新请求", async () => {
let n = 0;
vi.stubGlobal("fetch", async () => {
n++;
return n === 1 ? new Response("x", { status: 500 }) : new Response(new Blob(["img"]), { status: 200 });
});
await expect(fetchObjectUrl("/api/books/2/pages/0?v=h")).rejects.toThrow();
expect(await fetchObjectUrl("/api/books/2/pages/0?v=h")).toBe("blob:gen");
expect(n).toBe(2);
});
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client";
const jres = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
beforeEach(() => setToken(null));
afterEach(() => vi.unstubAllGlobals());
it("login 成功存 token,请求体带凭据", async () => {
const f = vi.fn(async (_url: any, _init: any) => jres(200, { token: "T" }));
vi.stubGlobal("fetch", f);
await expect(api.login("a", "b")).resolves.toEqual({ token: "T" });
expect(f.mock.calls[0][0]).toBe("/api/auth/login");
expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"');
});
it("401 清 token 并抛 HttpError(parsed code)", async () => {
setToken("T");
vi.stubGlobal("fetch", async (_url: any, init: any) => {
expect(init.headers.Authorization).toBe("Bearer T");
return jres(401, { error: { code: "unauthorized", message: "bad credentials" } });
});
const e = await api.login("a", "b").catch((e) => e);
expect(e).toBeInstanceOf(HttpError);
expect((e as HttpError).code).toBe("unauthorized");
expect(getToken()).toBeNull();
});
it("非 JSON 错误体回退 http_<status>", async () => {
vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 }));
const e = await api.listUsers().catch((e) => e);
expect((e as HttpError).code).toBe("http_500");
});
it("204 无体 resolve undefined", async () => {
vi.stubGlobal("fetch", async () => new Response(null, { status: 204 }));
await expect(api.deleteBook(1)).resolves.toBeUndefined();
});
it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => {
const seen: string[] = [];
vi.stubGlobal("fetch", async (url: any) => {
seen.push(String(url));
return jres(200, []);
});
await api.listBooks({ library: 3, q: "x" });
await apiRaw("/api/books/1/cover?v=h");
expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]);
});
it("page_url_fmt %d 替换", () => {
expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h");
});
it("putProgress keepalive 透传 + JSON body", async () => {
const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", f);
await api.putProgress(1, { page: 2 }, 0.5, true);
expect(f.mock.calls[0][1].keepalive).toBe(true);
expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5');
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from "vitest";
import { groupByDir } from "../src/lib/group";
import type { Book } from "../src/api/types";
let seq = 1;
const mk = (path: string): Book => ({
id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0,
state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover",
});
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
const gs = groupByDir([mk("b.cbz"), mk("z/1.cbz"), mk("a/2.cbz"), mk("a/sub/3.cbz")]);
expect(gs.map((g) => g.dir)).toEqual(["", "a", "a/sub", "z"]);
expect(gs[0].books.map((x) => x.path)).toEqual(["b.cbz"]);
expect(gs[1].books.map((x) => x.path)).toEqual(["a/2.cbz"]);
});
it("空输入出空数组", () => {
expect(groupByDir([])).toEqual([]);
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createProgressSaver } from "../src/lib/progress";
type Sent = [Record<string, unknown>, number, boolean];
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("leading 立即发;间隔内多次 report 合并成一次 trailing", () => {
const sent: Sent[] = [];
const s = createProgressSaver((l, p, ka) => {
sent.push([l, p, ka]);
return Promise.resolve();
});
s.report({ page: 1 }, 0.1);
expect(sent.length).toBe(1);
s.report({ page: 2 }, 0.2);
s.report({ page: 3 }, 0.3);
expect(sent.length).toBe(1);
vi.advanceTimersByTime(4999);
expect(sent.length).toBe(1);
vi.advanceTimersByTime(1);
expect(sent.length).toBe(2);
expect(sent[1]).toEqual([{ page: 3 }, 0.3, false]);
});
it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", () => {
const sent: Sent[] = [];
const s = createProgressSaver((l, p, ka) => {
sent.push([l, p, ka]);
return Promise.resolve();
});
s.report({ page: 0 }, 0);
s.report({ page: 5 }, 0.5);
s.flush();
expect(sent.length).toBe(2);
expect(sent[1]).toEqual([{ page: 5 }, 0.5, true]);
s.flush();
expect(sent.length).toBe(2);
vi.advanceTimersByTime(10_000);
expect(sent.length).toBe(2); // 挂起已被 flush 消费,定时器不再补发
});
+31
View File
@@ -0,0 +1,31 @@
import { expect, it } from "vitest";
import { PageHeights } from "../src/lib/virt";
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
const ph = new PageHeights(10, 100);
expect(ph.offset(3)).toBe(300);
expect(ph.total()).toBe(1000);
expect(ph.pageAt(350)).toBe(3);
expect(ph.pageAt(-10)).toBe(0);
expect(ph.pageAt(99_999)).toBe(9);
expect(ph.range(0, 100, 2)).toEqual([0, 3]);
expect(ph.range(950, 100, 2)).toEqual([7, 10]);
});
it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
const ph = new PageHeights(10, 100);
expect(ph.set(2, 150)).toBe(50);
expect(ph.offset(2)).toBe(200); // 自身 offset 不受自己影响
expect(ph.offset(3)).toBe(350); // 后续整体 +50
expect(ph.total()).toBe(1050);
expect(ph.pageAt(349)).toBe(2);
expect(ph.pageAt(351)).toBe(3);
expect(ph.set(2, 150)).toBe(0);
});
it("n=0 时一切归空", () => {
const ph = new PageHeights(0, 100);
expect(ph.total()).toBe(0);
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
expect(ph.set(0, 50)).toBe(0);
});