- 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
62 lines
2.8 KiB
TypeScript
62 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|