Initial
This commit is contained in:
commit
b43955e0d9
162 changed files with 15425 additions and 0 deletions
607
mocks/handlers.ts
Normal file
607
mocks/handlers.ts
Normal 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 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/<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
83
mocks/plugin.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue