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:
@@ -10,13 +10,17 @@
|
||||
"start:worker": "node dist/worker.js",
|
||||
"typecheck": "tsc -p .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts"
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --project unit",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@fastify/websocket": "^11.3.2",
|
||||
"@musicshare/shared": "*",
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"bullmq": "^6.3.11",
|
||||
"dotenv": "^18.0.5",
|
||||
@@ -25,8 +29,7 @@
|
||||
"ioredis": "^6.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
"undici": "^6.29.0",
|
||||
"zod": "^4.6.5",
|
||||
"@musicshare/shared": "*"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.4",
|
||||
@@ -35,6 +38,7 @@
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "~6.0.3",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decrypt, encrypt, sha256 } from './crypto';
|
||||
|
||||
describe('token encryption', () => {
|
||||
it('round-trips and uses a fresh IV each time', () => {
|
||||
const a = encrypt('spotify-refresh-token');
|
||||
const b = encrypt('spotify-refresh-token');
|
||||
expect(a).not.toBe(b);
|
||||
expect(decrypt(a)).toBe('spotify-refresh-token');
|
||||
});
|
||||
|
||||
it('detects tampering', () => {
|
||||
const buf = Buffer.from(encrypt('secret'), 'base64');
|
||||
buf[buf.length - 1]! ^= 1;
|
||||
expect(() => decrypt(buf.toString('base64'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sha256', () => {
|
||||
it('is stable hex', () => {
|
||||
expect(sha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLAN_ENTITLEMENTS } from '@musicshare/shared';
|
||||
import { effectivePlan, entitlementsFor } from './entitlements';
|
||||
|
||||
const day = 86_400_000;
|
||||
|
||||
describe('effectivePlan', () => {
|
||||
it('honours an active paid plan', () => {
|
||||
expect(effectivePlan({ plan: 'plus', planExpiresAt: new Date(Date.now() + day) })).toBe('plus');
|
||||
});
|
||||
|
||||
it('falls back to free once the plan expires', () => {
|
||||
expect(effectivePlan({ plan: 'plus', planExpiresAt: new Date(Date.now() - day) })).toBe('free');
|
||||
});
|
||||
|
||||
it('treats a paid plan without an expiry as active (e.g. lifetime/comped)', () => {
|
||||
expect(effectivePlan({ plan: 'plus', planExpiresAt: null })).toBe('plus');
|
||||
});
|
||||
|
||||
it('maps to that plan’s entitlements', () => {
|
||||
expect(entitlementsFor({ plan: 'free', planExpiresAt: null })).toBe(PLAN_ENTITLEMENTS.free);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchKey, normalize, primaryArtist } from './http';
|
||||
|
||||
describe('normalize', () => {
|
||||
it.each([
|
||||
['Midnight City', 'midnight city'],
|
||||
['Beyoncé', 'beyonce'],
|
||||
['Hey Jude - Remastered 2015', 'hey jude'],
|
||||
['Song (2011 Remaster)', 'song'],
|
||||
['Song [Live]', 'song'],
|
||||
['Song (feat. Someone)', 'song'],
|
||||
['HUMBLE.', 'humble'],
|
||||
])('%j → %j', (input, expected) => expect(normalize(input)).toBe(expected));
|
||||
});
|
||||
|
||||
describe('primaryArtist', () => {
|
||||
it.each([
|
||||
['Daft Punk', 'Daft Punk'],
|
||||
['Calvin Harris, Rihanna', 'Calvin Harris'],
|
||||
['Simon & Garfunkel', 'Simon'],
|
||||
['Drake feat. Rihanna', 'Drake'],
|
||||
])('%j → %j', (input, expected) => expect(primaryArtist(input)).toBe(expected));
|
||||
});
|
||||
|
||||
describe('matchKey', () => {
|
||||
it('matches the same song reported differently by two services', () => {
|
||||
expect(matchKey('Calvin Harris, Rihanna', 'This Is What You Came For')).toBe(
|
||||
matchKey('Calvin Harris', 'This Is What You Came For (feat. Rihanna)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps different songs apart', () => {
|
||||
expect(matchKey('M83', 'Midnight City')).not.toBe(matchKey('M83', 'Wait'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UnsafeUrlError, assertSafeServerUrl } from './ssrf';
|
||||
|
||||
// ALLOW_PRIVATE_SERVER_URLS is false in the test env. IP literals avoid DNS.
|
||||
describe('assertSafeServerUrl', () => {
|
||||
it.each([
|
||||
'http://127.0.0.1:8096',
|
||||
'http://10.0.0.5',
|
||||
'http://172.20.1.1',
|
||||
'http://192.168.1.20',
|
||||
'http://169.254.169.254/latest/meta-data', // cloud metadata
|
||||
'http://100.64.0.1', // carrier-grade NAT
|
||||
'http://0.0.0.0',
|
||||
'http://[::1]',
|
||||
'http://[fd00::1]',
|
||||
'http://[fe80::1]',
|
||||
'http://[::ffff:10.0.0.1]', // IPv4-mapped, normalized to hex by URL parsing
|
||||
'http://[::ffff:a9fe:a9fe]', // mapped metadata IP in hex form
|
||||
'http://[64:ff9b::a00:1]', // NAT64
|
||||
])('blocks private address %s', async (url) => {
|
||||
await expect(assertSafeServerUrl(url)).rejects.toBeInstanceOf(UnsafeUrlError);
|
||||
});
|
||||
|
||||
it.each(['ftp://8.8.8.8', 'https://user:pass@8.8.8.8', 'not a url'])('rejects malformed URL %s', async (url) => {
|
||||
await expect(assertSafeServerUrl(url)).rejects.toBeInstanceOf(UnsafeUrlError);
|
||||
});
|
||||
|
||||
it('allows public addresses and normalizes trailing slashes', async () => {
|
||||
await expect(assertSafeServerUrl('https://8.8.8.8/jellyfin/')).resolves.toBe('https://8.8.8.8/jellyfin');
|
||||
await expect(assertSafeServerUrl('http://[2606:4700::1111]')).resolves.toBe('http://[2606:4700::1111]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findBestMatch } from './match';
|
||||
|
||||
const target = { title: 'Midnight City', artist: 'M83', durationMs: 243_000 };
|
||||
|
||||
describe('findBestMatch', () => {
|
||||
it('prefers the exact title with a matching duration', () => {
|
||||
const best = findBestMatch(
|
||||
[
|
||||
{ id: 'remix', title: 'Midnight City (Eric Prydz Remix)', artist: 'M83', durationMs: 400_000 },
|
||||
{ id: 'cover', title: 'Midnight City', artist: 'Party Machine', durationMs: 243_000 },
|
||||
{ id: 'real', title: 'Midnight City', artist: 'M83', durationMs: 244_000 },
|
||||
],
|
||||
target,
|
||||
);
|
||||
expect(best?.id).toBe('real');
|
||||
});
|
||||
|
||||
it('rejects other artists even with the same title', () => {
|
||||
expect(findBestMatch([{ title: 'Midnight City', artist: 'Some Cover Band' }], target)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects durations more than 15s apart', () => {
|
||||
expect(findBestMatch([{ title: 'Midnight City', artist: 'M83', durationMs: 300_000 }], target)).toBeNull();
|
||||
});
|
||||
|
||||
it('matches when either side lacks a duration', () => {
|
||||
expect(findBestMatch([{ title: 'Midnight City', artist: 'M83' }], { title: 'Midnight City', artist: 'M83' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('tolerates featured-artist and remaster suffixes', () => {
|
||||
const best = findBestMatch([{ title: 'Midnight City - Remastered', artist: 'M83, Someone' }], target);
|
||||
expect(best).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Provider response parsing, with fetch stubbed. These encode the quirks of
|
||||
* each API (Last.fm's single-object responses, Jellyfin ticks, etc.).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Connection } from '../db/schema';
|
||||
import { encrypt } from '../lib/crypto';
|
||||
import { jellyfinProvider } from './jellyfin';
|
||||
import { lastfmProvider } from './lastfm';
|
||||
import { listenbrainzProvider } from './listenbrainz';
|
||||
import { ProviderAuthError } from './types';
|
||||
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const mock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => handler(String(input), init));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function conn(overrides: Partial<Connection>): Connection {
|
||||
return {
|
||||
id: 'c1',
|
||||
userId: 'u1',
|
||||
provider: 'lastfm',
|
||||
externalUserId: 'someone',
|
||||
displayName: null,
|
||||
accessTokenEnc: null,
|
||||
refreshTokenEnc: null,
|
||||
tokenExpiresAt: null,
|
||||
status: 'active',
|
||||
lastError: null,
|
||||
lastPolledAt: null,
|
||||
meta: {},
|
||||
webhookSecret: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('Last.fm', () => {
|
||||
const lfmTrack = (nowPlaying: boolean) => ({
|
||||
name: 'Teardrop',
|
||||
artist: { '#text': 'Massive Attack' },
|
||||
album: { '#text': 'Mezzanine' },
|
||||
image: [{ size: 'extralarge', '#text': 'https://img/teardrop.jpg' }],
|
||||
...(nowPlaying ? { '@attr': { nowplaying: 'true' } } : {}),
|
||||
});
|
||||
|
||||
it('returns the now-playing track', async () => {
|
||||
stubFetch(() => json({ recenttracks: { track: [lfmTrack(true), lfmTrack(false)] } }));
|
||||
const result = await lastfmProvider.poll(conn({}));
|
||||
expect(result).toEqual({
|
||||
track: { title: 'Teardrop', artist: 'Massive Attack', album: 'Mezzanine', artworkUrl: 'https://img/teardrop.jpg' },
|
||||
isPlaying: true,
|
||||
progressMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Last.fm returning a single object instead of an array', async () => {
|
||||
stubFetch(() => json({ recenttracks: { track: lfmTrack(true) } }));
|
||||
expect((await lastfmProvider.poll(conn({})))?.track.title).toBe('Teardrop');
|
||||
});
|
||||
|
||||
it('returns null when the latest scrobble is not playing now', async () => {
|
||||
stubFetch(() => json({ recenttracks: { track: [lfmTrack(false)] } }));
|
||||
expect(await lastfmProvider.poll(conn({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the grey-star placeholder artwork', async () => {
|
||||
const t = { ...lfmTrack(true), image: [{ size: 'extralarge', '#text': 'https://x/2a96cbd8b46e442fc41c2b86b821562f.png' }] };
|
||||
stubFetch(() => json({ recenttracks: { track: [t] } }));
|
||||
expect((await lastfmProvider.poll(conn({})))?.track.artworkUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('flags a deleted account as needing reconnect', async () => {
|
||||
stubFetch(() => json({ error: 6, message: 'User not found' }, 404));
|
||||
await expect(lastfmProvider.poll(conn({}))).rejects.toBeInstanceOf(ProviderAuthError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListenBrainz', () => {
|
||||
it('extracts ISRC, duration and a Spotify link when present', async () => {
|
||||
stubFetch(() =>
|
||||
json({
|
||||
payload: {
|
||||
listens: [
|
||||
{
|
||||
track_metadata: {
|
||||
artist_name: 'Burial',
|
||||
track_name: 'Archangel',
|
||||
release_name: 'Untrue',
|
||||
additional_info: { isrc: 'GBCFB0700013', duration_ms: 238000, spotify_id: 'https://open.spotify.com/track/6f6tQ' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await listenbrainzProvider.poll(conn({ provider: 'listenbrainz' }));
|
||||
expect(result?.track).toMatchObject({
|
||||
title: 'Archangel',
|
||||
isrc: 'GBCFB0700013',
|
||||
durationMs: 238000,
|
||||
links: { spotify: { id: '6f6tQ', url: 'https://open.spotify.com/track/6f6tQ' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when nothing is playing', async () => {
|
||||
stubFetch(() => json({ payload: { listens: [] } }));
|
||||
expect(await listenbrainzProvider.poll(conn({ provider: 'listenbrainz' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jellyfin', () => {
|
||||
const jfConn = conn({
|
||||
provider: 'jellyfin',
|
||||
externalUserId: 'user-1',
|
||||
accessTokenEnc: encrypt('jf-token'),
|
||||
meta: { serverUrl: 'https://8.8.8.8', serverName: 'Home', deviceId: 'dev-1' },
|
||||
});
|
||||
const session = (userId: string, type: string, paused = false) => ({
|
||||
UserId: userId,
|
||||
NowPlayingItem: { Type: type, Name: 'Digital Love', Artists: ['Daft Punk'], Album: 'Discovery', RunTimeTicks: 3_010_000_000 },
|
||||
PlayState: { IsPaused: paused, PositionTicks: 420_000_000 },
|
||||
});
|
||||
|
||||
it("picks this user's audio session and converts ticks to ms", async () => {
|
||||
const fetchMock = stubFetch(() => json([session('other', 'Audio'), session('user-1', 'Movie'), session('user-1', 'Audio')]));
|
||||
const result = await jellyfinProvider.poll(jfConn);
|
||||
expect(result).toEqual({
|
||||
track: { title: 'Digital Love', artist: 'Daft Punk', album: 'Discovery', durationMs: 301_000 },
|
||||
isPlaying: true,
|
||||
progressMs: 42_000,
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('https://8.8.8.8/Sessions?ActiveWithinSeconds=960');
|
||||
expect((init?.headers as Record<string, string>).Authorization).toContain('Token="jf-token"');
|
||||
expect(init?.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('reports paused sessions as not playing', async () => {
|
||||
stubFetch(() => json([session('user-1', 'Audio', true)]));
|
||||
expect((await jellyfinProvider.poll(jfConn))?.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a revoked token as needing reconnect', async () => {
|
||||
stubFetch(() => json({}, 401));
|
||||
await expect(jellyfinProvider.poll(jfConn)).rejects.toBeInstanceOf(ProviderAuthError);
|
||||
});
|
||||
|
||||
it('refuses servers on private networks', async () => {
|
||||
const fetchMock = stubFetch(() => json([]));
|
||||
await expect(jellyfinProvider.poll({ ...jfConn, meta: { ...jfConn.meta, serverUrl: 'http://192.168.1.5:8096' } })).rejects.toThrow(
|
||||
/private network/,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["src", "drizzle.config.ts"]
|
||||
"include": [
|
||||
"src",
|
||||
"test",
|
||||
"drizzle.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
/**
|
||||
* Tests run against a dedicated Postgres database and Redis DB index, never
|
||||
* the dev ones (test/helpers.ts refuses to wipe anything else). Override with
|
||||
* TEST_DATABASE_URL / TEST_REDIS_URL, e.g. in CI.
|
||||
*/
|
||||
const testEnv = {
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: process.env.TEST_DATABASE_URL ?? 'postgres://musicshare:musicshare@localhost:5447/musicshare_test',
|
||||
REDIS_URL: process.env.TEST_REDIS_URL ?? 'redis://localhost:6380/1',
|
||||
TOKEN_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'),
|
||||
WEB_URL: 'http://web.test',
|
||||
API_PUBLIC_URL: 'https://api.test',
|
||||
ALLOW_PRIVATE_SERVER_URLS: 'false',
|
||||
LASTFM_API_KEY: 'test-lastfm-key',
|
||||
SPOTIFY_CLIENT_ID: '',
|
||||
SPOTIFY_CLIENT_SECRET: '',
|
||||
ODESLI_API_KEY: '',
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: testEnv,
|
||||
projects: [
|
||||
{
|
||||
test: { name: 'unit', include: ['src/**/*.test.ts'] },
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'integration',
|
||||
include: ['test/**/*.test.ts'],
|
||||
globalSetup: ['test/global-setup.ts'],
|
||||
// one shared database: run files one at a time
|
||||
fileParallelism: false,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user