Files
web_hgcg_h5/tests/api-integration.test.mjs
T
谢宇宁andClaude Opus 5.5 8cdbfee08f Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:23:48 +07:00

1505 lines
114 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:'91短剧'},...seoConfig,config:{foreverUrlAddr:'https://default.drama.test',telegram:'https://example.com/contact',tgGroup:'https://example.com/group',twitterAddr:'https://example.com/twitter',officialAppAddr:downloadAddress,...seoConfig.config},navPageList:Object.hasOwn(seoConfig,'navPageList')?seoConfig.navPageList:[{line:2,url:'https://example.com/entry-two'},{line:1,url:'https://example.com/entry-one'},{line:3,url:'javascript:alert(1)'}]};
else if(path==='navigation/header'){data=headerPayload;if(headerFailure)code=500;}
else if(path==='config/footer/info'&&footerPayload)data=footerPayload;
else if(path==='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',async()=>{
try{
configFailure='http';
const response=await fetch(base+'/info/about-us/',{headers:{'X-Forwarded-Host':'untrusted.entry.test','X-Forwarded-Proto':'https'}});
assert.equal(response.status,200);
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(!head.includes('rel="canonical"'));assert.ok(!head.includes('property="og:url"'));
assert.ok(!head.includes('untrusted.entry.test'));assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.match(head,/<title>关于我们 - 91短剧<\/title>/);
}finally{configFailure='';}
});
test('robots.txt serves crawler text without sessions and advertises the configured absolute sitemap',async()=>{
const before=calls.length;
for(const agent of ['Googlebot','YandexBot','bingbot'])for(const method of ['GET','HEAD']){
const response=await fetch(base+'/robots.txt',{method,redirect:'manual',headers:{'User-Agent':agent,'X-Forwarded-Host':'untrusted.example','Cookie':'drama_session=expired'}});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
assert.equal(response.headers.get('set-cookie'),null);assert.match(response.headers.get('content-type'),/^text\/plain\b/);
const body=await response.text();
if(method==='HEAD'){assert.equal(body,'');continue;}
assert.match(body,/User-Agent: \*/i);assert.ok(!body.includes('<html'));assert.ok(!body.includes('untrusted.example'));
if(testEnvironment==='production'){
assert.match(body,/^Allow: \/$/m);assert.match(body,/^Disallow: \/api\/$/m);assert.match(body,/^Disallow: \/healthz$/m);
assert.match(body,/^Sitemap: https:\/\/default\.drama\.test\/sitemap\.xml$/m);
assert.ok(!/^Disallow: \/$/m.test(body));
}else {assert.match(body,/^Disallow: \/$/m);assert.ok(!body.includes('Sitemap:'));}
}
const reads=calls.slice(before);
assert.ok(reads.every(call=>call.path==='/api/app/config/info'&&!call.auth),'only public site configuration is needed');
});
test('robots sitemap uses permanent-domain precedence, survives configuration failure and never guesses a host',async()=>{
if(testEnvironment!=='production')return;
try{
seoConfig={config:{foreverUrlAddr:'https://robots.drama.test/nested/?source=1'},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test'}};
let response=await fetch(base+'/robots.txt');assert.equal(response.status,200);
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
configFailure='http';
response=await fetch(base+'/robots.txt',{headers:{'X-Forwarded-Host':'fake.test'}});assert.equal(response.status,200);
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
configFailure='';seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test/base/'}};
response=await fetch(base+'/robots.txt');assert.match(await response.text(),/^Sitemap: https:\/\/backup\.drama\.test\/sitemap\.xml$/m);
}finally{configFailure='';seoConfig={};}
});
test('the bare site origin and root slash both serve the homepage without redirecting to shorts',async()=>{
for(const address of [base,base+'/']){
const response=await fetch(address,{redirect:'manual'});const html=await response.text();
assert.equal(response.status,200,address);
assert.equal(response.headers.get('location'),null,address);
assert.equal(new URL(response.url).pathname,'/');
assert.ok(html.includes('class="home-content"'));
assert.ok(html.includes('后台配置标题'));
assert.match(html,/<a href="\/"[^>]*data-page="home"/);
assert.match(html,/<a href="\/shorts\/"[^>]*data-page="shorts"/);
}
});
test('SSR uses upstream content, escapes HTML, and does not restore homepage filters',async()=>{
const response=await fetch(base);const html=await response.text();
assert.equal(response.status,200);
assert.equal((html.match(/<h1\b/g)||[]).length,1,'only the homepage title may use h1, including hidden templates');
assert.match(html,/<h1 id="home-title" class="home-page-title">91短剧 · 百万大神原创短剧<\/h1>/);
assert.match(html,/<h2>接口剧名 &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/h2>/);
assert.ok(html.includes('后台配置标题'));
assert.ok(html.includes('接口剧名 &lt;script&gt;'));
assert.ok(!html.includes('<script>alert(1)</script>'));
assert.ok(!html.includes('data-must-watch-sort'));
assert.ok(!html.includes('人人嫌猎户粗野'));
assert.ok(html.includes('href="/shorts/"'));
assert.ok(html.includes('href="/detail/23456/"'));
assert.ok(html.includes('href="/config/31/"'));
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
assert.ok(html.includes('src="/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>官方入口 - 91短剧</title>'));
assert.ok(html.includes('<div id="entry-navigation" class="navigation-page">'));
assert.ok(!html.includes('<dialog id="entry-navigation"'));
assert.ok(html.includes('91短剧 · 线路一'));assert.ok(html.includes('91短剧 · 线路二'));assert.ok(!html.includes('91短剧 · 线路三'));
assert.ok(html.includes('href="https://example.com/entry-one"'));assert.ok(html.includes('href="https://example.com/entry-two"'));
assert.ok(!html.includes('href="javascript:'));
assert.ok(html.includes('href="https://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 renders configured identity, routes, permanent entry and channels in its initial HTML',async()=>{
try{
seoConfig={seoNavInfo:{dhSiteName:'动态导航站',dhDescription:'后端配置的导航介绍'},config:{telegram:'@configured_contact',tgGroup:'@configured_group',twitterAddr:'https://x.com/configured_91',foreverUrlAddr:'https://permanent.drama.test/entry'},navPageList:[{line:4,url:'https://four.drama.test'},{line:2,url:'https://two.drama.test'}]};
const start=calls.length,response=await fetch(base+'/navigation/'),html=await response.text();
assert.equal(response.status,200);assert.ok(html.includes('动态导航站官方入口'));assert.ok(html.includes('后端配置的导航介绍'));
assert.ok(html.includes('动态导航站 · 线路二'));assert.ok(html.includes('动态导航站 · 线路四'));assert.ok(!html.includes('动态导航站 · 线路一'));
assert.ok(html.indexOf('动态导航站 · 线路二')<html.indexOf('动态导航站 · 线路四'));
assert.match(html,/<a class="entry-permanent" href="https:\/\/permanent.drama.test\/entry" rel="sponsored nofollow noopener noreferrer"/);
for(const url of ['https://t.me/configured_contact','https://t.me/configured_group','https://x.com/configured_91'])assert.ok(html.includes(`href="${url}"`));
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 omits unconfigured routes and contacts instead of showing homepage links or demo accounts',async()=>{
const saved=downloadAddress;
try{
downloadAddress='';
seoConfig={navPageList:null,config:{foreverUrlAddr:'',telegram:'',telegramAdd:'',tgGroup:'',twitterAddr:''}};
const response=await fetch(base+'/navigation/'),html=await response.text();
assert.equal(response.status,200);assert.ok(html.includes('暂未配置访问线路'));
for(const className of ['entry-route','entry-permanent','entry-contact','entry-download'])assert.ok(!html.includes(`class="${className}"`));
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(2, 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',async()=>{
const paths=['/shorts/','/topics/','/rank/','/apps/','/home-road/','/topics/3/','/theme/8/','/tag/9/','/config/31/','/detail/23456/','/play/23456/90001/','/search/'+encodeURIComponent('都市 / 爱情?#%')+'/','/member/center/','/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/','/info/feedback/'];
for(const path of paths){
const response=await fetch(new URL(path,base+'/'),{redirect:'manual'});
assert.equal(response.status,200,path);
assert.equal(response.headers.get('location'),null,path);
assert.equal(new URL(response.url).pathname,new URL(path,base).pathname,path);
const html=await response.text();assert.ok(html.includes('id="channel-page"'),path);assert.ok(html.includes('/scripts/site-runtime.js'),path);
assert.equal((html.match(/<h1\b/g)||[]).length,1,path+' must retain its own h1 without inactive template headings');
assert.ok(!html.includes('id="entry-navigation"'),path);assert.ok(!html.includes('entry-navigation-pending'),path);
assertPageLinksHaveTrailingSlash(html);
const withoutSlash=await fetch(base+path.slice(0,-1),{redirect:'manual'});
assert.equal(withoutSlash.status,308,path);
assert.equal(new URL(withoutSlash.headers.get('location'),base).href,base+path,path);
}
const refreshed=await fetch(base+'/shorts/',{headers:{'Cache-Control':'no-cache'}});
assert.equal(refreshed.status,200);
});
test('page slash redirects preserve encoded paths and queries without requesting upstream content',async()=>{
const keyword=encodeURIComponent('都市 / 爱情?#% example.com');
const paths=['/navigation','/search','/search/'+keyword,'/search/'+keyword+'/page/2','/rank/hot','/rank/new','/rank/follow','/info/deposit/vip','/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 版权声明','mailto:huangguoaiai@gmail.com'],
]){
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} - 91短剧</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} - 91短剧</title>`),path);
assert.ok(html.includes('href="/rank/hot/"'));assertPageLinksHaveTrailingSlash(html);
}
}
});
test('SSR emits configured TDK, site_name, canonical and bounded pagination links inside head for browser and crawler user agents',async()=>{
try{
seoConfig={config:{foreverUrlAddr:'https://canonical.drama.test/entry/?channel=abc'},seoInfo:JSON.stringify({title:'后台首页标题',description:'后台首页描述',keyworld:'首页词,短剧',webSiteName:'91剧场'})};
categories=[{urlValue:'/playlet/',seoInfo:{title:'短剧导航标题',description:'短剧导航描述',keyworld:['导航关键词']}}];
listPayload={list:[fixture],totalCount:72,seoInfo:{title:'列表接口标题',description:'列表接口描述',keywords:['接口关键词']}};
const headOf=html=>{const match=html.match(/<head>([\s\S]*?)<\/head>/);assert.ok(match);return match[1];};
const start=calls.length;
const home=await (await fetch(base)).text(),head=headOf(home);
assert.match(head,/<title>后台首页标题 - 91剧场<\/title>/);
assert.match(head,/<meta name="description" content="后台首页描述"/);
assert.match(head,/<meta name="keywords" content="首页词,短剧"/);
assert.match(head,/<meta property="og:site_name" content="91剧场"/);
assert.match(head,/<link rel="canonical" href="https:\/\/canonical.drama.test\/"/);
assert.match(head,/<meta property="og:image" content="https:\/\/canonical.drama.test\/og-default.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');
assert.ok(!/googletagmanager|google-analytics|mc\.yandex/.test(head));
for(const agent of ['Mozilla/5.0 Chrome/130','Googlebot','facebookexternalhit']){
for(const [path,page] of [['/shorts/',1],['/shorts/page/2/',2],['/shorts/page/3/',3]]){
const response=await fetch(base+path+'?channel=campaign',{redirect:'manual',headers:{'User-Agent':agent}});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
const head=headOf(await response.text());
const canonical=`https://canonical.drama.test/shorts/${page>1?`page/${page}/`:''}`;
assert.ok(head.includes(`<link rel="canonical" href="${canonical}"`));
assert.ok(head.includes(`<meta property="og:url" content="${canonical}"`));
assert.ok(head.includes(`<title>短剧导航标题${page>1?' - 第'+page+'页':''}</title>`));
assert.ok(head.includes(`<meta name="keywords" content="导航关键词${page>1?',第'+page+'页':''}"`));
assert.equal(head.includes('<link rel="prev"'),page>1);assert.equal(head.includes('<link rel="next"'),page<3);
if(page===2)assert.ok(head.includes('rel="prev" href="https://canonical.drama.test/shorts/?channel=campaign"'));
assert.equal((head.match(/rel="canonical"/g)||[]).length,1);
}
}
for(const path of ['/search/爱情/','/info/deposit/','/member/history/','/navigation/','/shorts/?sort=2']){
const head=headOf(await (await fetch(base+path)).text());
assert.ok(!head.includes('rel="canonical"'),path);assert.match(head,/<meta name="robots" content="noindex/);
}
const overflowResponse=await fetch(base+'/shorts/page/4/');assert.equal(overflowResponse.status,404);
const overflow=headOf(await overflowResponse.text());
assert.match(overflow,/<meta name="robots" content="noindex/);assert.ok(!overflow.includes('rel="canonical"'));assert.ok(!overflow.includes('rel="next"'));
}finally{seoConfig={};categories=[];listPayload=undefined;}
});
test('detail SEO reads protected upstream content without exposing credentials and keeps registered sessions intact',async()=>{
try{
requireSeoAuth=true;
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://canonical.drama.test/'}};
detailSeo={seoInfo:{title:'剧集独立标题',description:'真实剧集描述',keywords:'剧集词'}};
const start=calls.length;
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://canonical.drama.test/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 signedOut=await registered.text();
assert.ok(signedOut.includes('请先登录账号'));assert.ok(signedOut.includes('data-auth-mode="login"'));assert.ok(signedOut.includes('noindex'));assert.ok(!signedOut.includes('rel="canonical"'));
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,0,'registered identity is never replaced with guest');
}finally{requireSeoAuth=false;seoConfig={};detailSeo=undefined;}
});
test('permanent canonical and site identity survive config errors, timeouts, missing fields and a server restart',async()=>{
const domain='https://stable.drama.test',brand='稳定站点';
const identity=async()=>{
const response=await fetch(base+'/shorts/page/2/?channel=campaign',{headers:{'X-Forwarded-Host':'alternate.entry.test','X-Forwarded-Proto':'http'}});
assert.equal(response.status,200);
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(head.includes(`rel="canonical" href="${domain}/shorts/page/2/"`));
assert.ok(head.includes(`property="og:url" content="${domain}/shorts/page/2/"`));
assert.ok(head.includes(`property="og:site_name" content="${brand}"`));
assert.ok(head.includes(`rel="prev" href="${domain}/shorts/?channel=campaign"`));
assert.ok(head.includes(`rel="next" href="${domain}/shorts/page/3/?channel=campaign"`));
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
const organization=graph.find(item=>item['@type']==='Organization'),website=graph.find(item=>item['@type']==='WebSite');
assert.equal(organization.url,domain+'/');assert.equal(organization.name,brand);assert.equal(website.name,brand);
assert.ok(!head.includes('alternate.entry.test'));assert.ok(!head.includes('noindex'));
};
try{
seoConfig={config:{foreverUrlAddr:domain},seoInfo:{webSiteName:brand}};
listPayload={list:[fixture],totalCount:72};
await identity();
for(const failure of ['http','business','malformed','timeout']){configFailure=failure;await identity();}
configFailure='';
seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:''},seoInfo:{webSiteName:'不可替换的错误品牌'}};
await identity();
configFailure='http';await stopNext();await startNext();await identity();
configFailure='';
seoConfig={config:{foreverUrlAddr:'https://updated.drama.test'},seoInfo:{webSiteName:'更新站点'}};
const html=await (await fetch(base+'/shorts/')).text();
assert.ok(html.includes('rel="canonical" href="https://updated.drama.test/shorts/"'));
assert.ok(html.includes('property="og:site_name" content="更新站点"'));
}finally{configFailure='';seoConfig={};categories=[];listPayload=undefined;}
});
test('SSR JSON-LD matches each canonical route, list page, content entity and visible FAQ without credentials or executable injection',async()=>{
const savedHome=homePayload;
const graphOf=html=>{
const scripts=[...html.matchAll(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/g)];
assert.equal(scripts.length,1,'one native JSON-LD script in the initial HTML');
const graph=JSON.parse(scripts[0][1]);assert.equal(graph['@context'],'https://schema.org');return graph['@graph'];
};
try{
const origin='https://schema.drama.test';
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:origin}};
listPayload={list:[{...fixture,id:7},{...fixture,id:8}],totalCount:72,name:'当前接口栏目'};
detailSeo={onlineAt:'2025-10-18 10:30:00',mediaUpdatedAt:'2025-10-19 09:00:00',title:'剧名 </script><script>alert(1)</script>',desc:'真实剧集简介'};
chapterPayload={id:90001,playletId:23456,currentEpisode:3,canWatch:true,needUnlock:false,videoUrl:'https://media.drama.test/episode.m3u8',mediaTime:123};
videoPayload={...fixture,coverImg:fixture.cover,createdAt:'2025-10-17',video:{url:'https://media.drama.test/movie.mp4',videoTime:600}};
let html=await (await fetch(base)).text(),graph=graphOf(html);
assert.ok(graph.some(node=>node['@type']==='Organization'&&node.name==='91剧场'));
assert.ok(graph.some(node=>node['@type']==='WebSite'&&node.url===origin+'/'));
assert.equal(graph.find(node=>node['@type']==='ItemList').itemListElement[0].url,origin+'/detail/23456/');
for(const [path,type] of [['/shorts/page/2/','CollectionPage'],['/rank/new/','CollectionPage'],['/topics/','CollectionPage'],['/topics/7/','CollectionPage'],['/info/about-us/','AboutPage'],['/info/help-center/','FAQPage']]){
html=await (await fetch(base+path+'?channel=campaign',{headers:{'User-Agent':'Googlebot'}})).text();graph=graphOf(html);
assert.ok(graph.some(node=>node['@type']===type),path);
assert.equal(graph.find(node=>node['@id']===origin+path+'#webpage').url,origin+path);
if(path==='/shorts/page/2/'){
const items=graph.find(node=>node['@type']==='ItemList').itemListElement;
assert.deepEqual(items.map(item=>[item.position,item.url]),[[25,origin+'/detail/7/'],[26,origin+'/detail/8/']]);
}
if(path==='/info/help-center/')assert.equal(graph.find(node=>node['@type']==='FAQPage').mainEntity.length,6);
}
for(const path of ['/detail/23456/','/play/23456/90001/']){
html=await (await fetch(base+path)).text();graph=graphOf(html);
assert.ok(!html.includes('<script>alert(1)</script>'));assert.ok(!JSON.stringify(graph).includes('test-session-secret'));
if(path.startsWith('/detail/')){assert.ok(graph.some(node=>node['@type']==='TVSeries'));assert.ok(!graph.some(node=>node['@type']==='VideoObject'));}
else{
const video=graph.find(node=>node['@type']==='VideoObject');assert.ok(video,path);assert.ok(video.thumbnailUrl[0]);assert.ok(video.uploadDate.includes('+08:00'));assert.ok(video.contentUrl.startsWith('https://media.drama.test/'));
if(path.startsWith('/play/'))assert.equal(graph.find(node=>node['@type']==='TVEpisode').episodeNumber,3);
}
}
for(const path of ['/search/爱情/','/member/center/','/info/deposit/','/navigation/','/apps/','/shorts/?sort=2','/unknown/']){
html=await (await fetch(base+path)).text();assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html),path);
}
}finally{homePayload=savedHome;seoConfig={};listPayload=undefined;detailSeo=undefined;chapterPayload=undefined;videoPayload=undefined;}
});
test('paginated routes serve slash URLs directly and redirect bare paths with their queries',async()=>{
const savedHome=homePayload;
try{
listPayload=accountListPayload={list:[fixture],totalCount:48};
searchPayload={list:[fixture],totalCount:48,pageSize:24};
commentPayload=transactionsPayload={list:[],totalCount:18};
homePayload={...savedHome,homeConfigData:[{id:31,status:true,name:'后台配置标题',type:1,list:Array.from({length:48},(_,i)=>({...fixture,id:fixture.id+i}))}]};
const keyword=encodeURIComponent('都市 / page/7/?#%');
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+keyword+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
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=accountListPayload=searchPayload=commentPayload=transactionsPayload=undefined;}
});
test('guest session token stays in HttpOnly cookie and device header is forwarded',async()=>{
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:'{}'});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
const payload=await response.json();assert.equal(payload.code,200);assert.equal(payload.data.token,undefined);
const cookies=response.headers.getSetCookie();
assert.ok(cookies.some(value=>value.startsWith('drama_session=')&&value.toLowerCase().includes('httponly')&&value.includes('SameSite=lax')));
assert.ok(calls.findLast(call=>call.path.endsWith('/auth/guest')).device.includes('DevID='));
});
test('browser BFF stays separate from Go app routes and preserves sessions, queries and logout',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);
const logout=await fetch(base+'/api/auth/logout',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:'{}'});
assert.equal(logout.status,200);assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
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('registered session expiry never retries a write as a guest',async()=>{
const guestCount=calls.filter(call=>call.path.endsWith('/auth/guest')).length;
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{Cookie:'drama_session=expired; drama_registered=1','Content-Type':'application/json'},body:JSON.stringify({id:23456,isCollect:true})});
assert.equal(response.status,401);assert.equal((await response.json()).code,1000);
assert.equal(calls.filter(call=>call.path.endsWith('/auth/guest')).length,guestCount);
assert.equal(response.headers.get('X-Auth-State'),'login-required');
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
});
test('login sends device/channel and trimmed credentials without attaching the previous identity',async()=>{
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json','X-Real-IP':'203.0.113.9',Cookie:'drama_session=old-guest; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({account:' player01 ',password:' Secure123 ',devID:'different',channel:'later'})});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.token,undefined);
const call=calls.at(-1);assert.equal(call.auth,undefined);assert.deepEqual(JSON.parse(call.body),{account:'player01',password:' Secure123 ',devID:'device-stable',channel:'first'});
assert.ok(call.device.includes('DevID=device-stable;'));
assert.equal(call.ip,'203.0.113.9');
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
});
test('binding retains the guest Authorization, device, channel and invite and fills the optional email',async()=>{
const response=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=old-guest; drama_registered=0; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({accountNo:' player01 ',password:'Secure123',agentInviteCode:' INV123 ',channel:'later'})});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,77);assert.equal(data.data.token,undefined);
const call=calls.at(-1);assert.equal(call.auth,'old-guest');
assert.deepEqual(JSON.parse(call.body),{accountNo:'player01',password:'Secure123',email:'player01@91dj.local',devID:'device-stable',channel:'first',agentInviteCode:'INV123'});
const memberResponse=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=registered; drama_registered=1'},body:JSON.stringify({accountNo:'player02',password:'Secure123',email:'me@example.com'})});
assert.equal(memberResponse.status,200);assert.equal(calls.at(-1).auth,undefined);assert.equal(JSON.parse(calls.at(-1).body).email,'me@example.com');
});
test('refresh restores an existing account and keeps its token when getInfo omits it',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-registered-secret; drama_registered=1'},body:'{"automatic":true}'});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.isRegister,true);assert.equal(data.data.token,undefined);
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=test-registered-secret;')));
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
});
test('an expired registered account is cleared during initialization without becoming a guest',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=1'},body:'{"automatic":true}'});
assert.equal(response.status,401);assert.equal(response.headers.get('X-Auth-State'),'login-required');
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
});
test('expired guest requests recover once with the same device, including an explicitly rejected write',async()=>{
for(const [path,method,body] of [['auth/getInfo','GET',undefined],['playlet/collect/set','POST',JSON.stringify({id:23456,isCollect:true})]]){
const count=calls.length;
const response=await fetch(base+'/api/app/'+path,{method,headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0; drama_device=device-stable'},body});
assert.equal(response.status,200);assert.equal((await response.json()).code,200);
const requests=calls.slice(count);assert.deepEqual(requests.map(call=>call.path),['/api/app/'+path,'/api/app/auth/guest','/api/app/'+path]);
assert.equal(requests[1].auth,undefined);assert.equal(JSON.parse(requests[1].body).devID,'device-stable');assert.equal(requests[2].auth,'test-session-secret');
}
const count=calls.length;
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0'},body:'{"id":999}'});
assert.equal(response.status,401);assert.equal(calls.length-count,3);
});
test('an HTTP 401 follows the same guest recovery path',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/getInfo',{headers:{Cookie:'drama_session=http-expired; drama_registered=0'}});
assert.equal(response.status,200);assert.equal((await response.json()).code,200);
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo','/api/app/auth/guest','/api/app/auth/getInfo']);
});
test('SDK snapshots reach order APIs and the same drama context survives the guest retry',async()=>{
const common={app_id:'1047',uid:'',channel:'organic',sid:'sdk-session',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-sdk-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'};
const sdk=Buffer.from(JSON.stringify(common)).toString('base64');
const requestId='1234567890abcdef1234567890abcdef',context={v:1,request_id:requestId,event_name:'drama_collect',common,drama:{media_id:'',drama_id:'23456',episode:1,video_id:'90001',recommend_trace_id:''}};
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':Buffer.from(JSON.stringify(context)).toString('base64url')},body:'{"id":23456,"isCollect":true}'});
assert.equal(response.status,200);
const requests=calls.slice(count);assert.equal(requests.length,3);
for(const call of [requests[0],requests[2]]){
assert.equal(call.trackerId,requestId);assert.equal(call.trackerEvent,'drama_collect');assert.deepEqual(JSON.parse(Buffer.from(call.trackerContext,'base64url')),context);assert.equal(call.sdk,sdk);
}
assert.equal(requests[1].trackerContext,undefined);assert.equal(requests[1].sdk,sdk);assert.equal(JSON.parse(requests[1].body).devID,'business-device');
await fetch(base+'/api/app/coin/buy',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret','X-SDK-Common':sdk},body:'{"packageId":1,"payType":"alipay"}'});
assert.equal(calls.at(-1).sdk,sdk);assert.equal(calls.at(-1).trackerContext,undefined);
});
test('bad credentials and malformed successful login responses never overwrite an existing account cookie',async()=>{
for(const [account,status] of [['wrong',200],['missing-token',502]]){
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=valid-account; drama_registered=1'},body:JSON.stringify({account,password:'Secure123'})});
assert.equal(response.status,status);assert.notEqual((await response.json()).code,200);
assert.ok(!response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')||value.startsWith('drama_registered=')));
}
});
test('manual logout skips the following automatic guest initialization and explicit sign-in can resume',async()=>{
const logout=await fetch(base+'/api/app/auth/logout',{method:'POST',body:'{}'});
assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=1;')));
const count=calls.length;
const skipped=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1'},body:'{"automatic":true}'});
assert.equal(skipped.status,200);assert.equal((await skipped.json()).data,null);assert.equal(calls.length,count);
assert.ok(skipped.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
const explicit=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1; drama_device=device-stable'},body:'{}'});
assert.equal(explicit.status,200);assert.equal((await explicit.json()).data.userId,77);
assert.ok(explicit.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
});
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('confirmed chapter unlock forwards the exact episode and identity and returns the upstream balance',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/playlet/chapter/unlock',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret; drama_device=unlock-test-device'},body:JSON.stringify({playletId:23456,chapterId:90001})});
assert.equal(response.status,200);const payload=await response.json();assert.equal(payload.code,200);assert.equal(payload.data.balance,77);
assert.equal(calls.length,count+1);const call=calls.at(-1);
assert.equal(call.path,'/api/app/playlet/chapter/unlock');assert.equal(call.auth,'test-session-secret');
assert.deepEqual(JSON.parse(call.body),{playletId:23456,chapterId:90001});assert.ok(call.device.includes('DevID=unlock-test-device;'));
});
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);
});
test('logout clears cookies without mutating upstream content',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
assert.equal(response.status,200);assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
assert.equal(calls.length,count);
});
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');
}
accountListPayload={list:[fixture],totalCount:1};
const member=await (await fetch(base+'/member/center/')).text();
const shelf=member.match(/<section class="member-library"[^>]*>([\s\S]*?)<\/section>/)?.[1];
assert.ok(shelf);assert.match(shelf,/<h2 id="member-library-title"/);assert.match(shelf,/<h3>/);
const detail=currentMain(await (await fetch(base+'/detail/23456/')).text(),'drama-play-page');
assert.match(detail,/<aside class="play-related"><h2>热门短剧<\/h2>[\s\S]*?<h3>同类推荐短剧<\/h3>/);
const search=currentMain(await (await fetch(base+'/search/关键词/')).text(),'search-results-page');
assert.match(search,/<h1\b/);assert.match(search,/<h2>/);assert.ok(!/<h3\b/.test(search));
}finally{homePayload=savedHome;listPayload=accountListPayload=undefined;}
});
test('detail, playback, video and comment pages contain real initial content without exposing private payloads or denied video sources',async()=>{
try{
detailSeo={desc:'服务端剧集完整简介',token:'private-detail-token',reportSdkConfig:{secret:'private-sdk-secret'}};
chapterListPayload={list:[{id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'},{id:90002,currentEpisode:2,canWatch:false,needUnlock:true,payType:1,singleCoins:13,videoUrl:'https://cdn.example/denied-video-secret.mp4'}]};
chapterPayload={...chapterListPayload.list[1],playletId:23456};
commentPayload={list:[{id:45,text:'服务端第2页评论',userName:'评论用户',token:'private-comment-token',children:[{id:46,text:'嵌套回复'}]}],totalCount:25};
videoPayload={...fixture,id:23457,title:'服务端独立视频',video:{url:'https://cdn.example/public-video.mp4',cover:fixture.cover},desc:'视频真实简介'};
for(const path of ['/detail/23456/page/2/?commentSort=1','/play/23456/90002/page/2/']){
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('服务端第2页评论'));assert.ok(main.includes('嵌套回复'));assert.ok(main.includes('评论 (25)'));
assert.ok(main.includes('/page/3/'));assert.ok(!html.includes('后台配置标题'));
for(const secret of ['private-detail-token','private-comment-token','private-sdk-secret','denied-video-secret.mp4'])assert.ok(!html.includes(secret),secret);
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/comment/list').length,1,'metadata/body must share comments');
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('data-locked-chapter="90002"'));
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;commentPayload=undefined;videoPayload=undefined;}
});
test('search paths decode keyword segments once and include keyword/result cards before JavaScript runs',async()=>{
for(const keyword of ['都市 爱情','都市 / 爱情 & 100% #1','字面%2F保持原样']){
const start=calls.length,path='/search/'+encodeURIComponent(keyword)+'/';
const response=await fetch(base+path);assert.equal(response.status,200);
const html=await response.text(),main=currentMain(html,'search-results-page');
const escaped=keyword.replaceAll('&','&amp;');
assert.ok(main.includes(escaped),keyword);assert.ok(main.includes('href="/detail/23456/"'));assert.ok(main.includes('接口剧名 &lt;script&gt;'));
assert.ok(!html.includes('后台配置标题'));assert.ok(!main.includes('<script>alert(1)</script>'));
const reads=calls.slice(start).filter(call=>call.path==='/api/app/playlet/search');
assert.equal(reads.length,1);assert.equal(new URLSearchParams(reads[0].search).get('keyword'),keyword);
}
});
test('search path pagination renders keyword links and rejects empty paginated searches',async()=>{
const keyword='都市 / 爱情 & 100% #1',path='/search/'+encodeURIComponent(keyword)+'/';
try{
searchPayload={list:[fixture],totalCount:76,pageSize:25};
const start=calls.length,response=await fetch(base+path+'page/2/',{redirect:'manual'});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
const html=await response.text(),main=currentMain(html,'search-results-page');
for(const href of [path,path+'page/3/'])assert.ok(main.includes('href="'+href+'"'),href);
assert.ok(!main.includes('/search/?q='));
assert.ok(html.includes('都市 / 爱情 &amp; 100% #1 · 搜索结果 - 第2页'));
assert.ok(!html.includes('rel="canonical"'));assert.match(html,/<meta name="robots" content="noindex, follow/);
const read=calls.slice(start).find(call=>call.path==='/api/app/playlet/search');
assert.equal(new URLSearchParams(read.search).get('keyword'),keyword);assert.equal(new URLSearchParams(read.search).get('pageNum'),'2');
const empty=await fetch(base+'/search/page/2/',{redirect:'manual'});
assert.equal(empty.status,404);assert.equal(empty.headers.get('location'),null);
}finally{searchPayload=undefined;}
});
test('Chinese content slugs generate ID-based links in rendered pages and structured data',async()=>{
const savedHome=homePayload;
try{
const drama={...fixture,slug:'都市故事'};
homePayload={homeBannerData:[drama],hotSearch:[{keyword:'都市爱情'}],homeConfigData:[{id:31,slug:'精选好剧',name:'精选好剧',status:true,type:1,list:[drama]},{id:32,slug:'专题合集',name:'专题合集',status:true,type:3,themeList:[{id:7,slug:'都市专题',name:'都市专题'}]}]};
const home=await (await fetch(base+'/')).text();
for(const href of ['/detail/23456/','/topics/7/','/config/31/','/search/'+encodeURIComponent('都市爱情')+'/'])assert.ok(home.includes('href="'+href+'"'),href);
detailSeo={slug:'都市故事',title:'都市故事',tagNames:['都市','无标识标签'],tagIds:[9],tagSlugs:['都市','中文标签']};
chapterPayload={id:90001,playletId:23456,currentEpisode:1,canWatch:true,videoUrl:'https://media.drama.test/1.mp4'};
const detail=await (await fetch(base+'/play/23456/90001/')).text();
assert.ok(detail.includes('href="/tag/9/"'));assert.ok(detail.includes('<span>无标识标签</span>'));assert.ok(!detail.includes('href="/tag//"'));
assert.ok(detail.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
for(const html of [home,detail]){
for(const [,href] of html.matchAll(/<a\b[^>]*href="(\/[^"#?]*)/g))if(!href.startsWith('/search/'))assert.ok(!/[^\x00-\x7f]/.test(decodeURIComponent(href)),href);
}
}finally{homePayload=savedHome;detailSeo=chapterPayload=undefined;}
});
test('a search service outage returns a server error instead of a successful empty search page',async()=>{
try{
searchFailure=true;
const response=await fetch(base+'/search/'+encodeURIComponent('服务异常')+'/');assert.equal(response.status,500);
const html=await response.text();assert.ok(!html.includes('后台配置标题'));assert.ok(!html.includes('rel="canonical"'));
}finally{searchFailure=false;}
});
test('missing resources return actual 404 responses for browsers and crawlers, without redirects or content canonical',async()=>{
try{
for(const agent of ['Mozilla/5.0 Chrome/130','Googlebot','YandexBot']){
for(const [path,endpoint,override] of [
['/detail/23456/','playlet/detail',{status:404}],
['/detail/23456/','playlet/detail',{code:404}],
['/detail/23456/','playlet/detail',{code:410}],
['/detail/23456/','playlet/detail',{code:5009}],
['/detail/23456/','playlet/detail',{data:null}],
['/play/23456/90001/','playlet/chapter/detail',{status:404}],
['/play/23456/90001/','playlet/chapter/detail',{data:null}],
['/play/23456/90001/','playlet/chapter/detail',{code:5009}],
['/topics/7/','playlet/topic/detail',{code:404}],
['/topics/7/','playlet/topic/detail',{code:5009}],
['/topics/7/','playlet/topic/detail',{data:{id:8,name:'其他专题',list:[],totalCount:0}}],
['/theme/7/','playlet/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};
commentPayload=transactionsPayload={list:[],totalCount:20};
accountListPayload={list:[],totalCount:48};
searchPayload={list:[fixture],totalCount:48,pageSize:24};
for(const path of ['/shorts/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/topics/7/','/theme/8/','/tag/9/',
'/detail/23456/','/play/23456/90001/','/search/关键词/','/member/history/','/member/favorites/','/member/purchased/','/info/deposit/','/info/deposit/vip/']){
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=commentPayload=transactionsPayload=accountListPayload=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://default.drama.test${target}"`),path);
assert.match(html,/<meta name="robots" content="index,\s*follow/);
assert.ok(html.includes(path.startsWith('/detail/')?'play-detail-title':'同一专题'));
if(path.startsWith('/theme/'))assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/topic/detail').length,1);
}
}finally{listPayload=undefined;}
});
test('comment pages stay usable but noindex, while ordinary catalog pagination remains indexable',async()=>{
try{
commentPayload={list:[{id:321,text:'第二页的真实评论'}],totalCount:25};
chapterPayload={id:90001,playletId:23456,currentEpisode:2,canWatch:true,videoUrl:'https://media.drama.test/2.mp4'};
for(const path of ['/detail/23456/page/2/','/play/23456/90001/page/2/']){
const response=await fetch(base+path,{redirect:'manual',headers:{'User-Agent':'YandexBot'}}),html=await response.text();
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
assert.match(html,/<meta name="robots" content="noindex,\s*follow"/);assert.ok(!html.includes('rel="canonical"'));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.ok(html.includes('第二页的真实评论'));assert.ok(html.includes('href="'+path.replace('/page/2/','/page/3/')+'"'));
}
listPayload={list:[fixture],totalCount:72};
const html=await (await fetch(base+'/shorts/page/2/')).text();
assert.match(html,/<meta name="robots" content="index,\s*follow/);
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/shorts/page/2/"'));
}finally{commentPayload=chapterPayload=listPayload=undefined;}
});
test('SSR applies more-list content rules and real episode access rules without exposing locked media',async()=>{
const savedHome=homePayload;
try{
const block={id:31,status:true,name:'都市精选',type:1,list:[fixture]};
for(const [desc,indexable] of [['',false],['精选都市短剧与独立栏目介绍',true]]){
homePayload={...savedHome,homeConfigData:[{...block,desc}]};
const response=await fetch(base+'/config/31/'),html=await response.text();assert.equal(response.status,200);
assert.equal(/<meta name="robots" content="index,/.test(html),indexable);
assert.equal(html.includes('rel="canonical"'),indexable);
assert.ok(currentMain(html,'channel-page').includes('都市精选'));
}
detailSeo={desc:'剧集共用简介',onlineAt:'2025-10-18 10:30:00'};
const chapter={id:90001,playletId:23456,currentEpisode:3,payType:1,videoUrl:'https://media.drama.test/paid-episode.m3u8'};
for(const allowed of [false,true]){
chapterPayload={...chapter,canWatch:allowed,needUnlock:!allowed};
const response=await fetch(base+'/play/23456/90001/'),html=await response.text();assert.equal(response.status,200);
assert.equal(/<meta name="robots" content="index,/.test(html),allowed);
assert.equal(html.includes('rel="canonical"'),allowed);assert.equal(html.includes(chapter.videoUrl),allowed);
assert.ok(html.includes('class="play-screen play-paywalled-content"'));
if(allowed){
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
const page=graph.find(node=>node['@type']==='WebPage');
assert.equal(page.isAccessibleForFree,false);assert.equal(page.hasPart.cssSelector,'.play-paywalled-content');
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,false);
}else assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
}
chapterPayload={...chapter,canWatch:true,needUnlock:false,payType:0,videoUrl:''};
const html=await (await fetch(base+'/play/23456/90001/')).text();
assert.match(html,/<meta name="robots" content="noindex/);assert.ok(!html.includes('rel="canonical"'));
}finally{homePayload=savedHome;detailSeo=chapterPayload=undefined;}
});
test('primary data outages and malformed responses return 5xx rather than 200 or a permanent 404',async()=>{
try{
for(const agent of ['Mozilla/5.0 Chrome/130','Googlebot','YandexBot']){
for(const [path,endpoint,override] of [
['/','playlet/home',{status:503}],['/','playlet/home',{data:null}],['/','playlet/home',{raw:'invalid json'}],
['/shorts/','playlet/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 and do not manufacture pagination 404s',async()=>{
try{
responseOverrides={
'comment/list':{status:503},'playlet/chapter/list':{status:503},'playlet/guess-like/list':{status:503},
};
for(const path of ['/detail/23456/','/play/23456/90001/']){
const response=await fetch(base+path);assert.equal(response.status,200,path);
const html=await response.text();assert.ok(html.includes('接口剧名'));assert.ok(html.includes('评论暂不可用'));
const commentsPage=await fetch(base+path+'page/2/');assert.equal(commentsPage.status,500,'unknown comments count must not become 404');await commentsPage.text();
}
responseOverrides={'coin/trans/list':{status:503}};
const wallet=await fetch(base+'/info/deposit/');assert.equal(wallet.status,200);await wallet.text();
const page=await fetch(base+'/info/deposit/page/2/');assert.equal(page.status,500);await page.text();
}finally{responseOverrides={};}
});
test('current Swagger contracts drive SSR listings, flat topic detail, home more and detail recommendations',async()=>{
const start=calls.length;
try{
strictContract=true;
listPayload={name:'专题真实标题',desc:'顶层专题简介',list:[{...fixture,id:24001}],totalCount:72};
for(const path of ['/shorts/page/2/?sort=2','/rank/new/page/2/','/topics/7/page/2/?sort=2','/theme/8/','/tag/9/?sort=2']){
const response=await fetch(base+path);assert.equal(response.status,200,path);
const html=await response.text();assert.ok(html.includes('href="/detail/24001/"'),path);
if(path.includes('/topics/7/')||path.includes('/theme/'))assert.ok(currentMain(html,'channel-page').includes('顶层专题简介'));
}
let html=await (await fetch(base+'/config/31/')).text();
assert.ok(currentMain(html,'channel-page').includes('后台配置标题'));assert.ok(currentMain(html,'channel-page').includes('href="/detail/23456/"'));assert.ok(!currentMain(html,'channel-page').includes('/page/2/'));
html=await (await fetch(base+'/detail/23456/')).text();
assert.ok(currentMain(html,'drama-play-page').includes('同类推荐短剧'));
const related=calls.slice(start).find(call=>call.path==='/api/app/playlet/guess-like/list');assert.equal(new URLSearchParams(related.search).get('playletId'),'23456');
assert.ok(!calls.slice(start).some(call=>/\/video\/|operate\/list|home\/config\/detail/.test(call.path)));
}finally{strictContract=false;listPayload=undefined;}
});
test('removed series and standalone video URLs return 404 without slash redirects or content reads',async()=>{
const before=calls.length;
for(const path of ['/series','/series/','/series/page/2','/series/page/2/','/video/23456','/video/23456/','/video/23456/page/2','/video/23456/page/2/']){
const response=await fetch(base+path+'?from=old-menu',{redirect:'manual'});
assert.equal(response.status,404,path);assert.equal(response.headers.get('location'),null,path);
const html=await response.text();assert.match(html,/<meta name="robots" content="noindex/);
}
assert.equal(calls.length,before);
const html=await (await fetch(base)).text();
assert.ok(!html.includes('href="/series/"'));assert.ok(!html.includes('data-page="series"'));
const runtime=await (await fetch(base+'/scripts/site-runtime.js')).text();
assert.ok(!runtime.includes('/series/'));assert.ok(html.includes('href="/shorts/"'));
});
function navigationHtml(html,surface){const start=html.indexOf('data-site-navigation="'+surface+'"');assert.ok(start>=0);return html.slice(start,html.indexOf('</nav>',start));}
test('top and sidebar use API names, order, destinations and groups in initial HTML without fixed entries',async()=>{
const before=calls.length;
try{
listPayload={list:[fixture],totalCount:48};
headerPayload={categoryList:[{id:80,categoryType:1,name:'精选专题 <精品>',urlValue:'/topics/'},{id:81,categoryType:2,name:'发现好剧',translationList:[{id:82,name:'新剧排行',urlValue:'/rank/new/'},{id:83,name:'官方社区',urlValue:'https://example.net/community'}]},{id:84,categoryType:1,name:'全部好剧',urlValue:'/shorts/',icon:'/assets/nav-custom.webp'},{id:85,categoryType:3,name:'只在底部',urlValue:'/info/about-us/'},{id:86,categoryType:1,name:'旧剧集',urlValue:'/series/'},{id:87,categoryType:1,name:'恶意地址',urlValue:'javascript:alert(1)'}]};
const html=await (await fetch(base+'/shorts/page/2/')).text();
for(const surface of ['top','sidebar']){
const nav=navigationHtml(html,surface);
assert.ok(nav.indexOf('精选专题 &lt;精品&gt;')<nav.indexOf('发现好剧'));assert.ok(nav.indexOf('发现好剧')<nav.indexOf('全部好剧'));
assert.ok(nav.includes('href="/rank/new/"'));assert.ok(nav.includes('href="/shorts/"'));assert.ok(nav.includes('data-site-nav="'+surface+'"'));
assert.ok(nav.includes('<details'));assert.ok(nav.includes('rel="sponsored nofollow noopener noreferrer"'));assert.ok(nav.includes('aria-current="page"'));
for(const value of ['只在底部','旧剧集','恶意地址','href="/home-road/"','href="/apps/"'])assert.ok(!nav.includes(value),value);
}
assert.ok(navigationHtml(html,'sidebar').includes('data-src="/assets/nav-custom.webp"'));
assert.equal(calls.slice(before).filter(call=>call.path==='/api/app/navigation/header').length,1);
headerPayload={categoryList:[]};
const empty=await (await fetch(base+'/shorts/')).text();assert.ok(!navigationHtml(empty,'top').includes('<a '));assert.ok(!navigationHtml(empty,'sidebar').includes('<a '));
}finally{headerPayload=defaultHeader;listPayload=undefined;}
});
test('navigation failure leaves page content available and never restores a hardcoded menu',async()=>{
try{
headerFailure=true;
const response=await fetch(base),html=await response.text();assert.equal(response.status,200);
assert.ok(html.includes('后台配置标题'));assert.ok(navigationHtml(html,'top').includes('导航暂不可用'));assert.ok(navigationHtml(html,'sidebar').includes('导航暂不可用'));
assert.ok(!navigationHtml(html,'top').includes('<a '));
}finally{headerFailure=false;}
});
test('applications, return-home, feedback 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/feedback/','info-page','id="feedback-form"'],['/info/about-us/','info-page','https://example.com/contact']]){
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('fresh private pages render real guest records with the same persisted identity and no credentials in HTML',async()=>{
try{
accountListPayload={list:[{...fixture,title:'游客自己的历史',password:'private-list-password'}],totalCount:72};
const start=calls.length,response=await fetch(base+'/member/history/page/2/'),html=await response.text(),main=currentMain(html,'info-page');
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
assert.ok(main.includes('游客自己的历史'));assert.ok(main.includes('真实接口用户'));assert.ok(main.includes('href="/member/history/page/3/"'));
assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of ['test-session-secret','private-list-password'])assert.ok(!html.includes(secret));
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')&&/HttpOnly/.test(value)));
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/auth/guest').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/auth/getInfo').length,0);
assert.equal(reads.find(call=>call.path==='/api/app/playlet/history/list').auth,'test-session-secret');
}finally{accountListPayload=undefined;}
});
test('likes pages render the current account list, native links and pagination before JavaScript without shared caching',async()=>{
try{
strictContract=true;
accountListPayload=(path,request)=>({list:[{...fixture,slug:'liked-story',title:request.headers.authorization==='registered-user-a-secret'?'甲的点赞短剧':'乙的点赞短剧',token:'private-liked-token'}],totalCount:72});
for(const [token,title,other] of [['registered-user-a-secret','甲的点赞短剧','乙的点赞短剧'],['registered-user-b-secret','乙的点赞短剧','甲的点赞短剧']]){
const start=calls.length;
const response=await fetch(base+'/member/likes/page/2/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}}),html=await response.text(),main=currentMain(html,'info-page');
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/private.*no-store/);
assert.match(html,/<meta name="robots" content="noindex,\s*nofollow"/);
assert.match(main,/<h1\b[^>]*>我的点赞<\/h1>/);assert.ok(main.includes(title));assert.ok(!main.includes(other));
assert.ok(main.includes('href="/member/likes/" aria-current="page"'));assert.ok(main.includes('href="/member/likes/page/3/"'));
assert.ok(main.includes('href="/detail/liked-story/"'));assert.ok(main.includes('共 72 部短剧'));
assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of [token,'private-liked-token'])assert.ok(!html.includes(secret));
const reads=calls.slice(start).filter(call=>call.path==='/api/app/playlet/like/list');
assert.equal(reads.length,1);assert.equal(reads[0].auth,token);assert.equal(reads[0].cookie,undefined);
assert.equal(new URLSearchParams(reads[0].search).get('pageNum'),'2');assert.equal(new URLSearchParams(reads[0].search).get('pageSize'),'24');
}
const response=await fetch(base+'/api/playlet/like/list?pageNum=3&pageSize=24',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
assert.equal((await response.json()).code,200);assert.equal(calls.at(-1).auth,'registered-user-a-secret');
}finally{accountListPayload=undefined;strictContract=false;}
});
test('likes distinguish empty records, out-of-range pages, backend failures and expired accounts',async()=>{
const headers={Cookie:'drama_session=registered-user-a-secret; drama_registered=1'};
try{
accountListPayload={list:[],totalCount:0};
const emptyResponse=await fetch(base+'/member/likes/',{headers}),emptyHtml=await emptyResponse.text();
assert.equal(emptyResponse.status,200);assert.ok(emptyHtml.includes('暂无点赞的短剧'));assert.ok(emptyHtml.includes('href="/shorts/">去发现好剧</a>'));
assert.equal((await fetch(base+'/member/likes/page/2/',{headers})).status,404);
responseOverrides={'playlet/like/list':{status:503,code:503}};
const failed=await fetch(base+'/member/likes/',{headers}),failedHtml=await failed.text();
assert.equal(failed.status,500);assert.match(failedHtml,/<meta name="robots" content="noindex/);assert.ok(!failedHtml.includes('暂无点赞的短剧'));
responseOverrides={};
const start=calls.length;
const expiredResponse=await fetch(base+'/member/likes/',{headers:{Cookie:'drama_session=expired; drama_registered=1'}}),expiredHtml=await expiredResponse.text();
assert.ok(expiredHtml.includes('请先登录账号'));assert.ok(expiredHtml.includes('data-auth-mode="login"'));
assert.ok(!calls.slice(start).some(call=>['/api/app/auth/guest','/api/app/playlet/like/list'].includes(call.path)));
}finally{accountListPayload=undefined;responseOverrides={};}
});
test('private settings isolate two users and ignore forged SSR identity headers',async()=>{
const start=calls.length;
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
const forged=Buffer.from(JSON.stringify({userId:999,nickName:'伪造管理员'})).toString('base64url');
const response=await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1','x-drama-ssr-session':'ready','x-drama-ssr-user':forged}});
const html=await response.text(),main=currentMain(html,'info-page');assert.ok(main.includes(name));assert.ok(!html.includes(other));assert.ok(!html.includes('伪造管理员'));
for(const secret of [token,'must-not-leak'])assert.ok(!html.includes(secret));assert.ok(!main.includes('api-loading'));
}
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/getInfo').length,2);assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
});
test('registered expiry and explicit logout render sign-in immediately without guest downgrade',async()=>{
for(const cookie of ['drama_session=expired; drama_registered=1','drama_skip_auto=1']){
const start=calls.length,response=await fetch(base+'/member/settings/',{headers:{Cookie:cookie}}),html=await response.text(),main=currentMain(html,'info-page');
assert.ok(main.includes('请先登录账号'));assert.ok(main.includes('data-auth-mode="login"'));assert.ok(!main.includes('id="profile-form"'));assert.ok(!main.includes('api-loading'));
assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
}
});
test('wallet SSR contains configured packages and transactions without creating an order or leaking extra fields',async()=>{
try{
packagesPayload={coinList:[{id:91,name:'真实金币套餐',price:1000,baseCoin:100,giftCoin:5,totalCoin:105,packageType:2,payMethods:[{type:'alipay',typeName:'支付宝'}],token:'private-package-token'}],vipList:[]};
transactionsPayload={list:[{title:'真实交易明细',changeAmount:25,createdAt:'2026-10-21 10:30:00',token:'private-transaction-token'}],totalCount:18};
const start=calls.length,response=await fetch(base+'/info/deposit/'),html=await response.text(),main=currentMain(html,'info-page');
assert.ok(main.includes('100金币'));assert.ok(main.includes('真实交易明细'));assert.ok(main.includes('href="/info/deposit/page/2/"'));assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of ['private-package-token','private-transaction-token','test-session-secret'])assert.ok(!html.includes(secret));
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/recharge/package/list').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/coin/trans/list').length,1);
assert.ok(!reads.some(call=>['/api/app/coin/buy','/api/app/vip/pay','/api/app/playlet/chapter/unlock'].includes(call.path)));
}finally{packagesPayload=undefined;transactionsPayload=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'};
commentPayload={list:[],totalCount:25};
for(const [path,chapter] of [['/detail/city-story/',false],['/play/city-story/90001/',true]]){
const start=calls.length,response=await fetch(base+path,{redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
const main=currentMain(html,'drama-play-page');assert.ok(main.includes('都市故事'));
assert.ok(main.includes('href="/play/city-story/90001/"'));
assert.ok(main.includes(`href="${path}page/2/"`));
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path}"`));
const reads=calls.slice(start),detail=reads.filter(call=>call.path==='/api/app/playlet/detail');assert.equal(detail.length,1);
assert.equal(new URLSearchParams(detail[0].search).get('slug'),'city-story');assert.equal(new URLSearchParams(detail[0].search).has('id'),false);
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/chapter/list').search).get('playletSlug'),'city-story');
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/guess-like/list').search).get('playletId'),'23456');
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/comment/list').search).get('objectId'),chapter?'90001':'23456');
if(chapter){assert.ok(main.includes('href="/tag/urban/"'));assert.ok(main.includes('href="/detail/city-story/"'));}
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://default.drama.test/detail/city-story/');
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://default.drama.test/play/city-story/90001/');
}
const legacy=await fetch(base+'/detail/23456/',{redirect:'manual'}),html=await legacy.text();
assert.equal(legacy.status,200);assert.equal(legacy.headers.get('location'),null);
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/detail/city-story/"'));assert.ok(html.includes('href="/detail/city-story/page/2/"'));
detailSeo={...detailSeo,slug:'都市 / 剧?#%2F'};
const path='/play/'+encodeURIComponent(detailSeo.slug)+'/90001/';
const special=await fetch(base+path);assert.equal(special.status,200);
const specialHtml=await special.text();
assert.ok(specialHtml.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
}finally{strictContract=false;detailSeo=chapterPayload=commentPayload=undefined;}
});
test('home, topics, aliases, tags, more and search generate slug-first destinations and serve refreshed slug URLs',async()=>{
const savedHome=homePayload;
try{
strictContract=true;
const drama={...fixture,slug:'city-story'},topic={id:7,slug:'urban-topic',name:'都市专题'};
homePayload={homeBannerData:[drama],homeConfigData:[{id:31,slug:'editors-choice',name:'编辑精选',desc:'独立精选介绍',status:true,type:1,list:[drama]},{id:32,slug:'collections',name:'专题合集',desc:'专题介绍',status:true,type:3,themeList:[topic]}]};
listPayload=endpoint=>endpoint==='playlet/topic/list'?{list:[topic],totalCount:1}:{id:7,slug:'urban-topic',name:'都市专题',desc:'专题内容介绍',list:[drama],totalCount:72};
tagOptionsPayload={list:[{id:9,slug:'urban',name:'都市'}]};
for(const path of ['/','/topics/','/topics/urban-topic/','/theme/urban-topic/','/topics/urban-topic/page/2/','/tag/urban/','/tag/urban/page/2/','/config/editors-choice/']){
const start=calls.length,response=await fetch(base+path,{redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
if(path==='/')for(const href of ['/detail/city-story/','/topics/urban-topic/','/config/editors-choice/'])assert.ok(html.includes(`href="${href}"`));
if(path.startsWith('/topics/urban')||path.startsWith('/theme/')){
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path.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, comments 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','comment/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 two accounts and 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 privateStart=calls.length;
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
const html=await (await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}})).text();
assert.ok(currentMain(html,'info-page').includes(name));assert.ok(!html.includes(other));
}
assert.equal(calls.slice(privateStart).filter(call=>call.path==='/api/app/auth/getInfo').length,2);
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 account, 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 privatePage=await fetch(origin+'/member/settings/',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
assert.equal(privatePage.status,200);assert.ok(currentMain(await privatePage.text(),'info-page').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();
}
});