import test, { after, before } from 'node:test'; import assert from 'node:assert/strict'; import { createServer, type Server } from 'node:http'; import { apiRoutes, handleAPI } from '../src/server/api'; import { createContext } from '../src/server/context'; import { resolveChannel } from '../src/server/auth'; import { handleRequest } from '../src/server/handler'; // Source contracts: app/api/web/{history,recharge,setting,comment}.go and // app/service/playletser/chapter_watch.go. All requests stay on a local server. type Received = { path: string; body: string }; let server: Server; let scenario = ''; const received: Received[] = []; const originalURL = process.env.UPSTREAM_API_URL; before(async () => { server = createServer(async (request, response) => { let body = ''; for await (const chunk of request) body += chunk; const path = request.url || ''; received.push({ path, body }); response.setHeader('Content-Type', 'application/json'); if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; } if (path === '/api/app/recharge/package/list') { response.end(JSON.stringify({ code: 200, data: { coinList: [{ id: 9, payMethods: [{ type: 'wechat' }] }] } })); return; } if (path.startsWith('/api/app/playlet/search/suggest?')) { response.end(JSON.stringify({ code: 200, data: { keyword: '短剧搜索', list: [ { id: 1, slug: '恋/爱%2F?片#段', playName: '搜索主推荐', cover: '/play/signed-cover.webp?signature=a%2Bb' }, { id: 42, title: '无 slug 作品' }, ] } })); return; } if (path === '/api/app/auth/getInfo') { response.end(JSON.stringify({ code: 200, data: { token: 'session', userId: 12345, nickName: '用户', type: 1 } })); return; } if (path === '/api/app/coin/buy') { response.end(JSON.stringify(scenario === 'expired' ? { code: 6001, msg: 'expired', tip: '', data: { internal: true } } : { code: 200, msg: 'ok', tip: '', data: { payUrl: 'https://pay.example/order' } })); return; } if (path.startsWith('/api/app/coin/trans/export')) { response.end(JSON.stringify({ code: 6001, msg: 'expired', data: null })); return; } if (path === '/api/app/comment/send') { response.end(JSON.stringify({ code: 1001, msg: '评论暂不可用', tip: '', data: null })); return; } response.end(JSON.stringify({ code: 200, msg: 'backend message', tip: '', data: { ok: true } })); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('No address'); process.env.UPSTREAM_API_URL = `http://127.0.0.1:${address.port}`; }); after(async () => { await new Promise(resolve => server.close(() => resolve())); if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL; }); const ctx = (pathname: string, body?: unknown) => createContext(new Request('http://local.example' + pathname, { method: body === undefined ? 'GET' : 'POST', headers: { Cookie: 'token=session; userType=1; isRegister=1', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), })); async function api(path: string, body?: unknown) { const response = await handleAPI(ctx(path, body)); assert.ok(response); return response; } test('surviving BFF routes retain their registered request method and removed account routes are unregistered', () => { const posts = ['auth/guest', 'upload/image', 'feedback/add', 'playlet/chapter/watch/report']; const gets = ['auth/session', 'playlet/guess-like/list', 'web/search/suggest']; for (const path of posts) assert.equal(apiRoutes['/api/' + path], 'POST', path); for (const path of gets) assert.equal(apiRoutes['/api/' + path], 'GET', path); assert.deepEqual(Object.keys(apiRoutes).sort(), [...posts, ...gets].map(path => '/api/' + path).sort()); const removed = ['comment/list', 'auth/login', 'auth/register', 'auth/logout', 'auth/resetPassword', 'auth/profile', 'recharge/coin/buy', 'coin/buy', 'playlet/history/clear', 'playlet/history/delete', 'video/history/clear', 'video/history/delete', 'comment/send', 'comment/like/set', 'playlet/chapter/like/set', 'playlet/chapter/collect/set', 'playlet/chapter/unlock', 'playlet/like/set', 'playlet/collect/set', 'video/like/set', 'video/collect/set', 'recharge/transactions', 'coin/trans/list', 'coin/trans/export', 'recharge/transactions/export']; for (const path of removed) assert.equal(apiRoutes['/api/' + path], undefined, path); }); test('removed account BFF routes fall through without reaching the upstream', async () => { scenario = ''; for (const [path, body] of [['auth/login', { account: 'a', password: 'b' }], ['comment/send', { objectId: 2, text: '评论' }], ['coin/buy', { packageId: 9 }], ['coin/trans/export?type=1', undefined]] as const) { received.length = 0; assert.equal(await handleAPI(ctx('/api/' + path, body)), null, path); assert.equal(received.length, 0, path); } }); test('watch report payload preserves IDs and omitted optional watch identifiers', async () => { scenario = ''; received.length = 0; assert.equal((await api('/api/playlet/chapter/watch/report', { chapterId: 5, ignored: 'discard' })).status, 200); assert.equal(received[0].path, '/api/app/playlet/chapter/watch/report'); assert.deepEqual(JSON.parse(received[0].body), { chapterId: 5 }); }); test('Go DTO type errors are HTTP 400 before forwarding any mutation', async () => { scenario = ''; const cases: [string, unknown][] = [ ['playlet/chapter/watch/report', { chapterId: 1.5 }], ['feedback/add', { type: 1, content: '反馈', screenshots: [123] }], ]; for (const [path, body] of cases) { received.length = 0; assert.equal((await api('/api/' + path, body)).status, 400, path); assert.equal(received.length, 0, path); } }); test('Set-Cookie replacement respects Path and Domain scopes', () => { const context = ctx('/'); context.responseHeaders.append('Set-Cookie', 'sid=sub; Path=/sub; HttpOnly'); context.responseHeaders.append('Set-Cookie', 'sid=domain; Path=/; Domain=example.com'); context.setCookie('sid', 'root'); context.setCookie('sid', 'updated'); const cookies = context.responseHeaders.getSetCookie(); assert.equal(cookies.length, 3); assert.ok(cookies.includes('sid=sub; Path=/sub; HttpOnly')); assert.ok(cookies.includes('sid=domain; Path=/; Domain=example.com')); assert.equal(cookies.filter(value => value.startsWith('sid=updated;')).length, 1); }); test('first attribution keeps the source seven-day cookie lifetime', () => { const context = ctx('/list/?dc=campaign'); assert.equal(resolveChannel(context), 'campaign'); assert.match(context.responseHeaders.getSetCookie().find(value => value.startsWith('channel=')) || '', /Max-Age=604800(?:;|$)/); }); test('tool and API trailing-slash redirects retain the original query and POST method', async () => { for (const path of ['/enkey/', '/newenkey/', '/api/web/search/suggest/', '/robots.txt/', '/sitemap.xml/']) { const response = await handleRequest(new Request('http://local.example' + path + '?sig=abc%2B123')); assert.equal(response.status, 301, path); assert.equal(response.headers.get('location'), path.slice(0, -1) + '?sig=abc%2B123'); } const response = await handleRequest(new Request('http://local.example/api/feedback/add/?x=1', { method: 'POST', body: '{}' })); assert.equal(response.status, 307); assert.equal(response.headers.get('location'), '/api/feedback/add?x=1'); }); test('navigation-only deployment exposes navigation and denies application API and media routes', async () => { const previous = process.env.NAVIGATION_ONLY; process.env.NAVIGATION_ONLY = 'true'; try { for (const path of ['/api/web/search/suggest?keyword=a', '/enkey', '/list/']) { const response = await handleRequest(new Request('http://local.example' + path)); assert.equal(response.status, 404, path); assert.match(response.headers.get('content-type') || '', /text\/html/); } const robots = await handleRequest(new Request('https://public.drama.test/robots.txt')); assert.equal(robots.status, 404); assert.match(robots.headers.get('content-type') || '', /text\/html/); assert.deepEqual(robots.headers.getSetCookie(), []); const nav = await handleRequest(new Request('http://local.example/nav')); assert.equal(nav.status, 301); assert.equal(nav.headers.get('location'), '/navigation/'); } finally { if (previous === undefined) delete process.env.NAVIGATION_ONLY; else process.env.NAVIGATION_ONLY = previous; } }); test('search suggestions expose semantic page URLs while retaining encoded slugs, ID fallback and media addresses', async () => { scenario = ''; received.length = 0; const response = await api('/api/web/search/suggest?keyword=' + encodeURIComponent('短剧')); const data = await response.json(); assert.equal(response.status, 200); assert.equal(data.feature.url, '/drama/%E6%81%8B%2F%E7%88%B1%252F%3F%E7%89%87%23%E6%AE%B5/'); assert.equal(data.feature.cover, '/play/signed-cover.webp?signature=a%2Bb'); assert.equal(data.items[0].url, '/drama/42/'); assert.equal(data.items[1].url, '/search/' + encodeURIComponent('短剧搜索') + '/'); assert.equal(new URL(received[0].path, 'http://local.example').pathname, '/api/app/playlet/search/suggest'); });