From fe503307df8bd309028888fbdfa24024221a848b Mon Sep 17 00:00:00 2001 From: chris Date: Sat, 3 Oct 2026 11:28:17 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- .nvmrc | 1 + README.md | 14 + apps/api/package.json | 12 +- apps/api/src/lib/crypto.test.ts | 23 + apps/api/src/lib/entitlements.test.ts | 23 + apps/api/src/lib/http.test.ts | 35 + apps/api/src/lib/ssrf.test.ts | 32 + apps/api/src/providers/match.test.ts | 35 + apps/api/src/providers/parsers.test.ts | 161 ++ apps/api/test/auth.test.ts | 94 ++ apps/api/test/connections.test.ts | 211 +++ apps/api/test/global-setup.ts | 28 + apps/api/test/helpers.ts | 72 + apps/api/test/now-playing.test.ts | 87 ++ apps/api/test/rate-limit.test.ts | 14 + apps/api/test/realtime.test.ts | 61 + apps/api/test/scheduler.test.ts | 49 + apps/api/test/social.test.ts | 117 ++ apps/api/tsconfig.json | 11 +- apps/api/vitest.config.ts | 41 + package-lock.json | 1979 +++++++++++++++++++++++- package.json | 6 +- packages/shared/package.json | 7 +- packages/shared/src/client.test.ts | 43 + packages/shared/src/dto.test.ts | 42 + packages/shared/src/schemas.test.ts | 36 + packages/shared/src/services.test.ts | 24 + packages/shared/src/time.test.ts | 20 + turbo.json | 13 + 29 files changed, 3281 insertions(+), 10 deletions(-) create mode 100644 .nvmrc create mode 100644 apps/api/src/lib/crypto.test.ts create mode 100644 apps/api/src/lib/entitlements.test.ts create mode 100644 apps/api/src/lib/http.test.ts create mode 100644 apps/api/src/lib/ssrf.test.ts create mode 100644 apps/api/src/providers/match.test.ts create mode 100644 apps/api/src/providers/parsers.test.ts create mode 100644 apps/api/test/auth.test.ts create mode 100644 apps/api/test/connections.test.ts create mode 100644 apps/api/test/global-setup.ts create mode 100644 apps/api/test/helpers.ts create mode 100644 apps/api/test/now-playing.test.ts create mode 100644 apps/api/test/rate-limit.test.ts create mode 100644 apps/api/test/realtime.test.ts create mode 100644 apps/api/test/scheduler.test.ts create mode 100644 apps/api/test/social.test.ts create mode 100644 apps/api/vitest.config.ts create mode 100644 packages/shared/src/client.test.ts create mode 100644 packages/shared/src/dto.test.ts create mode 100644 packages/shared/src/schemas.test.ts create mode 100644 packages/shared/src/services.test.ts create mode 100644 packages/shared/src/time.test.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 9c7033b..de6e386 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ npm run mobile # starts Metro; scan the QR code with a dev build or Expo - **Spotify/Plex sign-in from a phone:** the OAuth callback goes to `API_PUBLIC_URL`, which defaults to `127.0.0.1` — that's the phone itself. Expose the API with a tunnel (`cloudflared tunnel --url http://localhost:4000`) and set `API_PUBLIC_URL` to the https URL (and add it as a redirect URI in the Spotify dashboard). - Bundle IDs are `tech.chrisedwards.musicshare` (in `apps/mobile/app.json`); change before your first store build, they're permanent after. +### Tests + +```bash +npm test # all workspaces via Turborepo (needs `npm run infra:up`) +npm run test:unit -w @musicshare/api # API unit tests only, no database needed +``` + +- **Unit tests** (`*.test.ts` next to the code): matching, private-address blocking, crypto, plan logic, and provider response parsing with `fetch` stubbed. +- **Integration tests** (`apps/api/test/`): drive the real Fastify app with `app.inject()` against Postgres and Redis, covering auth, follows and pins, now-playing state, webhooks, OAuth return URLs, realtime WebSockets and rate limits. +- They use a separate `musicshare_test` database and Redis DB 1, created and migrated automatically. Test helpers refuse to wipe anything else, so your dev data is safe. +- Tests never touch the network: `routeFetch` stubs fail on unexpected requests. + +CI (`.github/workflows/ci.yml`) runs typecheck, lint, tests, a migrations-match-schema check, the web build, and the iOS/Android bundles. + Schema changes: edit `apps/api/src/db/schema.ts`, then `npm run db:generate && npm run db:migrate`. ## How it works diff --git a/apps/api/package.json b/apps/api/package.json index c9cf19d..16772aa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/lib/crypto.test.ts b/apps/api/src/lib/crypto.test.ts new file mode 100644 index 0000000..23fb753 --- /dev/null +++ b/apps/api/src/lib/crypto.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/lib/entitlements.test.ts b/apps/api/src/lib/entitlements.test.ts new file mode 100644 index 0000000..1226329 --- /dev/null +++ b/apps/api/src/lib/entitlements.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/lib/http.test.ts b/apps/api/src/lib/http.test.ts new file mode 100644 index 0000000..dc0c25a --- /dev/null +++ b/apps/api/src/lib/http.test.ts @@ -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')); + }); +}); diff --git a/apps/api/src/lib/ssrf.test.ts b/apps/api/src/lib/ssrf.test.ts new file mode 100644 index 0000000..f8c1ced --- /dev/null +++ b/apps/api/src/lib/ssrf.test.ts @@ -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]'); + }); +}); diff --git a/apps/api/src/providers/match.test.ts b/apps/api/src/providers/match.test.ts new file mode 100644 index 0000000..bb2f1f0 --- /dev/null +++ b/apps/api/src/providers/match.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/providers/parsers.test.ts b/apps/api/src/providers/parsers.test.ts new file mode 100644 index 0000000..974375b --- /dev/null +++ b/apps/api/src/providers/parsers.test.ts @@ -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 { + 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).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(); + }); +}); diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts new file mode 100644 index 0000000..3b22113 --- /dev/null +++ b/apps/api/test/auth.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/connections.test.ts b/apps/api/test/connections.test.ts new file mode 100644 index 0000000..887c992 --- /dev/null +++ b/apps/api/test/connections.test.ts @@ -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(); + }); +}); diff --git a/apps/api/test/global-setup.ts b/apps/api/test/global-setup.ts new file mode 100644 index 0000000..6ee5123 --- /dev/null +++ b/apps/api/test/global-setup.ts @@ -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), + }; +} diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts new file mode 100644 index 0000000..da32c5a --- /dev/null +++ b/apps/api/test/helpers.ts @@ -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 { + 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]; + +/** 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' } }); diff --git a/apps/api/test/now-playing.test.ts b/apps/api/test/now-playing.test.ts new file mode 100644 index 0000000..2f58836 --- /dev/null +++ b/apps/api/test/now-playing.test.ts @@ -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): 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); + }); +}); diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts new file mode 100644 index 0000000..bfdc7fd --- /dev/null +++ b/apps/api/test/rate-limit.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/realtime.test.ts b/apps/api/test/realtime.test.ts new file mode 100644 index 0000000..f3b8382 --- /dev/null +++ b/apps/api/test/realtime.test.ts @@ -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((resolve) => ws.once('close', (c: number) => resolve(c))); + expect(code).toBe(4401); + }); +}); diff --git a/apps/api/test/scheduler.test.ts b/apps/api/test/scheduler.test.ts new file mode 100644 index 0000000..2efcea0 --- /dev/null +++ b/apps/api/test/scheduler.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/social.test.ts b/apps/api/test/social.test.ts new file mode 100644 index 0000000..727fbe8 --- /dev/null +++ b/apps/api/test/social.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index a95f659..7b555aa 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -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" + ] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..098794c --- /dev/null +++ b/apps/api/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, + }, + }, + ], + }, +}); diff --git a/package-lock.json b/package-lock.json index cd165d9..c94dac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,760 @@ "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" + } + }, + "apps/api/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "apps/api/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "apps/api/node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "apps/api/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "apps/api/node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "apps/api/node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "apps/api/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, "apps/mobile": { @@ -4947,6 +5700,17 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -5839,6 +6603,284 @@ } } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.64.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", @@ -6308,6 +7350,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6907,6 +7967,16 @@ "win32" ] }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -7295,6 +8365,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7804,6 +8884,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -8812,6 +9902,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -9682,6 +10779,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9710,6 +10817,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { "version": "57.0.26", "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.26.tgz", @@ -14070,6 +15187,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -15543,6 +16674,41 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, "node_modules/rollup": { "version": "4.64.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", @@ -16263,6 +17429,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -16668,6 +17841,16 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -17380,6 +18563,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -17397,6 +18581,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -17414,6 +18599,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -17431,6 +18617,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -17448,6 +18635,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -17465,6 +18653,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -17482,6 +18671,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17499,6 +18689,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17516,6 +18707,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17533,6 +18725,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17550,6 +18743,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17567,6 +18761,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17584,6 +18779,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17601,6 +18797,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17618,6 +18815,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17635,6 +18833,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17652,6 +18851,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -17669,6 +18869,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17686,6 +18887,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17703,6 +18905,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17720,6 +18923,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -17737,6 +18941,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -17754,6 +18959,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -17771,6 +18977,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -17788,6 +18995,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -17805,6 +19013,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -18455,6 +19664,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -18652,6 +19874,761 @@ "version": "0.0.0", "dependencies": { "zod": "^4.6.5" + }, + "devDependencies": { + "vitest": "^5.0.3" + } + }, + "packages/shared/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/shared/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "packages/shared/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "packages/shared/node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "packages/shared/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/shared/node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/shared/node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } } } diff --git a/package.json b/package.json index e928c4c..274c324 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "infra:down": "docker compose down", "db:generate": "npm run db:generate -w @musicshare/api", "db:migrate": "npm run db:migrate -w @musicshare/api", - "mobile": "npm run start -w @musicshare/mobile" + "mobile": "npm run start -w @musicshare/mobile", + "test": "turbo run test", + "lint": "turbo run lint" }, "packageManager": "npm@10.8.1", "engines": { - "node": ">=22" + "node": ">=22.13" }, "devDependencies": { "turbo": "^2.11.7", diff --git a/packages/shared/package.json b/packages/shared/package.json index ca45345..e74f437 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,9 +7,14 @@ ".": "./src/index.ts" }, "scripts": { - "typecheck": "tsc -p ." + "typecheck": "tsc -p .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "zod": "^4.6.5" + }, + "devDependencies": { + "vitest": "^5.0.3" } } diff --git a/packages/shared/src/client.test.ts b/packages/shared/src/client.test.ts new file mode 100644 index 0000000..265d2ca --- /dev/null +++ b/packages/shared/src/client.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiError, createApiClient } from './client'; + +function stubFetch(status: number, body: unknown) { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('createApiClient', () => { + it('sends the bearer token and JSON body', async () => { + const fetchMock = stubFetch(200, { ok: true }); + const api = createApiClient({ baseUrl: 'https://api.test', getToken: () => 'tok' }); + await api.connectUsername('lastfm', 'rj'); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.test/connections/lastfm'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ Authorization: 'Bearer tok', 'Content-Type': 'application/json' }); + expect(JSON.parse(init?.body as string)).toEqual({ username: 'rj' }); + }); + + it('omits auth when there is no token and encodes path segments', async () => { + const fetchMock = stubFetch(200, {}); + const api = createApiClient({ baseUrl: 'https://api.test', credentials: 'include' }); + await api.profile('a/b'); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.test/users/a%2Fb'); + expect(init?.headers).not.toHaveProperty('Authorization'); + expect(init?.credentials).toBe('include'); + }); + + it('throws ApiError with the server message and status', async () => { + stubFetch(402, { error: 'You can pin up to 3 friends on your plan' }); + const api = createApiClient({ baseUrl: 'https://api.test' }); + const err = await api.pin('bob').catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err).toMatchObject({ status: 402, message: 'You can pin up to 3 friends on your plan' }); + }); +}); diff --git a/packages/shared/src/dto.test.ts b/packages/shared/src/dto.test.ts new file mode 100644 index 0000000..dec2167 --- /dev/null +++ b/packages/shared/src/dto.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { sortFeed, type FeedItemDTO } from './dto'; + +function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean; updatedAt?: string }): FeedItemDTO { + return { + user: { id: username, username, displayName: null, avatarUrl: null }, + track: { id: 't', title: 't', artist: 'a', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} }, + isPlaying: opts.isPlaying ?? false, + source: 'spotify', + progressMs: null, + updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z', + pinnedAt: opts.pinnedAt ?? null, + }; +} + +const names = (items: FeedItemDTO[]) => items.map((i) => i.user.username); + +describe('sortFeed', () => { + it('puts pinned friends first, in the order they were pinned', () => { + const feed = sortFeed([ + item('live', { isPlaying: true }), + item('pinnedSecond', { pinnedAt: '2026-01-02T00:00:00.000Z' }), + item('pinnedFirst', { pinnedAt: '2026-01-01T00:00:00.000Z' }), + ]); + expect(names(feed)).toEqual(['pinnedFirst', 'pinnedSecond', 'live']); + }); + + it('then live listeners, then most recently updated', () => { + const feed = sortFeed([ + item('old', { updatedAt: '2026-01-01T00:00:00.000Z' }), + item('recent', { updatedAt: '2026-01-03T00:00:00.000Z' }), + item('live', { isPlaying: true, updatedAt: '2025-12-01T00:00:00.000Z' }), + ]); + expect(names(feed)).toEqual(['live', 'recent', 'old']); + }); + + it('does not mutate its input', () => { + const input = [item('b', {}), item('a', { isPlaying: true })]; + sortFeed(input); + expect(names(input)).toEqual(['b', 'a']); + }); +}); diff --git a/packages/shared/src/schemas.test.ts b/packages/shared/src/schemas.test.ts new file mode 100644 index 0000000..1f78c6d --- /dev/null +++ b/packages/shared/src/schemas.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { registerSchema, setFeaturedSchema, updateMeSchema } from './schemas'; + +describe('registerSchema', () => { + it('normalizes username and email', () => { + const parsed = registerSchema.parse({ username: ' Alice_1 ', email: ' Alice@Example.COM ', password: 'password123' }); + expect(parsed).toMatchObject({ username: 'alice_1', email: 'alice@example.com' }); + }); + + it.each(['ab', 'has space', 'émoji', 'a'.repeat(33)])('rejects username %j', (username) => { + expect(registerSchema.safeParse({ username, email: 'a@b.co', password: 'password123' }).success).toBe(false); + }); + + it('requires 8+ character passwords', () => { + const result = registerSchema.safeParse({ username: 'alice', email: 'a@b.co', password: 'short' }); + expect(result.success).toBe(false); + }); +}); + +describe('updateMeSchema', () => { + it('only accepts known streaming services', () => { + expect(updateMeSchema.safeParse({ preferredService: 'spotify' }).success).toBe(true); + expect(updateMeSchema.safeParse({ preferredService: 'napster' }).success).toBe(false); + }); +}); + +describe('setFeaturedSchema', () => { + it('accepts a Deezer id or an existing track id', () => { + expect(setFeaturedSchema.safeParse({ deezerId: '3135553' }).success).toBe(true); + expect(setFeaturedSchema.safeParse({ trackId: '7ce84c24-e5d2-4af7-8d9c-4424ff70c373' }).success).toBe(true); + }); + + it('caps comments at 280 characters', () => { + expect(setFeaturedSchema.safeParse({ deezerId: '1', comment: 'x'.repeat(281) }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/services.test.ts b/packages/shared/src/services.test.ts new file mode 100644 index 0000000..d9a49f0 --- /dev/null +++ b/packages/shared/src/services.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { STREAMING_SERVICES, openUrl, searchUrl } from './services'; + +const track = { title: 'Midnight City', artist: 'M83', links: { spotify: 'https://open.spotify.com/track/abc' } }; + +describe('openUrl', () => { + it('uses the direct link when the service has one', () => { + expect(openUrl(track, 'spotify')).toEqual({ url: 'https://open.spotify.com/track/abc', exact: true }); + }); + + it('falls back to a search on that service', () => { + const result = openUrl(track, 'apple_music'); + expect(result.exact).toBe(false); + expect(result.url).toBe('https://music.apple.com/search?term=M83%20Midnight%20City'); + }); +}); + +describe('searchUrl', () => { + it.each(STREAMING_SERVICES)('builds an https URL for %s with the query encoded', (service) => { + const url = new URL(searchUrl(service, 'Café & Co', 'Ünïcode')); + expect(url.protocol).toBe('https:'); + expect(decodeURIComponent(url.href)).toContain('Ünïcode Café & Co'); + }); +}); diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts new file mode 100644 index 0000000..24ab6eb --- /dev/null +++ b/packages/shared/src/time.test.ts @@ -0,0 +1,20 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { timeAgo } from './time'; + +describe('timeAgo', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-03T12:00:00Z')); + }); + afterEach(() => vi.useRealTimers()); + + it.each([ + ['2026-10-03T11:59:30Z', 'just now'], + ['2026-10-03T11:55:00Z', '5m ago'], + ['2026-10-03T09:00:00Z', '3h ago'], + ['2026-09-30T12:00:00Z', '3d ago'], + ['2026-10-03T12:00:10Z', 'just now'], // slight clock skew into the future + ])('%s → %s', (iso, expected) => { + expect(timeAgo(iso)).toBe(expected); + }); +}); diff --git a/turbo.json b/turbo.json index 95aed72..686a8b2 100644 --- a/turbo.json +++ b/turbo.json @@ -7,10 +7,23 @@ }, "build": { "dependsOn": ["^build"], + "env": ["NEXT_PUBLIC_*", "EXPO_PUBLIC_*"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "typecheck": { "dependsOn": ["^typecheck"] + }, + "lint": {}, + "transit": { + "dependsOn": ["^transit"] + }, + "test": { + "dependsOn": ["transit"], + "env": ["TEST_DATABASE_URL", "TEST_REDIS_URL"] + }, + "test:watch": { + "cache": false, + "persistent": true } } }