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
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { sql } from '../src/db';
import { bearer, createUser, json, routeFetch, useTestApp } from './helpers';
const app = useTestApp();
afterEach(() => vi.unstubAllGlobals());
describe('auth', () => {
it('registers, returns a token, and authenticates with bearer or cookie', async () => {
const res = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'Alice', email: ' alice@example.com ', password: 'password123' },
});
expect(res.statusCode).toBe(200);
const { user, token } = res.json();
expect(user).toMatchObject({ username: 'alice', email: 'alice@example.com', plan: 'free' });
expect(user).not.toHaveProperty('passwordHash');
const cookie = res.cookies.find((c) => c.name === 'ms_session');
expect(cookie).toMatchObject({ httpOnly: true, sameSite: 'Lax' });
const viaBearer = await app().inject({ url: '/me', headers: bearer(token) });
const viaCookie = await app().inject({ url: '/me', cookies: { ms_session: cookie!.value } });
expect(viaBearer.json().id).toBe(user.id);
expect(viaCookie.json().id).toBe(user.id);
});
it('stores only a hash of the session token', async () => {
const { token } = await createUser(app(), 'alice');
const rows = await sql`select id from sessions`;
expect(rows).toHaveLength(1);
expect(rows[0]!.id).not.toBe(token);
});
it('rejects duplicate usernames and emails', async () => {
await createUser(app(), 'alice');
const dupName = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'ALICE', email: 'other@example.com', password: 'password123' },
});
const dupEmail = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'other', email: 'Alice@Example.com', password: 'password123' },
});
expect(dupName.statusCode).toBe(409);
expect(dupName.json().error).toBe('Username taken');
expect(dupEmail.statusCode).toBe(409);
expect(dupEmail.json().error).toBe('Email already registered');
});
it('returns a readable 400 for invalid input', async () => {
const res = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'al', email: 'a@b.co', password: 'password123' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/Username must be 3-32 characters/);
});
it('gives the same error for unknown email and wrong password', async () => {
await createUser(app(), 'alice');
const wrongPw = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'nope' } });
const noUser = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'ghost@example.com', password: 'nope' } });
expect(wrongPw.statusCode).toBe(401);
expect(noUser.statusCode).toBe(401);
expect(wrongPw.json()).toEqual(noUser.json());
});
it('logout invalidates the session', async () => {
const { token } = await createUser(app(), 'alice');
await app().inject({ method: 'POST', url: '/auth/logout', headers: bearer(token), payload: {} });
expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(401);
});
it('account deletion removes the user and everything attached', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await app().inject({ method: 'POST', url: '/users/bob/follow', headers: bearer(alice.token), payload: {} });
vi.stubGlobal('fetch', routeFetch([[/listenbrainz\.org/, () => json({ payload: { listens: [] } })]]));
const connected = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(alice.token), payload: { username: 'x' } });
expect(connected.statusCode).toBe(200);
const res = await app().inject({ method: 'DELETE', url: '/me', headers: bearer(alice.token) });
expect(res.statusCode).toBe(200);
expect((await app().inject({ url: '/me', headers: bearer(alice.token) })).statusCode).toBe(401);
const [counts] = await sql`select (select count(*)::int from follows) as follows, (select count(*)::int from connections) as connections`;
expect(counts).toEqual({ follows: 0, connections: 0 });
expect((await app().inject({ url: '/users/bob', headers: bearer(bob.token) })).json().followerCount).toBe(0);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { eq } from 'drizzle-orm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { db, schema } from '../src/db';
import { encrypt } from '../src/lib/crypto';
import { redis } from '../src/lib/redis';
import { plexProvider } from '../src/providers/plex';
import { upsertConnection } from '../src/services/connections';
import { bearer, createUser, json, routeFetch, useTestApp } from './helpers';
const app = useTestApp();
afterEach(() => vi.unstubAllGlobals());
const lastfmOk = routeFetch([[/audioscrobbler\.com.*user\.getinfo/, () => json({ user: { name: 'RJ' } })]]);
describe('username connections', () => {
it('connects Last.fm using the canonical username and schedules a poll', async () => {
vi.stubGlobal('fetch', lastfmOk);
const { token } = await createUser(app(), 'alice');
const res = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ provider: 'lastfm', externalUserId: 'RJ', status: 'active', webhookUrl: null });
expect(await redis.zcard('poll:due')).toBe(1);
});
it('404s for unknown users', async () => {
vi.stubGlobal('fetch', routeFetch([[/audioscrobbler/, () => json({ error: 6 }, 404)]]));
const { token } = await createUser(app(), 'alice');
const res = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'ghost' } });
expect(res.statusCode).toBe(404);
});
it('enforces the per-plan connection limit, but reconnecting is free', async () => {
vi.stubGlobal(
'fetch',
routeFetch([
[/audioscrobbler/, () => json({ user: { name: 'rj' } })],
[/listenbrainz/, () => json({ payload: { listens: [] } })],
]),
);
const { token, user } = await createUser(app(), 'alice');
await upsertConnection({ userId: user.id, provider: 'spotify', externalUserId: 's', displayName: null });
await upsertConnection({ userId: user.id, provider: 'plex', externalUserId: 'p', displayName: null });
const ok = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } });
expect(ok.statusCode).toBe(200);
const again = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } });
expect(again.statusCode).toBe(200);
const over = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(token), payload: { username: 'x' } });
expect(over.statusCode).toBe(402);
});
it('disconnect removes the connection and its schedule', async () => {
vi.stubGlobal('fetch', lastfmOk);
const { token } = await createUser(app(), 'alice');
await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } });
await app().inject({ method: 'DELETE', url: '/connections/lastfm', headers: bearer(token) });
expect((await app().inject({ url: '/connections', headers: bearer(token) })).json()).toEqual([]);
expect(await redis.zcard('poll:due')).toBe(0);
});
});
describe('Jellyfin', () => {
it('refuses private server addresses before making any request', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { token } = await createUser(app(), 'alice');
const res = await app().inject({
method: 'POST',
url: '/connections/jellyfin',
headers: bearer(token),
payload: { serverUrl: 'http://169.254.169.254', username: 'a', password: 'b' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/private network/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('connects with credentials, stores only the token, and issues a webhook URL', async () => {
const calls: { url: string; body?: string }[] = [];
vi.stubGlobal(
'fetch',
routeFetch([
[/System\/Info\/Public/, () => json({ ServerName: 'Home', Version: '10.11.0' })],
[
/AuthenticateByName/,
(url, init) => {
calls.push({ url, body: init?.body as string });
return json({ AccessToken: 'jf-token', User: { Id: 'u-1', Name: 'tester' } });
},
],
]),
);
const { token } = await createUser(app(), 'alice');
const res = await app().inject({
method: 'POST',
url: '/connections/jellyfin',
headers: bearer(token),
payload: { serverUrl: 'https://8.8.8.8/', username: 'tester', password: 'hunter2' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ displayName: 'tester on Home', webhookUrl: expect.stringMatching(/^https:\/\/api\.test\/webhooks\/jellyfin\/.{20,}/) });
expect(JSON.parse(calls[0]!.body!)).toEqual({ Username: 'tester', Pw: 'hunter2' });
const [row] = await db.select().from(schema.connections);
expect(JSON.stringify(row)).not.toContain('hunter2');
expect(row!.accessTokenEnc).not.toBe('jf-token');
});
});
describe('webhooks', () => {
async function connectionWithWebhook() {
const { user } = await createUser(app(), 'alice');
const conn = await upsertConnection({ userId: user.id, provider: 'jellyfin', externalUserId: 'u', displayName: null, meta: {} });
await redis.zadd('poll:due', Date.now() + 600_000, conn.id);
return conn;
}
it.each([
['JSON (Jellyfin plugin)', { 'content-type': 'application/json' }, '{"NotificationType":"PlaybackStart"}'],
['multipart (Plex)', { 'content-type': 'multipart/form-data; boundary=x' }, '--x\r\nContent-Disposition: form-data; name="payload"\r\n\r\n{}\r\n--x--'],
['malformed JSON', { 'content-type': 'application/json' }, '{not json'],
])('a %s webhook pulls the next poll forward', async (_label, headers, payload) => {
const conn = await connectionWithWebhook();
const res = await app().inject({ method: 'POST', url: `/webhooks/jellyfin/${conn.webhookSecret}`, headers, payload });
expect(res.statusCode).toBe(204);
expect(Number(await redis.zscore('poll:due', conn.id))).toBeLessThanOrEqual(Date.now());
});
it('answers unknown secrets the same way, without touching schedules', async () => {
const conn = await connectionWithWebhook();
const res = await app().inject({ method: 'POST', url: '/webhooks/jellyfin/not-a-secret', payload: {} });
expect(res.statusCode).toBe(204);
expect(Number(await redis.zscore('poll:due', conn.id))).toBeGreaterThan(Date.now());
});
it('keeps the webhook secret when a connection is re-established', async () => {
const conn = await connectionWithWebhook();
const again = await upsertConnection({ userId: conn.userId, provider: 'jellyfin', externalUserId: 'u', displayName: null, meta: {} });
expect(again.webhookSecret).toBe(conn.webhookSecret);
});
});
describe('OAuth return URLs', () => {
it.each([
['musicshare://settings', 'musicshare://settings'],
['exp://192.168.1.20:8081/--/settings', 'exp://192.168.1.20:8081/--/settings'],
['https://evil.example.com/steal', 'musicshare://settings'],
['javascript:alert(1)', 'musicshare://settings'],
])('mobile returnTo %s is stored as %s', async (requested, stored) => {
vi.stubGlobal('fetch', routeFetch([[/plex\.tv\/api\/v2\/pins/, () => json({ id: 1, code: 'abc' })]]));
const { token } = await createUser(app(), 'alice');
const res = await app().inject({
method: 'POST',
url: '/connections/plex/authorize',
headers: bearer(token),
payload: { client: 'mobile', returnTo: requested },
});
const state = decodeURIComponent(res.json().url).match(/state=([^&]+)/)![1];
expect(JSON.parse((await redis.get(`oauth:state:${state}`))!).returnTo).toBe(stored);
});
});
describe('Plex polling', () => {
async function plexConnection(uris: string[]) {
const { user } = await createUser(app(), 'alice');
return upsertConnection({
userId: user.id,
provider: 'plex',
externalUserId: '12345',
displayName: null,
accessTokenEnc: encrypt('account-token'),
meta: { discoveredAt: Date.now(), servers: [{ name: 'Home', owned: true, tokenEnc: encrypt('server-token'), uris }] },
});
}
const sessions = {
MediaContainer: {
Metadata: [
{ type: 'movie', title: 'A Movie', User: { id: '1' }, Player: { state: 'playing' } },
{ type: 'track', title: 'Theirs', grandparentTitle: 'X', User: { id: '999' }, Player: { state: 'playing' } },
{ type: 'track', title: 'Digital Love', grandparentTitle: 'Daft Punk', parentTitle: 'Discovery', viewOffset: 42000, User: { id: '1' }, Player: { state: 'playing' } },
],
},
};
it("returns the owner's track, ignoring video and other users, and fails over between addresses", async () => {
vi.stubGlobal(
'fetch',
routeFetch([
[/^https:\/\/1\.1\.1\.1/, () => Promise.reject(new TypeError('fetch failed'))],
[/^https:\/\/8\.8\.8\.8.*status\/sessions/, () => json(sessions)],
]),
);
const conn = await plexConnection(['https://1.1.1.1:32400', 'https://8.8.8.8:32400']);
const result = await plexProvider.poll(conn);
expect(result).toEqual({
track: { title: 'Digital Love', artist: 'Daft Punk', album: 'Discovery', durationMs: null },
isPlaying: true,
progressMs: 42000,
});
const [row] = await db.select().from(schema.connections).where(eq(schema.connections.id, conn.id));
expect((row!.meta as { servers: { uris: string[] }[] }).servers[0]!.uris[0]).toBe('https://8.8.8.8:32400');
});
it('skips LAN-only addresses in hosted mode and says why', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const conn = await plexConnection(['http://192.168.1.10:32400']);
await expect(plexProvider.poll(conn)).rejects.toThrow(/private network/);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
/** Creates the test database if needed and applies migrations, once per run. */
export async function setup() {
const url = new URL(process.env.TEST_DATABASE_URL ?? 'postgres://musicshare:musicshare@localhost:5447/musicshare_test');
const dbName = url.pathname.slice(1);
if (!dbName.endsWith('_test')) throw new Error(`Refusing to use non-test database "${dbName}"`);
const admin = postgres({ ...parse(url), database: 'postgres', max: 1, onnotice: () => {} });
const [exists] = await admin`select 1 from pg_database where datname = ${dbName}`;
if (!exists) await admin.unsafe(`create database "${dbName}"`);
await admin.end();
const sql = postgres(url.toString(), { max: 1, onnotice: () => {} });
await migrate(drizzle(sql), { migrationsFolder: new URL('../drizzle', import.meta.url).pathname });
await sql.end();
}
function parse(url: URL) {
return {
host: url.hostname,
port: Number(url.port || 5432),
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
}
+72
View File
@@ -0,0 +1,72 @@
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 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' } });
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { createRedis } from '../src/lib/redis';
import { applyPollResult } from '../src/services/nowPlaying';
import { resolveQueue } from '../src/services/queues';
import { upsertTrack } from '../src/services/tracks';
import { createUser, useTestApp } from './helpers';
const app = useTestApp();
/** Collect messages published for a user while `fn` runs. */
async function published(userId: string, fn: () => Promise<unknown>): Promise<{ track: { title: string }; isPlaying: boolean }[]> {
const sub = createRedis();
const messages: string[] = [];
await sub.subscribe(`np:${userId}`);
sub.on('message', (_c, m) => messages.push(m));
await fn();
await new Promise((r) => setTimeout(r, 50));
sub.disconnect();
return messages.map((m) => JSON.parse(m));
}
const track = (title: string, extra = {}) => ({ track: { title, artist: 'Daft Punk', ...extra }, isPlaying: true, progressMs: 1000 });
describe('applyPollResult', () => {
it('publishes on a new track and queues link resolution, but not on repeat polls', async () => {
const { user } = await createUser(app(), 'bob');
const first = await published(user.id, () => applyPollResult(user.id, 'spotify', track('One More Time')));
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({ track: { title: 'One More Time' }, isPlaying: true });
expect(await resolveQueue.getJobCounts('waiting')).toMatchObject({ waiting: 1 });
const repeat = await published(user.id, () => applyPollResult(user.id, 'spotify', track('One More Time')));
expect(repeat).toHaveLength(0);
});
it('only the source that reported playback can mark it stopped', async () => {
const { user } = await createUser(app(), 'bob');
await applyPollResult(user.id, 'spotify', track('Digital Love'));
// an idle Last.fm connection must not clobber the live Spotify session
expect(await applyPollResult(user.id, 'lastfm', null)).toBe(false);
let [np] = await db.select().from(schema.nowPlaying);
expect(np?.isPlaying).toBe(true);
const stopped = await published(user.id, () => applyPollResult(user.id, 'spotify', null));
[np] = await db.select().from(schema.nowPlaying);
expect(np?.isPlaying).toBe(false);
expect(stopped[0]?.isPlaying).toBe(false);
});
it('treats a paused result as not playing', async () => {
const { user } = await createUser(app(), 'bob');
await applyPollResult(user.id, 'plex', track('Digital Love'));
await applyPollResult(user.id, 'plex', { ...track('Digital Love'), isPlaying: false });
const [np] = await db.select().from(schema.nowPlaying);
expect(np?.isPlaying).toBe(false);
});
});
describe('upsertTrack dedupe', () => {
it('matches on ISRC first', async () => {
const a = await upsertTrack({ title: 'Song', artist: 'X', isrc: 'USABC1234567' });
const b = await upsertTrack({ title: 'Song (Radio Edit)', artist: 'Someone Else', isrc: 'USABC1234567' });
expect(b).toEqual({ id: a.id, created: false });
});
it('matches on a known provider id', async () => {
const a = await upsertTrack({ title: 'Song', artist: 'X', links: { spotify: { id: 'sp1', url: 'https://open.spotify.com/track/sp1' } } });
const b = await upsertTrack({ title: 'Totally Different Name', artist: 'Y', links: { spotify: { id: 'sp1', url: 'https://open.spotify.com/track/sp1' } } });
expect(b.id).toBe(a.id);
});
it('matches on normalized artist|title and backfills missing fields', async () => {
const a = await upsertTrack({ title: 'Midnight City', artist: 'M83' });
const b = await upsertTrack({ title: 'Midnight City (Remastered)', artist: 'M83', isrc: 'GB55H1100002', album: 'Hurry Up' });
expect(b.id).toBe(a.id);
const [row] = await db.select().from(schema.tracks);
expect(row).toMatchObject({ isrc: 'GB55H1100002', album: 'Hurry Up' });
});
it('keeps different songs separate', async () => {
const a = await upsertTrack({ title: 'Midnight City', artist: 'M83' });
const b = await upsertTrack({ title: 'Wait', artist: 'M83' });
expect(b.id).not.toBe(a.id);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { useTestApp } from './helpers';
const app = useTestApp({ rateLimits: true });
describe('rate limits', () => {
it('throttles login attempts per client', async () => {
const attempt = () => app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'a@b.co', password: 'x' } });
const codes = [];
for (let i = 0; i < 11; i++) codes.push((await attempt()).statusCode);
expect(codes.slice(0, 10).every((c) => c === 401)).toBe(true);
expect(codes[10]).toBe(429);
});
});
+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);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { redis } from '../src/lib/redis';
import { claimDue, ensureScheduled, markWatched, pollSoon, schedulePoll } from '../src/services/scheduler';
import { useTestApp } from './helpers';
useTestApp();
const score = async (id: string) => Number(await redis.zscore('poll:due', id));
describe('poll scheduler', () => {
it('claims only due connections, and concurrent claimers never get the same one', async () => {
for (let i = 0; i < 50; i++) await schedulePoll(`due-${i}`);
await schedulePoll('later', 60_000);
const batches = await Promise.all(Array.from({ length: 5 }, () => claimDue(20)));
const claimed = batches.flat();
expect(claimed).toHaveLength(50);
expect(new Set(claimed).size).toBe(50);
expect(claimed).not.toContain('later');
});
it('parks claimed items in the future so a crashed worker gets retried, not lost', async () => {
await schedulePoll('c1');
await claimDue();
expect(await score('c1')).toBeGreaterThan(Date.now() + 4 * 60_000);
});
it('pollSoon only moves polls earlier', async () => {
await schedulePoll('soon', 1_000);
await schedulePoll('late', 600_000);
const before = await score('soon');
await pollSoon(['soon', 'late']);
expect(await score('late')).toBeLessThanOrEqual(Date.now());
expect(await score('soon')).toBeLessThanOrEqual(before);
});
it('ensureScheduled never overrides an existing schedule', async () => {
await schedulePoll('existing', 600_000);
const before = await score('existing');
await ensureScheduled(['existing', 'new']);
expect(await score('existing')).toBe(before);
expect(await score('new')).toBeLessThanOrEqual(Date.now());
});
it('markWatched reports only newly watched users', async () => {
expect(await markWatched(['a', 'b'])).toEqual(['a', 'b']);
expect(await markWatched(['a', 'b', 'c'])).toEqual(['c']);
expect(await redis.ttl('watched:a')).toBeGreaterThan(80);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { applyPollResult } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
async function play(userId: string, title: string, isPlaying = true) {
await applyPollResult(userId, 'lastfm', { track: { title, artist: 'Artist' }, isPlaying, progressMs: null });
}
describe('follows and profiles', () => {
it('follow/unfollow updates counts and isFollowing', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'bob');
await follow(app(), alice.token, 'bob');
let profile = (await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json();
expect(profile).toMatchObject({ followerCount: 1, isFollowing: true, isPinned: false, isSelf: false });
await app().inject({ method: 'DELETE', url: '/users/bob/follow', headers: bearer(alice.token) });
profile = (await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json();
expect(profile).toMatchObject({ followerCount: 0, isFollowing: false });
});
it("can't follow yourself", async () => {
const alice = await createUser(app(), 'alice');
const res = await app().inject({ method: 'POST', url: '/users/alice/follow', headers: bearer(alice.token), payload: {} });
expect(res.statusCode).toBe(400);
});
it('profiles are public but follow needs auth', async () => {
await createUser(app(), 'bob');
expect((await app().inject({ url: '/users/bob' })).json()).toMatchObject({ isFollowing: false, isSelf: false });
expect((await app().inject({ method: 'POST', url: '/users/bob/follow', payload: {} })).statusCode).toBe(401);
});
it('searches by username prefix and display name, escaping LIKE wildcards', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'bobby');
await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { displayName: 'Alice Cooper' } });
const names = async (q: string) =>
(await app().inject({ url: `/users/search?q=${encodeURIComponent(q)}` })).json().map((u: { username: string }) => u.username);
expect(await names('bob')).toEqual(['bobby']);
expect(await names('cooper')).toEqual(['alice']);
expect(await names('%%')).toEqual([]);
});
});
describe('pinned friends', () => {
async function setup() {
const alice = await createUser(app(), 'alice');
const friends: Record<string, string> = {};
for (const name of ['bob', 'carol', 'dave', 'erin']) {
friends[name] = (await createUser(app(), name)).user.id;
await follow(app(), alice.token, name);
}
return { alice, friends };
}
const pin = (token: string, name: string, method: 'PUT' | 'DELETE' = 'PUT') =>
app().inject({ method, url: `/users/${name}/pin`, headers: bearer(token), ...(method === 'PUT' ? { payload: {} } : {}) });
const feedOrder = async (token: string) =>
(await app().inject({ url: '/feed', headers: bearer(token) }))
.json()
.map((n: { user: { username: string }; pinnedAt: string | null }) => `${n.pinnedAt ? '*' : ''}${n.user.username}`);
it('orders the feed: pinned (by pin time), then live, then recent', async () => {
const { alice, friends } = await setup();
await play(friends.bob!, 'B');
await play(friends.bob!, 'B', false); // bob played earlier, now stopped
await play(friends.carol!, 'C');
await play(friends.dave!, 'D');
await play(friends.erin!, 'E');
expect(await feedOrder(alice.token)).toEqual(['erin', 'dave', 'carol', 'bob']);
expect((await pin(alice.token, 'bob')).statusCode).toBe(200);
expect((await pin(alice.token, 'dave')).statusCode).toBe(200);
expect(await feedOrder(alice.token)).toEqual(['*bob', '*dave', 'erin', 'carol']);
});
it('enforces the plan limit with 402, and plus raises it', async () => {
const { alice } = await setup();
for (const name of ['bob', 'carol', 'dave']) expect((await pin(alice.token, name)).statusCode).toBe(200);
const fourth = await pin(alice.token, 'erin');
expect(fourth.statusCode).toBe(402);
expect(fourth.json().error).toBe('You can pin up to 3 friends on your plan');
await db.update(schema.users).set({ plan: 'plus' }).where(eq(schema.users.id, alice.user.id));
expect((await pin(alice.token, 'erin')).statusCode).toBe(200);
});
it('pinning is idempotent and does not count twice', async () => {
const { alice } = await setup();
for (let i = 0; i < 4; i++) expect((await pin(alice.token, 'bob')).statusCode).toBe(200);
expect((await pin(alice.token, 'carol')).statusCode).toBe(200);
});
it('requires following, and unfollowing drops the pin', async () => {
const { alice } = await setup();
await createUser(app(), 'stranger');
expect((await pin(alice.token, 'stranger')).statusCode).toBe(400);
await pin(alice.token, 'bob');
await app().inject({ method: 'DELETE', url: '/users/bob/follow', headers: bearer(alice.token) });
await follow(app(), alice.token, 'bob');
expect((await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json().isPinned).toBe(false);
});
it('unpin frees a slot', async () => {
const { alice } = await setup();
for (const name of ['bob', 'carol', 'dave']) await pin(alice.token, name);
await pin(alice.token, 'bob', 'DELETE');
expect((await pin(alice.token, 'erin')).statusCode).toBe(200);
});
});