mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test: add stalker-mock-server and integrate with e2e tests
- Introduced `stalker-mock-server` with TypeScript configuration for local development and testing. - Updated `playwright.config.ts` to run both Angular app and mock server in parallel during e2e tests. - Created comprehensive e2e tests for Stalker portal functionality, including health checks, portal addition, and content loading. - Added detailed architecture documentation for both `stalker-mock-server` and `xtream-mock-server`, outlining design decisions, data flow, and API protocols. - Implemented `xtream-mock-server` to simulate Xtream Codes API for local development and testing, with corresponding e2e tests. - Enhanced test isolation by resetting mock server state before each test run.
This commit is contained in:
1 parent
d7ee25e3aa
commit
95c32708ee
23 files changed
+2507
-7
No files matched your search
@@ -0,0 +1,250 @@
|
||||
# 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-21T10:30:00.000Z",
|
||||
"start_timestamp": 1740128400,
|
||||
"stop_timestamp": 1740130200,
|
||||
"descr": "...",
|
||||
"category": "News"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
EPG programs are generated as 30-minute slots spanning 3 hours past to 3 hours future relative to the time of generation.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Xtream Mock Server — Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
`apps/xtream-mock-server` is a self-contained Express server that emulates the
|
||||
Xtream Codes API protocol. It is used for:
|
||||
|
||||
- **Local development** — run a full portal without a real Xtream subscription
|
||||
- **E2E testing** — Playwright spins it up alongside the Angular dev server
|
||||
|
||||
---
|
||||
|
||||
## Data Pipeline
|
||||
|
||||
```
|
||||
credentials (username + password)
|
||||
│
|
||||
▼
|
||||
credentialsToSeed(u, p) ←── deterministic polynomial hash
|
||||
│
|
||||
▼
|
||||
faker.seed(seed) ←── all faker calls use same seed per credentials
|
||||
│
|
||||
▼
|
||||
generateCategories() ←── live / vod / series categories
|
||||
│
|
||||
generateLiveStreams() ←── live TV stream list
|
||||
generateVodStreams() ←── VOD movie list
|
||||
generateSeriesItems() ←── series list
|
||||
generateSeriesInfo() ←── nested seasons + episodes (pre-populated)
|
||||
│
|
||||
▼
|
||||
PortalData (cached) ←── Map<"username:password", PortalData>
|
||||
```
|
||||
|
||||
Re-requesting with the same credentials returns the exact same data until
|
||||
`POST /reset` clears all caches.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/xtream-mock-server/
|
||||
├── project.json ← Nx targets: serve (port 3211), serve-with-watch
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── main.ts ← Express app bootstrap, all routes wired up
|
||||
└── app/
|
||||
├── scenarios.ts ← Credential → ScenarioConfig mapping
|
||||
├── data-store.ts ← Lazy cache, per-credentials generation
|
||||
├── generators/
|
||||
│ ├── categories.generator.ts
|
||||
│ ├── live.generator.ts ← Live streams + EPG listings
|
||||
│ ├── vod.generator.ts ← VOD streams + VodDetails
|
||||
│ └── series.generator.ts ← Series items + SeriesInfo
|
||||
├── handlers/
|
||||
│ ├── get-account-info.handler.ts
|
||||
│ ├── get-categories.handler.ts ← live/vod/series categories
|
||||
│ ├── get-streams.handler.ts ← live/vod/series stream lists
|
||||
│ ├── get-vod-info.handler.ts
|
||||
│ ├── get-series-info.handler.ts
|
||||
│ └── get-short-epg.handler.ts
|
||||
└── routes/
|
||||
└── dispatch.ts ← Action → handler routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Protocol
|
||||
|
||||
### Direct Xtream endpoint
|
||||
|
||||
```
|
||||
GET /player_api.php?action=<action>&username=<u>&password=<p>[&...]
|
||||
```
|
||||
|
||||
Response: raw JSON (no envelope). Matches the real Xtream Codes API format.
|
||||
|
||||
### PWA proxy endpoint
|
||||
|
||||
IPTVnator's PWA routes Xtream calls through:
|
||||
```
|
||||
GET /xtream?url=<serverUrl>&action=<action>&username=<u>&password=<p>
|
||||
```
|
||||
Response: `{ payload: <data>, action: <action> }`
|
||||
|
||||
This mirrors the backend proxy in `apps/electron-backend` so the same
|
||||
Angular service code works in both environments.
|
||||
|
||||
### Stream stub endpoints
|
||||
|
||||
```
|
||||
GET /live/<username>/<password>/<streamId>.m3u8
|
||||
GET /movie/<username>/<password>/<streamId>.<ext>
|
||||
GET /series/<username>/<password>/<streamId>.<ext>
|
||||
```
|
||||
|
||||
All redirect to a publicly available HLS test stream
|
||||
(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`).
|
||||
|
||||
---
|
||||
|
||||
## Key Response Shapes
|
||||
|
||||
### `get_account_info`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_info": {
|
||||
"username": "user1", "password": "pass1",
|
||||
"status": "active", "exp_date": "4102444799",
|
||||
"is_trial": "0", "active_cons": "1", "max_connections": "2",
|
||||
"allowed_output_formats": ["m3u8", "ts", "rtmp"]
|
||||
},
|
||||
"server_info": {
|
||||
"url": "http://localhost:3211", "port": "3211",
|
||||
"timezone": "UTC", "timestamp_now": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `get_live_categories` / `get_vod_categories` / `get_series_categories`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "category_id": "101", "category_name": "News", "parent_id": 0 },
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### `get_live_streams` (sample item)
|
||||
|
||||
```json
|
||||
{
|
||||
"num": 1, "name": "Acme Corp TV",
|
||||
"stream_type": "live", "stream_id": 10000,
|
||||
"stream_icon": "https://picsum.photos/seed/live-10000/100/100",
|
||||
"epg_channel_id": "channel-10000.mock",
|
||||
"category_id": "101", "tv_archive": 0, "tv_archive_duration": 0
|
||||
}
|
||||
```
|
||||
|
||||
### `get_short_epg` (sample item)
|
||||
|
||||
```json
|
||||
{
|
||||
"epg_listings": [
|
||||
{
|
||||
"id": "1000000", "epg_id": "channel-10000.mock",
|
||||
"title": "base64encodedTitle",
|
||||
"description": "base64encodedDescription",
|
||||
"start": "2024-01-01 12:00:00", "end": "2024-01-01 12:30:00",
|
||||
"start_timestamp": "1704110400", "stop_timestamp": "1704112200"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: `title` and `description` are **base64-encoded**, matching the real Xtream API.
|
||||
|
||||
### `get_series_info` (structure)
|
||||
|
||||
```json
|
||||
{
|
||||
"seasons": [
|
||||
{
|
||||
"id": 3000100, "name": "Season 1", "season_number": 1,
|
||||
"episode_count": 8, "air_date": "2022-05-14",
|
||||
"cover": "https://picsum.photos/seed/season-30001-1/300/450"
|
||||
}
|
||||
],
|
||||
"info": { "name": "...", "cover": "...", "plot": "...", "cast": "...", ... },
|
||||
"episodes": {
|
||||
"1": [
|
||||
{
|
||||
"id": "80001", "episode_num": 1, "title": "Series Name S1E1",
|
||||
"season": 1, "container_extension": "mkv",
|
||||
"info": { "duration_secs": 2400, "rating": 8.3, ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
| Key (`username:password`) | Seed | Categories | Items/cat | Account status |
|
||||
|---------------------------|------|------------|-----------|----------------|
|
||||
| `user1:pass1` | 1001 | 8 each | 40 | active |
|
||||
| `large:large` | 9999 | 20 each | 200 | active |
|
||||
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
|
||||
| `minimal:minimal` | 3003 | 2 each | 5 | active |
|
||||
| `expired:expired` | 4004 | 4 each | 10 | Expired |
|
||||
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
|
||||
| `<any other>` | hash | 6 each | 30 | active |
|
||||
|
||||
---
|
||||
|
||||
## Playwright Integration
|
||||
|
||||
### Configuration (`apps/web-e2e/playwright.config.ts`)
|
||||
|
||||
The mock server is listed as a third `webServer` entry:
|
||||
|
||||
```typescript
|
||||
{
|
||||
command: 'pnpm nx run xtream-mock-server:serve',
|
||||
url: 'http://localhost:3211/health',
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
cwd: workspaceRoot,
|
||||
}
|
||||
```
|
||||
|
||||
### Request Interception
|
||||
|
||||
The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these:
|
||||
|
||||
```typescript
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL('http://localhost:3211/xtream');
|
||||
originalUrl.searchParams.forEach((v, k) => mockUrl.searchParams.set(k, v));
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Points
|
||||
|
||||
- **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts`
|
||||
- **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts`
|
||||
- **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario
|
||||
- **Custom stream URLs**: Edit the HLS stub redirect in `main.ts`
|
||||
Reference in new issue
Block a user