287 lines
18 KiB
TypeScript
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 { pageIdentity } 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 page 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(pageIdentity(current), pageIdentity(legacy), current);
|
|
assert.equal(pageIdentity(current)[0], key, current);
|
|
}
|
|
assert.equal(pageIdentity("/unknown/")[0], "internal_other");
|
|
});
|