// 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; interface MockUser { username: string; email: string; password: string; is_staff: boolean; is_active?: boolean; } const users = new Map([ ['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 = [ { 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 = [ { 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 (~2–30 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//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; const profile = (body.profile ?? []) as Array>; 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'); }