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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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' } });
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user