Add unit and integration test suites

- Shared: link building, feed ordering, input schemas, API client
- API unit: track matching, private-address blocking, token crypto,
  plan expiry, provider response parsing
- API integration against Postgres and Redis: auth, follows and pins,
  now-playing state, webhooks, OAuth return URLs, realtime, rate limits
- Tests use a dedicated *_test database and Redis DB, and refuse to wipe
  anything else; fetch is stubbed so they never hit the network
- Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-03 11:28:17 -04:00
co-authored by Claude Opus 5.5
parent 95cd740e51
commit fe503307df
29 changed files with 3281 additions and 10 deletions
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { applyPollResult } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
function nextMessage(ws: { once(event: 'message', cb: (data: Buffer) => void): void }, timeoutMs = 3000) {
return new Promise<{ type: string; data: { user: { username: string }; track: { title: string } } }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('no websocket message')), timeoutMs);
ws.once('message', (data) => {
clearTimeout(timer);
resolve(JSON.parse(data.toString()));
});
});
}
describe('realtime', () => {
it('pushes now-playing for people you follow', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), alice.token, 'bob');
const ws = await app().injectWS('/ws', { headers: bearer(alice.token) });
await new Promise((r) => setTimeout(r, 200)); // let the server register the subscription
const msg = nextMessage(ws);
await applyPollResult(bob.user.id, 'spotify', { track: { title: 'Teardrop', artist: 'Massive Attack' }, isPlaying: true, progressMs: 0 });
expect(await msg).toMatchObject({ type: 'now_playing', data: { user: { username: 'bob' }, track: { title: 'Teardrop' } } });
ws.terminate();
});
it('starts delivering for someone followed after connecting', async () => {
const alice = await createUser(app(), 'alice');
const carol = await createUser(app(), 'carol');
const ws = await app().injectWS('/ws', { headers: bearer(alice.token) });
await new Promise((r) => setTimeout(r, 200));
await follow(app(), alice.token, 'carol');
await new Promise((r) => setTimeout(r, 200));
const msg = nextMessage(ws);
await applyPollResult(carol.user.id, 'spotify', { track: { title: 'Dreams', artist: 'Fleetwood Mac' }, isPlaying: true, progressMs: 0 });
expect((await msg).data.user.username).toBe('carol');
ws.terminate();
});
it("does not leak updates from people you don't follow", async () => {
const alice = await createUser(app(), 'alice');
const stranger = await createUser(app(), 'stranger');
const ws = await app().injectWS('/ws', { headers: bearer(alice.token) });
await new Promise((r) => setTimeout(r, 200));
const msg = nextMessage(ws, 500);
await applyPollResult(stranger.user.id, 'spotify', { track: { title: 'Secret', artist: 'X' }, isPlaying: true, progressMs: 0 });
await expect(msg).rejects.toThrow('no websocket message');
ws.terminate();
});
it('closes unauthenticated sockets with 4401', async () => {
const ws = await app().injectWS('/ws');
const code = await new Promise<number>((resolve) => ws.once('close', (c: number) => resolve(c)));
expect(code).toBe(4401);
});
});