Files
iptvnator/apps/stalker-mock-server/src/main.ts
T
4gray e55d55b47f feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.

Supporting changes made while getting it green:

- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
  Tier A: it is fictional fixture data, so a statement percentage over it means
  nothing, and a Tier A entry would pull it into the merged coverage map and the
  ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
  of its own — it is already Tier C and the Tier B/C runner falls back to
  `pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
  `tools/release/capture-app-driver.ts`:
  - VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
    now lists the showcase movies first, so 62000-62002 became Black Harbor, The
    Paper Astronaut and Summer Static while the dashboard seeding still mapped
    those ids to the previous titles' backdrops.
  - the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
    tsconfig.base.json`, so the mock could not resolve
    `@iptvnator/shared/marketing-fixtures` and the capture never started. Both
    mock projects' own serve targets already passed the flag.
2026-07-27 08:10:57 +02:00

185 lines
6.2 KiB
TypeScript

import http from 'http';
import { join } from 'node:path';
import express, { Request, Response } from 'express';
import cors from 'cors';
import portalRouter from './app/routes/portal.route.js';
import dispatchPortalAction from './app/routes/dispatch.js';
import { resetAll } from './app/data-store.js';
import { SCENARIOS } from './app/scenarios.js';
import {
buildRequestOrigin,
resolveMarketingPosterUrls,
} from './app/marketing-poster-url.js';
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
const app = express();
const MARKETING_POSTER_DIRECTORY = join(
process.cwd(),
'apps/xtream-mock-server/public/marketing/poster'
);
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Log every request
app.use((req, _res, next) => {
const action = req.query['action'] ?? '-';
const mac =
(req.headers['cookie'] ?? '')
.split(';')
.find((c) => c.trim().startsWith('mac='))
?.split('=')[1]
?.trim() ?? (req.query['macAddress'] as string) ?? 'no-mac';
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.path} action=${action} mac=${mac}`
);
next();
});
// Marketing fixtures store deployment-neutral asset paths. Resolve them
// against the public origin of each request, including reverse-proxy headers.
app.use((req, res, next) => {
if (
!['get_ordered_list', 'favorites'].includes(
String(req.query['action'])
)
) {
next();
return;
}
const sendJson = res.json.bind(res);
const requestOrigin = buildRequestOrigin(req);
res.json = ((body: unknown) =>
sendJson(
resolveMarketingPosterUrls(body, requestOrigin)
)) as Response['json'];
next();
});
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Serve the shared screenshot-safe poster catalog directly from this process.
app.use(
'/assets/marketing/poster',
express.static(MARKETING_POSTER_DIRECTORY, {
fallthrough: false,
immutable: true,
maxAge: '1y',
})
);
// Stalker portal.php endpoint (direct portal protocol, Electron mode)
app.use('/portal.php', portalRouter);
/**
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
* → { payload: <stalker_response> }
*
* The IPTVnator PWA sends Stalker requests to AppConfig.BACKEND_URL/stalker.
* Playwright tests redirect those calls to this endpoint using page.route(),
* so no app code changes are required.
*/
app.get('/stalker', (req: Request, res: Response) => {
const { macAddress, url: _url, ...rest } = req.query as Record<string, string>;
const mac = macAddress ?? '00:1a:79:00:00:01';
// Build a lightweight synthetic request. We need a fresh object with mutable
// `query` and a Cookie header containing the MAC for the handler helpers.
const syntheticReq = {
query: rest,
headers: { cookie: `mac=${mac}` },
params: {},
} as unknown as Request;
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
let captured: unknown;
const syntheticRes = {
json: (data: unknown) => {
captured = data;
},
} as unknown as Response;
dispatchPortalAction(syntheticReq, syntheticRes);
res.json({ payload: captured });
});
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Reset all in-memory data (useful between Playwright test runs)
app.post('/reset', (_req: Request, res: Response) => {
resetAll();
res.json({ status: 'reset', timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const server = http.createServer(app);
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[stalker-mock] Port ${PORT} is already in use.`);
} else {
console.error('[stalker-mock] Server error:', err.message);
}
process.exit(1);
});
const shutdown = () => {
console.log('\n[stalker-mock] Shutting down...');
server.close(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
console.error('[stalker-mock] Uncaught exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[stalker-mock] Unhandled rejection:', reason);
process.exit(1);
});
// When Nx (or any process manager) closes stdin, prevent auto-exit.
// The HTTP server handle is what keeps the process alive.
process.stdin.resume();
process.stdin.on('end', () => { /* ignore stdin close */ });
server.listen(PORT, () => {
const divider = '─'.repeat(62);
console.log(`\n${divider}`);
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
console.log(divider);
console.log(' Portal URL (Electron/direct):');
console.log(` http://localhost:${PORT}/portal.php`);
console.log('');
console.log(' CORS proxy URL (PWA/Playwright e2e):');
console.log(` http://localhost:${PORT}/stalker?url=...&macAddress=...`);
console.log('');
console.log(' Predefined scenario MACs:');
for (const [mac, scenario] of Object.entries(SCENARIOS)) {
console.log(
` ${mac} → ${scenario.name.padEnd(16)} ${scenario.description}`
);
}
console.log('');
console.log(' Any other MAC generates deterministic unique data from MAC bytes.');
console.log(` Utilities:`);
console.log(` GET http://localhost:${PORT}/health`);
console.log(` POST http://localhost:${PORT}/reset (clears favorites + cache)`);
console.log(`${divider}\n`);
});