1304 lines
96 KiB
JavaScript
1304 lines
96 KiB
JavaScript
import {test, before, after} from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import {createServer, request as httpRequest} from "node:http";
|
||
import {spawn} from "node:child_process";
|
||
import {setTimeout as delay} from "node:timers/promises";
|
||
import {createHash} from 'node:crypto';
|
||
import {rm,readFile} from 'node:fs/promises';
|
||
const appContract=JSON.parse(await readFile(new URL('./fixtures/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\/">接口剧名 <script>alert\(1\)<\/script><\/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('接口剧名 <script>'));
|
||
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('&','&');
|
||
assert.ok(main.includes(escaped),keyword);assert.ok(main.includes('href="/detail/23456/"'));assert.ok(main.includes('接口剧名 <script>'));
|
||
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('都市 / 爱情 & 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('精选专题 <精品>')<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();
|
||
}
|
||
});
|