ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
392 lines
20 KiB
TypeScript
392 lines
20 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,
|
|
reviews_uploads: 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 onboarding = new Map<string, components['schemas']['OnboardingChoice'] | null>();
|
|
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 playlistOwners = new Map<string, string>();
|
|
const playlists = new Map<string, components['schemas']['Playlist']>();
|
|
|
|
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
|
|
let managementJob: components['schemas']['ServerJob'] | null = null;
|
|
let configRevision = 1;
|
|
let configPending = false;
|
|
const configValues: Record<string, string> = {};
|
|
const configEntries: components['schemas']['ServerConfigEntry'][] = [
|
|
{ key: 'LOG_LEVEL', group: 'shared', type: 'string', secret: false, readonly: false, effective: 'info', env: 'info', local: null, default_value: 'info', source: 'env' },
|
|
{ key: 'TRAIN_HOUR', group: 'recommender', type: 'integer', secret: false, readonly: false, effective: '3', env: null, local: null, default_value: '3', source: 'default' },
|
|
{ key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' },
|
|
];
|
|
const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => {
|
|
const local = configValues[entry.key] ?? entry.local;
|
|
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
|
|
}) });
|
|
const me = (u: FakeUser): Me => ({
|
|
id: u.id,
|
|
username: u.username,
|
|
display_name: u.display_name,
|
|
role: u.role,
|
|
reviews_uploads: u.reviews_uploads,
|
|
manages_updates: u.role === 'admin' || !!u.manages_updates,
|
|
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;
|
|
if (path.startsWith('/api/v1/admin/server/')) {
|
|
if (!user) return problem(401, 'auth.unauthorized');
|
|
const configs = path.includes('/config');
|
|
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden');
|
|
if (route === 'GET /api/v1/admin/server/updates') return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob });
|
|
if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); }
|
|
if (route === 'GET /api/v1/admin/server/config') return json(serverConfig());
|
|
if (route === 'PATCH /api/v1/admin/server/config') {
|
|
if (body?.revision !== String(configRevision)) return problem(409, 'management.config_conflict');
|
|
for (const [key, value] of Object.entries(body?.values as Record<string, string | null>)) {
|
|
if (value === null) delete configValues[key]; else configValues[key] = value;
|
|
}
|
|
configRevision++; configPending = true; return json(serverConfig());
|
|
}
|
|
if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') {
|
|
managementJob = { id: 'job-1', kind: configs ? 'config' : 'update', state: 'running', target: configs ? 'config.local' : body?.channel === 'version' ? String(body.version) : body?.channel === 'main' ? 'main' : 'v1.10.0', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: '→ build and restart' };
|
|
return json(managementJob, 202);
|
|
}
|
|
}
|
|
|
|
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 'GET /api/v1/app/clients':
|
|
return json({
|
|
version: '1.3.0',
|
|
version_code: 1_030_099,
|
|
notes_md: '',
|
|
clients: [
|
|
{
|
|
client: 'windows',
|
|
filename: 'pipu-music_1.3.0_x64-setup.exe',
|
|
size: 20_000_000,
|
|
download_url: '/api/v1/app/download/windows/1.3.0',
|
|
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
|
|
},
|
|
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
|
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
|
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
|
],
|
|
});
|
|
case 'GET /api/v1/app/android/latest':
|
|
return json({
|
|
version: '1.3.0',
|
|
version_code: 1_030_099,
|
|
notes_md: '',
|
|
size: 12_000_000,
|
|
sha256: 'ab'.repeat(32),
|
|
download_url: '/api/v1/app/android/download/1.3.0',
|
|
min_supported: 0,
|
|
});
|
|
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');
|
|
|
|
|
|
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
|
|
if (sharedPath && req.method === 'GET') {
|
|
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
|
|
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
|
|
}
|
|
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
|
|
if (sharePath) {
|
|
const item = playlists.get(sharePath[1]!);
|
|
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
|
|
if (req.method === 'POST') {
|
|
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
|
|
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
|
|
}
|
|
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
|
|
}
|
|
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
|
|
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
|
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
|
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
|
const old = playlists.get(id);
|
|
if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
|
|
if (req.method === 'GET') return json(old);
|
|
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
|
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
|
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
|
const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks,
|
|
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
|
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
|
playlists.set(id, item);
|
|
if (!old) playlistOwners.set(id, user.id);
|
|
return json(item, old ? 200 : 201);
|
|
}
|
|
|
|
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/uploads/unseen':
|
|
return json({ count: 0 });
|
|
case 'POST /api/v1/uploads/seen':
|
|
return new Response(null, { status: 204 });
|
|
case 'GET /api/v1/onboarding':
|
|
return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined });
|
|
case 'PUT /api/v1/me/onboarding':
|
|
onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']);
|
|
return new Response(null, { status: 204 });
|
|
case 'GET /api/v1/me/likes':
|
|
case 'GET /api/v1/me/dislikes':
|
|
case 'GET /api/v1/me/history':
|
|
return json({ items: [], next_cursor: null, total: 0 });
|
|
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',
|
|
reviews_uploads: body?.reviews_uploads === true && user.role === 'admin',
|
|
});
|
|
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';
|
|
const tracks = path === '/api/v1/search' && hit
|
|
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
|
|
: [];
|
|
return json({
|
|
artists: hit ? [kino] : [],
|
|
albums: hit ? [albumSummary] : [],
|
|
tracks,
|
|
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
|
|
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] });
|
|
}
|
|
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
|
|
onboarding.set(path.split('/')[5]!, null);
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
|
|
if (userPath && req.method === 'PATCH') {
|
|
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
|
const target = users.get(userPath[1]!);
|
|
if (!target) return problem(404, 'common.not_found');
|
|
if (typeof body?.username === 'string') {
|
|
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
|
const username = body.username.trim().toLowerCase();
|
|
if ([...users.values()].some((u) => u.id !== target.id && u.username.toLowerCase() === username))
|
|
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
|
target.username = username;
|
|
}
|
|
if (typeof body?.manages_updates === 'boolean') {
|
|
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
|
target.manages_updates = body.manages_updates;
|
|
}
|
|
if (typeof body?.reviews_uploads === 'boolean') {
|
|
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
|
target.reviews_uploads = body.reviews_uploads;
|
|
}
|
|
if (typeof body?.role === 'string' && user.role === 'admin') target.role = body.role as Role;
|
|
if (typeof body?.is_blocked === 'boolean') target.is_blocked = body.is_blocked;
|
|
users.set(target.id, target);
|
|
return json(admin(target));
|
|
}
|
|
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
|
|
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
|
|
}
|
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
|
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
|
if (trackPath && req.method === 'GET') {
|
|
const track = albumTracks.find(t => t.id === trackPath[1]);
|
|
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
|
|
}
|
|
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, playlists, signOut: () => (current = null) };
|
|
}
|