Files
pipu-music/apps/web/src/test/fakeApi.ts
T
mrleo1nidandClaude Opus 5.5 eaedd1fc62
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:29:38 +03:00

237 lines
9.7 KiB
TypeScript

import type { components } from '@pipu/api-client';
type Me = components['schemas']['Me'];
type AdminUser = components['schemas']['AdminUser'];
type Role = components['schemas']['Role'];
export interface FakeUser extends AdminUser {
password: string;
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
let seq = 0;
const WAVE_ID = '00000000-0000-4000-8000-0000000000ee';
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
id: `00000000-0000-4000-8000-00000000010${i}`,
title,
duration_ms: 200000,
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
album: albumRef,
genres: [],
disc_no: 1,
track_no: i + 1,
available: true,
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
reaction: null as 'like' | 'dislike' | null,
features: { bpm: 120, energy: 0.6, loudness_lufs: -12 },
}));
const albumSummary = {
id: '00000000-0000-4000-8000-0000000000bb',
title: 'Группа крови',
year: 1988,
cover_url: null,
cover_colors: [],
artist: kino,
track_count: 11,
};
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
seq++;
return {
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
username,
display_name: null,
role,
locale: 'ru',
is_blocked: false,
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
last_login_at: null,
password: `${username}-password`,
...extra,
};
}
/**
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
* Проверки прав упрощены — точная матрица покрыта тестами Go.
*/
export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }]));
const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>();
const me = (u: FakeUser): Me => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
must_change_credentials: u.must_change_credentials,
created_at: u.created_at,
});
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
const admin = (u: FakeUser): AdminUser => {
const { password, ...rest } = u;
void password;
return rest;
};
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const req = input as Request;
const url = new URL(req.url);
const path = url.pathname;
const text = await req.text();
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined;
switch (route) {
case 'GET /api/v1/server-info':
return json({
name: 'pipu-music',
version: '0.2.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 0,
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
});
case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials');
current = u.id;
return tokens(u);
}
case 'POST /api/v1/auth/refresh':
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
}
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
switch (route) {
case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all':
current = null;
return new Response(null, { status: 204 });
case 'GET /api/v1/me':
return json(me(user));
case 'PATCH /api/v1/me': {
if (typeof body?.locale === 'string') user.locale = body.locale;
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
return json(me(user));
}
case 'POST /api/v1/me/bootstrap-credentials': {
const pw = String(body?.new_password ?? '');
if (pw.length < 12)
return problem(422, 'validation.password_too_short', {
params: { min: 12 },
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
});
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
return tokens(user);
}
case 'POST /api/v1/me/password':
Object.assign(user, { password: body?.new_password, must_change_password: false });
return tokens(user);
case 'GET /api/v1/me/sessions':
return json({ items: [] });
case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase();
const role = url.searchParams.get('role');
const items = [...users.values()]
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
.sort((a, b) => a.username.localeCompare(b.username))
.map(admin);
return json({ items, next_cursor: null });
}
case 'POST /api/v1/admin/users': {
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
const u = makeUser(String(body?.username), body?.role as Role, {
display_name: (body?.display_name as string) ?? null,
must_change_password: true,
password: 'Temp-Pass-123',
});
users.set(u.id, u);
return json({ user: admin(u), temporary_password: u.password }, 201);
}
case 'GET /api/v1/admin/audit':
return json({ items: [], next_cursor: null });
case 'GET /api/v1/search/suggest':
case 'GET /api/v1/search': {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj';
return json({
artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [],
tracks: [],
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null,
});
}
case `GET /api/v1/albums/${albumSummary.id}`:
return json({
...albumRef,
artist: kino,
duration_ms: 400000,
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
});
case 'GET /api/v1/wave/options':
return json({
activities: [{ key: 'running', icon: 'directions_run' }, { key: 'focus', icon: 'headphones' }],
moods: [{ key: 'calm', icon: 'spa' }, { key: 'happy', icon: 'sentiment_satisfied' }],
});
case 'POST /api/v1/wave/sessions':
return json(
{ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: albumTracks, fallback: false },
201,
);
case `PATCH /api/v1/wave/sessions/${WAVE_ID}`:
case `POST /api/v1/wave/sessions/${WAVE_ID}/next`:
return json({ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: [], fallback: false });
case `DELETE /api/v1/wave/sessions/${WAVE_ID}`:
return new Response(null, { status: 204 });
case 'POST /api/v1/events':
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
case 'GET /api/v1/me/playback-state':
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
case 'PUT /api/v1/me/playback-state':
return json({ ...body, updated_at: new Date().toISOString() });
case `GET /api/v1/artists/${kino.id}`:
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!);
return new Response(null, { status: 204 });
}
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
}