Files
web_hgdjc_h5/tests/api-integration.test.mjs
T
2026-11-06 00:13:27 +07:00

4153 lines
141 KiB
JavaScript

import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, request as httpRequest } from "node:http";
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import { createHash } from "node:crypto";
import { rm, readFile } from "node:fs/promises";
const appContract = JSON.parse(
await readFile(
new URL("./fixtures/sgdj-app-contract.json", import.meta.url),
"utf8",
),
).operations;
let strictContract = false;
import { join } from "node:path";
let mock,
next,
base,
output = "";
let mockOrigin,
seoSnapshotPath,
configFailure = "";
let downloadAddress = "https://example.com/download";
let seoConfig = {},
categories = [],
listPayload,
detailSeo;
let chapterPayload, videoPayload, chapterListPayload, commentPayload;
let tagOptionsPayload;
let requireSeoAuth = false,
searchFailure = false,
searchPayload;
let responseOverrides = {};
let upstreamKeySlash = false;
const defaultHeader = {
categoryList: [
{ id: 1, name: "首页", urlValue: "", categoryType: 1 },
{ id: 2, name: "短剧", urlValue: "/shorts/", categoryType: 1 },
{ id: 3, name: "专题", urlValue: "/topics/", categoryType: 1 },
{ id: 4, name: "热榜", urlValue: "/hot", categoryType: 1 },
{ id: 5, name: "回家的路", urlValue: "", categoryType: 1 },
{ id: 6, name: "福利应用", urlValue: "", categoryType: 1 },
],
};
let headerPayload = defaultHeader,
headerFailure = false;
let appsPayload, packagesPayload, accountListPayload, transactionsPayload;
let blockedDetail, footerPayload, themePayload, richTextPayload;
const calls = [];
const testEnvironment =
process.env.TEST_APP_ENV === "test" ? "test" : "production";
const testBuildDirectory = testEnvironment === "test" ? ".next-test" : ".next";
const fixture = {
id: 23456,
title: "接口剧名 <script>alert(1)</script>",
cover: "https://example.com/poster.webp",
totalEpisode: 7,
countLike: 1234,
tagNames: ["都市"],
showOnBanner: true,
};
let homePayload = {
homeBannerData: [fixture],
homeConfigData: [
{ id: 31, status: true, name: "后台配置标题", type: 1, list: [fixture] },
],
};
const playlist =
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="/enkey?token=a%2Fb"\n#EXTINF:2,\nhttps://cdn.example/segment.ts?md=signed+value\n#EXT-X-ENDLIST\n';
async function listen(server) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return server.address().port;
}
function assertPageLinksHaveTrailingSlash(html) {
for (const match of html.matchAll(
/<a\b[^>]*\bhref="(\/[^"#?]*)(?:[?#][^"]*)?"/g,
))
assert.ok(match[1].endsWith("/"), match[1]);
}
async function startNext(environment = {}) {
const reservation = createServer();
const port = await listen(reservation);
await new Promise((resolve) => reservation.close(resolve));
base = `http://127.0.0.1:${port}`;
output = "";
next = spawn(
process.execPath,
[
"node_modules/next/dist/bin/next",
"start",
"--hostname",
"0.0.0.0",
"--port",
String(port),
],
{
cwd: process.cwd(),
env: {
...process.env,
APP_ENV: testEnvironment,
APP_DIST_DIR: testBuildDirectory,
UPSTREAM_API_URL: mockOrigin,
API_BASE_URL: "http://127.0.0.1:1",
PUBLIC_DATA_CACHE_TTL_MS: "0",
...environment,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
next.stdout.on("data", (chunk) => {
output += chunk.toString();
});
next.stderr.on("data", (chunk) => {
output += chunk.toString();
});
for (let attempt = 0; attempt < 60; attempt++) {
try {
if ((await fetch(base + "/favicon.png")).ok) return;
} catch {}
if (next.exitCode !== null) throw new Error(output);
await delay(100);
}
throw new Error("Test server did not start: " + output);
}
async function stopNext() {
if (!next || next.exitCode !== null) return;
const stopped = new Promise((resolve) => next.once("exit", resolve));
next.kill("SIGTERM");
await stopped;
}
test("deployment health endpoint bypasses guest creation, avoids redirects and never caches", async () => {
for (const path of ["/healthz", "/healthz/"]) {
const previous = calls.length;
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 200);
assert.equal(response.headers.get("set-cookie"), null);
assert.match(response.headers.get("cache-control"), /no-store/);
assert.match(response.headers.get("x-robots-tag"), /noindex/);
assert.deepEqual(await response.json(), {
status: "ok",
environment: testEnvironment,
application: "jha-1050-jgdj-web",
});
assert.equal(calls.length, previous);
}
const head = await fetch(base + "/healthz", {
method: "HEAD",
redirect: "manual",
});
assert.equal(head.status, 200);
assert.equal(await head.text(), "");
});
before(
async () => {
mock = createServer(async (request, response) => {
let raw = "";
for await (const chunk of request) raw += chunk;
const url = new URL(request.url, "http://local");
calls.push({
path: url.pathname,
search: url.search,
method: request.method,
auth: request.headers.authorization,
cookie: request.headers.cookie,
range: request.headers.range,
device: request.headers["x-user-agent"],
ua: request.headers["user-agent"],
accept: request.headers.accept,
contentType: request.headers["content-type"],
ip: request.headers["x-real-ip"],
forwarded: request.headers["x-forwarded-for"],
sdk: request.headers["x-sdk-common"],
trackerId: request.headers["x-tracker-request-id"],
trackerEvent: request.headers["x-tracker-event-name"],
trackerContext: request.headers["x-dc-request-context"],
body: raw,
});
if (url.pathname.startsWith("/h5/m3u8/")) {
if (url.pathname.endsWith("redirect.m3u8")) {
response.writeHead(302, { Location: "/unexpected" });
response.end();
return;
}
if (url.pathname.endsWith("missing.m3u8")) {
response.writeHead(404);
response.end();
return;
}
if (url.pathname.endsWith("denied.m3u8")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({ code: 1000, msg: "expired signed URL" }),
);
return;
}
if (url.pathname.endsWith("invalid.m3u8")) {
response.writeHead(200, { "Content-Type": "text/html" });
response.end("<html>Error</html>");
return;
}
if (url.pathname.endsWith(".ts")) {
response.writeHead(206, {
"Content-Type": "application/octet-stream",
"Content-Range": "bytes 0-2/10",
"Accept-Ranges": "bytes",
});
response.end(Buffer.from([9, 8, 7]));
return;
}
response.writeHead(200, { "Content-Type": "application/octet-stream" });
response.end(playlist);
return;
}
if (
["/enkey", "/enkey/", "/newenkey", "/newenkey/"].includes(url.pathname)
) {
const canonical =
url.pathname.replace(/\/$/, "") + (upstreamKeySlash ? "/" : "");
if (url.pathname !== canonical) {
response.writeHead(301, { Location: canonical + url.search });
response.end();
return;
}
response.writeHead(200, { "Content-Type": "application/octet-stream" });
response.end(
url.searchParams.has("invalid")
? Buffer.from(JSON.stringify({ code: 1000, msg: "bad key" }))
: Buffer.from(Array.from({ length: 16 }, (_, index) => index)),
);
return;
}
const path = url.pathname.replace("/api/app/", "");
if (Object.hasOwn(responseOverrides, path)) {
const {
status = 200,
code = 200,
data = null,
raw: overrideRaw,
} = responseOverrides[path];
response.writeHead(status, { "Content-Type": "application/json" });
response.end(overrideRaw ?? JSON.stringify({ code, data }));
return;
}
if (path === "playlet/detail" && blockedDetail)
await blockedDetail.promise;
if (strictContract) {
const op = appContract[request.method + " " + path];
const invalid =
!op ||
[...url.searchParams.keys()].some((key) => !op.query.includes(key)) ||
op.requiredQuery.some((key) => !url.searchParams.has(key));
if (invalid) {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
code: 400,
msg: "Undocumented endpoint or query",
data: null,
}),
);
return;
}
}
let data = { list: [], totalCount: 0 };
let code = 200;
if (path === "auth/guest")
data = {
token: "test-session-secret",
userId: 77,
nickName: "真实接口用户",
type: 0,
};
else if (path === "auth/login") {
const body = JSON.parse(raw);
if (body.account === "wrong") code = 1000;
data = {
...(body.account === "missing-token"
? {}
: { token: "test-registered-secret" }),
userId: 88,
nickName: "正式用户",
type: 1,
isRegister: true,
};
} else if (path === "auth/register")
data = {
token: "test-bound-secret",
userId: 77,
nickName: "绑定游客",
type: 1,
isRegister: true,
};
else if (path === "auth/getInfo") {
if (request.headers.authorization === "expired") code = 1000;
else if (request.headers.authorization === "test-session-secret")
data = { userId: 77, nickName: "真实接口用户", type: 0, recharge: 90 };
else if (request.headers.authorization === "test-registered-secret")
data = { userId: 88, nickName: "正式用户", type: 1 };
else if (request.headers.authorization === "http-expired") {
response.writeHead(401, { "Content-Type": "application/json" });
response.end(JSON.stringify({ code: 401, data: null }));
return;
} else if (
request.headers.authorization === "registered-user-a-secret" ||
request.headers.authorization === "registered-user-b-secret"
) {
const first =
request.headers.authorization === "registered-user-a-secret";
data = {
userId: first ? 101 : 202,
nickName: first ? "用户甲" : "用户乙",
recharge: first ? 111 : 222,
type: 1,
isRegister: true,
token: request.headers.authorization,
password: "must-not-leak",
};
} else
data = {
userId: 77,
nickName: "接口昵称",
token: "must-not-leak",
recharge: 90,
};
} else if (path === "config/info")
data = {
seoInfo: { webSiteName: "黄果短剧" },
...seoConfig,
config: {
foreverUrlAddr: "https://default.drama.test",
telegram: "https://example.com/contact",
tgGroup: "https://example.com/group",
twitterAddr: "https://example.com/twitter",
officialAppAddr: downloadAddress,
...seoConfig.config,
},
navPageList: Object.hasOwn(seoConfig, "navPageList")
? seoConfig.navPageList
: [
{ line: 2, url: "https://example.com/entry-two" },
{ line: 1, url: "https://example.com/entry-one" },
{ line: 3, url: "javascript:alert(1)" },
],
};
else if (path === "navigation/header") {
data = headerPayload;
if (headerFailure) code = 500;
} else if (path === "config/footer/info" && footerPayload)
data = footerPayload;
else if (path === "config/richtext/list") {
const titles = new Set(
(url.searchParams.get("titles") || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
);
const list = Array.isArray(richTextPayload) ? richTextPayload : [];
data = {
list: titles.size
? list.filter((item) => titles.has(item.title))
: list,
};
}
else if (path === "translation/category/list")
data = { categoryList: categories };
else if (path === "theme/list")
data = themePayload || {
list: [7, 8, 10404].map((id) => ({
id,
name: "题材" + id,
slug: id === 7 ? "urban-topic" : undefined,
})),
};
else if (path === "applist/list" && appsPayload) data = appsPayload;
else if (path === "recharge/package/list" && packagesPayload)
data = packagesPayload;
else if (
[
"playlet/history/list",
"playlet/collect/list",
"playlet/like/list",
"playlet/bought/list",
].includes(path) &&
accountListPayload
)
data =
typeof accountListPayload === "function"
? accountListPayload(path, request)
: accountListPayload;
else if (path === "coin/trans/list" && transactionsPayload)
data = transactionsPayload;
else if (path === "playlet/home") data = homePayload;
else if (path === "playlet/home-config/list") {
const modules =
homePayload.modules ||
(homePayload.homeConfigData || [])
.filter((block) => block && block.status)
.map((block) => ({
id: block.id,
slug: block.slug,
title: block.name,
desc: block.desc,
moduleType: block.type === 3 ? 2 : 1,
list: block.list,
topicList: block.themeList,
}));
const module = modules.find((item) =>
url.searchParams.has("slug")
? item.slug === url.searchParams.get("slug")
: item.id === Number(url.searchParams.get("id")),
);
if (!module) {
code = 5009;
data = null;
} else {
const page = Number(url.searchParams.get("pageNum") || 1),
size = Number(url.searchParams.get("pageSize") || 24),
items =
(module.moduleType === 2 ? module.topicList : module.list) || [];
data = {
config: module,
list:
module.moduleType === 1
? items.slice((page - 1) * size, page * size)
: [],
topicList:
module.moduleType === 2
? items.slice((page - 1) * size, page * size)
: [],
totalCount: items.length,
pageNum: page,
pageSize: size,
};
}
} else if (path === "playlet/tag/options")
data = tagOptionsPayload || { list: [{ id: 9, name: "测试标签" }] };
else if (path === "playlet/search")
data = JSON.stringify(
searchPayload || {
list: [fixture],
totalCount: 1,
pageNum: 1,
pageSize: 24,
},
);
else if (path === "playlet/guess-like/list")
data = {
list: [{ ...fixture, id: 23458, title: "同类推荐短剧" }],
totalCount: 1,
};
else if (path === "playlet/detail")
data = {
...fixture,
...detailSeo,
isCollect: 2,
isLike: 2,
playletChapterInfo: [
{ id: 90001, currentEpisode: 1, canWatch: true },
],
};
else if (path === "video/detail" && videoPayload) data = videoPayload;
else if (
listPayload &&
[
"playlet/hot/rank/list",
"playlet/topic/list",
"playlet/topic/detail",
"playlet/all/list",
"playlet/operate/list",
"playlet/tag/list",
].includes(path)
)
data =
typeof listPayload === "function"
? listPayload(path, url)
: listPayload;
else if (path === "playlet/chapter/list")
data = chapterListPayload || {
list: [{ id: 90001, currentEpisode: 1, canWatch: true }],
};
else if (path === "playlet/chapter/detail")
data = chapterPayload || {
id: 90001,
playletId: 23456,
currentEpisode: 1,
canWatch: false,
needUnlock: true,
singleCoins: 13,
balance: 90,
};
else if (path === "playlet/chapter/unlock") {
const body = JSON.parse(raw);
data = { ...body, balance: 77 };
} else if (path === "playlet/collect/set") {
if (
request.headers.authorization === "expired" ||
JSON.parse(raw).id === 999
)
code = 1000;
else data = { id: 23456, isCollect: 1, countCollect: 1 };
} else if (path === "comment/list")
data = commentPayload || { list: [], totalCount: 0 };
if (path === "playlet/topic/detail" && !data.id) {
const slug = url.searchParams.get("topicSlug");
if (slug) {
const topic = listPayload?.list?.find((item) => item.slug === slug);
if (topic) data = { ...data, ...topic };
else {
code = 5009;
data = null;
}
} else data = { ...data, id: Number(url.searchParams.get("id")) };
}
if (path === "playlet/search" && searchFailure) {
code = 5100;
data = null;
}
if (path === "config/info" && configFailure) {
if (configFailure === "http" || configFailure === "timeout") {
if (configFailure === "timeout") await delay(5200);
response.writeHead(503, { "Content-Type": "application/json" });
response.end(JSON.stringify({ code: 503, data: null }));
return;
}
if (configFailure === "business") code = 500;
if (configFailure === "malformed") data = null;
}
if (
requireSeoAuth &&
["playlet/detail", "comment/list", "playlet/search"].includes(path) &&
!request.headers.authorization
)
code = 1000;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
code,
data,
msg: code === 200 ? "success" : "session expired",
}),
);
});
const mockPort = await listen(mock);
mockOrigin = `http://127.0.0.1:${mockPort}`;
seoSnapshotPath = join(
process.cwd(),
testBuildDirectory,
"cache",
"seo",
createHash("sha256").update(mockOrigin).digest("hex") + ".json",
);
await rm(seoSnapshotPath, { force: true });
await startNext();
},
{ timeout: 15000 },
);
after(async () => {
await stopNext();
if (mock) await new Promise((resolve) => mock.close(resolve));
if (seoSnapshotPath) await rm(seoSnapshotPath, { force: true });
});
test("a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs", async () => {
try {
configFailure = "http";
const response = await fetch(base + "/info/about-us/", {
headers: {
"X-Forwarded-Host": "untrusted.entry.test",
"X-Forwarded-Proto": "https",
},
});
assert.equal(response.status, 200);
const html = await response.text(),
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(!head.includes('rel="canonical"'));
assert.ok(!head.includes('property="og:url"'));
assert.ok(!head.includes("untrusted.entry.test"));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.match(head, /<title>关于我们 - 黄果短剧<\/title>/);
} finally {
configFailure = "";
}
});
test("robots.txt serves crawler text without sessions and advertises the configured absolute sitemap", async () => {
const before = calls.length;
for (const agent of ["Googlebot", "YandexBot", "bingbot"])
for (const method of ["GET", "HEAD"]) {
const response = await fetch(base + "/robots.txt", {
method,
redirect: "manual",
headers: {
"User-Agent": agent,
"X-Forwarded-Host": "untrusted.example",
Cookie: "drama_session=expired",
},
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.equal(response.headers.get("set-cookie"), null);
assert.match(response.headers.get("content-type"), /^text\/plain\b/);
const body = await response.text();
if (method === "HEAD") {
assert.equal(body, "");
continue;
}
assert.match(body, /User-Agent: \*/i);
assert.ok(!body.includes("<html"));
assert.ok(!body.includes("untrusted.example"));
if (testEnvironment === "production") {
assert.match(body, /^Allow: \/$/m);
assert.match(body, /^Disallow: \/api\/$/m);
assert.match(body, /^Disallow: \/healthz$/m);
assert.match(
body,
/^Sitemap: https:\/\/default\.drama\.test\/sitemap\.xml$/m,
);
assert.ok(!/^Disallow: \/$/m.test(body));
} else {
assert.match(body, /^Disallow: \/$/m);
assert.ok(!body.includes("Sitemap:"));
}
}
const reads = calls.slice(before);
assert.ok(
reads.every((call) => call.path === "/api/app/config/info" && !call.auth),
"only public site configuration is needed",
);
});
test("robots sitemap uses permanent-domain precedence, survives configuration failure and never guesses a host", async () => {
if (testEnvironment !== "production") return;
try {
seoConfig = {
config: { foreverUrlAddr: "https://robots.drama.test/nested/?source=1" },
newAddressInfo: { foreverUrlAddr: "https://backup.drama.test" },
};
let response = await fetch(base + "/robots.txt");
assert.equal(response.status, 200);
assert.match(
await response.text(),
/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m,
);
configFailure = "http";
response = await fetch(base + "/robots.txt", {
headers: { "X-Forwarded-Host": "fake.test" },
});
assert.equal(response.status, 200);
assert.match(
await response.text(),
/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m,
);
configFailure = "";
seoConfig = {
config: { foreverUrlAddr: "" },
newAddressInfo: { foreverUrlAddr: "https://backup.drama.test/base/" },
};
response = await fetch(base + "/robots.txt");
assert.match(
await response.text(),
/^Sitemap: https:\/\/backup\.drama\.test\/sitemap\.xml$/m,
);
} finally {
configFailure = "";
seoConfig = {};
}
});
test("the bare site origin and root slash both serve the homepage without redirecting to shorts", async () => {
for (const address of [base, base + "/"]) {
const response = await fetch(address, { redirect: "manual" });
const html = await response.text();
assert.equal(response.status, 200, address);
assert.equal(response.headers.get("location"), null, address);
assert.equal(new URL(response.url).pathname, "/");
assert.ok(html.includes('class="home-content"'));
assert.ok(html.includes("后台配置标题"));
assert.match(html, /<a href="\/"[^>]*data-page="home"/);
assert.match(html, /<a href="\/shorts\/"[^>]*data-page="shorts"/);
}
});
test("SSR uses upstream content, escapes HTML, and does not restore homepage filters", async () => {
const response = await fetch(base);
const html = await response.text();
assert.equal(response.status, 200);
assert.equal(
(html.match(/<h1\b/g) || []).length,
1,
"only the homepage title may use h1, including hidden templates",
);
assert.match(
html,
/<h1 id="home-title" class="home-page-title">黄果短剧 · 精品原创 · 日更爽看<\/h1>/,
);
assert.match(
html,
/<h2 class="hero-feature-title"><a[^>]*>接口剧名 &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/a><\/h2>/,
);
assert.ok(html.includes("后台配置标题"));
assert.ok(html.includes("接口剧名 &lt;script&gt;"));
assert.ok(!html.includes("<script>alert(1)</script>"));
assert.ok(!html.includes("data-must-watch-sort"));
assert.ok(!html.includes("人人嫌猎户粗野"));
assert.ok(html.includes('href="/shorts/"'));
assert.ok(html.includes('href="/detail/23456/"'));
assert.ok(html.includes('href="/config/31/"'));
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
assert.ok(html.includes('src="/placeholder.png"'));
assert.ok(
!/<img\b[^>]*\ssrc="https:\/\/example.com\/poster.webp"/.test(html),
);
assert.ok(
!/href="#(?:page|info|member|detail|play|video|search)\//.test(html),
);
assert.ok(!html.includes('id="entry-navigation"'));
assert.ok(!html.includes("entry-navigation-pending"));
assert.match(
html,
/<dialog id="startup-grid" class="startup-popup" aria-label="应用推广">/,
);
assert.match(
html,
/<div class="startup-promotions" id="startup-promotions"><\/div>/,
);
assert.ok(!html.includes('id="header-download-qr"'));
assert.ok(!html.includes('id="download-qr-panel"'));
const entries = [
...html.matchAll(
/<(?:div|section|a)\b[^>]*\bdata-app-download-entry\b[^>]*>/g,
),
];
assert.equal(entries.length, 3);
for (const entry of entries) assert.match(entry[0], /\bhidden(?:[\s=>])/);
assertPageLinksHaveTrailingSlash(html);
});
test("homepage stays available when upstream ad and content lists are null or malformed", async () => {
const saved = homePayload;
try {
homePayload = { ...saved, advitise: { nineGridList: null } };
let response = await fetch(base, { redirect: "manual" });
let html = await response.text();
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(html.includes("后台配置标题"));
assert.ok(html.includes('href="/detail/23456/"'));
for (const value of [null, {}, "bad"]) {
homePayload = {
homeBannerData: value,
homeConfigData: value,
announcements: value,
advitise: { nineGridList: value },
};
response = await fetch(base, { redirect: "manual" });
html = await response.text();
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.equal(
(html.match(/<h1\b/g) || []).length,
1,
"the homepage keeps its title without banners",
);
assert.ok(html.includes('id="home-title"'));
assert.ok(html.includes("暂无推荐剧集"));
assert.ok(html.includes("暂时没有内容"));
}
homePayload = {
homeBannerData: [null, fixture],
homeConfigData: [null, ...saved.homeConfigData],
announcements: [null, { title: "正常公告" }],
advitise: { nineGridList: [null] },
};
response = await fetch(base);
html = await response.text();
assert.equal(response.status, 200);
assert.ok(html.includes("后台配置标题"));
assert.ok(html.includes("正常公告"));
} finally {
homePayload = saved;
}
});
test("/navigation renders direct download links without a hover QR panel", async () => {
const before = calls.length;
const response = await fetch(base + "/navigation/", { redirect: "manual" });
const html = await response.text();
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(html.includes("<title>官方入口 - 黄果短剧</title>"));
assert.ok(
html.includes('<div id="entry-navigation" class="navigation-page">'),
);
assert.ok(!html.includes('<dialog id="entry-navigation"'));
assert.ok(html.includes("黄果短剧 · 线路一"));
assert.ok(html.includes("黄果短剧 · 线路二"));
assert.ok(!html.includes("黄果短剧 · 线路三"));
assert.ok(html.includes('href="https://example.com/entry-one"'));
assert.ok(html.includes('href="https://example.com/entry-two"'));
assert.ok(!html.includes('href="javascript:'));
assert.ok(html.includes('href="https://t.me/jbd125"'));
assert.ok(!html.includes('href="https://example.com/contact"'));
assert.ok(!html.includes('href="https://example.com/group"'));
assert.ok(html.includes('href="/home-road/"'));
assert.ok(!html.includes('href="/apps/"'));
assert.ok(!html.includes("福利应用"));
assert.match(
html,
/<a class="entry-download" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/,
);
assert.match(
html,
/<a class="entry-channel" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/,
);
assert.ok(!html.includes('src="data:image/png;base64,'));
assert.ok(!html.includes('id="site-sidebar"'));
assert.ok(!html.includes("/scripts/site-runtime.js"));
assert.ok(!html.includes('id="startup-age"'));
assert.ok(!html.includes("entry-navigation-pending"));
assertPageLinksHaveTrailingSlash(html);
// jha-1048: SSR never guests; client GuestAuthBootstrap owns auth/guest.
assert.deepEqual(
calls.slice(before).map((call) => call.path),
["/api/app/config/info"],
);
assert.ok(
!calls.slice(before).some((call) => call.path === "/api/app/auth/guest"),
);
assert.equal(
(
await fetch(base + "/navigation/", {
redirect: "manual",
headers: { "Cache-Control": "no-cache" },
})
).status,
200,
);
});
test("/navigation renders configured identity, routes, permanent entry and channels in its initial HTML", async () => {
try {
seoConfig = {
seoNavInfo: {
dhSiteName: "动态导航站",
dhDescription: "后端配置的导航介绍",
},
config: {
telegram: "@configured_contact",
tgGroup: "@configured_group",
twitterAddr: "https://x.com/configured_91",
foreverUrlAddr: "https://permanent.drama.test/entry",
},
navPageList: [
{ line: 4, url: "https://four.drama.test" },
{ line: 2, url: "https://two.drama.test" },
],
};
const start = calls.length,
response = await fetch(base + "/navigation/"),
html = await response.text();
assert.equal(response.status, 200);
assert.ok(html.includes("动态导航站官方入口"));
assert.ok(html.includes("后端配置的导航介绍"));
assert.ok(html.includes("动态导航站 · 线路二"));
assert.ok(html.includes("动态导航站 · 线路四"));
assert.ok(!html.includes("动态导航站 · 线路一"));
assert.ok(
html.indexOf("动态导航站 · 线路二") < html.indexOf("动态导航站 · 线路四"),
);
assert.match(
html,
/<a class="entry-permanent" href="https:\/\/permanent.drama.test\/entry" rel="sponsored nofollow noopener noreferrer"/,
);
for (const url of [
"https://t.me/configured_contact",
"https://t.me/configured_group",
"https://x.com/configured_91",
])
assert.ok(!html.includes(`href="${url}"`), url);
assert.ok(html.includes('href="https://t.me/jbd125"'));
assert.ok(!html.includes("huangguoshortdrama"));
assert.ok(!html.includes("huangguodrama"));
assert.deepEqual(
calls.slice(start).map((call) => call.path),
["/api/app/config/info"],
);
assert.ok(
!calls.slice(start).some((call) => call.path === "/api/app/auth/guest"),
);
} finally {
seoConfig = {};
}
});
test("/navigation omits unconfigured routes and contacts instead of showing homepage links or demo accounts", async () => {
const saved = downloadAddress;
try {
downloadAddress = "";
seoConfig = {
navPageList: null,
config: {
foreverUrlAddr: "",
telegram: "",
telegramAdd: "",
tgGroup: "",
twitterAddr: "",
},
};
const response = await fetch(base + "/navigation/"),
html = await response.text();
assert.equal(response.status, 200);
assert.ok(html.includes("暂未配置访问线路"));
for (const className of [
"entry-route",
"entry-permanent",
"entry-download",
])
assert.ok(!html.includes(`class="${className}"`));
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));
for (const label of ["官方TG群", "官方推特"])
assert.ok(!html.includes(label));
assert.ok(html.includes('href="/home-road/"'));
assert.ok(!html.includes('href="/apps/"'));
// business contact and return-home remain without a download address
assert.ok(html.includes("grid-template-columns:repeat(2, minmax(0, 1fr))"));
} finally {
seoConfig = {};
downloadAddress = saved;
}
});
test("sidebar and footer always render the single business contact and never configured or unsafe channels", async () => {
try {
for (const configured of [true, false]) {
seoConfig = {
config: configured
? {
telegram: "@jgdj_contact",
tgGroup: "@jgdj_group",
twitterAddr: "https://x.com/jgdj_channel",
}
: {
telegram: "",
telegramAdd: "",
tgGroup: "javascript:alert(1)",
twitterAddr: "data:text/html,bad",
},
};
const response = await fetch(base + "/"),
html = await response.text();
assert.equal(response.status, 200);
for (const className of ["sidebar-social", "site-footer-social"]) {
const channels = html.match(
new RegExp(`<div class="${className}"[^>]*>([\\s\\S]*?)</div>`),
)?.[1];
assert.notEqual(channels, undefined, className);
assert.equal((channels.match(/<a\b/g) || []).length, 1, className);
assert.ok(channels.includes('href="https://t.me/jbd125"'), className);
for (const url of ["jgdj_contact", "jgdj_group", "x.com/jgdj_channel", "javascript:", "data:text"])
assert.ok(!channels.includes(url), className + " " + url);
}
assert.doesNotMatch(html, /huangguoAI_2026|huangguoAITV/);
}
} finally {
seoConfig = {};
}
});
test("/navigation omits every download entry when no valid App URL is configured", async () => {
const saved = downloadAddress;
try {
for (const value of ["", "javascript:alert(1)"]) {
downloadAddress = value;
const response = await fetch(base + "/navigation/", {
redirect: "manual",
});
const html = await response.text();
assert.equal(response.status, 200);
assert.ok(!html.includes('class="entry-download"'));
assert.ok(!html.includes('data-app-download=""'));
assert.ok(!html.includes('id="app-download-dialog"'));
assert.ok(html.includes("官方渠道"));
assert.ok(!html.includes('href="/apps/"'));
}
} finally {
downloadAddress = saved;
}
});
test("all clean page URLs support direct access and refresh", async () => {
const paths = [
"/shorts/",
"/topics/",
"/rank/",
"/home-road/",
"/topics/3/",
"/theme/8/",
"/tag/9/",
"/config/31/",
"/detail/23456/",
"/play/23456/90001/",
"/search/" + encodeURIComponent("都市 / 爱情?#%") + "/",
"/info/about-us/",
"/info/user-agreement/",
"/info/privacy-policy/",
"/info/help-center/",
"/info/2257/",
"/info/dmca/",
];
for (const path of [
"/apps/",
"/member/center/",
"/member/favorites/",
"/member/history/",
"/member/likes/",
"/member/purchased/",
"/member/settings/",
"/info/deposit/",
"/info/deposit/vip/",
"/info/user-center/",
"/info/settings/",
"/info/feedback/",
]) {
const removedPage = await fetch(new URL(path, base + "/"), { redirect: "manual" });
assert.equal(removedPage.status, 404, path + " was removed");
await removedPage.arrayBuffer();
}
for (const path of paths) {
const response = await fetch(new URL(path, base + "/"), {
redirect: "manual",
});
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null, path);
assert.equal(
new URL(response.url).pathname,
new URL(path, base).pathname,
path,
);
const html = await response.text();
assert.ok(html.includes('id="channel-page"'), path);
assert.ok(html.includes("/scripts/site-runtime.js"), path);
assert.equal(
(html.match(/<h1\b/g) || []).length,
1,
path + " must retain its own h1 without inactive template headings",
);
assert.ok(!html.includes('id="entry-navigation"'), path);
assert.ok(!html.includes("entry-navigation-pending"), path);
assertPageLinksHaveTrailingSlash(html);
const withoutSlash = await fetch(base + path.slice(0, -1), {
redirect: "manual",
});
assert.equal(withoutSlash.status, 308, path);
assert.equal(
new URL(withoutSlash.headers.get("location"), base).href,
base + path,
path,
);
}
const refreshed = await fetch(base + "/shorts/", {
headers: { "Cache-Control": "no-cache" },
});
assert.equal(refreshed.status, 200);
});
test("page slash redirects preserve encoded paths and queries without requesting upstream content", async () => {
const keyword = encodeURIComponent("都市 / 爱情?#% example.com");
const paths = [
"/navigation",
"/search",
"/search/" + keyword,
"/search/" + keyword + "/page/2",
"/rank/hot",
"/rank/new",
"/rank/follow",
"/info/about-us",
"/shorts/page/2",
];
const query = "?channel=a%2Fb+1&sort=2&source=x%252Fy";
const before = calls.length;
for (const path of paths) {
for (const method of ["GET", "HEAD"]) {
const response = await fetch(base + path + query, {
method,
redirect: "manual",
});
assert.equal(response.status, 308, path);
assert.equal(
new URL(response.headers.get("location"), base).href,
base + path + "/" + query,
path,
);
}
}
assert.equal(
calls.length,
before,
"redirects must run before page/API data fetching",
);
for (const path of [
"/favicon.png",
"/placeholder.png",
"/scripts/site-runtime.js",
]) {
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null, path);
await response.arrayBuffer();
}
const missing = await fetch(base + "/unknown-page", { redirect: "manual" });
assert.equal(missing.status, 404);
assert.equal(missing.headers.get("location"), null);
});
test("empty and paginated search entries retain native paths and keyword-specific metadata", async () => {
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
try {
for (const path of [
"/search/",
"/search/" + encodeURIComponent("都市 / 爱情") + "/",
"/search/" + encodeURIComponent("都市 / 爱情") + "/page/2/",
]) {
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null);
const html = await response.text();
assert.ok(
html.includes("搜索短剧") || html.includes("都市 / 爱情 · 搜索结果"),
);
if (path.includes("/page/2/")) assert.match(html, /第\s*2\s*页/);
}
} finally {
searchPayload = undefined;
}
});
test("fixed information pages retain local article markup and only load shared head configuration in SSR", async () => {
for (const [key, title, marker] of [
["about-us", "关于我们", "品牌介绍"],
["user-agreement", "用户协议", "关于《使用条款》"],
["privacy-policy", "隐私政策", "八、问题和建议"],
["help-center", "常见问题", "<details open>"],
["2257", "2257 合规声明", "资料留存"],
["dmca", "DMCA 版权声明", "版权通知及反通知请通过<a href=\"https://t.me/jbd125\""],
]) {
const count = calls.length,
response = await fetch(base + `/info/${key}/`, { redirect: "manual" });
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
const html = await response.text();
assert.ok(html.includes(`<title>${title} - 黄果短剧</title>`));
assert.ok(html.includes(marker), key);
const main = html.match(/<main\b[^>]*\bid="info-page"[^>]*>/)?.[0];
assert.ok(main);
assert.ok(main.includes(`data-info="${key}"`));
assert.ok(!/\shidden(?:[=\s>])/.test(main));
assert.deepEqual(
calls
.slice(count)
.map((call) => call.path)
.sort(),
[
"/api/app/config/info",
"/api/app/config/footer/info",
"/api/app/navigation/header",
"/api/app/translation/category/list",
].sort(),
"fixed page SSR must not fetch homepage, guest, or rich text",
);
assert.ok(
!calls.slice(count).some((call) => call.path === "/api/app/auth/guest"),
);
assert.ok(html.includes('class="home-content" hidden=""'));
}
});
test("footer navigation comes from footer info and opens the matching rich text page", async () => {
const previousFooter = footerPayload;
const previousRich = richTextPayload;
try {
footerPayload = {
footerInfo: {
brand: { title: "接口品牌", content: "接口页脚" },
token: "private-footer-token",
navList: [
{
id: 1,
name: "规则",
urlValue: "/ignored/",
translationList: [
{ id: 2, name: "关于我们", urlValue: "/richetext/关于我们" },
{ id: 3, name: "外链", urlValue: "https://example.com/rules" },
{ id: 4, name: "脚本", urlValue: "javascript:alert(1)" },
],
},
{ id: 5, name: "用户协议", urlValue: "用户协议" },
],
},
};
richTextPayload = [
{
id: 7,
title: "关于我们",
desc: "企业介绍",
content: "<h2>品牌正文</h2><p>富文本段落</p>",
token: "private-richtext-token",
},
];
const home = await (await fetch(base + "/")).text();
const links = home.match(
/<nav class="site-footer-links"[^>]*>[\s\S]*?<\/nav>/,
)?.[0];
assert.ok(links);
assert.ok(home.includes("接口页脚"));
assert.match(home, /<strong class="site-footer-title">接口品牌<i/);
assert.match(home, /© \d{4} 黄果短剧 All rights reserved\./);
assert.ok(!home.match(/<strong class="site-footer-title">[^<]*黄果短剧/));
assert.ok(links.includes('href="/richtext/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC/"'));
assert.ok(links.includes('href="/richtext/%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE/"'));
assert.ok(links.includes('href="https://example.com/rules"'));
assert.ok(links.includes('target="_blank"'));
assert.ok(!links.includes("javascript:"));
assert.ok(!links.includes('href="/ignored/"'));
assert.ok(!links.includes(">规则<"));
assert.ok(!home.includes("private-footer-token"));
const start = calls.length;
const response = await fetch(base + "/richtext/" + encodeURIComponent("关于我们") + "/", {
redirect: "manual",
});
assert.equal(response.status, 200);
const html = await response.text();
const main = currentMain(html, "info-page");
assert.ok(main.includes('data-info="richtext"'));
assert.ok(main.includes("企业介绍"));
assert.ok(main.includes("品牌正文"));
assert.ok(main.includes("富文本段落"));
assert.ok(!html.includes("private-richtext-token"));
assert.match(html, /<link rel="canonical" href="https:\/\/default\.drama\.test\/richtext\/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC\/"/);
assert.ok(
calls.slice(start).some((call) => {
return (
call.path === "/api/app/config/richtext/list" &&
new URLSearchParams(call.search).get("titles") === "关于我们"
);
}),
);
const alias = await (
await fetch(base + "/richetext/" + encodeURIComponent("关于我们") + "/")
).text();
assert.ok(alias.includes("品牌正文"));
assert.match(alias, /<link rel="canonical" href="https:\/\/default\.drama\.test\/richtext\/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC\/"/);
const missing = await (
await fetch(base + "/richtext/" + encodeURIComponent("没有这篇") + "/")
).text();
assert.ok(currentMain(missing, "info-page").includes("这篇内容暂时没有发布。"));
assert.match(missing, /<meta name="robots" content="noindex,\s*follow"/);
} finally {
footerPayload = previousFooter;
richTextPayload = previousRich;
}
});
test("unknown pages and malformed detail paths return a visible 404", async () => {
for (const path of [
"/unknown-page",
"/shorts/extra/",
"/play/23456/",
"/detail/not-a-number/",
"/detail/0/",
"/detail/9007199254740992/",
"/play/23456/0/",
"/member/unknown/",
"/info/unknown/",
"/richtext/",
"/richtext/a/b/",
"/assets/missing.png",
"/rank/unknown/",
"/rank/new/extra/",
]) {
const response = await fetch(base + path);
const html = await response.text();
assert.equal(response.status, 404, path);
assert.ok(html.includes("route-not-found"), path);
assert.ok(html.includes("返回首页"), path);
}
});
test("ranking slash URLs render their titles and bare URLs redirect to the same ranking", async () => {
for (const [slug, title] of [
["hot", "热播榜"],
["new", "新剧榜"],
["follow", "追剧榜"],
]) {
for (const suffix of ["/", ""]) {
const path = `/rank/${slug}${suffix}`;
const response = await fetch(base + path, {
redirect: "manual",
headers: { "Cache-Control": "no-cache" },
});
if (!suffix) {
assert.equal(response.status, 308, path);
assert.equal(
new URL(response.headers.get("location"), base).href,
base + path + "/",
path,
);
continue;
}
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null, path);
const html = await response.text();
assert.ok(html.includes(`<title>${title} - 黄果短剧</title>`), path);
assert.ok(html.includes('href="/rank/hot/"'));
assertPageLinksHaveTrailingSlash(html);
}
}
});
test("SSR emits configured TDK, site_name, canonical and bounded pagination links inside head for browser and crawler user agents", async () => {
try {
seoConfig = {
config: {
foreverUrlAddr: "https://canonical.drama.test/entry/?channel=abc",
},
seoInfo: JSON.stringify({
title: "后台首页标题",
description: "后台首页描述",
keyworld: "首页词,短剧",
webSiteName: "91剧场",
}),
};
categories = [
{
urlValue: "/playlet/",
seoInfo: {
title: "短剧导航标题",
description: "短剧导航描述",
keyworld: ["导航关键词"],
},
},
];
listPayload = {
list: [fixture],
totalCount: 72,
seoInfo: {
title: "列表接口标题",
description: "列表接口描述",
keywords: ["接口关键词"],
},
};
const headOf = (html) => {
const match = html.match(/<head>([\s\S]*?)<\/head>/);
assert.ok(match);
return match[1];
};
const start = calls.length;
const home = await (await fetch(base)).text(),
head = headOf(home);
assert.match(head, /<title>后台首页标题 - 91剧场<\/title>/);
assert.match(head, /<meta name="description" content="后台首页描述"/);
assert.match(head, /<meta name="keywords" content="首页词,短剧"/);
assert.match(head, /<meta property="og:site_name" content="91剧场"/);
assert.match(
head,
/<link rel="canonical" href="https:\/\/canonical.drama.test\/"/,
);
assert.match(
head,
/<meta property="og:image" content="https:\/\/canonical.drama.test\/og-default.jpg"/,
);
assert.match(
head,
/<meta name="twitter:card" content="summary_large_image"/,
);
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/playlet/home")
.length,
1,
"SSR and metadata share one homepage request",
);
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/config/info")
.length,
1,
"SSR and metadata share one configuration request",
);
assert.match(head,/google-site-verification" content="QJFFfKYHJsNfa-lGTdGjsCJc4rytPw-4wsTty-BM6iU"/);
assert.ok(!/google-analytics|mc\.yandex/.test(head));
assert.match(home,/googletagmanager\.com\/gtm\.js\?id=/);
assert.match(home,/['"]GTM-TXLP67SX['"]/);
assert.match(home,/googletagmanager\.com\/ns\.html\?id=GTM-TXLP67SX/);
for (const agent of [
"Mozilla/5.0 Chrome/130",
"Googlebot",
"facebookexternalhit",
]) {
for (const [path, page] of [
["/shorts/", 1],
["/shorts/page/2/", 2],
["/shorts/page/3/", 3],
]) {
const response = await fetch(base + path + "?channel=campaign", {
redirect: "manual",
headers: { "User-Agent": agent },
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
const head = headOf(await response.text());
const canonical = `https://canonical.drama.test/shorts/${page > 1 ? `page/${page}/` : ""}`;
assert.ok(head.includes(`<link rel="canonical" href="${canonical}"`));
assert.ok(
head.includes(`<meta property="og:url" content="${canonical}"`),
);
assert.ok(
head.includes(
`<title>短剧导航标题${page > 1 ? " - 第" + page + "页" : ""}</title>`,
),
);
assert.ok(
head.includes(
`<meta name="keywords" content="导航关键词${page > 1 ? ",第" + page + "页" : ""}"`,
),
);
assert.equal(head.includes('<link rel="prev"'), page > 1);
assert.equal(head.includes('<link rel="next"'), page < 3);
if (page === 2)
assert.ok(
head.includes(
'rel="prev" href="https://canonical.drama.test/shorts/?channel=campaign"',
),
);
assert.equal((head.match(/rel="canonical"/g) || []).length, 1);
}
}
for (const path of [
"/search/爱情/",
"/info/deposit/",
"/member/history/",
"/navigation/",
"/shorts/?sort=2",
]) {
const head = headOf(await (await fetch(base + path)).text());
assert.ok(!head.includes('rel="canonical"'), path);
assert.match(head, /<meta name="robots" content="noindex/);
}
const overflowResponse = await fetch(base + "/shorts/page/4/");
assert.equal(overflowResponse.status, 404);
const overflow = headOf(await overflowResponse.text());
assert.match(overflow, /<meta name="robots" content="noindex/);
assert.ok(!overflow.includes('rel="canonical"'));
assert.ok(!overflow.includes('rel="next"'));
} finally {
seoConfig = {};
categories = [];
listPayload = undefined;
}
});
test("detail SEO reads protected upstream content without exposing credentials and keeps registered sessions intact", async () => {
try {
requireSeoAuth = true;
seoConfig = {
seoInfo: { webSiteName: "91剧场" },
config: { foreverUrlAddr: "https://canonical.drama.test/" },
};
detailSeo = {
seoInfo: {
title: "剧集独立标题",
description: "真实剧集描述",
keywords: "剧集词",
},
};
const start = calls.length;
// Protected SEO reads use the client cookie token (jha-1048); SSR must not invent a guest.
const response = await fetch(base + "/detail/23456/", {
headers: {
"X-Real-IP": "203.0.113.8",
Cookie: "drama_bearer=test-session-secret",
},
});
assert.equal(response.status, 200);
const html = await response.text(),
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(head.includes("<title>剧集独立标题 - 91剧场</title>"));
assert.ok(head.includes('content="真实剧集描述"'));
assert.ok(
head.includes(
'rel="canonical" href="https://canonical.drama.test/detail/23456/"',
),
);
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/auth/guest")
.length,
0,
);
assert.ok(
calls
.slice(start)
.some(
(call) =>
call.path === "/api/app/auth/getInfo" &&
call.ip === "203.0.113.8",
),
);
assert.ok(!html.includes("test-session-secret"));
assert.ok(!html.includes("drama_session"));
const registeredStart = calls.length;
const registered = await fetch(base + "/detail/23456/", {
headers: { Cookie: "drama_session=expired; drama_registered=1" },
});
assert.equal(registered.status, 200);
const signedOut = await registered.text();
assert.ok(signedOut.includes("内容暂不可用"));
assert.ok(!signedOut.includes("请先登录账号"));
assert.ok(!signedOut.includes("data-auth-mode"));
assert.ok(signedOut.includes("noindex"));
assert.ok(!signedOut.includes('rel="canonical"'));
assert.equal(
calls
.slice(registeredStart)
.filter((call) => call.path === "/api/app/auth/guest").length,
0,
"registered identity is never replaced with guest",
);
} finally {
requireSeoAuth = false;
seoConfig = {};
detailSeo = undefined;
}
});
test("permanent canonical and site identity survive config errors, timeouts, missing fields and a server restart", async () => {
const domain = "https://stable.drama.test",
brand = "稳定站点";
const identity = async () => {
const response = await fetch(base + "/shorts/page/2/?channel=campaign", {
headers: {
"X-Forwarded-Host": "alternate.entry.test",
"X-Forwarded-Proto": "http",
},
});
assert.equal(response.status, 200);
const html = await response.text(),
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(head.includes(`rel="canonical" href="${domain}/shorts/page/2/"`));
assert.ok(
head.includes(`property="og:url" content="${domain}/shorts/page/2/"`),
);
assert.ok(head.includes(`property="og:site_name" content="${brand}"`));
assert.ok(
head.includes(`rel="prev" href="${domain}/shorts/?channel=campaign"`),
);
assert.ok(
head.includes(
`rel="next" href="${domain}/shorts/page/3/?channel=campaign"`,
),
);
const graph = JSON.parse(
html.match(
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
)[1],
)["@graph"];
const organization = graph.find((item) => item["@type"] === "Organization"),
website = graph.find((item) => item["@type"] === "WebSite");
assert.equal(organization.url, domain + "/");
assert.equal(organization.name, brand);
assert.equal(website.name, brand);
assert.ok(!head.includes("alternate.entry.test"));
assert.ok(!head.includes("noindex"));
};
try {
seoConfig = {
config: { foreverUrlAddr: domain },
seoInfo: { webSiteName: brand },
};
listPayload = { list: [fixture], totalCount: 72 };
await identity();
for (const failure of ["http", "business", "malformed", "timeout"]) {
configFailure = failure;
await identity();
}
configFailure = "";
seoConfig = {
config: { foreverUrlAddr: "" },
newAddressInfo: { foreverUrlAddr: "" },
seoInfo: { webSiteName: "不可替换的错误品牌" },
};
await identity();
configFailure = "http";
await stopNext();
await startNext();
await identity();
configFailure = "";
seoConfig = {
config: { foreverUrlAddr: "https://updated.drama.test" },
seoInfo: { webSiteName: "更新站点" },
};
const html = await (await fetch(base + "/shorts/")).text();
assert.ok(
html.includes(
'rel="canonical" href="https://updated.drama.test/shorts/"',
),
);
assert.ok(html.includes('property="og:site_name" content="更新站点"'));
} finally {
configFailure = "";
seoConfig = {};
categories = [];
listPayload = undefined;
}
});
test("SSR JSON-LD matches each canonical route, list page, content entity and visible FAQ without credentials or executable injection", async () => {
const savedHome = homePayload;
const graphOf = (html) => {
const scripts = [
...html.matchAll(
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/g,
),
];
assert.equal(
scripts.length,
1,
"one native JSON-LD script in the initial HTML",
);
const graph = JSON.parse(scripts[0][1]);
assert.equal(graph["@context"], "https://schema.org");
return graph["@graph"];
};
try {
const origin = "https://schema.drama.test";
seoConfig = {
seoInfo: { webSiteName: "91剧场" },
config: { foreverUrlAddr: origin },
};
listPayload = {
list: [
{ ...fixture, id: 7 },
{ ...fixture, id: 8 },
],
totalCount: 72,
name: "当前接口栏目",
};
detailSeo = {
onlineAt: "2025-10-18 10:30:00",
mediaUpdatedAt: "2025-10-19 09:00:00",
title: "剧名 </script><script>alert(1)</script>",
desc: "真实剧集简介",
};
chapterPayload = {
id: 90001,
playletId: 23456,
currentEpisode: 3,
canWatch: true,
needUnlock: false,
videoUrl: "https://media.drama.test/episode.m3u8",
mediaTime: 123,
};
videoPayload = {
...fixture,
coverImg: fixture.cover,
createdAt: "2025-10-17",
video: { url: "https://media.drama.test/movie.mp4", videoTime: 600 },
};
let html = await (await fetch(base)).text(),
graph = graphOf(html);
assert.ok(
graph.some(
(node) => node["@type"] === "Organization" && node.name === "91剧场",
),
);
assert.ok(
graph.some(
(node) => node["@type"] === "WebSite" && node.url === origin + "/",
),
);
assert.equal(
graph.find((node) => node["@type"] === "ItemList").itemListElement[0].url,
origin + "/detail/23456/",
);
for (const [path, type] of [
["/shorts/page/2/", "CollectionPage"],
["/rank/new/", "CollectionPage"],
["/topics/", "CollectionPage"],
["/topics/7/", "CollectionPage"],
["/info/about-us/", "AboutPage"],
["/info/help-center/", "FAQPage"],
]) {
html = await (
await fetch(base + path + "?channel=campaign", {
headers: { "User-Agent": "Googlebot" },
})
).text();
graph = graphOf(html);
assert.ok(
graph.some((node) => node["@type"] === type),
path,
);
assert.equal(
graph.find((node) => node["@id"] === origin + path + "#webpage").url,
origin + path,
);
if (path === "/shorts/page/2/") {
const items = graph.find(
(node) => node["@type"] === "ItemList",
).itemListElement;
assert.deepEqual(
items.map((item) => [item.position, item.url]),
[
[25, origin + "/detail/7/"],
[26, origin + "/detail/8/"],
],
);
}
if (path === "/info/help-center/")
assert.equal(
graph.find((node) => node["@type"] === "FAQPage").mainEntity.length,
5,
);
}
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
html = await (await fetch(base + path)).text();
graph = graphOf(html);
assert.ok(!html.includes("<script>alert(1)</script>"));
assert.ok(!JSON.stringify(graph).includes("test-session-secret"));
if (path.startsWith("/detail/")) {
assert.ok(graph.some((node) => node["@type"] === "TVSeries"));
assert.ok(!graph.some((node) => node["@type"] === "VideoObject"));
} else {
const video = graph.find((node) => node["@type"] === "VideoObject");
assert.ok(video, path);
assert.ok(video.thumbnailUrl[0]);
assert.ok(video.uploadDate.includes("+08:00"));
assert.ok(video.contentUrl.startsWith("https://media.drama.test/"));
if (path.startsWith("/play/"))
assert.equal(
graph.find((node) => node["@type"] === "TVEpisode").episodeNumber,
3,
);
}
}
for (const path of [
"/search/爱情/",
"/member/center/",
"/info/deposit/",
"/navigation/",
"/apps/",
"/shorts/?sort=2",
"/unknown/",
]) {
html = await (await fetch(base + path)).text();
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html), path);
}
} finally {
homePayload = savedHome;
seoConfig = {};
listPayload = undefined;
detailSeo = undefined;
chapterPayload = undefined;
videoPayload = undefined;
}
});
test("paginated routes serve slash URLs directly and redirect bare paths with their queries", async () => {
const savedHome = homePayload;
try {
listPayload = accountListPayload = { list: [fixture], totalCount: 48 };
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
commentPayload = transactionsPayload = { list: [], totalCount: 18 };
homePayload = {
...savedHome,
homeConfigData: [
{
id: 31,
status: true,
name: "后台配置标题",
type: 1,
list: Array.from({ length: 48 }, (_, i) => ({
...fixture,
id: fixture.id + i,
})),
},
],
};
const keyword = encodeURIComponent("都市 / page/7/?#%");
const bases = [
"/shorts/",
"/topics/",
"/topics/7/",
"/theme/8/",
"/tag/9/",
"/config/31/",
"/rank/",
"/rank/hot/",
"/rank/new/",
"/rank/follow/",
"/search/" + keyword + "/",
];
for (const basePath of bases) {
for (const suffix of ["page/2/", "page/2"]) {
const path = basePath + suffix + "?sort=2";
const response = await fetch(base + path, {
redirect: "manual",
headers: { "Cache-Control": "no-cache" },
});
if (!suffix.endsWith("/")) {
assert.equal(response.status, 308, path);
assert.equal(
new URL(response.headers.get("location"), base).href,
base + basePath + suffix + "/?sort=2",
path,
);
continue;
}
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null, path);
assert.equal(new URL(response.url).pathname, basePath + suffix, path);
const html = await response.text();
if (response.status === 200) assert.match(html, /第\s*2\s*页/, path);
assertPageLinksHaveTrailingSlash(html);
}
}
for (const path of [
"/shorts/page/0/",
"/shorts/page/01/",
"/shorts/page/2/page/3/",
"/apps/page/2/",
"/home-road/page/2/",
"/info/about-us/page/2/",
"/detail/23456/page/2/",
"/play/23456/90001/page/2/",
"/member/favorites/page/2/",
"/info/deposit/page/2/",
]) {
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 404, path);
assert.equal(response.headers.get("location"), null, path);
}
} finally {
homePayload = savedHome;
listPayload =
accountListPayload =
searchPayload =
commentPayload =
transactionsPayload =
undefined;
}
});
test("guest session token stays in HttpOnly cookie and device header is forwarded", async () => {
const response = await fetch(base + "/api/app/auth/guest", {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json" },
body: "{}",
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
const payload = await response.json();
assert.equal(payload.code, 200);
assert.equal(payload.data.token, undefined);
const cookies = response.headers.getSetCookie();
assert.ok(
cookies.some(
(value) =>
value.startsWith("drama_session=") &&
value.toLowerCase().includes("httponly") &&
value.includes("SameSite=lax"),
),
);
assert.ok(
calls
.findLast((call) => call.path.endsWith("/auth/guest"))
.device.includes("SysType=h5_pc;Ver=1.0.0;"),
);
});
test("browser app API routes preserve sessions and queries through Next", async () => {
let start = calls.length;
const guest = await fetch(base + "/api/app/auth/guest", {
method: "POST",
headers: { Origin: base, "Content-Type": "application/json" },
body: JSON.stringify({ devID: "bff-device" }),
});
assert.equal(guest.status, 200);
assert.equal((await guest.json()).data.token, undefined);
assert.ok(
guest.headers
.getSetCookie()
.some(
(value) =>
value.startsWith("drama_session=") && value.includes("HttpOnly"),
),
);
assert.deepEqual(
calls.slice(start).map((call) => call.path),
["/api/app/auth/guest"],
);
const cookie = guest.headers
.getSetCookie()
.map((value) => value.split(";")[0])
.join("; ");
const info = await fetch(base + "/api/app/auth/getInfo", {
headers: { Cookie: cookie },
});
assert.equal(info.status, 200);
assert.equal((await info.json()).data.userId, 77);
assert.equal(calls.at(-1).auth, "test-session-secret");
const search = await fetch(
base + "/api/app/playlet/search?keyword=test&pageNum=2&pageSize=4",
{ headers: { Cookie: cookie } },
);
assert.equal(search.status, 200);
assert.ok(Array.isArray((await search.json()).data.list));
assert.equal(calls.at(-1).path, "/api/app/playlet/search");
assert.equal(new URLSearchParams(calls.at(-1).search).get("pageNum"), "2");
start = calls.length;
assert.equal((await fetch(base + "/api/app/not-allowed")).status, 404);
assert.equal(calls.length, start);
});
test("both API routes preserve native and client UA, SDK identity, IP and JSON request headers", async () => {
const ua =
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A) Chrome/124.0 Mobile Safari/537.36";
const xua =
"DevID=client-device;DevType=Android;SysType=h5_android;Ver=2.0;BuildID=web;sid=client-sid";
const fields = {
sid: "sdk-sid",
device_id: "sdk-device",
device: "Android",
device_fingerprint: "raw",
fp_version: "3",
app_version: "1.0.0",
};
for (const prefix of ["/api", "/api/app"]) {
const response = await fetch(base + prefix + "/auth/guest", {
method: "POST",
headers: {
"User-Agent": ua,
"X-User-Agent": xua,
"X-Real-IP": "203.0.113.9",
"X-Forwarded-For": "203.0.113.8, 10.0.0.1",
"X-SDK-Common": Buffer.from(JSON.stringify(fields)).toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify({ devID: "business-device" }),
});
assert.equal(response.status, 200);
assert.equal((await response.json()).data.token, undefined);
const call = calls.at(-1);
assert.equal(call.ua, ua);
assert.equal(call.device, xua);
assert.equal(call.ip, "203.0.113.9");
assert.equal(call.accept, "application/json");
assert.equal(call.contentType, "application/json");
assert.equal(JSON.parse(call.body).devID, "business-device");
// Only the visitor identity survives; analytics fingerprint fields are dropped.
assert.deepEqual(JSON.parse(Buffer.from(call.sdk, "base64")), {
sid: fields.sid,
device_id: fields.device_id,
app_version: fields.app_version,
});
assert.equal(call.forwarded, undefined);
assert.equal(call.cookie, undefined);
}
});
test("public SSR and page session requests use the visitor UA, SID and IP with the configured upstream", async () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15";
const start = calls.length;
const response = await fetch(base + "/shorts/", {
headers: {
"User-Agent": ua,
"X-Real-IP": "203.0.113.6",
Cookie:
"drama_session=registered-user-a-secret; drama_registered=1; sid=ssr-sid",
},
});
assert.equal(response.status, 200);
await response.text();
const forwarded = calls.slice(start);
assert.ok(forwarded.some((call) => call.path === "/api/app/auth/getInfo"));
assert.ok(
forwarded.some((call) => call.path === "/api/app/playlet/operate/list"),
);
for (const call of forwarded) {
assert.equal(call.ua, ua, call.path);
assert.equal(call.ip, "203.0.113.6", call.path);
assert.equal(
call.device,
"DevType=Apple Mac desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Safari 17.5;device_brand=Apple;device_model=Mac;system_name=macOS;system_version=14.5;sid=ssr-sid",
call.path,
);
if (call.path !== "/api/app/auth/getInfo")
assert.equal(call.auth, undefined);
}
});
test("browser POSTs reach upstream through CDN host and protocol changes on both API routes", async () => {
const port = new URL(base).port;
const browserOrigin = "https://cdn.drama.example";
const cases = [
{ Host: `localhost:${port}`, Origin: `http://localhost:${port}` },
{ Host: `127.0.0.1:${port}`, Origin: `http://127.0.0.1:${port}` },
{ Host: "cdn.drama.example", Origin: browserOrigin },
{
Host: "next.internal:21030",
Origin: browserOrigin,
"X-Forwarded-Host": "cdn.drama.example",
"X-Forwarded-Proto": "https",
},
{ Host: "next.internal:21030", Origin: "null" },
{
Host: "next.internal:21030",
Origin: "https://other.example",
"Sec-Fetch-Site": "cross-site",
},
];
for (const prefix of ["/api", "/api/app"])
for (const headers of cases) {
const count = calls.length;
// Node fetch replaces a custom Host; use HTTP directly to exercise the actual authority.
const { status, payload } = await new Promise((resolve, reject) => {
const request = httpRequest(
base + prefix + "/auth/guest",
{
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"Content-Type": "application/json",
...headers,
},
},
(response) => {
let body = "";
response.on("data", (chunk) => (body += chunk));
response.on("end", () => {
try {
resolve({
status: response.statusCode,
payload: JSON.parse(body),
});
} catch (error) {
reject(error);
}
});
},
);
request.on("error", reject);
request.end(
JSON.stringify({ automatic: true, devID: "origin-test-device" }),
);
});
assert.equal(
status,
200,
prefix + " " + JSON.stringify(headers) + ": " + payload.msg,
);
assert.equal(payload.data.userId, 77);
assert.deepEqual(
calls.slice(count).map((call) => call.path),
["/api/app/auth/guest"],
);
assert.equal(payload.data.token, undefined);
}
});
test("browser POSTs preserve upstream authorization errors after proxy forwarding", async () => {
responseOverrides["auth/guest"] = { status: 403, code: 403 };
try {
for (const prefix of ["/api", "/api/app"]) {
const count = calls.length;
const response = await fetch(base + prefix + "/auth/guest", {
method: "POST",
headers: {
Origin: "https://cdn.drama.example",
"Content-Type": "application/json",
},
body: "{}",
});
assert.equal(response.status, 403);
assert.equal((await response.json()).code, 403);
assert.deepEqual(
calls.slice(count).map((call) => call.path),
["/api/app/auth/guest"],
);
}
} finally {
delete responseOverrides["auth/guest"];
}
});
test("public content ignores expired credentials; double-encoded data is normalized", async () => {
await fetch(base + "/api/app/playlet/home", {
headers: { Cookie: "drama_session=expired" },
});
assert.equal(
calls.findLast((call) => call.path.endsWith("/playlet/home")).auth,
undefined,
);
const response = await fetch(base + "/api/app/playlet/search?keyword=abc");
const payload = await response.json();
assert.equal(payload.data.list[0].id, 23456);
});
test("registered session expiry never retries a write as a guest", async () => {
const guestCount = calls.filter((call) =>
call.path.endsWith("/auth/guest"),
).length;
const response = await fetch(base + "/api/app/playlet/collect/set", {
method: "POST",
headers: {
Cookie: "drama_session=expired; drama_registered=1",
"Content-Type": "application/json",
},
body: JSON.stringify({ id: 23456, isCollect: true }),
});
assert.equal(response.status, 401);
assert.equal((await response.json()).code, 1000);
assert.equal(
calls.filter((call) => call.path.endsWith("/auth/guest")).length,
guestCount,
);
assert.equal(response.headers.get("X-Auth-State"), "login-required");
assert.ok(
!response.headers
.getSetCookie()
.some(
(value) =>
value.startsWith("drama_session=;") && value.includes("Max-Age=0"),
),
);
});
test("refresh restores an existing account and keeps its token when getInfo omits it", async () => {
const count = calls.length;
const response = await fetch(base + "/api/app/auth/guest", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: "drama_session=test-registered-secret; drama_registered=1",
},
body: '{"automatic":true}',
});
assert.equal(response.status, 200);
const data = await response.json();
assert.equal(data.data.userId, 88);
assert.equal(data.data.isRegister, true);
assert.equal(data.data.token, undefined);
assert.deepEqual(
calls.slice(count).map((call) => call.path),
["/api/app/auth/getInfo"],
);
// Keep the browser cookie as-is when getInfo echoes the same token, so a later
// login cookie cannot be overwritten by a stale refresh of the previous identity.
assert.equal(
response.headers
.getSetCookie()
.some((value) => value.startsWith("drama_session=")),
false,
);
assert.equal(
response.headers
.getSetCookie()
.some((value) => value.startsWith("drama_registered=")),
false,
);
});
test("an expired registered account is cleared during initialization without becoming a guest", async () => {
const count = calls.length;
const response = await fetch(base + "/api/app/auth/guest", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: "drama_session=expired; drama_registered=1",
},
body: '{"automatic":true}',
});
assert.equal(response.status, 401);
assert.equal(response.headers.get("X-Auth-State"), "login-required");
assert.deepEqual(
calls.slice(count).map((call) => call.path),
["/api/app/auth/getInfo"],
);
assert.ok(
response.headers
.getSetCookie()
.some(
(value) =>
value.startsWith("drama_session=;") && value.includes("Max-Age=0"),
),
);
});
test("expired guest requests recover once with the same device, including an explicitly rejected write", async () => {
for (const [path, method, body] of [
["auth/getInfo", "GET", undefined],
[
"playlet/collect/set",
"POST",
JSON.stringify({ id: 23456, isCollect: true }),
],
]) {
const count = calls.length;
const response = await fetch(base + "/api/app/" + path, {
method,
headers: {
"Content-Type": "application/json",
Cookie:
"drama_session=expired; drama_registered=0; drama_device=device-stable",
},
body,
});
assert.equal(response.status, 200);
assert.equal((await response.json()).code, 200);
const requests = calls.slice(count);
assert.deepEqual(
requests.map((call) => call.path),
["/api/app/" + path, "/api/app/auth/guest", "/api/app/" + path],
);
assert.equal(requests[1].auth, undefined);
assert.equal(JSON.parse(requests[1].body).devID, "device-stable");
assert.equal(requests[2].auth, "test-session-secret");
}
const count = calls.length;
const response = await fetch(base + "/api/app/playlet/collect/set", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: "drama_session=expired; drama_registered=0",
},
body: '{"id":999}',
});
assert.equal(response.status, 401);
assert.equal(calls.length - count, 3);
});
test("an HTTP 401 follows the same guest recovery path", async () => {
const count = calls.length;
const response = await fetch(base + "/api/app/auth/getInfo", {
headers: { Cookie: "drama_session=http-expired; drama_registered=0" },
});
assert.equal(response.status, 200);
assert.equal((await response.json()).code, 200);
assert.deepEqual(
calls.slice(count).map((call) => call.path),
["/api/app/auth/getInfo", "/api/app/auth/guest", "/api/app/auth/getInfo"],
);
});
test("proxy rejects unlisted endpoints and wrong methods before calling upstream", async () => {
const count = calls.length;
assert.equal(
(
await fetch(base + "/api/app/statcenter/sync", {
method: "POST",
body: "{}",
})
).status,
404,
);
assert.equal(
(
await fetch(base + "/api/app/playlet/home", {
method: "POST",
body: "{}",
})
).status,
404,
);
assert.equal(calls.length, count);
});
test("chapter permissions and identifiers are preserved from the API", async () => {
const response = await fetch(
base + "/api/app/playlet/chapter/detail?playletId=23456&chapterId=90001",
{ headers: { Cookie: "drama_session=test-session-secret" } },
);
const payload = await response.json();
assert.equal(payload.data.id, 90001);
assert.equal(payload.data.canWatch, false);
assert.equal(payload.data.singleCoins, 13);
});
test("media proxy preserves signed URLs and HLS content while isolating application credentials", async () => {
const query = "?token=a%2Fb+1&sig=x%252Fy";
const ua = "browser-media-ua",
xua = "DevType=client;SysType=h5_pc;Ver=1.0.0;BuildID=web;sid=media-sid";
const response = await fetch(base + "/h5/m3u8/folder/movie.m3u8" + query, {
redirect: "manual",
headers: {
Authorization: "private",
Cookie: "drama_session=private",
"User-Agent": ua,
"X-User-Agent": xua,
"X-Forwarded-For": "203.0.113.10, 10.0.0.1",
},
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.equal(
response.headers.get("content-type"),
"application/vnd.apple.mpegurl",
);
assert.equal(await response.text(), playlist);
assert.equal(response.headers.get("cache-control"), "no-store");
const upstream = calls.findLast(
(call) => call.path === "/h5/m3u8/folder/movie.m3u8",
);
assert.equal(upstream.search, query);
assert.equal(upstream.auth, undefined);
assert.equal(upstream.cookie, undefined);
assert.equal(upstream.ip, "203.0.113.10");
assert.equal(upstream.forwarded, undefined);
assert.equal(upstream.ua, ua);
assert.equal(upstream.device, xua);
assert.equal(upstream.accept, "*/*");
});
test("both key endpoint forms proxy exact AES bytes without redirects or credential forwarding", async () => {
for (const path of ["/enkey", "/enkey/", "/newenkey", "/newenkey/"]) {
const response = await fetch(base + path + "?token=a%2Fb+1", {
redirect: "manual",
headers: { Cookie: "drama_session=private", "X-Real-IP": "203.0.113.11" },
});
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null);
assert.equal(
response.headers.get("content-type"),
"application/octet-stream",
);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("access-control-allow-origin"), "*");
assert.deepEqual(
Buffer.from(await response.arrayBuffer()),
Buffer.from(Array.from({ length: 16 }, (_, index) => index)),
);
const upstream = calls.at(-1);
assert.equal(upstream.path, path.replace(/\/$/, ""));
assert.equal(upstream.search, "?token=a%2Fb+1");
assert.equal(upstream.cookie, undefined);
assert.equal(upstream.ip, "203.0.113.11");
assert.equal(upstream.forwarded, undefined);
}
});
test("key endpoints support a legacy upstream slash redirect internally without redirecting the browser or losing signatures", async () => {
upstreamKeySlash = true;
try {
for (const path of ["/enkey", "/newenkey"]) {
const count = calls.length,
query = "?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy";
const response = await fetch(base + path + query, {
redirect: "manual",
headers: {
Cookie: "drama_session=private",
Authorization: "private",
"X-Real-IP": "203.0.113.12",
},
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.equal((await response.arrayBuffer()).byteLength, 16);
const forwarded = calls.slice(count);
assert.deepEqual(
forwarded.map((call) => call.path),
[path, path + "/"],
);
for (const call of forwarded) {
assert.equal(call.search, query);
assert.equal(call.ip, "203.0.113.12");
assert.equal(call.auth, undefined);
assert.equal(call.cookie, undefined);
assert.equal(call.forwarded, undefined);
}
}
} finally {
upstreamKeySlash = false;
}
});
test("media byte ranges and upstream failure states remain usable by the player", async () => {
const segment = await fetch(base + "/h5/m3u8/folder/a.ts?token=signed", {
headers: { Range: "bytes=0-2" },
});
assert.equal(segment.status, 206);
assert.equal(segment.headers.get("content-range"), "bytes 0-2/10");
assert.equal(segment.headers.get("content-type"), "video/mp2t");
assert.deepEqual(
Buffer.from(await segment.arrayBuffer()),
Buffer.from([9, 8, 7]),
);
assert.equal(calls.at(-1).range, "bytes=0-2");
for (const path of [
"/h5/m3u8/denied.m3u8",
"/h5/m3u8/invalid.m3u8",
"/h5/m3u8/redirect.m3u8",
"/enkey?invalid=1",
"/newenkey/?invalid=1",
]) {
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 502, path);
assert.equal(response.headers.get("location"), null, path);
}
assert.equal((await fetch(base + "/h5/m3u8/missing.m3u8")).status, 404);
});
function currentMain(html, id) {
const match = html.match(
new RegExp('<main\\b[^>]*\\bid="' + id + '"[^>]*>[\\s\\S]*?<\\/main>'),
);
assert.ok(match, "main " + id + " must exist");
assert.ok(
!/^<main[^>]*\shidden(?:[=\s>])/.test(match[0]),
id + " must be visible",
);
return match[0];
}
test("public listings return their own cards, page links and titles as initial HTML with one shared metadata/body read", async () => {
try {
listPayload = (endpoint, url) => {
const page = Number(url.searchParams.get("pageNum") || 1),
id = page === 2 ? 24002 : 24001;
return {
name: "栏目专属标题",
desc: "专题简介",
list: [
{
...fixture,
id,
title: "列表第" + page + "页内容",
name: "专题第" + page + "页",
desc: "专题描述",
},
],
totalCount: 72,
};
};
const routes = [
["/shorts/", "playlet/operate/list"],
["/shorts/page/2/", "playlet/operate/list"],
["/rank/hot/", "playlet/hot/rank/list"],
["/rank/new/page/2/", "playlet/hot/rank/list"],
["/rank/follow/", "playlet/hot/rank/list"],
["/topics/", "playlet/topic/list"],
["/topics/7/page/2/", "playlet/topic/detail"],
["/theme/8/", "playlet/all/list"],
["/tag/9/", "playlet/tag/list"],
];
for (const [path, endpoint] of routes) {
const start = calls.length,
response = await fetch(base + path);
assert.equal(response.status, 200, path);
const html = await response.text(),
main = currentMain(html, "channel-page"),
page = path.includes("/page/2/") ? 2 : 1;
assert.ok(
main.includes(
path === "/topics/" ? "专题第1页" : "列表第" + page + "页内容",
),
path,
);
assert.ok(
main.includes("/page/3/"),
path + " must contain native pagination links",
);
assert.ok(
!html.includes("后台配置标题"),
path + " must not embed the homepage",
);
const reads = calls.slice(start);
assert.equal(
reads.filter((call) => call.path === "/api/app/" + endpoint).length,
1,
path + " must share metadata/body data",
);
if (!path.startsWith("/topics/"))
assert.ok(
!reads.some((call) => call.path === "/api/app/playlet/home"),
path,
);
assertPageLinksHaveTrailingSlash(main);
}
for (const slug of ["hot", "new", "follow"]) {
const main = currentMain(
await (await fetch(base + "/rank/" + slug + "/")).text(),
"channel-page",
);
assert.match(
main,
new RegExp('href="/rank/' + slug + '/" aria-current="page"'),
);
}
} finally {
listPayload = undefined;
}
});
test("standalone lists use H2 card titles while cards within named sections remain H3 in initial HTML", async () => {
const savedHome = homePayload;
try {
listPayload = {
name: "列表名称",
list: [{ ...fixture, name: "专题卡片" }],
totalCount: 72,
};
homePayload = {
...savedHome,
homeConfigData: [
{ id: 31, name: "短剧分区", status: true, type: 1, list: [fixture] },
{
id: 32,
name: "专题推荐分区",
status: true,
type: 3,
themeList: [{ id: 8, name: "首页专题卡片" }],
},
],
};
for (const path of [
"/shorts/",
"/shorts/page/2/",
"/rank/hot/",
"/rank/new/page/2/",
"/rank/follow/",
"/topics/",
"/topics/7/",
"/theme/8/",
"/tag/9/",
"/config/31/",
"/config/32/",
]) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
const main = currentMain(await response.text(), "channel-page");
assert.equal((main.match(/<h1\b/g) || []).length, 1, path);
assert.equal((main.match(/<h2\b/g) || []).length, 1, path);
assert.ok(!/<h3\b/.test(main), path + " has no intermediate H2 section");
}
const home = await (await fetch(base)).text();
for (const id of [31, 32]) {
const section = home.match(
new RegExp(
'<section\\b[^>]*\\baria-labelledby="home-block-' +
id +
'"[^>]*>([\\s\\S]*?)<\\/section>',
),
)?.[1];
assert.ok(section);
assert.equal((section.match(/<h2\b/g) || []).length, 1);
assert.equal(
(section.match(/<h3\b/g) || []).length,
1,
"homepage cards are below their section heading",
);
}
const detail = currentMain(
await (await fetch(base + "/detail/23456/")).text(),
"drama-play-page",
);
assert.match(
detail,
/<h2 id="play-recommend-title">推荐视频<\/h2>[\s\S]*?<h3 class="hg-card-title">/,
);
const search = currentMain(
await (await fetch(base + "/search/关键词/")).text(),
"search-results-page",
);
assert.match(search, /<h1\b/);
assert.match(search, /<h2\b/);
assert.ok(!/<h3\b/.test(search));
} finally {
homePayload = savedHome;
listPayload = accountListPayload = undefined;
}
});
test("detail and playback pages contain real initial content, the App gate and recommendations without comments, private payloads or denied video sources", async () => {
try {
detailSeo = {
desc: "服务端剧集完整简介",
token: "private-detail-token",
reportSdkConfig: { secret: "private-sdk-secret" },
};
chapterListPayload = {
list: [
{
id: 90001,
currentEpisode: 1,
canWatch: true,
videoUrl: "https://cdn.example/free.mp4",
},
{
id: 90002,
currentEpisode: 2,
canWatch: false,
needUnlock: true,
payType: 1,
singleCoins: 13,
videoUrl: "https://cdn.example/denied-video-secret.mp4",
},
],
};
chapterPayload = { ...chapterListPayload.list[1], playletId: 23456 };
commentPayload = {
list: [
{
id: 45,
text: "服务端第2页评论",
userName: "评论用户",
token: "private-comment-token",
children: [{ id: 46, text: "嵌套回复" }],
},
],
totalCount: 25,
};
videoPayload = {
...fixture,
id: 23457,
title: "服务端独立视频",
video: {
url: "https://cdn.example/public-video.mp4",
cover: fixture.cover,
},
desc: "视频真实简介",
};
for (const path of ["/detail/23456/", "/play/23456/90002/"]) {
const start = calls.length,
response = await fetch(base + path);
assert.equal(response.status, 200);
const html = await response.text(),
main = currentMain(html, "drama-play-page");
for (const text of ["服务端第2页评论", "嵌套回复", "评论", "反馈", "客服", "金币"])
assert.ok(!main.includes(text), text);
assert.ok(main.includes('<h2 id="play-recommend-title">推荐视频</h2>'));
assert.ok(!html.includes("后台配置标题"));
for (const secret of [
"private-detail-token",
"private-comment-token",
"private-sdk-secret",
"denied-video-secret.mp4",
])
assert.ok(!html.includes(secret), secret);
assert.equal(
calls
.slice(start)
.filter((call) => call.path === "/api/app/comment/list").length,
0,
"comments are no longer requested",
);
if (path.startsWith("/video/")) {
assert.ok(main.includes("服务端独立视频"));
assert.ok(main.includes("<video "));
} else {
assert.ok(main.includes("服务端剧集完整简介"));
assert.ok(main.includes('href="/play/23456/90001/"'));
assert.match(main, /class="film-tile[^"]*\bis-locked" href="\/play\/23456\/90002\/"/);
assert.ok(main.includes('href="/play/23456/90002/"'));
assert.ok(!main.includes("data-locked-chapter"));
if (path.startsWith("/detail/"))
assert.ok(main.includes("is-overview"));
else {
assert.ok(main.includes("<video "));
assert.ok(main.includes("本集请在 黄果短剧 App 观看"));
assert.ok(main.includes("请在 App 观看"));
}
}
assertPageLinksHaveTrailingSlash(main);
}
} finally {
detailSeo = undefined;
chapterPayload = undefined;
chapterListPayload = undefined;
commentPayload = undefined;
videoPayload = undefined;
}
});
test("search paths decode keyword segments once and include keyword/result cards before JavaScript runs", async () => {
for (const keyword of [
"都市 爱情",
"都市 / 爱情 & 100% #1",
"字面%2F保持原样",
]) {
const start = calls.length,
path = "/search/" + encodeURIComponent(keyword) + "/";
const response = await fetch(base + path);
assert.equal(response.status, 200);
const html = await response.text(),
main = currentMain(html, "search-results-page");
const escaped = keyword.replaceAll("&", "&amp;");
assert.ok(main.includes(escaped), keyword);
assert.ok(main.includes('href="/detail/23456/"'));
assert.ok(main.includes("接口剧名 &lt;script&gt;"));
assert.ok(!html.includes("后台配置标题"));
assert.ok(!main.includes("<script>alert(1)</script>"));
const reads = calls
.slice(start)
.filter((call) => call.path === "/api/app/playlet/search");
assert.equal(reads.length, 1);
assert.equal(new URLSearchParams(reads[0].search).get("keyword"), keyword);
}
});
test("search path pagination renders keyword links and rejects empty paginated searches", async () => {
const keyword = "都市 / 爱情 & 100% #1",
path = "/search/" + encodeURIComponent(keyword) + "/";
try {
searchPayload = { list: [fixture], totalCount: 76, pageSize: 25 };
const start = calls.length,
response = await fetch(base + path + "page/2/", { redirect: "manual" });
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
const html = await response.text(),
main = currentMain(html, "search-results-page");
for (const href of [path, path + "page/3/"])
assert.ok(main.includes('href="' + href + '"'), href);
assert.ok(!main.includes("/search/?q="));
assert.ok(html.includes("都市 / 爱情 &amp; 100% #1 · 搜索结果 - 第2页"));
assert.ok(!html.includes('rel="canonical"'));
assert.match(html, /<meta name="robots" content="noindex, follow/);
const read = calls
.slice(start)
.find((call) => call.path === "/api/app/playlet/search");
assert.equal(new URLSearchParams(read.search).get("keyword"), keyword);
assert.equal(new URLSearchParams(read.search).get("pageNum"), "2");
const empty = await fetch(base + "/search/page/2/", { redirect: "manual" });
assert.equal(empty.status, 404);
assert.equal(empty.headers.get("location"), null);
} finally {
searchPayload = undefined;
}
});
test("Chinese content slugs generate ID-based links in rendered pages and structured data", async () => {
const savedHome = homePayload;
try {
const drama = { ...fixture, slug: "都市故事" };
homePayload = {
homeBannerData: [drama],
hotSearch: [{ keyword: "都市爱情" }],
homeConfigData: [
{
id: 31,
slug: "精选好剧",
name: "精选好剧",
status: true,
type: 1,
list: [drama],
},
{
id: 32,
slug: "专题合集",
name: "专题合集",
status: true,
type: 3,
themeList: [{ id: 7, slug: "都市专题", name: "都市专题" }],
},
],
};
const home = await (await fetch(base + "/")).text();
for (const href of [
"/detail/23456/",
"/topics/7/",
"/config/31/",
"/search/" + encodeURIComponent("都市爱情") + "/",
])
assert.ok(home.includes('href="' + href + '"'), href);
detailSeo = {
slug: "都市故事",
title: "都市故事",
tagNames: ["都市", "无标识标签"],
tagIds: [9],
tagSlugs: ["都市", "中文标签"],
};
chapterPayload = {
id: 90001,
playletId: 23456,
currentEpisode: 1,
canWatch: true,
videoUrl: "https://media.drama.test/1.mp4",
};
const detail = await (await fetch(base + "/play/23456/90001/")).text();
assert.ok(detail.includes('href="/tag/9/"'));
assert.ok(detail.includes("<span>#无标识标签</span>"));
assert.ok(!detail.includes('href="/tag//"'));
assert.ok(
detail.includes(
'rel="canonical" href="https://default.drama.test/play/23456/90001/"',
),
);
for (const html of [home, detail]) {
for (const [, href] of html.matchAll(/<a\b[^>]*href="(\/[^"#?]*)/g))
if (!href.startsWith("/search/"))
assert.ok(!/[^\x00-\x7f]/.test(decodeURIComponent(href)), href);
}
} finally {
homePayload = savedHome;
detailSeo = chapterPayload = undefined;
}
});
test("a search service outage returns a server error instead of a successful empty search page", async () => {
try {
searchFailure = true;
const response = await fetch(
base + "/search/" + encodeURIComponent("服务异常") + "/",
);
assert.equal(response.status, 500);
const html = await response.text();
assert.ok(!html.includes("后台配置标题"));
assert.ok(!html.includes('rel="canonical"'));
} finally {
searchFailure = false;
}
});
test("missing resources return actual 404 responses for browsers and crawlers, without redirects or content canonical", async () => {
try {
for (const agent of ["Mozilla/5.0 Chrome/130", "Googlebot", "YandexBot"]) {
for (const [path, endpoint, override] of [
["/detail/23456/", "playlet/detail", { status: 404 }],
["/detail/23456/", "playlet/detail", { code: 404 }],
["/detail/23456/", "playlet/detail", { code: 410 }],
["/detail/23456/", "playlet/detail", { code: 5009 }],
["/detail/23456/", "playlet/detail", { data: null }],
["/play/23456/90001/", "playlet/chapter/detail", { status: 404 }],
["/play/23456/90001/", "playlet/chapter/detail", { data: null }],
["/play/23456/90001/", "playlet/chapter/detail", { code: 5009 }],
["/topics/7/", "playlet/topic/detail", { code: 404 }],
["/topics/7/", "playlet/topic/detail", { code: 5009 }],
[
"/topics/7/",
"playlet/topic/detail",
{ data: { id: 8, name: "其他专题", list: [], totalCount: 0 } },
],
["/theme/7/", "playlet/all/list", { status: 404 }],
["/tag/7/", "playlet/tag/list", { code: 410 }],
]) {
responseOverrides = { [endpoint]: override };
const response = await fetch(base + path, {
redirect: "manual",
headers: { "User-Agent": agent },
}),
html = await response.text();
assert.equal(
response.status,
404,
agent + " " + path + " " + JSON.stringify(override),
);
assert.equal(response.headers.get("location"), null);
assert.ok(html.includes("noindex"));
assert.match(response.headers.get("cache-control"), /no-store/);
assert.ok(!html.includes('rel="canonical"'));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.ok(html.includes("返回首页"));
}
}
responseOverrides = {};
for (const path of [
"/config/999999/",
"/detail/999999/",
"/play/23456/999999/",
"/tag/999999/",
]) {
const response = await fetch(base + path, { redirect: "manual" });
assert.equal(response.status, 404, path);
await response.text();
}
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
const response = await fetch(base + path);
assert.equal(
response.status,
200,
"valid/locked content stays available",
);
await response.text();
}
} finally {
responseOverrides = {};
}
});
test("known pagination limits return 404 while existing empty first pages remain 200", async () => {
try {
listPayload = { list: [], totalCount: 0 };
for (const path of [
"/shorts/",
"/rank/hot/",
"/rank/new/",
"/rank/follow/",
"/topics/",
"/topics/7/",
"/theme/8/",
"/tag/9/",
]) {
const first = await fetch(base + path);
assert.equal(first.status, 200, path);
const emptyHtml = await first.text();
assert.match(
emptyHtml,
/<meta name="robots" content="noindex,\s*follow"/,
);
assert.ok(!emptyHtml.includes('rel="canonical"'));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(emptyHtml));
const overflow = await fetch(base + path + "page/2/", {
redirect: "manual",
});
assert.equal(overflow.status, 404, path);
await overflow.text();
}
listPayload = { list: [fixture], totalCount: 48 };
commentPayload = transactionsPayload = { list: [], totalCount: 20 };
accountListPayload = { list: [], totalCount: 48 };
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
for (const path of [
"/shorts/",
"/rank/hot/",
"/rank/new/",
"/rank/follow/",
"/topics/",
"/topics/7/",
"/theme/8/",
"/tag/9/",
"/search/关键词/",
]) {
for (const [page, status] of [
[2, 200],
[3, 404],
]) {
const privatePath=/^\/(member\/|info\/deposit)/.test(path);
const response = await fetch(base + path + "page/" + page + "/", {
redirect: "manual",
headers: privatePath
? { Cookie: "drama_bearer=test-session-secret" }
: undefined,
}),
html = await response.text();
assert.equal(response.status, status, path + " page " + page);
assert.equal(response.headers.get("location"), null);
if (status === 404) {
assert.ok(html.includes("noindex"));
assert.ok(!html.includes('rel="canonical"'));
assert.ok(!html.includes('rel="next"'));
}
}
}
for (const path of ["/config/31/page/2/", "/detail/23456/page/2/", "/play/23456/90001/page/2/", "/member/history/page/2/", "/info/deposit/page/2/"]) {
const removed = await fetch(base + path, { redirect: "manual" });
assert.equal(removed.status, 404, path);
await removed.text();
}
searchPayload = { list: [], totalCount: 0 };
const emptySearch = await fetch(base + "/search/没有匹配项/");
assert.equal(emptySearch.status, 200);
assert.ok((await emptySearch.text()).includes("搜索无结果"));
} finally {
listPayload =
commentPayload =
transactionsPayload =
accountListPayload =
searchPayload =
undefined;
}
});
test("independent theme/topic resources and leading-zero IDs retain their primary canonical", async () => {
try {
themePayload = { list: [{ id: 7, name: "同一题材" }] };
listPayload = {
name: "同一专题",
desc: "同一专题介绍",
list: [fixture],
totalCount: 72,
};
for (const [path, target] of [
["/theme/007/", "/theme/7/"],
["/theme/007/page/2/", "/theme/7/page/2/"],
["/topics/007/", "/topics/7/"],
["/detail/023456/", "/detail/23456/"],
]) {
const start = calls.length,
response = await fetch(base + path + "?utm_source=campaign", {
redirect: "manual",
}),
html = await response.text();
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null);
assert.ok(
html.includes(
`rel="canonical" href="https://default.drama.test${target}"`,
),
path,
);
assert.match(html, /<meta name="robots" content="index,\s*follow/);
assert.ok(
html.includes(
path.startsWith("/detail/")
? "play-detail-title"
: path.startsWith("/theme/")
? "同一题材"
: "同一专题",
),
);
if (path.startsWith("/theme/"))
assert.equal(
calls
.slice(start)
.filter((call) => call.path === "/api/app/playlet/all/list").length,
1,
);
}
} finally {
listPayload = themePayload = undefined;
}
});
test("former comment pages are gone, while ordinary catalog pagination remains indexable", async () => {
try {
commentPayload = {
list: [{ id: 321, text: "第二页的真实评论" }],
totalCount: 25,
};
chapterPayload = {
id: 90001,
playletId: 23456,
currentEpisode: 2,
canWatch: true,
videoUrl: "https://media.drama.test/2.mp4",
};
for (const path of ["/detail/23456/page/2/", "/play/23456/90001/page/2/"]) {
const response = await fetch(base + path, {
redirect: "manual",
headers: { "User-Agent": "YandexBot" },
}),
html = await response.text();
assert.equal(response.status, 404, path);
assert.equal(response.headers.get("location"), null);
assert.match(html, /<meta name="robots" content="noindex/);
assert.ok(!html.includes('rel="canonical"'));
assert.ok(!html.includes("第二页的真实评论"));
}
listPayload = { list: [fixture], totalCount: 72 };
const html = await (await fetch(base + "/shorts/page/2/")).text();
assert.match(html, /<meta name="robots" content="index,\s*follow/);
assert.ok(
html.includes(
'rel="canonical" href="https://default.drama.test/shorts/page/2/"',
),
);
} finally {
commentPayload = chapterPayload = listPayload = undefined;
}
});
test("SSR applies more-list content rules and real episode access rules without exposing locked media", async () => {
const savedHome = homePayload;
try {
const block = {
id: 31,
status: true,
name: "都市精选",
type: 1,
list: [fixture],
};
for (const [desc, indexable] of [
["", false],
["精选都市短剧与独立栏目介绍", true],
]) {
homePayload = { ...savedHome, homeConfigData: [{ ...block, desc }] };
const response = await fetch(base + "/config/31/"),
html = await response.text();
assert.equal(response.status, 200);
assert.equal(/<meta name="robots" content="index,/.test(html), indexable);
assert.equal(html.includes('rel="canonical"'), indexable);
assert.ok(currentMain(html, "channel-page").includes("都市精选"));
}
detailSeo = { desc: "剧集共用简介", onlineAt: "2025-10-18 10:30:00" };
const chapter = {
id: 90001,
playletId: 23456,
currentEpisode: 3,
payType: 1,
videoUrl: "https://media.drama.test/paid-episode.m3u8",
};
for (const allowed of [false, true]) {
chapterPayload = { ...chapter, canWatch: allowed, needUnlock: !allowed };
const response = await fetch(base + "/play/23456/90001/"),
html = await response.text();
assert.equal(response.status, 200);
assert.equal(/<meta name="robots" content="index,/.test(html), allowed);
assert.equal(html.includes('rel="canonical"'), allowed);
assert.equal(html.includes(chapter.videoUrl), allowed);
assert.ok(html.includes('class="play-screen play-paywalled-content"'));
if (allowed) {
const graph = JSON.parse(
html.match(
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
)[1],
)["@graph"];
const page = graph.find((node) => node["@type"] === "WebPage");
assert.equal(page.isAccessibleForFree, false);
assert.equal(page.hasPart.cssSelector, ".play-paywalled-content");
assert.equal(
graph.find((node) => node["@type"] === "VideoObject")
.isAccessibleForFree,
false,
);
} else assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
}
chapterPayload = {
...chapter,
canWatch: true,
needUnlock: false,
payType: 0,
videoUrl: "",
};
const html = await (await fetch(base + "/play/23456/90001/")).text();
assert.match(html, /<meta name="robots" content="noindex/);
assert.ok(!html.includes('rel="canonical"'));
} finally {
homePayload = savedHome;
detailSeo = chapterPayload = undefined;
}
});
test("primary data outages and malformed responses return 5xx rather than 200 or a permanent 404", async () => {
try {
for (const agent of ["Mozilla/5.0 Chrome/130", "Googlebot", "YandexBot"]) {
for (const [path, endpoint, override] of [
["/", "playlet/home", { status: 503 }],
["/", "playlet/home", { data: null }],
["/", "playlet/home", { raw: "invalid json" }],
["/shorts/", "playlet/operate/list", { status: 503 }],
["/shorts/page/9/", "playlet/operate/list", { code: 5100 }],
["/shorts/", "playlet/operate/list", { status: 404 }],
["/shorts/", "playlet/operate/list", { data: null }],
["/detail/23456/", "playlet/detail", { status: 503 }],
["/detail/23456/", "playlet/detail", { code: 5100 }],
["/detail/23456/", "playlet/detail", { raw: "invalid json" }],
["/detail/23456/", "playlet/detail", { data: "bad" }],
["/detail/23456/", "playlet/detail", { data: { id: 23456 } }],
["/play/23456/90001/", "playlet/chapter/detail", { status: 503 }],
["/topics/7/", "playlet/topic/detail", { status: 503 }],
["/search/关键词/", "playlet/search", { code: 5100 }],
["/navigation/", "config/info", { status: 503 }],
["/home-road/", "config/info", { status: 503 }],
["/tag/9/", "playlet/tag/options", { status: 503 }],
["/tag/9/", "playlet/tag/options", { status: 404 }],
["/tag/9/", "playlet/tag/options", { code: 5009 }],
]) {
responseOverrides = { [endpoint]: override };
const response = await fetch(base + path, {
redirect: "manual",
headers: { "User-Agent": agent },
}),
html = await response.text();
assert.equal(
response.status,
500,
agent + " " + path + " " + JSON.stringify(override),
);
assert.equal(response.headers.get("location"), null);
assert.match(response.headers.get("cache-control"), /no-store/);
assert.ok(!html.includes('rel="canonical"'));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.ok(!html.includes("test-session-secret"));
}
}
responseOverrides = {};
const recovered = await fetch(base + "/shorts/");
assert.equal(recovered.status, 200);
await recovered.text();
} finally {
responseOverrides = {};
}
});
test("HEAD requests report the same missing-resource and temporary-error statuses as GET requests", async () => {
try {
for (const [path, endpoint, override, status] of [
["/detail/23456/", "playlet/detail", { code: 5009 }, 404],
[
"/tag/999999/",
"playlet/tag/list",
{ data: { list: [], totalCount: 0 } },
404,
],
[
"/shorts/page/2/",
"playlet/operate/list",
{ data: { list: [], totalCount: 0 } },
404,
],
["/detail/23456/", "playlet/detail", { status: 503 }, 500],
["/", "playlet/home", { status: 503 }, 500],
]) {
responseOverrides = { [endpoint]: override };
const response = await fetch(base + path, {
method: "HEAD",
redirect: "manual",
});
assert.equal(response.status, status, path);
assert.equal(response.headers.get("location"), null);
assert.equal(await response.text(), "");
}
} finally {
responseOverrides = {};
}
});
test("optional data failures preserve real detail content", async () => {
try {
responseOverrides = {
"comment/list": { status: 503 },
"playlet/chapter/list": { status: 503 },
"playlet/guess-like/list": { status: 503 },
};
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
const html = await response.text();
assert.ok(html.includes("接口剧名"));
assert.ok(html.includes("暂无推荐视频"));
assert.ok(!html.includes("评论"));
}
} finally {
responseOverrides = {};
}
});
test("current Swagger contracts drive SSR listings, flat topic detail, home more and detail recommendations", async () => {
const start = calls.length;
try {
strictContract = true;
listPayload = {
name: "专题真实标题",
desc: "顶层专题简介",
list: [{ ...fixture, id: 24001 }],
totalCount: 72,
};
for (const path of [
"/shorts/page/2/?sort=2",
"/rank/new/page/2/",
"/topics/7/page/2/?sort=2",
"/theme/8/",
"/tag/9/?sort=2",
]) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
const html = await response.text();
assert.ok(html.includes('href="/detail/24001/"'), path);
if (path.includes("/topics/7/"))
assert.ok(currentMain(html, "channel-page").includes("顶层专题简介"));
if (path.startsWith("/theme/"))
assert.ok(currentMain(html, "channel-page").includes("题材8"));
}
let html = await (await fetch(base + "/config/31/")).text();
assert.ok(currentMain(html, "channel-page").includes("后台配置标题"));
assert.ok(
currentMain(html, "channel-page").includes('href="/detail/23456/"'),
);
assert.ok(!currentMain(html, "channel-page").includes("/page/2/"));
html = await (await fetch(base + "/detail/23456/")).text();
assert.ok(currentMain(html, "drama-play-page").includes("同类推荐短剧"));
const related = calls
.slice(start)
.find((call) => call.path === "/api/app/playlet/guess-like/list");
assert.equal(new URLSearchParams(related.search).get("playletId"), "23456");
assert.ok(
!calls
.slice(start)
.some((call) =>
/\/video\/|playlet\/hot\/list|home\/config\/detail/.test(call.path),
),
);
} finally {
strictContract = false;
listPayload = undefined;
}
});
test("new Swagger modules, grouped topics, config pagination and direct topic slugs render complete SSR pages", async () => {
const savedHome = homePayload;
try {
strictContract = true;
const works = Array.from({ length: 49 }, (_, index) => ({
...fixture,
id: 24000 + index,
slug: "work-" + index,
}));
homePayload = {
homeBannerData: [works[0]],
modules: [
{
id: 31,
slug: "editors",
title: "新首页栏目",
desc: "独立栏目简介",
moduleType: 1,
style: "A",
limit: 3,
list: works,
},
{
id: 32,
title: "题材模块",
desc: "题材介绍",
moduleType: 2,
style: "E",
topicList: [{ id: 9, slug: "urban", name: "都市题材" }],
},
],
};
themePayload = {
list: [
{
id: 9,
slug: "urban",
name: "都市题材",
seoInfo: { title: "题材独立标题" },
},
],
};
const groups = [
{
id: 201,
name: "热门剧单",
desc: "专题区块介绍",
totalCount: 49,
list: [{ id: 7, slug: "city-topic", name: "都市专题" }],
},
{
id: 202,
name: "近期专题",
totalCount: 25,
list: [{ id: 8, name: "古装专题" }],
},
];
listPayload = (endpoint) =>
endpoint === "playlet/topic/list"
? {
list: groups,
pageNum: 2,
pageSize: 24,
seoInfo: { title: "专题独立标题" },
}
: endpoint === "playlet/topic/detail"
? {
id: 7,
slug: "city-topic",
name: "都市专题",
list: [works[0]],
totalCount: 49,
}
: { list: [works[0]], totalCount: 49 };
let response = await fetch(base + "/"),
html = await response.text();
assert.equal(response.status, 200);
for (const text of [
"新首页栏目",
"题材模块",
'href="/theme/urban/"',
'href="/config/editors/"',
])
assert.ok(html.includes(text), text);
assert.ok(html.includes('data-home-style="A"'));
assert.ok(html.includes('data-home-style="E"'));
assert.ok(html.includes('href="/detail/work-2/"'));
assert.ok(!html.includes('href="/detail/work-6/"'));
response = await fetch(base + "/config/editors/page/2/");
html = await response.text();
assert.equal(response.status, 200);
const main = currentMain(html, "channel-page");
assert.ok(main.includes("/detail/work-24/"));
assert.ok(!main.includes("/detail/work-0/"));
assert.ok(main.includes("/config/editors/page/3/"));
const configRead = calls.findLast(
(call) => call.path === "/api/app/playlet/home-config/list",
);
assert.equal(new URLSearchParams(configRead.search).get("pageNum"), "2");
response = await fetch(base + "/topics/page/2/");
html = await response.text();
assert.equal(response.status, 200);
for (const text of [
"热门剧单",
"近期专题",
'href="/topics/city-topic/"',
"/topics/page/3/",
])
assert.ok(currentMain(html, "channel-page").includes(text), text);
assert.ok(!currentMain(html, "channel-page").includes("/topics/page/4/"));
const start = calls.length;
response = await fetch(base + "/topics/city-topic/");
assert.equal(response.status, 200);
await response.text();
assert.ok(
!calls
.slice(start)
.some((call) => call.path === "/api/app/playlet/topic/list"),
);
assert.equal(
new URLSearchParams(
calls.findLast((call) => call.path === "/api/app/playlet/topic/detail")
.search,
).get("topicSlug"),
"city-topic",
);
response = await fetch(base + "/theme/urban/?sort=3&updateStatus=2");
html = await response.text();
assert.equal(response.status, 200);
const themeQuery = new URLSearchParams(
calls.findLast((call) => call.path === "/api/app/playlet/all/list")
.search,
);
assert.equal(themeQuery.get("themeSlug"), "urban");
assert.equal(themeQuery.get("updateStatus"), "2");
assert.equal(themeQuery.get("sortType"), "3");
assert.match(html, /<meta name="robots" content="noindex/);
assert.ok(!html.includes('rel="canonical"'));
} finally {
homePayload = savedHome;
themePayload = listPayload = undefined;
strictContract = false;
}
});
test("homepage SSR renders each category style directly from API configuration", async () => {
const savedHome = homePayload;
try {
const styles = ["D", "A", "E", "C", "B"];
const counts = { A: 3, B: 9, C: 8, D: 7, E: 5 };
homePayload = {
modules: styles.map((style, index) => ({
id: 31 + index,
title: "后台分类",
slug: "section-" + style.toLowerCase(),
style,
limit: counts[style],
moduleType: style === "E" ? 2 : 1,
label: "上新推荐",
list: Array.from({ length: 12 }, (_, item) => ({
...fixture,
id: 25000 + index * 100 + item,
slug: "style-" + style.toLowerCase() + "-" + item,
})),
topicList: Array.from({ length: 12 }, (_, item) => ({
id: 100 + item,
name: "主题 " + item,
slug: "topic-" + item,
})),
})),
};
const response = await fetch(base + "/"),
html = await response.text();
assert.equal(response.status, 200);
const blocks = [
...html.matchAll(
/<section\b[^>]*\bdata-home-style="([A-E])"[^>]*>[\s\S]*?<\/section>/g,
),
];
assert.deepEqual(
blocks.map((match) => match[1]),
styles,
);
assert.ok(html.includes('class="topic-section romance-editorial home-block" data-home-style="A"'));
assert.ok(html.includes('id="library" class="home-block" data-home-style="B"'));
assert.ok(html.includes('class="topic-section topic-recommendations home-block" data-home-style="E"'));
for (const [markup, style] of blocks) {
assert.ok(!markup.includes("data-track"), style);
assert.equal(
(
markup.match(
/class="(?:hg-card|curated-card drama-data-card|ranked-card drama-data-card|topic-recommendation-card)"/g,
) || []
).length,
counts[style],
style,
);
assert.ok(
markup.includes(`href="/config/section-${style.toLowerCase()}/"`),
);
if (style === "C")
assert.ok(markup.includes('class="curated-card drama-data-card"'));
if (style === "D")
assert.ok(markup.includes('aria-label="第 7 名,查看'));
if (style === "E") assert.ok(markup.includes('href="/theme/topic-0/"'));
}
} finally {
homePayload = savedHome;
}
});
test("removed series and standalone video URLs return 404 without slash redirects or content reads", async () => {
const before = calls.length;
for (const path of [
"/series",
"/series/",
"/series/page/2",
"/series/page/2/",
"/video/23456",
"/video/23456/",
"/video/23456/page/2",
"/video/23456/page/2/",
]) {
const response = await fetch(base + path + "?from=old-menu", {
redirect: "manual",
});
assert.equal(response.status, 404, path);
assert.equal(response.headers.get("location"), null, path);
const html = await response.text();
assert.match(html, /<meta name="robots" content="noindex/);
}
assert.equal(calls.length, before);
const html = await (await fetch(base)).text();
assert.ok(!html.includes('href="/series/"'));
assert.ok(!html.includes('data-page="series"'));
const runtime = await (await fetch(base + "/scripts/site-runtime.js")).text();
assert.ok(!runtime.includes("/series/"));
assert.ok(html.includes('href="/shorts/"'));
});
function navigationHtml(html, surface) {
const start = html.indexOf('data-site-navigation="' + surface + '"');
assert.ok(start >= 0);
return html.slice(start, html.indexOf("</nav>", start));
}
test("top and sidebar use API names, order, destinations and groups in initial HTML without fixed entries", async () => {
const before = calls.length;
try {
listPayload = { list: [fixture], totalCount: 48 };
headerPayload = {
categoryList: [
{
id: 80,
categoryType: 1,
name: "精选专题 <精品>",
urlValue: "/topics/",
},
{
id: 81,
categoryType: 2,
name: "发现好剧",
translationList: [
{ id: 82, name: "新剧排行", urlValue: "/rank/new/" },
{
id: 83,
name: "官方社区",
urlValue: "https://example.net/community",
},
],
},
{
id: 84,
categoryType: 1,
name: "全部好剧",
urlValue: "/shorts/",
icon: "/assets/nav-custom.webp",
},
{
id: 85,
categoryType: 3,
name: "只在底部",
urlValue: "/info/about-us/",
},
{ id: 86, categoryType: 1, name: "旧剧集", urlValue: "/series/" },
{
id: 87,
categoryType: 1,
name: "恶意地址",
urlValue: "javascript:alert(1)",
},
],
};
const html = await (await fetch(base + "/shorts/page/2/")).text();
for (const surface of ["top", "sidebar"]) {
const nav = navigationHtml(html, surface);
assert.ok(nav.indexOf("精选专题 &lt;精品&gt;") < nav.indexOf("发现好剧"));
assert.ok(nav.indexOf("发现好剧") < nav.indexOf("全部好剧"));
assert.ok(nav.includes('href="/rank/new/"'));
assert.ok(nav.includes('href="/shorts/"'));
assert.ok(nav.includes('data-site-nav="' + surface + '"'));
assert.ok(nav.includes("<details"));
assert.ok(nav.includes('rel="sponsored nofollow noopener noreferrer"'));
assert.ok(nav.includes('aria-current="page"'));
for (const value of [
"只在底部",
"旧剧集",
"恶意地址",
'href="/home-road/"',
'href="/apps/"',
])
assert.ok(!nav.includes(value), value);
}
assert.ok(
navigationHtml(html, "sidebar").includes(
'data-src="/assets/nav-custom.webp"',
),
);
assert.equal(
calls
.slice(before)
.filter((call) => call.path === "/api/app/navigation/header").length,
1,
);
headerPayload = { categoryList: [] };
const empty = await (await fetch(base + "/shorts/")).text();
assert.ok(!navigationHtml(empty, "top").includes("<a "));
assert.ok(!navigationHtml(empty, "sidebar").includes("<a "));
} finally {
headerPayload = defaultHeader;
listPayload = undefined;
}
});
test("navigation failure leaves page content available and never restores a hardcoded menu", async () => {
try {
headerFailure = true;
const response = await fetch(base),
html = await response.text();
assert.equal(response.status, 200);
assert.ok(html.includes("后台配置标题"));
assert.ok(navigationHtml(html, "top").includes("导航暂不可用"));
assert.ok(navigationHtml(html, "sidebar").includes("导航暂不可用"));
assert.ok(!navigationHtml(html, "top").includes("<a "));
} finally {
headerFailure = false;
}
});
test("return-home and contacts arrive as complete HTML without a loading shell; the app directory is gone", async () => {
for (const [path, panel, marker] of [
["/home-road/", "channel-page", "return-home-directory"],
["/info/about-us/", "info-page", "https://t.me/jbd125"],
]) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
const html = await response.text(),
main = currentMain(html, panel);
assert.ok(main.includes(marker), path);
assert.ok(!/api-loading|正在加载|加载中/.test(main), path);
assert.ok(!html.includes('href="/apps/"'), path);
assert.ok(!html.includes("福利应用"), path);
}
const start = calls.length;
for (const path of ["/apps/", "/apps"]) {
const removed = await fetch(base + path, { redirect: "manual" });
assert.equal(removed.status, 404, path);
await removed.arrayBuffer();
}
assert.ok(!calls.slice(start).some((call) => call.path === "/api/app/applist/list"));
});
test("header and complete page content share the server-prepared identity before scripts run", async () => {
const start = calls.length,
response = await fetch(base + "/shorts/", {
headers: { Cookie: "drama_bearer=test-session-secret" },
}),
html = await response.text();
assert.ok(
html.indexOf('id="global-site-header"') <
html.indexOf('<main class="shell"'),
);
assert.ok(html.includes('class="hg-header-brand"'));
assert.ok(html.includes("接口昵称") || html.includes("真实接口用户"));
assert.ok(!html.includes("正在连接"));
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/auth/guest")
.length,
0,
);
assert.ok(
calls.slice(start).some((call) => call.path === "/api/app/auth/getInfo"),
);
for (const secret of ["test-session-secret", "drama_session"])
assert.ok(!html.includes(secret));
});
test("slug details and episodes render directly with numeric dependent reads, slug links and matching canonical schema", async () => {
try {
strictContract = true;
detailSeo = {
slug: "city-story",
title: "都市故事",
desc: "作品独立介绍",
tagNames: ["都市"],
tagIds: [9],
tagSlugs: ["urban"],
onlineAt: "2026-10-20",
};
chapterPayload = {
id: 90001,
playletId: 23456,
currentEpisode: 3,
canWatch: true,
videoUrl: "https://media.drama.test/3.mp4",
};
commentPayload = { list: [], totalCount: 25 };
for (const [path, chapter] of [
["/detail/city-story/", false],
["/play/city-story/90001/", true],
]) {
const start = calls.length,
response = await fetch(base + path, { redirect: "manual" }),
html = await response.text();
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null);
const main = currentMain(html, "drama-play-page");
assert.ok(main.includes("都市故事"));
assert.ok(main.includes('href="/play/city-story/90001/"'));
assert.ok(!main.includes(`href="${path}page/2/"`));
assert.ok(
html.includes(
`rel="canonical" href="https://default.drama.test${path}"`,
),
);
const reads = calls.slice(start),
detail = reads.filter(
(call) => call.path === "/api/app/playlet/detail",
);
assert.equal(detail.length, 1);
assert.equal(
new URLSearchParams(detail[0].search).get("slug"),
"city-story",
);
assert.equal(new URLSearchParams(detail[0].search).has("id"), false);
assert.equal(
new URLSearchParams(
reads.find((call) => call.path === "/api/app/playlet/chapter/list")
.search,
).get("playletSlug"),
"city-story",
);
assert.equal(
new URLSearchParams(
reads.find((call) => call.path === "/api/app/playlet/guess-like/list")
.search,
).get("playletId"),
"23456",
);
assert.ok(!reads.some((call) => call.path === "/api/app/comment/list"));
assert.equal(
new URLSearchParams(
reads.find((call) => call.path === "/api/app/playlet/guess-like/list")
.search,
).get("pageSize"),
"12",
);
if (chapter) {
assert.ok(main.includes('href="/tag/urban/"'));
assert.ok(main.includes('href="/detail/city-story/"'));
}
const graph = JSON.parse(
html.match(
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
)[1],
)["@graph"];
assert.equal(
graph.find((node) => node["@type"] === "TVSeries").url,
"https://default.drama.test/detail/city-story/",
);
if (chapter)
assert.equal(
graph.find((node) => node["@type"] === "TVEpisode").url,
"https://default.drama.test/play/city-story/90001/",
);
}
const legacy = await fetch(base + "/detail/23456/", { redirect: "manual" }),
html = await legacy.text();
assert.equal(legacy.status, 200);
assert.equal(legacy.headers.get("location"), null);
assert.ok(
html.includes(
'rel="canonical" href="https://default.drama.test/detail/city-story/"',
),
);
assert.ok(!html.includes('href="/detail/city-story/page/2/"'));
detailSeo = { ...detailSeo, slug: "都市 / 剧?#%2F" };
const path = "/play/" + encodeURIComponent(detailSeo.slug) + "/90001/";
const special = await fetch(base + path);
assert.equal(special.status, 200);
const specialHtml = await special.text();
assert.ok(
specialHtml.includes(
'rel="canonical" href="https://default.drama.test/play/23456/90001/"',
),
);
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
} finally {
strictContract = false;
detailSeo = chapterPayload = commentPayload = undefined;
}
});
test("home, topics, aliases, tags, more and search generate slug-first destinations and serve refreshed slug URLs", async () => {
const savedHome = homePayload;
try {
strictContract = true;
const drama = { ...fixture, slug: "city-story" },
topic = { id: 7, slug: "urban-topic", name: "都市专题" };
homePayload = {
homeBannerData: [drama],
homeConfigData: [
{
id: 31,
slug: "editors-choice",
name: "编辑精选",
desc: "独立精选介绍",
status: true,
type: 1,
list: [drama],
},
{
id: 32,
slug: "collections",
name: "专题合集",
desc: "专题介绍",
status: true,
type: 3,
themeList: [topic],
},
],
};
listPayload = (endpoint) =>
endpoint === "playlet/topic/list"
? { list: [topic], totalCount: 1 }
: {
id: 7,
slug: "urban-topic",
name: "都市专题",
desc: "专题内容介绍",
list: [drama],
totalCount: 72,
};
tagOptionsPayload = { list: [{ id: 9, slug: "urban", name: "都市" }] };
for (const path of [
"/",
"/topics/",
"/topics/urban-topic/",
"/theme/urban-topic/",
"/topics/urban-topic/page/2/",
"/tag/urban/",
"/tag/urban/page/2/",
"/config/editors-choice/",
]) {
const start = calls.length,
response = await fetch(base + path, { redirect: "manual" }),
html = await response.text();
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("location"), null);
if (path === "/")
for (const href of [
"/detail/city-story/",
"/topics/urban-topic/",
"/config/editors-choice/",
])
assert.ok(html.includes(`href="${href}"`));
if (path.startsWith("/topics/urban") || path.startsWith("/theme/")) {
assert.ok(
html.includes(
`rel="canonical" href="https://default.drama.test${path}"`,
),
);
const query = new URLSearchParams(
calls
.slice(start)
.find(
(call) =>
call.path ===
"/api/app/" +
(path.startsWith("/theme/")
? "playlet/all/list"
: "playlet/topic/detail"),
).search,
);
assert.equal(
query.get(path.startsWith("/theme/") ? "themeSlug" : "topicSlug"),
"urban-topic",
);
assert.equal(query.has("id"), false);
}
if (path.startsWith("/tag/"))
assert.equal(
new URLSearchParams(
calls
.slice(start)
.find((call) => call.path === "/api/app/playlet/tag/list").search,
).get("tagSlug"),
"urban",
);
if (path === "/config/editors-choice/")
assert.ok(html.includes('href="/detail/city-story/"'));
}
searchPayload = { list: [drama], totalCount: 1, pageSize: 25 };
const html = await (await fetch(base + "/search/city/")).text();
assert.ok(
currentMain(html, "search-results-page").includes(
'href="/detail/city-story/"',
),
);
} finally {
homePayload = savedHome;
strictContract = false;
listPayload = tagOptionsPayload = searchPayload = undefined;
}
});
test("missing slugs stay real 404 responses and topic directory outages stay server errors", async () => {
try {
detailSeo = { slug: "known-story" };
listPayload = { list: [{ id: 7, slug: "known-topic" }], totalCount: 1 };
for (const path of [
"/detail/unknown-story/",
"/play/unknown-story/90001/",
"/topics/unknown-topic/",
"/tag/unknown-tag/",
"/config/unknown-config/",
]) {
const response = await fetch(base + path, { redirect: "manual" }),
html = await response.text();
assert.equal(response.status, 404, path);
assert.equal(response.headers.get("location"), null);
assert.ok(!html.includes('rel="canonical"'));
assert.match(html, /<meta name="robots" content="noindex/);
}
responseOverrides = { "playlet/topic/detail": { status: 503, code: 503 } };
const response = await fetch(base + "/topics/known-topic/");
assert.equal(response.status, 500);
assert.match(await response.text(), /<meta name="robots" content="noindex/);
} finally {
detailSeo = listPayload = undefined;
responseOverrides = {};
}
});
test("detail, episode, chapter list and recommendations are requested before detail finishes", async () => {
let release;
blockedDetail = { promise: new Promise((resolve) => (release = resolve)) };
const start = calls.length;
const pending = fetch(base + "/play/23456/90001/");
try {
const paths = [
"playlet/detail",
"playlet/chapter/detail",
"playlet/chapter/list",
"playlet/guess-like/list",
].map((path) => "/api/app/" + path);
for (let attempt = 0; attempt < 100; attempt++) {
if (
paths.every((path) =>
calls.slice(start).some((call) => call.path === path),
)
)
break;
await delay(10);
}
for (const path of paths)
assert.ok(
calls.slice(start).some((call) => call.path === path),
path + " must not wait for detail",
);
} finally {
release();
blockedDetail = undefined;
}
const html = await (await pending).text();
assert.ok(currentMain(html, "drama-play-page").includes("play-detail-title"));
assert.ok(!calls.slice(start).some((call) => call.path === "/api/app/comment/list"));
});
test("public payloads are reused across page requests while episode permissions stay uncached", async () => {
await stopNext();
await startNext({ PUBLIC_DATA_CACHE_TTL_MS: "60000" });
const previousHeader = headerPayload;
try {
seoConfig = {
reportSdkConfig: "private-shared-sdk-cipher",
config: { secret: "private-shared-config-secret" },
};
footerPayload = {
footerInfo: {
brand: { content: "真实直出页脚" },
contactList: [{ name: "服务时间", value: "每天" }],
token: "private-footer-token",
},
};
const start = calls.length;
const pages = await Promise.all(
["/shorts/", "/topics/", "/home-road/"].map(async (path) =>
(await fetch(base + path)).text(),
),
);
for (const path of [
"config/info",
"config/footer/info",
"navigation/header",
"translation/category/list",
])
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/" + path)
.length,
1,
path,
);
for (const html of pages) {
assert.ok(html.includes("真实直出页脚"));
for (const secret of [
"private-shared-sdk-cipher",
"private-shared-config-secret",
"private-footer-token",
])
assert.ok(!html.includes(secret));
}
await (await fetch(base)).text();
await (await fetch(base)).text();
assert.equal(
calls.slice(start).filter((call) => call.path === "/api/app/playlet/home")
.length,
1,
);
headerPayload = { categoryList: [] };
assert.ok(
navigationHtml(
await (await fetch(base + "/shorts/")).text(),
"top",
).includes("短剧"),
"public navigation remains valid within TTL",
);
const permissionStart = calls.length;
await (await fetch(base + "/play/23456/90001/")).text();
await (await fetch(base + "/play/23456/90001/")).text();
for (const path of ["playlet/detail", "playlet/chapter/detail"])
assert.equal(
calls
.slice(permissionStart)
.filter((call) => call.path === "/api/app/" + path).length,
2,
path + " must stay realtime",
);
} finally {
seoConfig = {};
footerPayload = undefined;
headerPayload = previousHeader;
await stopNext();
await startNext();
}
});
test("current-origin ingress separates direct Go responses from Next session cookies while serving SSR and media", async () => {
await stopNext();
await startNext({ UPSTREAM_API_URL: "current" });
// Emulate Nginx: preserve the visitor Host and route only backend paths to Go.
const ingress = createServer((request, response) => {
const backend = /^\/(?:api\/app\/|h5\/|(?:new)?enkey(?:\?|\/|$))/.test(
request.url,
);
const target = new URL(request.url, backend ? mockOrigin : base);
const forwarded = httpRequest(
target,
{
method: request.method,
headers: { ...request.headers, "x-forwarded-proto": "http" },
},
(result) => {
response.writeHead(result.statusCode, result.headers);
result.pipe(response);
},
);
forwarded.on("error", () => {
response.writeHead(502);
response.end();
});
request.pipe(forwarded);
});
const origin = "http://127.0.0.1:" + (await listen(ingress));
try {
const start = calls.length;
const homepage = await fetch(origin + "/"),
html = await homepage.text();
assert.equal(homepage.status, 200);
assert.ok(html.includes("后台配置标题"));
// Direct-Go ingress + jha-1048: HTML SSR must not invent a guest token.
assert.ok(
!calls.slice(start).some((call) => call.path === "/api/app/auth/guest"),
);
assert.ok(
calls.slice(start).some((call) => call.path === "/api/app/playlet/home"),
);
const config = await fetch(origin + "/api/app/config/info");
assert.equal(config.status, 200);
assert.equal((await config.json()).code, 200);
// This ingress sends /api/app/* straight to Go, bypassing Next's cookie handling.
const directSession = await fetch(origin + "/api/app/auth/guest", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: origin },
body: "{}",
});
assert.equal(directSession.status, 200);
assert.equal(directSession.headers.get("set-cookie"), null);
const directPayload = await directSession.json();
assert.equal(directPayload.code, 200);
assert.equal(directPayload.data.token, "test-session-secret");
// The compatibility alias reaches Next, which converts the token into an HttpOnly cookie.
const session = await fetch(origin + "/api/auth/guest", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: origin },
body: "{}",
});
assert.equal(session.status, 200);
assert.match(session.headers.get("set-cookie"), /drama_session=/);
const sessionPayload = await session.json();
assert.equal(sessionPayload.code, 200);
assert.equal(sessionPayload.data.token, undefined);
const sessionPage = await fetch(origin + "/detail/23456/", {
headers: {
Cookie: "drama_session=registered-user-a-secret; drama_registered=1",
},
});
assert.equal(sessionPage.status, 200);
const sessionHtml = await sessionPage.text();
assert.ok(currentMain(sessionHtml, "drama-play-page").includes("play-detail-title"));
assert.ok(!sessionHtml.includes("registered-user-a-secret"));
assert.equal(calls.at(-1).cookie, undefined);
const key = await fetch(origin + "/enkey?id=12700&ticket=a%2Fb+1");
assert.equal(key.status, 200);
assert.equal((await key.arrayBuffer()).byteLength, 16);
assert.equal(calls.at(-1).search, "?id=12700&ticket=a%2Fb+1");
const media = await fetch(origin + "/h5/m3u8/video.m3u8?md=signed+value");
assert.equal(media.status, 200);
assert.match(await media.text(), /#EXTM3U/);
// A direct Node URL has no Go forwarding. It must fail once, rather than recurse.
const loop = await fetch(base + "/api/app/config/info");
assert.equal(loop.status, 502);
assert.match((await loop.json()).msg, /转发配置/);
const keyLoop = await fetch(base + "/enkey?id=12700&ticket=original");
assert.equal(keyLoop.status, 502);
} finally {
await new Promise((resolve) => ingress.close(resolve));
await stopNext();
await startNext();
}
});