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 { 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(); let current: string | null = null; // id вошедшего (одна сессия на тест) let token = 0; const calls: { method: string; path: string; body: unknown }[] = []; const reactions = new Map(); 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 => { 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) : 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) }; }