Files
UHD_Media_Tool/packages/adapters/src/sites/mteam/mteam.test.ts
T
rootandClaude Opus 5.5 d9f2af2f3c feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)
- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time
- sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback
- sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE
- scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md
- license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:39:27 +08:00

197 lines
8.4 KiB
TypeScript

import { createMockMteamApi, type MockMteamApi } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
createMteamAdapter,
createSiteRateLimiter,
getDownloadVolumeFactor,
mteamDefinition,
type SiteAdapter,
SiteError,
type SiteRateLimiter,
staticSecretResolver,
} from "../index.ts";
let api: MockMteamApi | undefined;
afterEach(async () => {
await api?.close();
api = undefined;
});
function adapterFor(m: MockMteamApi, key: string | null = m.apiKey, limiter?: SiteRateLimiter): SiteAdapter {
return createMteamAdapter(mteamDefinition, {
baseUrl: m.baseUrl,
secrets: staticSecretResolver(key ? { mteam: { apiKey: key } } : {}),
limiter: limiter ?? createSiteRateLimiter({ minIntervalMs: 0 }),
});
}
async function expectSiteError(p: Promise<unknown>, kind: string): Promise<SiteError> {
try {
await p;
} catch (e) {
expect(e).toBeInstanceOf(SiteError);
expect((e as SiteError).kind).toBe(kind);
return e as SiteError;
}
throw new Error(`expected SiteError ${kind}`);
}
describe("M-Team API 适配器(mock)", () => {
it("列表:x-api-key 头 + 搜索体,映射 MV1 字段,结果不含 API Key", async () => {
api = await createMockMteamApi();
const page = await adapterFor(api).list(1);
const req = api.requests[0]!;
expect(req.path).toBe("/api/torrent/search");
expect(req.headers["x-api-key"]).toBe(api.apiKey);
expect(api.lastSearchBody).toEqual({ keyword: null, categories: [], pageNumber: 1, pageSize: 100, visible: 1 });
expect(page).toMatchObject({ page: 1, totalPages: 1, totalPagesEstimated: false });
const shogun = page.items.find((i) => i.siteReleaseId === "912233")!;
expect(shogun).toMatchObject({
siteId: "mteam",
title: "Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
subtitle: "幕府将军 第一季 全10集 杜比视界",
category: { id: "402", name: "剧集" },
raw: expect.objectContaining({ category: "电视剧", category_id: "402" }),
sizeBytes: 61234567890,
seeders: 532,
leechers: 4,
snatched: 2011,
promotion: { type: "50%", downloadFactor: 0.5, uploadFactor: 1, expiresAt: "2026-09-28T00:15:00.000Z" },
hr: "unknown",
uploadedAt: "2026-09-20T00:15:00.000Z",
detailUrl: "https://tp.m-team.cc/detail/912233",
imdbId: "tt2788316",
tags: ["中字", "DV"],
});
expect(shogun.downloadUrl).toBeUndefined();
expect(JSON.stringify(page)).not.toContain(api.apiKey);
});
it("促销优先级:商城单种免费 > 全站促销 > 基础折扣;2x 免费;标签位图", async () => {
api = await createMockMteamApi();
const { items } = await adapterFor(api).list(1);
const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
expect(by["912234"]!.promotion).toEqual({
type: "free",
downloadFactor: 0,
uploadFactor: 1,
expiresAt: "2026-09-27T12:00:00.000Z",
});
expect(by["912234"]!.tags).toEqual(["国配", "中字"]);
expect(by["912235"]!.promotion).toMatchObject({
type: "free",
downloadFactor: 0,
expiresAt: "2026-10-07T15:59:59.000Z",
});
expect(by["912236"]!.promotion).toMatchObject({ type: "2xfree", downloadFactor: 0, uploadFactor: 2 });
expect(by["912237"]!.promotion).toEqual({ type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null });
// MP __get_downloadvolumefactor:未知折扣代码按 1 处理
expect(getDownloadVolumeFactor("SOMETHING_NEW")).toBe(1);
});
it("分类原始 ID 保留(404 纪录 / 405 动画 / 407 体育)", async () => {
api = await createMockMteamApi();
const { items } = await adapterFor(api).list(1);
const cats = Object.fromEntries(items.map((i) => [i.siteReleaseId, i.category]));
expect(cats["912235"]).toEqual({ id: "405", name: "动画" });
expect(cats["912236"]).toEqual({ id: "404", name: "纪录" });
expect(cats["912237"]).toEqual({ id: "407", name: "体育" });
});
it("搜索:IMDb 关键词改写为完整 URL;分类与免费筛选下推", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const r = await a.search("tt15398776");
expect(api.lastSearchBody!.keyword).toBe("https://www.imdb.com/title/tt15398776");
expect(r.items.map((i) => i.siteReleaseId)).toEqual(["912234"]);
const free = await a.search("", { freeOnly: true, categoryIds: ["404", "405"] });
expect(api.lastSearchBody).toMatchObject({ keyword: null, categories: ["404", "405"] });
const tv = await a.search("Shogun", { mtype: "tv" });
expect(api.lastSearchBody!.categories).toEqual(["403", "402", "435", "438", "404", "405"]);
expect(tv.items.map((i) => i.siteReleaseId)).toEqual(["912233"]);
// 912235 基础 NORMAL 但全站促销 FREE → 下载系数 0
expect(free.items.map((i) => i.siteReleaseId)).toEqual(["912235", "912236"]);
const kw = await a.search("芙莉莲");
expect(kw.items.map((i) => i.siteReleaseId)).toEqual(["912235"]);
});
it("详情:表单 id,带简介;不存在 → not_found", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const d = await a.detail("912233");
expect(api.requests[0]!.body).toBe("id=912233");
expect(d.descriptionText).toContain("幕府将军");
expect(d.imdbId).toBe("tt2788316");
const byUrl = await a.detail("https://tp.m-team.cc/detail/912233");
expect(byUrl.siteReleaseId).toBe("912233");
await expectSiteError(a.detail("1"), "not_found");
});
it("下载令牌:现场 genDlToken → 一次性直链 → .torrent,infohash 校验", async () => {
api = await createMockMteamApi();
const a = adapterFor(api);
const t = await a.downloadTarget("912233");
expect(api.issuedTokens).toBe(1);
expect(api.requests.map((r) => r.path.split("?")[0])).toEqual(["/api/torrent/genDlToken", "/api/rss/dlv2"]);
// MP:genDlToken 的 id 走 query 参数;直链下载不带 x-api-key
expect(api.requests[0]!.path).toBe("/api/torrent/genDlToken?id=912233");
expect(api.requests[1]!.headers["x-api-key"]).toBeUndefined();
expect(t.kind).toBe("torrent");
if (t.kind === "torrent") expect(t.infoHash).toBe(api.infoHashOf("912233"));
// 每次下载都重新签发令牌
await a.downloadTarget("912233");
expect(api.issuedTokens).toBe(2);
api.setFailMode("no_dl_token");
await expectSiteError(a.downloadTarget("912233"), "rate_limited");
});
it("Key 失效/停用 → auth_invalid(停止重试),健康检查报告;缺 Key → auth_missing", async () => {
api = await createMockMteamApi();
const wrong = await expectSiteError(adapterFor(api, "wrong-key").list(1), "auth_invalid");
expect(wrong.isAuthError).toBe(true);
api.setFailMode("banned_key");
const banned = await expectSiteError(adapterFor(api).search("x"), "auth_invalid");
expect(banned.message).not.toContain(api.apiKey);
expect(await adapterFor(api).healthCheck()).toMatchObject({ ok: false, status: "auth_invalid" });
api.setFailMode(null);
expect((await adapterFor(api).healthCheck()).status).toBe("ok");
const before = api.requests.length;
await expectSiteError(adapterFor(api, null).list(1), "auth_missing");
expect(api.requests.length).toBe(before);
});
it("非 JSON 维护页 → site_maintenance;业务限流/HTTP 429 → rate_limited 并顺延限流器", async () => {
api = await createMockMteamApi();
const limiter = createSiteRateLimiter({ minIntervalMs: 0 });
const a = adapterFor(api, api.apiKey, limiter);
api.setFailMode("non_json");
await expectSiteError(a.list(1), "site_maintenance");
api.setFailMode("rate_limit");
await expectSiteError(a.list(1), "rate_limited");
api.setFailMode("http_429");
const e = await expectSiteError(a.list(1), "rate_limited");
expect(e.retryAfterMs).toBe(5000);
expect(limiter.tryAcquire()).toBe(false);
});
it("站点级限流 1s:列表/搜索/详情/下载共享", async () => {
api = await createMockMteamApi();
let clock = 0;
const sleeps: number[] = [];
const limiter = createSiteRateLimiter({
minIntervalMs: mteamDefinition.umt.rateLimit.minIntervalMs,
now: () => clock,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
});
const a = adapterFor(api, api.apiKey, limiter);
await a.list(1);
await a.search("Shogun");
await a.detail("912233");
await a.downloadTarget("912233"); // genDlToken + GET 直链 = 2 次
expect(sleeps).toEqual([1000, 1000, 1000, 1000]);
});
});