Files
web_huakui_web/tests/routes.test.ts
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

287 lines
18 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { canonicalizePageURLs, ROUTE_MIGRATIONS, toLegacyPath, toPublicPath, toPublicURL } from "../src/lib/routes";
import { createContext } from "../src/server/context";
import { canonicalRedirect } from "../src/server/redirects";
import { matchPageRoute } from "../src/server/pages";
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
import { preparePage } from "../src/server/react/page-data";
import { trackerPage } from "../src/server/services/shared";
test("Huakui route prefixes preserve encoded slugs, raw queries and fragments", () => {
const suffix = "/%E7%9F%AD%E5%89%A7%2F%E9%9B%86%252F1/?x=a+b&x=%2b&token=a%2FB#episode%202";
assert.equal(toPublicPath("/play" + suffix), "/drama" + suffix);
assert.equal(toLegacyPath("/drama" + suffix), "/play" + suffix);
assert.equal(toLegacyPath("/series/watch/video/recommendations/page/2/"), "/series-play/video/recommendations/page/2/");
for (const path of ["/account/settings/", "/settings/", "/profile/", "/account/login/"]) {
assert.equal(toPublicPath(path), path, "removed account path is not migrated " + path);
assert.equal(toLegacyPath(path), path, "removed account path is not migrated " + path);
}
assert.equal(toPublicPath("/dh/"), "/navigation/");
assert.equal(toPublicPath("/home-road/"), "/return-home/");
assert.equal(toLegacyPath("/return-home/"), "/home-road/");
for (const path of ["/", "/series/anime/all/1/", "/search/word/", "/listings/", "/api/playlet/list", "/h5/m3u8/signed", "/static/images/play/foo.svg", "//external.example/play/a/"]) {
assert.equal(toPublicPath(path), path, path);
}
for (const { legacy, public: current } of ROUTE_MIGRATIONS) {
assert.equal(toPublicPath(legacy + "/"), current + "/");
assert.equal(toPublicPath(current + "/"), current + "/", "idempotent " + current);
}
});
test("all registered HTML routes resolve under new public URLs with identical parameters", () => {
const values: Record<string, string> = { q: "短剧/a", theme: "romance", slug: "剧名/a", configId: "recommend", media: "anime", topic: "12", updateStatus: "2", sortType: "3", sortOrder: "2", rankType: "3", timeRange: "1", page: "2", guessPage: "2", ep: "100", filter: "chapter" };
assert.equal(pageRoutes.length, 59);
assert.equal(publicPageRoutes.length, pageRoutes.length);
for (const [pattern, handler] of pageRoutes) {
const params: Record<string, string> = {};
const legacy = pattern.replace(/:(\w+)/g, (_, key: string) => {
params[key] = values[key];
return encodeURIComponent(values[key]);
});
const current = toPublicPath(legacy);
assert.deepEqual(matchPageRoute(current), { handler, params }, pattern);
assert.deepEqual(matchPageRoute(legacy), { handler, params }, "legacy " + pattern);
for (const method of ["GET", "HEAD"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current, { method }))), null, "canonical " + current);
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy, { method })));
if (current === legacy) assert.equal(response, null, "unchanged " + legacy);
else {
assert.equal(response?.status, 301, "legacy " + legacy);
assert.equal(response?.headers.get("Location"), current, "single-hop " + legacy);
}
}
}
for (const path of ["/dramas//", "/dramas/theme/extra/", "/series/watch/", "/account/unknown/", "/account/", "/account/settings/", "/account/favorites/chapter/", "/profile/", "/login/", "/collection/a/page/2/extra/", "/curated/dramas/", "/discover/search/extra/", "/return-home//", "/return-home/extra/", "/home-road/extra/"]) {
assert.equal(matchPageRoute(path), null, path);
}
});
test("legacy pages redirect once with 301 to new canonical path and keep request suffix bytes", () => {
const saved = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "false";
try {
const suffix = "?x=a+b&x=%2b&return=%2Fplay%2Fa%2F#part%202";
for (const [legacy, current] of [["/play/a%2Fb", "/drama/a%2Fb/"], ["/list/romance/2/1/page/2/", "/dramas/romance/2/1/page/2/"], ["/dh", "/navigation/"], ["/home-road", "/return-home/"], ["/home-road/", "/return-home/"]]) {
for (const method of ["GET", "HEAD"]) {
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy + suffix, { method })));
assert.equal(response?.status, 301, legacy);
assert.equal(response?.headers.get("Location"), current + suffix, legacy);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current + suffix, { method }))), null, "single hop " + current);
}
}
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/dramas")))?.headers.get("Location"), "/dramas/");
// Removed account surfaces permanently return home without carrying the query or fragment.
for (const path of ["/account", "/account/", "/account/login/", "/account/register/", "/account/history/page/2/", "/account/favorites/chapter/page/3/", "/account/purchases/", "/account/recharge/", "/account/settings/", "/login", "/login/", "/register/", "/profile", "/profile/", "/history/page/2/", "/favorites/chapter/", "/purchased/", "/setting/", "/settings", "/settings/", "/recharge/"]) {
for (const method of ["GET", "HEAD"]) {
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + path + suffix, { method })));
assert.equal(response?.status, 301, method + " " + path);
assert.equal(response?.headers.get("Location"), "/", method + " " + path);
}
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, "POST " + path);
}
for (const path of ["/accounts/", "/account-settings/", "/loginx/", "/history-of-drama/", "/settingsx"]) {
assert.notEqual(canonicalRedirect(createContext(new Request("https://huakui.example" + path)))?.headers.get("Location"), "/", "segment boundary " + path);
}
for (const path of ["/list//", "/list/theme/extra/", "/static/css/list.css", "/h5/m3u8/signed?sign=%2B", "/api/feedback/add", "/api/playlet/collect/set/"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path))), null, path);
}
for (const path of ["/play/a", "/drama/a", "/profile", "/account", "/home-road/", "/return-home/"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, path);
}
const post = canonicalRedirect(createContext(new Request("https://huakui.example/api/feedback/add/?sign=%2B", { method: "POST" })));
assert.equal(post?.status, 307);
assert.equal(post?.headers.get("Location"), "/api/feedback/add?sign=%2B");
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/api/playlet/collect/set/?sign=%2B", { method: "POST" }))), null);
} finally {
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = saved;
}
});
test("DTO link canonicalization leaves external, media, SDK, payment and SEO inputs intact", () => {
const external = "https://cdn.example/play/signed.m3u8?sign=%2B";
const data = {
requestPath: "/play/a/1/", canonicalPath: "/play/a/",
works: [{ URL: "/play/a%2Fb/", url: "https://huakui.example/topic-detail/a/?q=%2b" }],
pagination: { PrevURL: "/list/", nextURL: "/list/romance/1/1/page/2/" },
categoryList: [{ urlValue: "/rank/" }, { urlValue: "https://external.example/list/" }],
chapter: { url: "/play/signed-media.m3u8?sign=%2B" },
video: { video: { url: "/play/signed-video.m3u8?sign=%2B" } },
advitise: { url: "https://huakui.example/play/ad/" },
config: { url: "/play/config/" },
mainURL: external, previewURL: "/play/signed-preview.m3u8?sign=%2B",
payment: { url: "/play/payment/" },
canonicalURL: "https://huakui.example/play/a/",
seoInfo: { canonicalUrl: "https://huakui.example/play/a/" },
jsonLD: '{"url":"https://huakui.example/play/a/"}',
};
assert.equal(canonicalizePageURLs(data, "https://huakui.example"), data);
assert.equal(data.requestPath, "/drama/a/1/");
assert.equal(data.canonicalPath, "/drama/a/");
assert.equal(data.works[0].URL, "/drama/a%2Fb/");
assert.equal(data.works[0].url, "https://huakui.example/collection/a/?q=%2b");
assert.equal(data.pagination.PrevURL, "/dramas/");
assert.equal(data.pagination.nextURL, "/dramas/romance/1/1/page/2/");
assert.equal(data.categoryList[0].urlValue, "/rankings/");
assert.equal(data.categoryList[1].urlValue, "https://external.example/list/");
assert.equal(data.chapter.url, "/play/signed-media.m3u8?sign=%2B");
assert.equal(data.video.video.url, "/play/signed-video.m3u8?sign=%2B");
assert.equal(data.advitise.url, "https://huakui.example/play/ad/");
assert.equal(data.config.url, "/play/config/");
assert.equal(data.mainURL, external);
assert.equal(data.previewURL, "/play/signed-preview.m3u8?sign=%2B");
assert.equal(data.payment.url, "/play/payment/");
assert.equal(data.canonicalURL, "https://huakui.example/play/a/");
assert.equal(data.seoInfo.canonicalUrl, "https://huakui.example/play/a/");
assert.equal(data.jsonLD, '{"url":"https://huakui.example/play/a/"}');
assert.equal(toPublicURL("https://huakui.example/play/a%2Fb/?sign=a+b#e", "https://huakui.example/"), "https://huakui.example/drama/a%2Fb/?sign=a+b#e");
assert.equal(toPublicURL(external, "https://huakui.example"), external);
});
test("navigation-only mode recognizes the new navigation path without enabling other page redirects", () => {
const saved = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "true";
try {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/nav?channel=%2B")))?.headers.get("Location"), "/navigation/?channel=%2B");
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/navigation/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/list/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/home-road/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/account/settings/"))), null);
} finally {
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = saved;
}
});
test("new content paths reach unchanged upstream loaders and preserve navigation-only isolation", async () => {
const savedFetch = globalThis.fetch;
const savedUpstream = process.env.UPSTREAM_API_URL;
const savedNavigation = process.env.NAVIGATION_ONLY;
process.env.UPSTREAM_API_URL = "http://huakui-route-loader-test";
process.env.NAVIGATION_ONLY = "false";
const calls: URL[] = [];
globalThis.fetch = async (input) => {
const url = new URL(String(input));
calls.push(url);
let data: Record<string, unknown> = {};
if (url.pathname === "/api/app/config/info") data = { config: { foreverUrlAddr: "https://huakui.example" } };
if (url.pathname === "/api/app/theme/list") data = { list: [{ id: 12, name: "爱情", slug: "romance", status: true }] };
if (url.pathname === "/api/app/playlet/all/list") data = { list: [{ id: 10, playName: "作品", slug: "story" }], totalCount: 100, pageNum: Number(url.searchParams.get("pageNum")), pageSize: 24 };
return Response.json({ code: 200, data });
};
const context = (path: string) => {
const ctx = createContext(new Request("https://huakui.example" + path, { headers: { cookie: "token=route-test; isRegister=1" } }));
ctx.user = { userId: 7, nickName: "读者", type: 1, isRegister: true };
ctx.state.set("auth:fresh", true);
return ctx;
};
try {
const list = await preparePage(context("/dramas/romance/1/1/page/2/"));
assert.equal(list.status || 200, 200);
assert.equal(list.view, "list");
assert.equal(list.data.themeId, 12);
assert.equal(list.data.updateStatus, 1);
assert.equal(list.data.requestPath, "/dramas/romance/1/1/page/2/");
assert.equal(list.data.canonicalPath, "/dramas/romance/1/1/page/2/");
assert.equal(list.data.pagination.NextURL || list.data.pagination.nextURL, "/dramas/romance/1/1/page/3/");
assert.equal(list.data.canonicalURL, "https://huakui.example/dramas/romance/1/1/page/2/");
assert.match(list.data.robots, /^index,follow/);
assert.equal(list.data.paginationNextURL, "https://huakui.example/dramas/romance/1/1/page/3/");
const sorted = await preparePage(context("/dramas/romance/1/2/"));
assert.equal(sorted.data.robots, "noindex,follow");
assert.equal(sorted.data.canonicalURL, undefined);
const upstream = calls.find((url) => url.pathname === "/api/app/playlet/all/list");
assert.equal(upstream?.searchParams.get("themeSlug"), "romance");
assert.equal(upstream?.searchParams.get("pageNum"), "2");
for (const path of ["/account/", "/account/settings/", "/account/favorites/chapter/", "/setting/"])
assert.equal((await preparePage(context(path))).status, 404, path);
const homeRoad = await preparePage(context("/return-home/"));
assert.equal(homeRoad.status || 200, 200);
assert.equal(homeRoad.view, "homeRoad");
assert.equal(homeRoad.data.requestPath, "/return-home/");
assert.equal(homeRoad.data.pageKey, "home_road");
assert.match(homeRoad.data.metaTitle, /回家的路.*花魁短剧/);
assert.equal(homeRoad.data.robots, "noindex,follow");
assert.equal(homeRoad.data.canonicalURL, undefined);
assert.equal(homeRoad.data.jsonLD, undefined);
assert.ok(homeRoad.data.faqItems.length > 0);
assert.equal((await preparePage(context("/navigation/"))).data.homeRoadURL, "/return-home/");
process.env.NAVIGATION_ONLY = "true";
const navigation = await preparePage(context("/navigation/"));
assert.equal(navigation.view, "nav");
assert.equal(navigation.data.isNavPage, true);
assert.equal(navigation.data.homeRoadURL, "https://huakui.example/return-home/");
assert.equal((await preparePage(context("/dramas/"))).status, 404);
assert.equal((await preparePage(context("/return-home/"))).status, 404);
} finally {
globalThis.fetch = savedFetch;
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
else process.env.UPSTREAM_API_URL = savedUpstream;
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = savedNavigation;
}
});
test("navigation-only home road links use a configured main-site origin, then the saved PDF fallback", async () => {
const savedFetch = globalThis.fetch;
const savedUpstream = process.env.UPSTREAM_API_URL;
const savedNavigation = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "true";
let config: Record<string, string> = {};
globalThis.fetch = async (input) => {
const url = new URL(String(input));
return Response.json({ code: 200, data: url.pathname === "/api/app/config/info" ? { config } : {} });
};
const cases: [Record<string, string>, string][] = [
[{ foreverUrlAddr: "https://main.huakui.test/old/path?ch=main", newUrlAddr: "https://latest.huakui.test/" }, "https://main.huakui.test/return-home/"],
[{ foreverUrlAddr: "https://example.com/", newUrlAddr: "https://latest.huakui.test/old/path" }, "https://latest.huakui.test/return-home/"],
[{ foreverUrlAddr: "javascript:invalid", newUrlAddr: "/", pdfAddr: "https://files.huakui.test/access.pdf" }, "https://files.huakui.test/access.pdf"],
[{}, ""],
];
try {
for (const [index, [configured, expected]] of cases.entries()) {
// Isolate public-config caches while testing each configured deployment.
process.env.UPSTREAM_API_URL = `http://home-road-navigation-test-${index}`;
config = configured;
const context = createContext(new Request("https://navigation.huakui.test/navigation/", { headers: { cookie: "token=nav-test; isRegister=1" } }));
context.user = { userId: 7, type: 1, isRegister: true };
context.state.set("auth:fresh", true);
const page = await preparePage(context);
assert.equal(page.status || 200, 200);
assert.equal(page.view, "nav");
assert.equal(page.data.homeRoadURL, expected);
}
} finally {
globalThis.fetch = savedFetch;
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
else process.env.UPSTREAM_API_URL = savedUpstream;
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = savedNavigation;
}
});
test("semantic page paths retain their original analytics classification", () => {
const paths: [string, string, string][] = [
["/list/romance/", "/dramas/romance/", "list"],
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
["/series-play/film/", "/series/watch/film/", "series_play"],
["/series/all/all/1/", "/series/all/all/1/", "series"],
["/topic/", "/collections/", "topic"],
["/topic-detail/story/", "/collection/story/", "topic_detail"],
["/rank/2/", "/rankings/2/", "rank"],
["/popular-recommend/", "/featured/", "popular_recommend"],
["/recently-added/", "/latest/", "recently_added"],
["/more-list/recommend/", "/curated/dramas/recommend/", "more_list"],
["/more-series-list/recommend/", "/curated/series/recommend/", "more_series_list"],
["/search-mobile/", "/discover/search/", "search"],
["/home-road/", "/return-home/", "home_road"],
];
for (const [legacy, current, key] of paths) {
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
assert.equal(trackerPage(current)[0], key, current);
}
assert.equal(trackerPage("/unknown/")[0], "internal_other");
});