import 'next/dist/server/node-environment'; import assert from 'node:assert/strict'; import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { NextRequest } from 'next/server'; import { unstable_doesMiddlewareMatch as doesProxyMatch } from 'next/experimental/testing/server'; import nextConfig from '../next.config'; import { config as proxyConfig, proxy } from '../src/proxy'; import { createContext } from '../src/server/context'; import { handleDiscoveryFileRequest } from '../src/server/seo/discovery-handler'; import { finalizeResponse } from '../src/server/handler'; import { enrichSEO, pageRepresentsMissingResource, toNextMetadata } from '../src/server/seo'; import { firstExistingFile, handleSEORequest, isRegisteredSEOPath, isValidSitemapShardName, sitemapCandidates } from '../src/server/seo/routes'; import { canonicalRedirect } from '../src/server/redirects'; import { receivePage } from '../src/server/react/handoff'; const origin = 'https://public.drama.test'; const request = (pathname: string, method = 'GET', extra: HeadersInit = {}) => new Request(origin + pathname, { method, headers: { Cookie: 'token=must-stay-private', Authorization: 'private-session', ...Object.fromEntries(new Headers(extra)) } }); let sequence = 0; async function isolated(work: (directory: string) => Promise) { const keys = ['SITEMAP_DIR', 'NAVIGATION_ONLY', 'APP_ENV', 'UPSTREAM_API_URL'] as const; const before = Object.fromEntries(keys.map(key => [key, process.env[key]])); const directory = await mkdtemp(path.join(os.tmpdir(), 'huakui-discovery-')); const originalFetch = globalThis.fetch; process.env.SITEMAP_DIR = directory; process.env.UPSTREAM_API_URL = 'https://discovery-fixture-' + (++sequence) + '.test'; process.env.APP_ENV = 'test'; delete process.env.NAVIGATION_ONLY; try { await work(directory); } finally { globalThis.fetch = originalFetch; for (const key of keys) { if (before[key] === undefined) delete process.env[key]; else process.env[key] = before[key]; } await rm(directory, { recursive: true, force: true }); } } const mockConfig = { config: { foreverUrlAddr: 'https://canonical.drama.test/' }, seoInfo: { webSiteName: '花魁短剧', description: '后端配置的介绍' } }; function mockUpstream() { const calls: string[] = []; globalThis.fetch = async (url, init) => { const route = new URL(String(url)).pathname; calls.push(route); if (route === '/api/app/config/info') { const headers = new Headers(init?.headers); assert.equal(headers.has('cookie'), false); assert.equal(headers.has('authorization'), false); } return Response.json({ code: 200, data: route === '/api/app/config/info' ? mockConfig : {} }, { headers: { 'Set-Cookie': 'public_upstream=must-not-escape; Path=/' } }); }; return calls; } test('robots and llms load cached public config and return original text headers without guest/session work', async () => { await isolated(async () => { const calls = mockUpstream(); for (const pathname of ['/robots.txt', '/llms.txt']) { const ctx = createContext(request(pathname)); const response = await handleSEORequest(ctx); assert.ok(response); assert.equal(response.status, 200); assert.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8'); assert.equal(response.headers.get('cache-control'), null); assert.equal(response.headers.get('etag'), null); assert.deepEqual(ctx.responseHeaders.getSetCookie(), []); const body = await response.text(); assert.ok(body.includes('https://canonical.drama.test/sitemap.xml')); if (pathname === '/llms.txt') assert.ok(body.includes('后端配置的介绍')); } assert.deepEqual(calls, ['/api/app/config/info']); const response = await handleDiscoveryFileRequest(request('/robots.txt', 'HEAD')); assert.equal(response.status, 200); assert.equal(await response.text(), ''); assert.ok(Number(response.headers.get('content-length')) > 0); assert.deepEqual(response.headers.getSetCookie(), []); }); }); test('first config failure still serves robots from request origin, never invokes guest auth', async () => { await isolated(async () => { const calls: string[] = []; globalThis.fetch = async url => { calls.push(String(url)); throw new Error('fixture offline'); }; const response = await handleDiscoveryFileRequest(request('/robots.txt')); assert.equal(response.status, 200); assert.match(await response.text(), /Sitemap: https:\/\/public\.drama\.test\/sitemap\.xml/); assert.deepEqual(response.headers.getSetCookie(), []); assert.equal(calls.length, 1); assert.ok(calls[0]?.endsWith('/api/app/config/info')); }); }); test('sitemap keeps Go candidate order and all stat failures or directories fall through', async () => { await isolated(async directory => { const root = path.join(directory, 'runtime'); assert.deepEqual(sitemapCandidates('/sitemap.xml', root, ''), [path.join(root, 'template/pages/sitemaps/sitemap.xml'), path.join(root, 'template/pages/sitemap.xml')]); assert.deepEqual(sitemapCandidates('/sitemap.xml', root, '/mounted/xml'), ['/mounted/xml/sitemap.xml', path.join(root, 'template/pages/sitemap.xml')]); assert.deepEqual(sitemapCandidates('/sitemaps/sitemap-drama-1.xml', root, ''), [path.join(root, 'template/pages/sitemaps/sitemap-drama-1.xml')]); await mkdir(path.join(directory, 'directory.xml')); await writeFile(path.join(directory, 'empty.xml'), ''); await writeFile(path.join(directory, 'later.xml'), 'later'); await symlink('recursive.xml', path.join(directory, 'recursive.xml')); assert.equal(await firstExistingFile([path.join(directory, 'missing'), path.join(directory, 'directory.xml'), path.join(directory, 'recursive.xml'), path.join(directory, 'empty.xml'), path.join(directory, 'later.xml')]), path.join(directory, 'empty.xml')); assert.equal(await firstExistingFile([path.join(directory, 'missing'), path.join(directory, 'directory.xml')]), null); }); }); test('sitemap returns original bytes, including old URLs, signed assets, empty and non-XML files', async () => { await isolated(async directory => { globalThis.fetch = async () => { throw new Error('File service must not call an API'); }; const xml = '\ufeff\r\nhttps://old.test/play/story/?sig=a%2Bb%3D&x=2\r\n'; for (const bytes of [Buffer.from(xml), Buffer.from([0, 255, 128, 10]), Buffer.alloc(0)]) { await writeFile(path.join(directory, 'sitemap.xml'), bytes); assert.equal((await proxy(new NextRequest(request('/sitemap.xml')))).headers.get('x-middleware-next'), '1'); const response = await handleDiscoveryFileRequest(request('/sitemap.xml')); assert.equal(response.status, 200); assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes); assert.equal(response.headers.get('content-type'), 'text/xml; charset=utf-8'); assert.equal(response.headers.get('content-length'), String(bytes.length)); assert.equal(response.headers.get('accept-ranges'), 'bytes'); assert.ok(response.headers.get('last-modified')); assert.equal(response.headers.get('cache-control'), null); assert.equal(response.headers.get('etag'), null); assert.deepEqual(response.headers.getSetCookie(), []); } }); }); test('missing index never synthesizes static XML or discovers shards; missing and invalid shards use page 404', async () => { await isolated(async directory => { await writeFile(path.join(directory, 'sitemap-drama-1.xml'), ''); const calls = mockUpstream(); assert.equal(await handleSEORequest(createContext(request('/sitemap.xml'))), null); const shard = await handleSEORequest(createContext(request('/sitemaps/sitemap-drama-1.xml'))); assert.equal(await shard?.text(), ''); assert.equal(calls.length, 0); for (const pathname of ['/sitemap.xml', '/sitemaps', '/sitemaps/sitemap-missing.xml', '/sitemaps/secret.xml', '/sitemaps/sitemap-a.XML', '/sitemaps/sitemap-a.xml/secret']) { const response = await proxy(new NextRequest(request(pathname))); assert.equal(response.status, 404, pathname); assert.match(response.headers.get('x-robots-tag') || '', /noindex/); const forwarded = new Headers(); for (const [key, value] of response.headers) if (key.startsWith('x-middleware-request-')) forwarded.set(key.slice('x-middleware-request-'.length), value); const page = await receivePage(forwarded); assert.equal(page?.view, '404'); assert.equal(page?.data.canonicalURL, undefined); assert.equal(page?.data.jsonLD, undefined); assert.ok(page?.data.appConfig); } assert.ok(calls.includes('/api/app/auth/getInfo'), 'full 404 retains normal session initialization'); }); }); test('Gin single-segment matching, shard validation and trailing slash redirects preserve query', async () => { await isolated(async directory => { await writeFile(path.join(directory, 'sitemap-a.xml'), ''); for (const name of ['sitemap-a.xml', 'sitemap-0.xml', 'sitemap--.xml']) assert.equal(isValidSitemapShardName(name), true); for (const name of ['sitemap-.xml', 'sitemap-A.xml', 'sitemap-a.XML', 'sitemap-a_1.xml', 'sitemap-a.xml/child']) assert.equal(isValidSitemapShardName(name), false); assert.equal(isRegisteredSEOPath('/sitemaps/invalid.xml'), true); assert.equal(isRegisteredSEOPath('/sitemaps/sitemap-a.xml/child'), false); const encoded = await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2Exml'))); assert.equal(await encoded?.text(), ''); assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2F.xml'))), null); assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%252Exml'))), null, 'Gin decodes exactly once'); for (const pathname of ['/robots.txt/', '/llms.txt/', '/sitemap.xml/', '/sitemaps/invalid.xml/', '/sitemap.xml%2F', '/site%6Daps/invalid.xml%2F']) { for (const method of ['GET', 'HEAD']) { const redirect = canonicalRedirect(createContext(request(pathname + '?signature=a%2Bb&x=1', method))); assert.equal(redirect?.status, 301); assert.equal(redirect?.headers.get('location'), decodeURIComponent(pathname).slice(0, -1) + '?signature=a%2Bb&x=1'); } } assert.equal(canonicalRedirect(createContext(request('/sitemap.xml/', 'POST'))), null); }); }); test('Next matcher executes original discovery dispatch, but navigation-only exposes none of the Go SEO routes', async () => { await isolated(async directory => { const calls = mockUpstream(); await writeFile(path.join(directory, 'sitemap.xml'), ''); for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), true); process.env.NAVIGATION_ONLY = 'true'; for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) { assert.equal(await handleSEORequest(createContext(request(pathname))), null); const response = await proxy(new NextRequest(request(pathname))); assert.equal(response.status, 404); assert.match(response.headers.get('x-robots-tag') || '', /noindex/); } assert.ok(calls.length > 0); for (const pathname of ['/manifest.webmanifest', '/sw.js']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), false); }); }); test('HEAD 304 keeps the original absent length while text and file responses retain Go HSTS', async () => { const ctx = createContext(request('/sitemap.xml', 'HEAD')); const response = await finalizeResponse(ctx, new Response(null, { status: 304 })); assert.equal(response.headers.get('content-length'), null); assert.equal(response.headers.get('strict-transport-security'), 'max-age=31536000'); assert.equal(await response.text(), ''); }); test('new public page URLs preserve SEO policy, canonical pagination, structured document links and genuine media', () => { const site = 'https://canonical.drama.test'; const config = { foreverUrlAddr: site, seoInfo: { title: '花魁短剧', webSiteName: '花魁短剧' } }; const data = enrichSEO(createContext(new Request(origin + '/dramas/all/0/1/page/2/')), { appConfig: config, pagination: { Visible: true, HasPrev: true, PrevURL: '/dramas/', HasNext: true, NextURL: '/dramas/all/0/1/page/3/' }, list: [{ id: 1, slug: 'story' }, { id: 2, slug: 'other' }], }); assert.equal(data.canonicalURL, site + '/dramas/all/0/1/page/2/'); assert.equal(data.paginationPrevURL, site + '/dramas/'); assert.equal(data.paginationNextURL, site + '/dramas/all/0/1/page/3/'); assert.equal(toNextMetadata(data).alternates?.canonical, data.canonicalURL); const graph = JSON.parse(data.jsonLD)['@graph']; assert.equal(graph.find((node: any) => node['@type'] === 'ItemList').itemListElement[0].url, site + '/drama/story/'); const media = site + '/play/signed.m3u8?token=a%2Bb%3D'; const cover = site + '/play/signed.webp?token=a%2Bb%3D'; const playback = enrichSEO(createContext(new Request(origin + '/drama/story/')), { appConfig: config, playlet: { id: 1, slug: 'story', title: '故事', cover, onlineAt: '2026-10-12 12:00:00' }, chapter: { currentEpisode: 1, videoUrl: media } }); assert.equal(playback.canonicalURL, site + '/drama/story/'); const video = JSON.parse(playback.jsonLD)['@graph'].find((node: any) => node['@type'] === 'VideoObject'); assert.equal(video.contentUrl, media); assert.deepEqual(video.thumbnailUrl, [cover]); for (const pathname of ['/drama/story/2/', '/account/', '/account/settings/', '/navigation/']) { const page = enrichSEO(createContext(new Request(origin + pathname)), { appConfig: config }); assert.match(page.robots, /noindex/); assert.equal(page.canonicalURL, undefined); } assert.equal(pageRepresentsMissingResource(createContext(new Request(origin + '/drama/story/recommendations/page/1/')), {}), false); });