Add unit and integration test suites
- Shared: link building, feed ordering, input schemas, API client - API unit: track matching, private-address blocking, token crypto, plan expiry, provider response parsing - API integration against Postgres and Redis: auth, follows and pins, now-playing state, webhooks, OAuth return URLs, realtime, rate limits - Tests use a dedicated *_test database and Redis DB, and refuse to wipe anything else; fetch is stubbed so they never hit the network - Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user