Phones that read system media sessions report playback themselves:
- POST /me/devices issues a per-phone token that can only call
POST /devices/now-playing; revocable, hashed at rest, 10 per user
- Reports name the app ("via Spotify"); unknown apps use the OS label
- Push-reported playback expires after 9 minutes of silence, since
phones send a heartbeat every 4 minutes while playing
Users can pause sharing (PUT /me/sharing: 1h, 8h, indefinitely). While
paused, connections aren't polled, phone reports are dropped, nothing is
added to history, and the current track switches to "played … ago".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
73 lines
3.0 KiB
TypeScript
73 lines
3.0 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 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' } });
|