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
+8 -4
View File
@@ -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"
}
}
+23
View File
@@ -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');
});
});
+23
View File
@@ -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);
});
});
+35
View File
@@ -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'));
});
});
+32
View File
@@ -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]');
});
});
+35
View File
@@ -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();
});
});
+161
View File
@@ -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();
});
});
+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);
});
});
+9 -2
View File
@@ -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"
]
}
+41
View File
@@ -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,
},
},
],
},
});