Files
web_hgcg_h5/tests/api-integration.test.mjs
2026-10-27 21:28:04 +07:00

1304 lines
96 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/91av-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;
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:'91-drama-next'});
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'],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-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://huangguocg.com',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==='translation/category/list')data={categoryList:categories};
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/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/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/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, only the site domain',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.match(head,/rel="canonical" href="https:\/\/[^"]+\/info\/about-us\/"/);
assert.ok(!head.includes('untrusted.entry.test'));
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:\/\/huangguocg\.com\/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 always uses the site domain, survives configuration failure and never guesses a host',async()=>{
if(testEnvironment!=='production')return;
try{
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/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:\/\/huangguocg\.com\/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:\/\/huangguocg\.com\/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:\/\/huangguocg\.com\/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="hg-hero-title"><a href="\/detail\/23456\/">接口剧名 &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/a><\/h2>/);
assert.match(html,/<ol class="hg-hero-list" aria-label="今日推荐剧集"><li><a href="\/detail\/23456\/" data-feature="0" aria-current="true">/);
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="/assets/poster-placeholder.svg"'));
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('class="entry-route" href="https://huangguocg.com/"'));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('官方TG:@jbd125'));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.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);
assert.deepEqual(calls.slice(before).map(call=>call.path),['/api/app/config/info']);
assert.equal((await fetch(base+'/navigation/',{redirect:'manual',headers:{'Cache-Control':'no-cache'}})).status,200);
});
test('/navigation uses the fixed name and site domain with configured description 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.match(/<title>[^<]*<\/title>/)[0].includes('动态导航站'));assert.ok(html.includes('后端配置的导航介绍'));
assert.ok(html.includes('黄果吃瓜 · 线路一'));assert.ok(!html.includes('黄果吃瓜 · 线路二'));for(const host of ['permanent.drama.test','two.drama.test','four.drama.test'])assert.ok(!html.includes('href="https://'+host),host);
assert.match(html,/<a class="entry-permanent" href="https:\/\/huangguocg.com\/" 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('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('官方推特'));
assert.ok(!html.includes('huangguoshortdrama'));assert.ok(!html.includes('huangguodrama'));
assert.deepEqual(calls.slice(start).map(call=>call.path),['/api/app/config/info']);
}finally{seoConfig={};}
});
test('/navigation keeps the site entry and official TG but omits unconfigured downloads',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'])assert.ok(html.includes(`class="${className}" href="https://huangguocg.com/"`),className);
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('class="entry-download"'));
for(const label of ['官方TG群','官方推特'])assert.ok(!html.includes(label));
assert.ok(html.includes('href="/home-road/"'));assert.ok(html.includes('href="/apps/"'));
assert.ok(html.includes('grid-template-columns:repeat(3, minmax(0, 1fr))'));
}finally{seoConfig={};downloadAddress=saved;}
});
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, while removed account and recharge pages are 404',async()=>{
for(const path of ['/member/center/','/member/history/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/'])assert.equal((await fetch(base+path,{redirect:'manual'})).status,404,path);
const paths=['/shorts/','/topics/','/rank/','/apps/','/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 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','/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','/assets/poster-placeholder.svg','/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 版权声明','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/auth/guest','/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 or rich text');
assert.ok(html.includes('class="home-content" hidden=""'));
}
});
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/','/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://huangguocg.com/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:\/\/huangguocg.com\/"/);
assert.match(head,/<meta property="og:image" content="https:\/\/huangguocg.com\/og-default.png"/);
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');
if(testEnvironment==='production'){const html=home;assert.ok(html.includes('googletagmanager.com/gtag/js?id=G-7ZYCTGDJ4F'));assert.ok(html.includes('metrika/tag.js?id=113827715'));assert.ok(html.includes('mc.yandex.ru/watch/113827715'));}
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://huangguocg.com/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://huangguocg.com/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 replaces stale accounts with a guest',async()=>{
try{
requireSeoAuth=true;
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://huangguocg.com/'}};
detailSeo={seoInfo:{title:'剧集独立标题',description:'真实剧集描述',keywords:'剧集词'}};
const start=calls.length;
const response=await fetch(base+'/detail/23456/',{headers:{'X-Real-IP':'203.0.113.8'}});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://huangguocg.com/detail/23456/"'));
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/guest').length,1);
assert.equal(calls.slice(start).find(call=>call.path==='/api/app/auth/guest').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 restored=await registered.text();
assert.ok(!restored.includes('请先登录账号'));assert.ok(!restored.includes('data-auth-mode'));assert.ok(restored.includes('rel="canonical" href="https://huangguocg.com/detail/23456/"'));
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,1,'an expired session always becomes a guest');
}finally{requireSeoAuth=false;seoConfig={};detailSeo=undefined;}
});
test('site-domain canonical and site identity survive config errors, timeouts and a server restart',async()=>{
const domain='https://huangguocg.com',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='http';await stopNext();await startNext();await identity();
configFailure='';
seoConfig={config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'更新站点'}};
const html=await (await fetch(base+'/shorts/')).text();
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/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://huangguocg.com';
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};
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,basePath==='/config/31/'?404: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/']){
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=searchPayload=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('DevID='));
});
test('browser BFF stays separate from Go app routes, preserves sessions and queries, and rejects removed account routes',async()=>{
let start=calls.length;
const guest=await fetch(base+'/api/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/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/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/not-allowed')).status,404);
for(const path of ['auth/logout','auth/login','auth/register','coin/buy','playlet/chapter/unlock'])assert.equal((await fetch(base+'/api/'+path,{method:'POST',headers:{Origin:base,'Content-Type':'application/json',Cookie:cookie},body:'{}'})).status,404,path);
assert.equal(calls.length,start);
});
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://d94azi0n98az4.cloudfront.net';
const cases=[
{Host:`localhost:${port}`,Origin:`http://localhost:${port}`},
{Host:`127.0.0.1:${port}`,Origin:`http://127.0.0.1:${port}`},
{Host:'d94azi0n98az4.cloudfront.net',Origin:browserOrigin},
{Host:'next.internal:21021',Origin:browserOrigin,'X-Forwarded-Host':'d94azi0n98az4.cloudfront.net','X-Forwarded-Proto':'https'},
{Host:'next.internal:21021',Origin:'null'},
{Host:'next.internal:21021',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://d94azi0n98az4.cloudfront.net','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('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('client tracking headers are never forwarded, including across the guest retry',async()=>{
const sdk=Buffer.from(JSON.stringify({app_id:'1047',device_id:'sdk-device'})).toString('base64'),requestId='1234567890abcdef1234567890abcdef';
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; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':'e30'},body:'{"playletId":23456,"isCollect":1}'});
assert.equal(response.status,200);
const requests=calls.slice(count);assert.equal(requests.length,3);
for(const call of requests){assert.equal(call.sdk,undefined);assert.equal(call.trackerId,undefined);assert.equal(call.trackerEvent,undefined);assert.equal(call.trackerContext,undefined);}
assert.equal(JSON.parse(requests[1].body).devID,'business-device');
});
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 response=await fetch(base+'/h5/m3u8/folder/movie.m3u8'+query,{redirect:'manual',headers:{Authorization:'private',Cookie:'drama_session=private','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);
});
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/all/list'],['/shorts/page/2/','playlet/all/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/topic/detail'],['/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 class="topic-section home-block" aria-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.equal((detail.match(/<h1\b/g)||[]).length,1);
assert.match(detail,/<h2 id="hg-recommend-title">推荐视频<\/h2>[\s\S]*?<h3 class="hg-vitem-title"><a href="\/detail\/23458\/" title="《同类推荐短剧》在线观看">同类推荐短剧<\/a><\/h3>/);
const search=currentMain(await (await fetch(base+'/search/关键词/')).text(),'search-results-page');
assert.match(search,/<h1\b/);assert.match(search,/<h2 class="hg-card-title">/);assert.ok(!/<h3\b/.test(search));
}finally{homePayload=savedHome;listPayload=undefined;}
});
test('detail and playback pages contain real initial content and recommendations without exposing private payloads, and play every supplied source',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};
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');
assert.ok(main.includes('<h2 id="hg-recommend-title">推荐视频</h2>'));assert.ok(!main.includes('play-comment'));assert.ok(main.includes('剧情简介</h2>'));assert.ok(main.includes('全集列表</h2>'));
assert.ok(!html.includes('后台配置标题'));
for(const secret of ['private-detail-token','private-sdk-secret'])assert.ok(!html.includes(secret),secret);
if(path.startsWith('/play/'))assert.ok(html.includes('denied-video-secret.mp4'),'a supplied source is free to watch');
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.ok(main.includes('href="/play/23456/90002/"'));assert.ok(!main.includes('play-episode-locked'));
if(path.startsWith('/detail/'))assert.ok(main.includes('is-overview'));else assert.ok(main.includes('<video '));
}
assertPageLinksHaveTrailingSlash(main);
}
}finally{detailSeo=undefined;chapterPayload=undefined;chapterListPayload=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://huangguocg.com/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/topic/detail',{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};
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 response=await fetch(base+path+'page/'+page+'/',{redirect:'manual'}),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"'));}
}
}
const config=await fetch(base+'/config/31/page/2/');assert.equal(config.status,404);await config.text();
searchPayload={list:[],totalCount:0};
const emptySearch=await fetch(base+'/search/没有匹配项/');assert.equal(emptySearch.status,200);assert.ok((await emptySearch.text()).includes('搜索无结果'));
}finally{listPayload=searchPayload=undefined;}
});
test('topic aliases and leading-zero IDs serve content directly with a single primary canonical',async()=>{
try{
listPayload={name:'同一专题',desc:'同一专题介绍',list:[fixture],totalCount:72};
for(const [path,target] of [['/theme/007/','/topics/7/'],['/theme/007/page/2/','/topics/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://huangguocg.com${target}"`),path);
assert.match(html,/<meta name="robots" content="index,\s*follow/);
assert.ok(html.includes(path.startsWith('/detail/')?'play-detail-title':'同一专题'));
if(path.startsWith('/theme/'))assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/topic/detail').length,1);
}
}finally{listPayload=undefined;}
});
test('detail and play pages have no comment pagination, while ordinary catalog pagination remains indexable',async()=>{
try{
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'});assert.equal(response.status,404,path);await response.text();
}
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://huangguocg.com/shorts/page/2/"'));
}finally{chapterPayload=listPayload=undefined;}
});
test('SSR applies more-list content rules and treats every supplied episode source as free',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]){
// Payment flags are ignored: a supplied source is always free to watch and indexable.
chapterPayload={...chapter,canWatch:allowed,needUnlock:!allowed};
const response=await fetch(base+'/play/23456/90001/'),html=await response.text();assert.equal(response.status,200);
assert.match(html,/<meta name="robots" content="index,/);
assert.ok(html.includes('rel="canonical"'));assert.ok(html.includes(chapter.videoUrl));
assert.ok(html.includes('class="play-screen"'));assert.ok(!html.includes('play-paywalled-content'));
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
assert.equal(graph.find(node=>node['@type']==='WebPage').isAccessibleForFree,undefined);
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,true);
}
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/all/list',{status:503}],['/shorts/page/9/','playlet/all/list',{code:5100}],
['/shorts/','playlet/all/list',{status:404}],['/shorts/','playlet/all/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}],
['/apps/','applist/list',{status:503}],['/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/all/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={
'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('暂无推荐视频'));
}
}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/')||path.includes('/theme/'))assert.ok(currentMain(html,'channel-page').includes('顶层专题简介'));
}
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\/|operate\/list|home\/config\/detail/.test(call.path)));
}finally{strictContract=false;listPayload=undefined;}
});
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('applications, return-home and contacts arrive as complete HTML without a loading shell',async()=>{
try{
appsPayload={categoryList:[{id:80,title:'接口应用分类',appList:[{id:81,name:'接口应用',url:'https://apps.example/download',logo:'https://apps.example/logo.webp'}]}]};
for(const [path,panel,marker] of [['/apps/','channel-page','接口应用分类'],['/home-road/','channel-page','return-home-directory'],['/info/about-us/','info-page','官方TG:<a href="https://t.me/jbd125"']]){
const start=calls.length,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);
if(path==='/apps/')assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/applist/list').length,1);
}
}finally{appsPayload=undefined;}
});
test('header and complete page content share the server-prepared identity before scripts run',async()=>{
const start=calls.length,response=await fetch(base+'/shorts/'),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('真实接口用户'));assert.ok(!html.includes('正在连接'));
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/guest').length,1);
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'};
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(html.includes(`rel="canonical" href="https://huangguocg.com${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'));
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://huangguocg.com/detail/city-story/');
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://huangguocg.com/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://huangguocg.com/detail/city-story/"'));
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://huangguocg.com/play/23456/90001/"'));
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
}finally{strictContract=false;detailSeo=chapterPayload=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://huangguocg.com${path.replace('/theme/','/topics/')}"`));
const query=new URLSearchParams(calls.slice(start).find(call=>call.path==='/api/app/playlet/topic/detail').search);
assert.equal(query.get('id'),'7');assert.equal(query.has('slug'),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/list':{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'));
});
test('public payloads are reused across page requests while 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 deployments render SSR and proxy guest, API and signed media requests through the current site',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('后台配置标题'));
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/config/info');assert.equal(config.status,200);assert.equal((await config.json()).code,200);
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=/);assert.equal((await session.json()).code,200);
const detailPage=await fetch(origin+'/detail/23456/');
assert.equal(detailPage.status,200);assert.ok((await detailPage.text()).includes('接口剧名'));
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/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();
}
});