Files
2026-05-03 23:36:48 +02:00

281 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stalker Mock Server Architecture
This document describes the design decisions, data flow, and extension points of the `stalker-mock-server` development tool.
## Related Docs
- [Stalker Portal Architecture](./stalker-portal.md)
- [Stalker EPG Architecture](./stalker-epg.md)
## Purpose
The mock server enables:
1. **Local development** without access to a real Stalker portal
2. **Playwright E2E testing** with predictable, deterministic data
3. **Scenario-based testing** via predefined MAC addresses that map to specific data shapes
## Key Design Decisions
### Seeded Determinism (Not Per-Request Random)
Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:
- Data is generated **once per MAC address** on first request, then cached in memory.
- `@faker-js/faker` is seeded with a numeric value derived from the MAC address before generation.
- Same MAC → identical data on every server restart.
- Restart the server to reshuffle all data.
### MAC Address as Identity
Stalker portals use MAC address as the primary credential. The mock server follows the same model:
- Each unique MAC gets its own isolated dataset.
- Predefined MACs map to specific `ScenarioConfig` shapes (see `src/app/scenarios.ts`).
- Unknown MACs use the sum of their byte values as a seed, producing unique but deterministic data.
### In-Memory Only
No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
## Data Generation Pipeline
```
faker.seed(macToNumber(mac))
│
├── generateCategories('itv', N) → itvCategories[]
│ └── generateChannels() → channels Map<categoryId, channel[]>
│ └── generateEpg() → epg Map<channelId, program[]>
│
├── generateCategories('vod', N) → vodCategories[]
│ └── generateVodItems() → vod Map<categoryId, item[]>
│ ├── normal VOD items
│ ├── is_series=1 items (fraction, Ministra flow)
│ └── embedded series[] items (fraction)
│
└── generateCategories('series', N) → seriesCategories[]
└── generateSeriesItems() → series Map<categoryId, item[]>
└── generateSeasons() → seasons Map<seriesItemId, season[]>
```
## Response Shapes
All responses follow the Stalker `portal.php` envelope:
```json
{ "js": <action-specific payload> }
```
### `get_categories`
```json
{
"js": [
{ "id": "2001", "title": "Action", "alias": "action" },
...
]
}
```
### `get_ordered_list` (content)
```json
{
"js": {
"data": [
{
"id": "20001",
"name": "...",
"cmd": "ffrt4://vod/20001/index.m3u8",
"screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200",
"cover": "https://picsum.photos/seed/vod-cover-20001/300/450",
"description": "...",
"actors": "...",
"director": "...",
"year": "2019",
"rating_imdb": "7.3",
"category_id": "2001",
"is_series": 0,
"has_files": 1
}
],
"total_items": 40,
"max_page_items": 14,
"cur_page": 1,
"total_pages": 3
}
}
```
### `get_ordered_list` (seasons — when `movie_id` is present)
```json
{
"js": [
{
"id": "30001-s1",
"name": "Season 1",
"cmd": "ffrt4://series/30001/season/1",
"series": ["30001-s1-e1", "30001-s1-e2", ...],
"screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
"director": "...",
"actors": "...",
"year": "2021",
"rating_imdb": "8.1"
}
]
}
```
### `create_link`
```json
{
"js": {
"cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
"streamer_id": "1",
"load": "",
"error": ""
}
}
```
The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream.
### `get_short_epg`
```json
{
"js": {
"data": [
{
"id": "1",
"name": "Channel Name: Program Title",
"start": "2026-02-21T10:00:00.000Z",
"stop": "2026-02-21T12:00:00.000Z",
"start_timestamp": 1740128400,
"stop_timestamp": 1740135600,
"descr": "...",
"category": "News"
}
]
}
}
```
`get_short_epg` returns the current program and upcoming items from the
generated schedule, limited by the requested `size`.
### `get_epg_info`
```json
{
"js": {
"data": {
"10000": [
{
"id": "1",
"name": "Channel Name: Program Title",
"start": "2026-02-21T10:00:00.000Z",
"stop": "2026-02-21T12:00:00.000Z",
"start_timestamp": 1740128400,
"stop_timestamp": 1740135600,
"descr": "...",
"category": "News"
}
]
}
}
}
```
`get_epg_info` returns bulk EPG keyed by channel id and filters the generated
7-day schedule from the current UTC day start through `now + period`.
EPG programs are generated as 2-hour slots across 7 days for each channel,
starting at the current UTC day boundary.
## Scenarios
Scenarios are defined in `src/app/scenarios.ts`. Each scenario is a `ScenarioConfig`:
```typescript
interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: { itv: number; vod: number; series: number };
itemsPerCategory: number;
seasonsPerSeries: number;
episodesPerSeason: number;
isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1
embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
}
```
### Adding a New Scenario
1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`.
2. Use any unique MAC address as the key (lowercase, colon-separated).
3. Document it in `README.md` and this file.
## Favorites
Favorites are stored in a `Map<mac, Set<itemId>>` in `src/app/data-store.ts`. They persist for the lifetime of the server process and are shared across all requests for the same MAC.
Call `POST /reset` to clear all favorites (and regenerated data) between test runs.
## Playwright Integration
`apps/web-e2e/playwright.config.ts` registers the mock server as a second `webServer` entry:
```typescript
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
},
{
command: 'pnpm nx run stalker-mock-server:serve',
url: 'http://localhost:3210/health',
reuseExistingServer: !process.env['CI'],
},
]
```
Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.
### Test Isolation
Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.
The generated content (categories, items) is **not** cleared on reset — it's deterministic and doesn't need to be. Only in-memory favorites are cleared.
### Recommended Test Structure
```typescript
import { test, expect } from '@playwright/test';
const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario
test.beforeEach(async ({ request }) => {
await request.post('http://localhost:3210/reset');
});
test('browse VOD categories', async ({ page }) => {
// Add portal via UI or programmatically via IndexedDB
// Navigate to portal
// Assert category list matches expected count (8 for default scenario)
});
```
## Extension Points
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows.
- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store.
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.