Files
musicshare/apps/api/test/helpers.ts
T
chrisandClaude Opus 5.5 2b02aa8e89
CI / check (push) Canceled after 0s
Profile pictures, display name default, coloured service names
- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-05 23:38:00 -04:00

73 lines
3.1 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach } from 'vitest';
import type { AuthResponse } from '@musicshare/shared';
import { buildApp } from '../src/app';
import { sql } from '../src/db';
import { env } from '../src/env';
import { closeConnections } from '../src/lib/lifecycle';
import { redis } from '../src/lib/redis';
function assertTestTargets() {
// These wipe data: make it impossible to point them at a dev or prod database.
if (!new URL(env.DATABASE_URL).pathname.endsWith('_test')) throw new Error('DATABASE_URL is not a *_test database');
if (new URL(env.REDIS_URL).pathname.replace('/', '') === '' || new URL(env.REDIS_URL).pathname === '/0') {
throw new Error('REDIS_URL must select a non-default DB index for tests (e.g. redis://host:6379/1)');
}
}
export async function resetState() {
assertTestTargets();
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`;
await redis.flushdb();
}
/**
* Per-file setup: a fresh app (rate limits off unless asked) and clean state
* before each test. Returns a getter because the app is built in beforeAll.
*/
export function useTestApp(opts: { rateLimits?: boolean } = {}) {
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp({ logger: false, rateLimits: opts.rateLimits ?? false });
await app.ready();
});
beforeEach(resetState);
afterAll(async () => {
await app.close();
await closeConnections();
});
return () => app;
}
export const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
export async function createUser(app: FastifyInstance, username: string): Promise<AuthResponse> {
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { username, email: `${username}@example.com`, password: 'password123' },
});
if (res.statusCode !== 200) throw new Error(`register ${username} failed: ${res.body}`);
return res.json();
}
export async function follow(app: FastifyInstance, token: string, username: string) {
const res = await app.inject({ method: 'POST', url: `/users/${username}/follow`, headers: bearer(token), payload: {} });
if (res.statusCode !== 200) throw new Error(`follow ${username} failed: ${res.body}`);
}
type FetchRoute = [match: RegExp, respond: (url: string, init?: RequestInit) => Response | Promise<Response>];
/** Replace global fetch with a router; unmatched requests fail loudly so tests never hit the network. */
export function routeFetch(routes: FetchRoute[]) {
return async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const route = routes.find(([re]) => re.test(url));
if (!route) throw new Error(`Unexpected fetch in test: ${url}`);
return route[1](url, init);
};
}
export const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });