import { metadataCacheFor, PgCache, sql } from "@umt/db"; import { createMockTmdb, type MockTmdb } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createCacheModule } from "../../src/modules/cache/index.ts"; import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts"; import { metaRequestCache } from "../../src/modules/sources-identity/service/meta-subjects.ts"; import { metaCachePolicy, regionHitStats, resetTrackedTmdbRefs } from "../../src/shared-cache.ts"; import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; import { DEV_KEY, json } from "../helpers.ts"; // 统一元数据缓存接到 TMDB / 豆瓣 / bgm:策略表、过期先用旧值后台刷新、同请求合并、失败不缓存、 // 关注中作品 pinned 不被淘汰、按 region 统计命中(GET /cache/regions)。 const hasPg = await pgAvailable(); const KEY = "0123456789abcdef0123456789abcdef"; const until = async (cond: () => boolean | Promise, ms = 5000) => { const end = Date.now() + ms; while (!(await cond())) { if (Date.now() > end) throw new Error("等待超时"); await new Promise((r) => setTimeout(r, 20)); } }; describe("策略表(来源 × 资源类型)", () => { it("缺省:搜索 7 天(空 1 天)、详情 30 天(空 1 天)、列表 6 小时(空 1 小时)、图片 90 天", async () => { const { DEFAULT_STORAGE_SETTINGS } = await import("@umt/contracts"); const t = DEFAULT_STORAGE_SETTINGS.cacheTtl; const day = 86_400; for (const s of ["tmdb", "douban", "bangumi"] as const) { expect(metaCachePolicy(t, s, "search")).toEqual({ ttl: 7 * day, emptyTtl: day }); expect(metaCachePolicy(t, s, "detail")).toEqual({ ttl: 30 * day, emptyTtl: day }); expect(metaCachePolicy(t, s, "list")).toEqual({ ttl: 6 * 3600, emptyTtl: 3600 }); } expect(metaCachePolicy(t, "tmdb", "image")).toEqual({ ttl: 90 * day, emptyTtl: day }); }); }); describe.skipIf(!hasPg)("统一元数据缓存(真实 PG)", () => { let h: Harness; let admin: ReturnType; let tmdb: MockTmdb; beforeAll(async () => { h = await createHarness({ extraModules: [createCacheModule()] }); admin = bearer(h.app, DEV_KEY); tmdb = await createMockTmdb({ apiKey: KEY }); const r = await admin("PATCH", "/settings", { metadata: { tmdbApiKey: KEY, tmdbBaseUrl: `${tmdb.url}/3` }, cache: { tmdbMaxSize: 16 }, }); expect(r.status).toBe(200); }); afterAll(async () => { await tmdb?.close(); await h?.close(); }); const client = async () => (await new SourcesRuntime(h.ctx).tmdb())!; const row = async (like: string) => ( await sql<{ key: string; fresh: number; pinned: boolean }>`select key, extract(epoch from fresh_until - now()) / 86400 as fresh, pinned from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db) ).rows[0]; it("TMDB 详情过期在宽限期内:先返回旧值,后台只刷一次;刷新后是新值", async () => { const c = await client(); await c.details("movie", 872585); const before = tmdb.callsTo("/3/movie/872585").length; // 改成「已过新鲜期、还在宽限期」并在值里做个记号(热度 12345) await sql`update kv_cache set fresh_until = now() - interval '1 second', expires_at = now() + interval '1 day', value = jsonb_set(value, '{popularity}', '12345') where region = 'tmdb.request' and key like '/movie/872585?%'`.execute(h.db); const keys = ( await sql<{ key: string; }>`select key from kv_cache where region = 'tmdb.request' and key like '/movie/872585?%'`.execute(h.db) ).rows.map((r) => r.key); expect(keys.length).toBeGreaterThan(0); // 回源慢一点,让两个调用方都在刷新完成前读到 tmdb.fault("/3/movie/872585", { delayMs: 300, times: 10 }); const [a, b] = await Promise.all([c.details("movie", 872585), c.details("movie", 872585)]); expect(a.popularity).toBe(12345); expect(b.popularity).toBe(12345); // 后台刷新:两个调用方同时拿到旧值,每个过期键只回源一次;刷新后新鲜且是新值 await until(async () => { const r = await sql<{ n: number }>`select count(*)::int as n from kv_cache where region = 'tmdb.request' and key like '/movie/872585?%' and fresh_until > now() + interval '29 days'`.execute(h.db); return r.rows[0]!.n === keys.length; }); expect(tmdb.callsTo("/3/movie/872585").length - before).toBe(keys.length); tmdb.clearFaults(); expect((await c.details("movie", 872585)).popularity).not.toBe(12345); }); it("同请求合并:并发同一详情只回源一次;失败(5xx)不缓存", async () => { const c = await client(); const n0 = tmdb.callsTo("/3/tv/95396/season/1").length; await Promise.all(Array.from({ length: 5 }, () => c.getJson("/tv/95396/season/1", { language: "zh-CN" }))); expect(tmdb.callsTo("/3/tv/95396/season/1").length - n0).toBe(1); // 5xx 重试用尽后抛错(客户端缺省重试 3 次,退避约 3.5 秒),不写缓存 tmdb.fault("/3/tv/100088/season/1", { status: 503, times: 10 }); await expect(c.getJson("/tv/100088/season/1", { language: "zh-CN" })).rejects.toThrow(); expect(await row("/tv/100088/season/1?%")).toBeUndefined(); tmdb.clearFaults(); }); it("关注中的作品:条目 pinned,条数上限淘汰时不被清掉", async () => { const ent = ( await sql<{ id: string; }>`insert into media_entity (kind, title) values ('series', '人生切割术') returning id`.execute(h.db) ).rows[0]!.id; await sql`insert into external_identity (media_entity_id, provider, entity_type, external_id, status) values (${ent}, 'tmdb', 'tv', '95396', 'accepted')`.execute(h.db); await sql`insert into subscription (media_entity_id, media_kind, status) values (${ent}, 'series', 'active')`.execute( h.db, ); resetTrackedTmdbRefs(h.ctx); await sql`delete from kv_cache where region = 'tmdb.request'`.execute(h.db); const c = await client(); await c.getJson("/tv/95396", { language: "zh-CN" }); expect((await row("/tv/95396?%"))?.pinned).toBe(true); // 上限 16:再写一批别的请求,关注作品的条目留着 for (let i = 0; i < 24; i++) await c.searchRaw("movie", `不存在${i}`); expect(await row("/tv/95396?%")).toBeDefined(); const n = await sql<{ n: number }>`select count(*)::int as n from kv_cache where region = 'tmdb.request'`.execute( h.db, ); expect(n.rows[0]!.n).toBeLessThanOrEqual(16); }); it("豆瓣 / bgm 请求缓存按策略表:搜索 7 天、列表 6 小时、空结果按查无 / 列表空;失败不写", async () => { const cache = metaRequestCache(h.ctx); await cache.cached({ source: "douban", key: "GET s1", kind: "search", empty: () => false }, async () => [1]); await cache.cached({ source: "douban", key: "GET s0", kind: "search", empty: () => true }, async () => []); await cache.cached({ source: "bangumi", key: "GET l1", kind: "list", empty: () => false }, async () => ({ data: [1], })); await expect( cache.cached({ source: "bangumi", key: "GET bad", kind: "list", empty: () => false }, async () => { throw new Error("HTTP 403"); }), ).rejects.toThrow("403"); const ttl = async (region: string, key: string) => Number( ( await sql<{ s: number }>`select extract(epoch from fresh_until - now()) as s from kv_cache where region = ${region} and key = ${key}`.execute(h.db) ).rows[0]?.s ?? -1, ); expect(await ttl("douban.request", "GET s1")).toBeGreaterThan(7 * 86400 - 60); expect(await ttl("douban.request", "GET s0")).toBeLessThanOrEqual(86400); expect(await ttl("bangumi.request", "GET l1")).toBeLessThanOrEqual(6 * 3600); expect(await ttl("bangumi.request", "GET l1")).toBeGreaterThan(6 * 3600 - 60); expect(await ttl("bangumi.request", "GET bad")).toBe(-1); }); it("命中统计:各 region 计 L1 / L2 / 旧值 / 未命中 / 失败 / 后台刷新;写入 cache_stat 后接口合计并给命中率", async () => { const mc = metadataCacheFor(h.db); const pending = mc.pendingStats().get("tmdb.request"); expect(pending?.misses).toBeGreaterThan(0); expect(pending?.staleHits).toBeGreaterThan(0); expect(pending?.refreshes).toBeGreaterThan(0); expect(pending?.failures).toBeGreaterThan(0); await mc.flushStats(); expect(mc.pendingStats().size).toBe(0); // 再来一次 L2 命中(未写入 cache_stat 的也算进接口) await (await client()).getJson("/tv/95396", { language: "zh-CN" }); const stats = await regionHitStats(h.ctx); expect(stats.get("tmdb.request")!.l2Hits).toBeGreaterThan(0); const list = await json(await admin("GET", "/cache/regions")); const tmdbRow = list.data.items.find((i: { region: string }) => i.region === "tmdb.request"); expect(tmdbRow.hits.hitRate).toBeGreaterThan(0); expect(tmdbRow.hits.hitRate).toBeLessThan(1); expect(tmdbRow.hits).toMatchObject({ misses: expect.any(Number), l2Hits: expect.any(Number) }); // 非元数据 region 没有命中统计 await new PgCache(h.db).set("site.detail", "x", 1, { ttl: 60 }); const again = await json(await admin("GET", "/cache/regions")); expect(again.data.items.find((i: { region: string }) => i.region === "site.detail").hits).toBeUndefined(); }); });