stratoflights-web/mocks/handlers.ts
2026-08-14 00:01:49 +09:00

607 lines
21 KiB
TypeScript
Raw 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.

// In-memory backend used when VITE_USE_MOCK_API=true, so the frontend runs and
// e2e-tests with no Django. The session is carried by a cookie (set via the
// `session` field below), so each browser context is independent — no global
// session state to leak between tests.
type Json = Record<string, unknown>;
interface MockUser {
username: string;
email: string;
password: string;
is_staff: boolean;
is_active?: boolean;
}
const users = new Map<string, MockUser>([
['demo', { username: 'demo', email: 'demo@example.com', password: 'demo', is_staff: false }],
['admin', { username: 'admin', email: 'admin@example.com', password: 'admin', is_staff: true }]
]);
// Library items (saved points / templates), seeded so the pickers have content.
let nextId = 100;
const points: Array<Json & { id: number }> = [
{ id: 1, name: 'sjsa-start', lat: 62.1234, lon: 129.1234, alt: 100 },
{ id: 2, name: 'Тикси', lat: 71.6372, lon: 128.8686, alt: 10 }
];
const templates: Array<Json & { id: number }> = [
{
id: 1,
name: 'Высотный',
description: '',
prediction_mode: 'single',
model: '',
dataset: '',
flight_parameters: {
ascent_rate: 5,
burst_altitude: 33000,
dataset: '',
descent_rate: 6,
format: 'json',
launch_altitude: 100,
launch_latitude: 62.1234,
launch_longitude: 129.1234,
profile: 'standard_profile',
version: 2
}
}
];
export interface HandleResult {
status: number;
body: Json;
/** undefined = leave the session cookie unchanged; string = set; null = clear. */
session?: string | null;
}
const ok = (body: Json, status = 200): HandleResult => ({ status, body });
const err = (status: number, detail: string): HandleResult => ({ status, body: { detail } });
/** Build a plausible ascent+descent trajectory from the request params. */
function synthPrediction(body: Json): Json {
const lat0 = Number(body.launch_latitude ?? 62.1234);
const lon0 = Number(body.launch_longitude ?? 129.1234);
const alt0 = Number(body.launch_altitude ?? 0);
const ascentRate = Math.max(Number(body.ascent_rate ?? 5), 0.1);
const burstAlt = Number(body.burst_altitude ?? 30000);
const descentRate = Math.max(Number(body.descent_rate ?? 5), 0.1);
const startIso = String(body.launch_datetime ?? new Date().toISOString());
const start = new Date(startIso).getTime();
const N = 24;
const windLat = 0.004;
const windLon = 0.01;
const ascentDur = ((burstAlt - alt0) / ascentRate) * 1000; // ms
const descentDur = (burstAlt / descentRate) * 1000; // ms
const ascent = [];
for (let i = 0; i <= N; i++) {
const f = i / N;
ascent.push({
altitude: alt0 + (burstAlt - alt0) * f,
datetime: new Date(start + ascentDur * f).toISOString(),
latitude: lat0 + windLat * i,
longitude: lon0 + windLon * i
});
}
const burstLat = lat0 + windLat * N;
const burstLon = lon0 + windLon * N;
const descent = [];
for (let i = 0; i <= N; i++) {
const f = i / N;
descent.push({
altitude: burstAlt * (1 - f),
datetime: new Date(start + ascentDur + descentDur * f).toISOString(),
latitude: burstLat + windLat * i * 0.6,
longitude: burstLon + windLon * i * 0.6
});
}
return {
metadata: {
start_datetime: startIso,
complete_datetime: new Date(start + ascentDur + descentDur).toISOString()
},
prediction: [
{ stage: 'ascent', trajectory: ascent },
{ stage: 'descent', trajectory: descent }
]
};
}
/**
* Synthetic [U, V] wind grid in the wind-js-server format, matching the real
* predictor's conventions: longitudes in 0..360 and a north→south scan
* (la1 > la2) with positive dy.
*/
function synthWindField(q: URLSearchParams): Json[] {
const minLat = Number(q.get('min_lat') ?? 55);
const maxLat = Number(q.get('max_lat') ?? 70);
const minLng = Number(q.get('min_lng') ?? 120);
const maxLng = Number(q.get('max_lng') ?? 140);
const step = Math.max(0.25, Number(q.get('step') ?? 1));
const nx = Math.max(2, Math.floor((maxLng - minLng) / step) + 1);
const ny = Math.max(2, Math.floor((maxLat - minLat) / step) + 1);
const to360 = (lng: number) => ((lng % 360) + 360) % 360;
const u: number[] = [];
const v: number[] = [];
for (let j = 0; j < ny; j++) {
const lat = maxLat - j * step; // north → south
for (let i = 0; i < nx; i++) {
const lng = minLng + i * step;
// Smooth jet-like flow with a wide speed spread (~230 m/s) so the
// colour ramp and trail density are representative in mock mode.
u.push(14 + 13 * Math.sin((lat * Math.PI) / 9) * Math.cos((lng * Math.PI) / 14));
v.push(7 * Math.cos((lng * Math.PI) / 11) + 5 * Math.sin((lat * Math.PI) / 7));
}
}
const header = (name: string, unit: string) => ({
parameterUnit: unit,
parameterNumberName: name,
nx,
ny,
lo1: to360(minLng),
la1: maxLat,
lo2: to360(minLng + (nx - 1) * step),
la2: maxLat - (ny - 1) * step,
dx: step,
dy: step,
refTime: '2026-01-01T00:00:00Z'
});
return [
{ header: header('eastward_wind', 'm.s-1'), data: u },
{ header: header('northward_wind', 'm.s-1'), data: v }
];
}
/** Deterministic ascent track for a satellite id, newest-first like the backend. */
export function synthTelemetryHistory(id: string, count = 20): Json[] {
// Seed from the id so a given satellite always yields the same track.
let seed = 0;
for (const ch of id) seed = (seed * 31 + ch.charCodeAt(0)) % 997;
const lat0 = 62 + (seed % 100) / 1000;
const lon0 = 129 + (seed % 137) / 1000;
const startSec = Math.floor(Date.parse('2026-01-01T00:00:00Z') / 1000);
const packets: Json[] = [];
for (let i = 0; i < count; i++) {
packets.push({
id: `${id}-${i}`,
timestamp: startSec + i * 60,
lat: lat0 + i * 0.004,
lon: lon0 + i * 0.01,
alt: i * 900,
payload: { temp: -5 - i, volt: 4.2 - i * 0.01 },
raw_data: {}
});
}
return packets.reverse(); // newest-first
}
interface MockFlight {
id: string;
name: string;
privacy: 'public' | 'unlisted' | 'private';
owner: string;
}
const flights: MockFlight[] = [
{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'ЯКС-1', privacy: 'public', owner: 'demo' },
{ id: '550e8400-e29b-41d4-a716-446655440001', name: 'Тестовый', privacy: 'unlisted', owner: 'demo' },
{ id: '550e8400-e29b-41d4-a716-446655440002', name: 'Секретный', privacy: 'private', owner: 'admin' }
];
function flightOut(f: MockFlight, me: MockUser | null): Json {
const history = synthTelemetryHistory(f.id);
const last = history[0] as { timestamp: number; lat: number; lon: number; alt: number };
return {
id: f.id,
name: f.name,
privacy: f.privacy,
created_at: '2026-01-01T00:00:00Z',
is_owner: !!me && me.username === f.owner,
last_position: { timestamp: last.timestamp, lat: last.lat, lon: last.lon, alt: last.alt },
packet_count: history.length
};
}
const flightVisible = (f: MockFlight, me: MockUser | null) =>
f.privacy !== 'private' || (!!me && me.username === f.owner);
interface StoredRun {
id: string;
created_at: string;
updated_at: string;
result: Json;
request: Json;
/** Set only once the owner shares the run; access is by token, never by id. */
share_token?: string | null;
}
const runs: StoredRun[] = [];
let runSeq = 0;
export function handle(
method: string,
path: string,
body: Json,
sessionUser: string | null,
query: URLSearchParams = new URLSearchParams()
): HandleResult {
const me = sessionUser ? (users.get(sessionUser) ?? null) : null;
if (path === '/api/csrf/') return ok({ detail: 'CSRF cookie set' });
if (path === '/api/session/') return ok({ isAuthenticated: me !== null });
if (path === '/api/whoami/') {
if (!me) return err(401, 'Unauthorized');
return ok({ username: me.username, is_staff: me.is_staff });
}
if (path === '/api/login/' && method === 'POST') {
const u = users.get(String(body.username ?? ''));
if (u && u.password === String(body.password ?? ''))
return { status: 200, body: { detail: 'Successfully logged in.' }, session: u.username };
return err(400, 'Invalid credentials.');
}
if (path === '/api/logout/' && method === 'POST')
return { status: 200, body: { detail: 'Successfully logged out.' }, session: null };
if (path === '/api/register/' && method === 'POST') {
const username = String(body.username ?? '').trim();
const email = String(body.email ?? '').trim();
const password = String(body.password ?? '');
if (username.length < 3) return err(400, 'Username must be at least 3 characters');
if (users.has(username)) return err(400, 'Username already taken.');
if ([...users.values()].some((u) => u.email.toLowerCase() === email.toLowerCase()))
return err(400, 'Email already registered.');
users.set(username, { username, email, password, is_staff: false });
return { status: 201, body: { detail: 'Account created.' }, session: username };
}
if (path === '/api/profile/') {
if (!me) return err(401, 'Unauthorized');
if (method === 'GET')
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
if (method === 'PATCH') {
if (typeof body.email === 'string') me.email = body.email;
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
}
}
if (path === '/api/profile/change-password/' && method === 'POST') {
if (!me) return err(401, 'Unauthorized');
if (me.password !== String(body.old_password ?? '')) return err(400, 'Old password is incorrect');
me.password = String(body.new_password ?? '');
return ok({ detail: 'Password changed successfully' });
}
if (path === '/api/profile/delete-data/' && method === 'DELETE') {
if (!me) return err(401, 'Unauthorized');
return ok({ detail: 'All user data deleted successfully' });
}
if (path === '/api/profile/delete-account/' && method === 'DELETE') {
if (!me) return err(401, 'Unauthorized');
if (me.password !== String(body.password ?? '')) return err(400, 'Incorrect password');
users.delete(me.username);
return { status: 200, body: { detail: 'Account deleted successfully' }, session: null };
}
if (path === '/api/flights/public/' && method === 'GET') {
return ok({
flights: flights.filter((f) => f.privacy === 'public').map((f) => flightOut(f, me))
} as unknown as Json);
}
const flightMatch = path.match(/^\/api\/flights\/([0-9a-f-]{36})\/$/i);
if (flightMatch) {
const f = flights.find((x) => x.id === flightMatch[1]);
if (method === 'GET') {
// A private flight is indistinguishable from a missing one.
if (!f || !flightVisible(f, me)) return err(404, 'Not found');
return ok(flightOut(f, me));
}
if (method === 'PATCH') {
if (!me) return err(401, 'Authentication required');
if (!f) return err(404, 'Not found');
if (f.owner !== me.username) return err(403, 'Not your flight');
if (typeof body.privacy === 'string') {
if (!['public', 'unlisted', 'private'].includes(body.privacy))
return err(400, 'privacy must be one of: public, unlisted, private');
f.privacy = body.privacy as MockFlight['privacy'];
}
if (typeof body.name === 'string') f.name = body.name;
return ok(flightOut(f, me));
}
}
// Telemetry history: /api/<uuid>/telemetry/ (newest-first, like the backend).
const telemetryMatch = path.match(/^\/api\/([0-9a-f-]{36})\/telemetry\/$/i);
if (telemetryMatch && method === 'GET') {
// Public/unlisted tracks are readable anonymously; private ones are not.
const f = flights.find((x) => x.id === telemetryMatch[1]);
if (f && !flightVisible(f, me)) return err(404, 'Not found');
return ok(synthTelemetryHistory(telemetryMatch[1]) as unknown as Json);
}
if (path === '/api/wind/field/' && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
return ok(synthWindField(query) as unknown as Json);
}
if (path === '/api/wind/meta/' && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
return ok({
source: 'gfs-0p50-3h',
epoch: '2026-01-01T00:00:00Z',
altitudes: [0, 5000, 10000, 20000, 30000],
bbox: { min_lat: -90, max_lat: 90, min_lng: -180, max_lng: 180 }
});
}
if (path === '/api/admin/users/' && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
if (!me.is_staff) return err(403, 'Staff only');
const q = (query.get('q') ?? '').toLowerCase();
const limit = Number(query.get('limit') ?? 20);
const skip = Number(query.get('skip') ?? 0);
const all = [...users.values()]
.map((u, i) => ({
id: i + 1,
username: u.username,
email: u.email,
is_active: u.is_active ?? true,
is_staff: u.is_staff,
date_joined: '2026-01-01T00:00:00Z',
last_login: null
}))
.filter(
(u) => !q || u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
return ok({ total: all.length, limit, skip, users: all.slice(skip, skip + limit) } as unknown as Json);
}
const adminUserMatch = path.match(/^\/api\/admin\/users\/(\d+)\/$/);
if (adminUserMatch && method === 'PATCH') {
if (!me) return err(401, 'Unauthorized');
if (!me.is_staff) return err(403, 'Staff only');
const idx = Number(adminUserMatch[1]) - 1;
const target = [...users.values()][idx];
if (!target) return err(404, 'Not found');
if (target.username === me.username && body.is_active === false)
return err(400, 'You cannot deactivate your own account.');
target.is_active = Boolean(body.is_active);
return ok({
id: idx + 1,
username: target.username,
email: target.email,
is_active: target.is_active,
is_staff: target.is_staff,
date_joined: '2026-01-01T00:00:00Z',
last_login: null
});
}
if (path === '/api/predictions/' && method === 'POST') {
if (!me) return err(401, 'Unauthorized');
const result = synthPrediction(body);
// Mirror the backend: every run is persisted and shows up in history.
runSeq += 1;
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
runs.unshift({
id: `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`,
created_at: stamp,
updated_at: stamp,
result,
request: body
});
return ok({ result });
}
if (path === '/api/predictions/v2/' && method === 'POST') {
if (!me) return err(401, 'Unauthorized');
// Flatten the v2 stage request into the params synthPrediction understands,
// so a custom profile still yields a plausible ascent+descent track.
const launch = (body.launch ?? {}) as Record<string, unknown>;
const profile = (body.profile ?? []) as Array<Record<string, any>>;
const ascent = profile.find((st) => st.model?.type === 'constant_rate');
const descent = profile.find((st) => st.model?.type === 'parachute_descent');
const burst = ascent?.constraints?.find((c: any) => c.type === 'altitude')?.limit;
const result = synthPrediction({
launch_latitude: launch.latitude,
launch_longitude: launch.longitude,
launch_altitude: launch.altitude,
launch_datetime: launch.time,
ascent_rate: ascent?.model?.rate ?? 5,
descent_rate: descent?.model?.sea_level_rate ?? 5,
burst_altitude: burst ?? 30000
});
runSeq += 1;
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
runs.unshift({ id, created_at: stamp, updated_at: stamp, request: body, result });
return ok({ id, created_at: stamp, result });
}
if (path === '/api/predictions/ensemble/' && method === 'POST') {
if (!me) return err(401, 'Unauthorized');
// Members scatter around the deterministic single-run landing.
const base = synthPrediction(body) as { prediction: Array<{ trajectory: Json[] }> };
const descent = base.prediction[1].trajectory;
const last = descent[descent.length - 1] as unknown as { latitude: number; longitude: number };
const members = Array.from({ length: 21 }, (_, i) => ({
member: i,
landing: {
lat: last.latitude + Math.sin(i * 1.7) * 0.05,
lng: last.longitude + Math.cos(i * 2.3) * 0.09,
alt: 0
}
}));
const n = members.length;
const meanLat = members.reduce((a, m) => a + m.landing.lat, 0) / n;
const meanLng = members.reduce((a, m) => a + m.landing.lng, 0) / n;
const kmLat = 111.32;
const kmLng = 111.32 * Math.cos((meanLat * Math.PI) / 180);
const radius =
members.reduce(
(a, m) =>
a +
Math.hypot((m.landing.lng - meanLng) * kmLng, (m.landing.lat - meanLat) * kmLat),
0
) / n;
runSeq += 1;
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
runs.unshift({
id,
created_at: stamp,
updated_at: stamp,
request: body,
result: {
ensemble: {
members,
failed: [],
footprint: {
count: n,
mean: { lat: meanLat, lng: meanLng },
radius_km: radius,
ellipse: { semi_major_km: radius * 1.6, semi_minor_km: radius * 1.1, bearing_deg: 35 }
}
}
} as unknown as Json
});
return ok({ id, status: 'pending', created_at: stamp }, 202);
}
const statusMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/status\/$/i);
if (statusMatch && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
const run = runs.find((r) => r.id === statusMatch[1]);
if (!run) return err(404, 'Not found');
return ok({
id: run.id,
status: 'complete',
result: run.result,
error: null,
created_at: run.created_at,
updated_at: run.updated_at
});
}
if (path === '/api/predictions/list_user/' && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
const limit = Number(query.get('limit') ?? 10);
const skip = Number(query.get('skip') ?? 0);
return ok({
total: runs.length,
limit,
skip,
predictions: runs.slice(skip, skip + limit)
} as unknown as Json);
}
const shareMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/share\/$/i);
if (shareMatch) {
if (!me) return err(401, 'Unauthorized');
const run = runs.find((r) => r.id === shareMatch[1]);
if (!run) return err(404, 'Not found');
if (method === 'POST') {
// Re-sharing keeps the existing token so circulated links keep working.
run.share_token =
run.share_token ?? `11111111-2222-4333-8444-${String(runs.indexOf(run)).padStart(12, '0')}`;
return ok({ id: run.id, share_token: run.share_token }, 201);
}
if (method === 'DELETE') {
run.share_token = null;
return ok({ id: run.id, share_token: null });
}
}
const sharedMatch = path.match(/^\/api\/predictions\/shared\/([0-9a-f-]{36})\/$/i);
if (sharedMatch && method === 'GET') {
// Resolved by token only — anonymous, and an id grants nothing.
const run = runs.find((r) => r.share_token && r.share_token === sharedMatch[1]);
if (!run) return err(404, 'Not found');
return ok({
id: run.id,
created_at: run.created_at,
status: 'complete',
result: run.result
});
}
const detailMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/detail\/$/i);
if (detailMatch && method === 'GET') {
if (!me) return err(401, 'Unauthorized');
const run = runs.find((r) => r.id === detailMatch[1]);
if (!run) return err(404, 'Not found');
return ok({
...run,
status: 'complete',
error: null,
start_point: null,
template: null,
rate_profile: null
} as unknown as Json);
}
const deleteMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/delete\/$/i);
if (deleteMatch && method === 'DELETE') {
if (!me) return err(401, 'Unauthorized');
const idx = runs.findIndex((r) => r.id === deleteMatch[1]);
if (idx === -1) return err(404, 'Not found');
runs.splice(idx, 1);
return { status: 204, body: {} };
}
if (path.startsWith('/api/saved-points/')) {
if (!me) return err(401, 'Unauthorized');
if (path === '/api/saved-points/') {
if (method === 'GET') return ok(points as unknown as Json);
if (method === 'POST') {
const created = { ...body, id: nextId++ };
points.push(created);
return ok(created as Json, 201);
}
}
const id = Number(path.split('/').filter(Boolean).pop());
const idx = points.findIndex((p) => p.id === id);
if (idx === -1) return err(404, 'Not found');
if (method === 'PUT') {
points[idx] = { ...points[idx], ...body, id };
return ok(points[idx] as Json);
}
if (method === 'DELETE') {
points.splice(idx, 1);
return ok({ detail: 'deleted' });
}
}
if (path.startsWith('/api/saved-templates/')) {
if (!me) return err(401, 'Unauthorized');
if (path === '/api/saved-templates/') {
if (method === 'GET') return ok(templates as unknown as Json);
if (method === 'POST') {
const created = { ...body, id: nextId++ };
templates.push(created);
return ok(created as Json, 201);
}
}
const id = Number(path.split('/').filter(Boolean).pop());
const idx = templates.findIndex((tpl) => tpl.id === id);
if (idx === -1) return err(404, 'Not found');
if (method === 'PUT') {
templates[idx] = { ...templates[idx], ...body, id };
return ok(templates[idx] as Json);
}
if (method === 'DELETE') {
templates.splice(idx, 1);
return ok({ detail: 'deleted' });
}
}
return err(404, 'Not found');
}