This commit is contained in:
gili8420 2026-08-14 00:01:49 +09:00
commit b43955e0d9
162 changed files with 15425 additions and 0 deletions

607
mocks/handlers.ts Normal file
View file

@ -0,0 +1,607 @@
// 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 northsouth 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');
}

83
mocks/plugin.ts Normal file
View file

@ -0,0 +1,83 @@
import type { Plugin } from 'vite';
import { WebSocketServer } from 'ws';
import { handle, synthTelemetryHistory } from './handlers';
const COOKIE = 'mocksid';
const WS_PATH = /^\/api\/ws\/satellite\/([0-9a-f-]{36})\/telemetry\/$/i;
function readCookie(header: string | undefined, name: string): string | null {
if (!header) return null;
for (const part of header.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === name) return decodeURIComponent(v.join('='));
}
return null;
}
/** Dev-only middleware that answers /api/* from the in-memory mock backend. */
export function mockApi(): Plugin {
return {
name: 'mock-api',
configureServer(server) {
// Live telemetry: a real WebSocket so the tracking flow is exercised
// end-to-end (the read-only satellite consumer in the real backend).
const wss = new WebSocketServer({ noServer: true });
server.httpServer?.on('upgrade', (req, socket, head) => {
const url = req.url ?? '';
const match = url.split('?')[0].match(WS_PATH);
if (!match) return; // let Vite's own HMR socket handle it
wss.handleUpgrade(req, socket as never, head, (ws) => {
// Continue the satellite's synthetic track from where history ended.
const history = synthTelemetryHistory(match[1]);
let next = history.length;
const first = history[0] as { timestamp: number; lat: number; lon: number };
const timer = setInterval(() => {
ws.send(
JSON.stringify({
id: `${match[1]}-live-${next}`,
timestamp: first.timestamp + (next - history.length + 1) * 60,
lat: first.lat + (next - history.length + 1) * 0.004,
lon: first.lon + (next - history.length + 1) * 0.01,
alt: 18000 + (next - history.length + 1) * 900,
payload: { live: true },
raw_data: {}
})
);
next += 1;
}, 1000);
ws.on('close', () => clearInterval(timer));
});
});
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/api/')) return next();
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const body = raw ? JSON.parse(raw) : {};
const sessionUser = readCookie(req.headers.cookie, COOKIE);
const [pathname, search = ''] = req.url!.split('?');
const result = handle(
req.method ?? 'GET',
pathname,
body,
sessionUser,
new URLSearchParams(search)
);
res.statusCode = result.status;
res.setHeader('content-type', 'application/json');
res.setHeader('x-csrftoken', 'mock-csrf');
if (result.session !== undefined) {
res.setHeader(
'set-cookie',
result.session === null
? `${COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
: `${COOKIE}=${encodeURIComponent(result.session)}; Path=/; SameSite=Lax`
);
}
res.end(JSON.stringify(result.body));
});
});
}
};
}