From e783bb29463e3984ec9df50e6f1309593e318b31 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:49:15 +0100 Subject: [PATCH 1/9] test(api): add resolve endpoint tests Cover valid platform URLs (Spotify, YouTube, Apple Music, Tidal, Deezer, SoundCloud, song.link, album.link, odesli.co), invalid/missing URL params, unsupported platforms, protocol enforcement (HTTPS only), Odesli API error forwarding (404, 500), network failures (502), and Content-Type header. --- src/pages/api/__tests__/resolve.test.ts | 191 ++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/pages/api/__tests__/resolve.test.ts diff --git a/src/pages/api/__tests__/resolve.test.ts b/src/pages/api/__tests__/resolve.test.ts new file mode 100644 index 0000000..ea72d5b --- /dev/null +++ b/src/pages/api/__tests__/resolve.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GET } from '../resolve'; + +// --- Helpers --- + +/** Build a minimal Astro APIContext with the given search params */ +function makeContext(params?: Record) { + const url = new URL('http://localhost:4321/api/resolve'); + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + // Cast to the shape expected by the APIRoute handler + return { url } as Parameters[0]; +} + +/** Parse the JSON body and status from a Response */ +async function parseResponse(response: Response) { + const body = await response.json(); + return { status: response.status, body }; +} + +describe('GET /api/resolve', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // --- Missing / invalid URL parameter --- + + it('should return 400 when url parameter is missing', async () => { + const response = await GET(makeContext()); + const { status, body } = await parseResponse(response); + + expect(status).toBe(400); + expect(body.error).toBe('Missing url parameter'); + }); + + it('should return 400 when url parameter is empty string', async () => { + const response = await GET(makeContext({ url: '' })); + const { status } = await parseResponse(response); + + // Empty string is treated as missing by searchParams.get + expect(status).toBe(400); + }); + + it('should return 400 when url is malformed', async () => { + const response = await GET(makeContext({ url: 'not-a-url' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(400); + expect(body.error).toBe('Invalid URL'); + }); + + // --- Unsupported platforms --- + + it('should return 400 when url is from an unsupported platform', async () => { + const response = await GET(makeContext({ url: 'https://example.com/track/123' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(400); + expect(body.error).toBe('URL not from a supported music platform'); + }); + + it('should return 400 when url uses HTTP protocol instead of HTTPS', async () => { + const response = await GET(makeContext({ url: 'http://open.spotify.com/track/abc' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(400); + expect(body.error).toBe('URL not from a supported music platform'); + }); + + // --- Valid platform URLs forwarded to Odesli --- + + const validPlatformUrls = [ + { platform: 'Spotify', url: 'https://open.spotify.com/track/abc123' }, + { platform: 'Spotify short link', url: 'https://spotify.link/abc123' }, + { platform: 'YouTube Music', url: 'https://music.youtube.com/watch?v=abc123' }, + { platform: 'YouTube', url: 'https://www.youtube.com/watch?v=abc123' }, + { platform: 'YouTube short', url: 'https://youtu.be/abc123' }, + { platform: 'Apple Music', url: 'https://music.apple.com/us/album/abc123' }, + { platform: 'iTunes', url: 'https://itunes.apple.com/us/album/abc123' }, + { platform: 'Tidal', url: 'https://tidal.com/browse/track/123' }, + { platform: 'Tidal listen', url: 'https://listen.tidal.com/track/123' }, + { platform: 'Deezer', url: 'https://www.deezer.com/track/123' }, + { platform: 'Deezer page link', url: 'https://deezer.page.link/abc' }, + { platform: 'SoundCloud', url: 'https://soundcloud.com/artist/track' }, + { platform: 'song.link', url: 'https://song.link/s/abc123' }, + { platform: 'album.link', url: 'https://album.link/s/abc123' }, + { platform: 'odesli.co', url: 'https://odesli.co/s/abc123' }, + ]; + + for (const { platform, url } of validPlatformUrls) { + it(`should forward ${platform} URL to Odesli API`, async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + const response = await GET(makeContext({ url })); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledOnce(); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('api.song.link/v1-alpha.1/links'); + expect(calledUrl).toContain(encodeURIComponent(url)); + }); + } + + // --- Odesli API response forwarding --- + + it('should forward the Odesli API response body and status as-is', async () => { + const mockFetch = vi.mocked(fetch); + const odesliBody = { entityUniqueId: 'SPOTIFY::abc', pageUrl: 'https://song.link/s/abc' }; + mockFetch.mockResolvedValue(new Response(JSON.stringify(odesliBody), { status: 200 })); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(200); + expect(body.entityUniqueId).toBe('SPOTIFY::abc'); + }); + + it('should forward Odesli 404 status when song is not found', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'not found' }), { status: 404 }), + ); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/notfound' })); + + expect(response.status).toBe(404); + }); + + it('should forward Odesli 500 status when API has internal error', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'internal' }), { status: 500 }), + ); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + + expect(response.status).toBe(500); + }); + + // --- Network errors --- + + it('should return 502 when fetch to Odesli API fails with network error', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(502); + expect(body.error).toBe('Failed to reach song resolution service'); + }); + + // --- Security: protocol enforcement --- + + it('should reject FTP protocol URLs', async () => { + const response = await GET(makeContext({ url: 'ftp://open.spotify.com/track/abc' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(400); + expect(body.error).toBe('URL not from a supported music platform'); + }); + + it('should reject javascript: protocol URLs', async () => { + const response = await GET(makeContext({ url: 'javascript:alert(1)' })); + + expect(response.status).toBe(400); + }); + + it('should reject data: protocol URLs', async () => { + const response = await GET(makeContext({ url: 'data:text/html,' })); + + expect(response.status).toBe(400); + }); + + // --- Content-Type header --- + + it('should always return Content-Type application/json', async () => { + const response = await GET(makeContext()); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); +}); From 3d07144d902eb0a004b935664b8cf0ff5496770c Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:55:05 +0100 Subject: [PATCH 2/9] feat(security): add in-memory sliding window rate limiter Reusable rate limiter with configurable max requests and window duration. Prunes expired timestamps per key and runs periodic cleanup to prevent unbounded memory growth. Includes 8 unit tests. --- src/lib/__tests__/rate-limiter.test.ts | 117 +++++++++++++++++++++++++ src/lib/rate-limiter.ts | 84 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/lib/__tests__/rate-limiter.test.ts create mode 100644 src/lib/rate-limiter.ts diff --git a/src/lib/__tests__/rate-limiter.test.ts b/src/lib/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000..5d7e38b --- /dev/null +++ b/src/lib/__tests__/rate-limiter.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createRateLimiter } from '../rate-limiter'; + +describe('createRateLimiter', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should allow requests under the limit', () => { + const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 }); + + expect(limiter.check('192.168.1.1')).toBe(true); + expect(limiter.check('192.168.1.1')).toBe(true); + expect(limiter.check('192.168.1.1')).toBe(true); + }); + + it('should block requests that exceed the limit', () => { + const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 }); + + expect(limiter.check('192.168.1.1')).toBe(true); + expect(limiter.check('192.168.1.1')).toBe(true); + expect(limiter.check('192.168.1.1')).toBe(true); + expect(limiter.check('192.168.1.1')).toBe(false); + expect(limiter.check('192.168.1.1')).toBe(false); + }); + + it('should track each key independently', () => { + const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 }); + + expect(limiter.check('ip-a')).toBe(true); + expect(limiter.check('ip-a')).toBe(true); + expect(limiter.check('ip-a')).toBe(false); + + // Different key should still be allowed + expect(limiter.check('ip-b')).toBe(true); + expect(limiter.check('ip-b')).toBe(true); + expect(limiter.check('ip-b')).toBe(false); + }); + + it('should allow requests again after the window expires (sliding window)', () => { + const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 }); + + expect(limiter.check('ip-a')).toBe(true); + expect(limiter.check('ip-a')).toBe(true); + expect(limiter.check('ip-a')).toBe(false); + + // Advance time past the window + vi.advanceTimersByTime(60_001); + + expect(limiter.check('ip-a')).toBe(true); + }); + + it('should use sliding window — only old timestamps expire', () => { + const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 }); + + // t=0s: first request + expect(limiter.check('ip-a')).toBe(true); + + // t=20s: second request + vi.advanceTimersByTime(20_000); + expect(limiter.check('ip-a')).toBe(true); + + // t=40s: third request + vi.advanceTimersByTime(20_000); + expect(limiter.check('ip-a')).toBe(true); + + // t=40s: fourth request — blocked + expect(limiter.check('ip-a')).toBe(false); + + // t=61s: first request has expired, but second and third are still within window + vi.advanceTimersByTime(21_000); + expect(limiter.check('ip-a')).toBe(true); + + // Now at limit again (requests at t=20, t=40, t=61) + expect(limiter.check('ip-a')).toBe(false); + }); + + it('should clean up stale entries when cleanup runs', () => { + const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 }); + + // Create entries for many keys (100 checks triggers internal cleanup) + for (let i = 0; i < 99; i++) { + limiter.check(`ip-${i}`); + } + expect(limiter.size()).toBe(99); + + // Advance past the window so all entries become stale + vi.advanceTimersByTime(60_001); + + // The 100th check triggers cleanup + limiter.check('ip-new'); + + // Only the new entry should remain after cleanup + expect(limiter.size()).toBe(1); + }); + + it('should use default values when no options are provided', () => { + const limiter = createRateLimiter(); + + // Default is 10 requests per 60s — should allow 10 + for (let i = 0; i < 10; i++) { + expect(limiter.check('ip-a')).toBe(true); + } + expect(limiter.check('ip-a')).toBe(false); + }); + + it('should handle empty string key', () => { + const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 }); + + expect(limiter.check('')).toBe(true); + expect(limiter.check('')).toBe(false); + }); +}); diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts new file mode 100644 index 0000000..dc56326 --- /dev/null +++ b/src/lib/rate-limiter.ts @@ -0,0 +1,84 @@ +/** + * In-memory sliding window rate limiter. + * + * Tracks timestamps of requests per key (typically IP address). + * Expired timestamps are pruned on each check. A periodic cleanup + * removes stale entries to prevent unbounded memory growth. + */ + +interface RateLimiterOptions { + /** Maximum requests allowed within the window. Default: 10 */ + maxRequests?: number; + /** Window duration in milliseconds. Default: 60_000 (1 minute) */ + windowMs?: number; +} + +interface RateLimiter { + /** Returns true if the request is allowed, false if rate-limited */ + check: (key: string) => boolean; + /** Returns the number of tracked keys (for testing/monitoring) */ + size: () => number; +} + +export function createRateLimiter(options: RateLimiterOptions = {}): RateLimiter { + const maxRequests = options.maxRequests ?? 10; + const windowMs = options.windowMs ?? 60_000; + + /** Map of key -> array of request timestamps within the window */ + const requests = new Map(); + + /** Counter to trigger periodic cleanup of stale entries */ + let checkCount = 0; + const CLEANUP_INTERVAL = 100; + + function cleanup(): void { + const now = Date.now(); + const cutoff = now - windowMs; + + for (const [key, timestamps] of requests) { + const valid = timestamps.filter((t) => t > cutoff); + if (valid.length === 0) { + requests.delete(key); + } else { + requests.set(key, valid); + } + } + } + + function check(key: string): boolean { + const now = Date.now(); + const cutoff = now - windowMs; + + // Periodic cleanup to prevent memory leak from abandoned keys + checkCount++; + if (checkCount >= CLEANUP_INTERVAL) { + checkCount = 0; + cleanup(); + } + + const timestamps = requests.get(key); + + if (!timestamps) { + requests.set(key, [now]); + return true; + } + + // Prune expired timestamps for this key + const valid = timestamps.filter((t) => t > cutoff); + + if (valid.length >= maxRequests) { + requests.set(key, valid); + return false; + } + + valid.push(now); + requests.set(key, valid); + return true; + } + + function size(): number { + return requests.size; + } + + return { check, size }; +} From fd294774ddbd2f4718a181a3d9c92da4234e42d0 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:55:07 +0100 Subject: [PATCH 3/9] test(hooks): add tests for useMember, useGroup, and useLeaderboard useMember: member loading from localStorage, supabase fetch, null handling for missing/invalid IDs. useGroup: initialization, error states, addSong/voteSong guards (no round, no member, rating bounds), songsPerRound default of 3, initial members prop. useLeaderboard: stats calculation (totalScore, avgReceived, songsAdded, roundsWon), sorting by totalScore, pastRounds summary, current round exclusion from wins. --- src/features/group/__tests__/useGroup.test.ts | 228 +++++++++++ .../__tests__/useLeaderboard.test.ts | 356 ++++++++++++++++++ src/hooks/__tests__/useMember.test.ts | 134 +++++++ 3 files changed, 718 insertions(+) create mode 100644 src/features/group/__tests__/useGroup.test.ts create mode 100644 src/features/leaderboard/__tests__/useLeaderboard.test.ts create mode 100644 src/hooks/__tests__/useMember.test.ts diff --git a/src/features/group/__tests__/useGroup.test.ts b/src/features/group/__tests__/useGroup.test.ts new file mode 100644 index 0000000..ca20d15 --- /dev/null +++ b/src/features/group/__tests__/useGroup.test.ts @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useGroup } from '../useGroup'; +import type { Round } from '../../../lib/types'; + +// --- Mocks at boundaries --- + +const mockEnsureCurrentRound = vi.fn(); +vi.mock('../../../lib/rounds', () => ({ + ensureCurrentRound: (...args: unknown[]) => mockEnsureCurrentRound(...args), +})); + +const mockResolveSongLink = vi.fn(); +vi.mock('../../../lib/odesli', () => ({ + resolveSongLink: (...args: unknown[]) => mockResolveSongLink(...args), +})); + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + from: () => ({ + select: () => ({ + eq: () => ({ + order: () => Promise.resolve({ data: [], error: null }), + }), + in: () => Promise.resolve({ data: [], error: null }), + }), + insert: () => ({ + select: () => ({ + single: () => Promise.resolve({ data: null, error: null }), + }), + }), + upsert: () => Promise.resolve({ error: null }), + }), + }, +})); + +// --- Factories --- + +function makeRound(overrides: Partial = {}): Round { + return { + id: 'round-1', + group_id: 'group-1', + number: 1, + starts_at: '2026-02-23T00:00:00.000Z', + ends_at: '2026-03-01T23:59:59.999Z', + created_at: '2026-02-23T00:00:00.000Z', + ...overrides, + }; +} + +describe('useGroup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should start in loading state', () => { + mockEnsureCurrentRound.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + expect(result.current.isLoading).toBe(true); + expect(result.current.songs).toEqual([]); + expect(result.current.round).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('should load round and empty songs on initialization', async () => { + const round = makeRound(); + mockEnsureCurrentRound.mockResolvedValue(round); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.round).toEqual(round); + expect(result.current.songs).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('should set error when ensureCurrentRound fails', async () => { + mockEnsureCurrentRound.mockRejectedValue(new Error('DB connection failed')); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('DB connection failed'); + expect(result.current.round).toBeNull(); + }); + + it('should pass groupId to ensureCurrentRound', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + renderHook(() => useGroup('my-group-id', 'member-1')); + + await waitFor(() => { + expect(mockEnsureCurrentRound).toHaveBeenCalledWith('my-group-id'); + }); + }); + + it('should throw error when addSong is called without a round', async () => { + mockEnsureCurrentRound.mockRejectedValue(new Error('fail')); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect(result.current.addSong('https://open.spotify.com/track/abc')).rejects.toThrow( + 'Cannot add song: no active round or member', + ); + }); + + it('should throw error when addSong is called without a member ID', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + const { result } = renderHook(() => useGroup('group-1', null)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect(result.current.addSong('https://open.spotify.com/track/abc')).rejects.toThrow( + 'Cannot add song: no active round or member', + ); + }); + + it('should throw error when voteSong is called without a member ID', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + const { result } = renderHook(() => useGroup('group-1', null)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect(result.current.voteSong('song-1', 4)).rejects.toThrow('Cannot vote: not a member'); + }); + + it('should throw error when rating exceeds 5', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // 5.3 rounds to 5.5 which is > 5 + await expect(result.current.voteSong('song-1', 5.3)).rejects.toThrow( + 'Rating must be between 0 and 5', + ); + }); + + it('should throw error when rating is negative', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect(result.current.voteSong('song-1', -1)).rejects.toThrow( + 'Rating must be between 0 and 5', + ); + }); + + it('should default songsPerRound to 3', () => { + mockEnsureCurrentRound.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + // Verify the hook initializes (the default is in the function signature) + expect(result.current.songs).toEqual([]); + }); + + it('should expose refetch, addSong, and voteSong functions', async () => { + mockEnsureCurrentRound.mockResolvedValue(makeRound()); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(typeof result.current.refetch).toBe('function'); + expect(typeof result.current.addSong).toBe('function'); + expect(typeof result.current.voteSong).toBe('function'); + }); + + it('should use provided initial members', () => { + mockEnsureCurrentRound.mockReturnValue(new Promise(() => {})); + + const members = [ + { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + const { result } = renderHook(() => useGroup('group-1', 'member-1', 3, members)); + + expect(result.current.members).toEqual(members); + }); + + it('should set error with generic message when non-Error is thrown', async () => { + mockEnsureCurrentRound.mockRejectedValue('some string error'); + + const { result } = renderHook(() => useGroup('group-1', 'member-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Failed to load group data'); + }); +}); diff --git a/src/features/leaderboard/__tests__/useLeaderboard.test.ts b/src/features/leaderboard/__tests__/useLeaderboard.test.ts new file mode 100644 index 0000000..fe6e309 --- /dev/null +++ b/src/features/leaderboard/__tests__/useLeaderboard.test.ts @@ -0,0 +1,356 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useLeaderboard } from '../useLeaderboard'; +import type { Member, Round, Song, Vote } from '../../../lib/types'; + +// --- Configurable supabase mock --- + +let mockData: { + members: { data: Member[] | null; error: unknown }; + rounds: { data: Round[] | null; error: unknown }; + songs: { data: Song[] | null; error: unknown }; + votes: { data: Vote[] | null; error: unknown }; +}; + +function resetMockData() { + mockData = { + members: { data: [], error: null }, + rounds: { data: [], error: null }, + songs: { data: [], error: null }, + votes: { data: [], error: null }, + }; +} + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + from: (table: string) => { + const tableData = () => { + switch (table) { + case 'members': + return mockData.members; + case 'rounds': + return mockData.rounds; + case 'songs': + return mockData.songs; + case 'votes': + return mockData.votes; + default: + return { data: [], error: null }; + } + }; + + const chain: Record = {}; + const resolve = () => Promise.resolve(tableData()); + + // Build a chainable mock that always resolves with the table's data + chain.select = () => chain; + chain.eq = () => chain; + chain.in = () => resolve(); + chain.order = () => resolve(); + chain.then = (fn: (v: unknown) => void) => resolve().then(fn); + + return chain; + }, + }, +})); + +// --- Factories --- + +function makeMember(overrides: Partial = {}): Member { + return { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeRound(overrides: Partial = {}): Round { + return { + id: 'round-1', + group_id: 'group-1', + number: 1, + starts_at: '2026-01-01T00:00:00Z', + ends_at: '2026-01-07T23:59:59Z', + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeSong(overrides: Partial = {}): Song { + return { + id: 'song-1', + round_id: 'round-1', + member_id: 'member-1', + title: 'Test Song', + artist: 'Test Artist', + album: null, + thumbnail_url: null, + platform_links: [], + odesli_page_url: null, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeVote(overrides: Partial = {}): Vote { + return { + id: 'vote-1', + song_id: 'song-1', + member_id: 'member-2', + rating: 4, + created_at: '2026-01-02T00:00:00Z', + ...overrides, + }; +} + +describe('useLeaderboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMockData(); + }); + + it('should start in loading state', () => { + const { result } = renderHook(() => useLeaderboard('group-1')); + + expect(result.current.isLoading).toBe(true); + expect(result.current.members).toEqual([]); + expect(result.current.pastRounds).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('should return members with zero stats when no rounds exist', async () => { + const alice = makeMember({ id: 'alice', name: 'Alice' }); + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [], error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members).toHaveLength(1); + expect(result.current.members[0].totalScore).toBe(0); + expect(result.current.members[0].songsAdded).toBe(0); + expect(result.current.members[0].avgReceived).toBe(0); + expect(result.current.members[0].roundsWon).toBe(0); + }); + + it('should calculate totalScore as sum of all ratings received', async () => { + const alice = makeMember({ id: 'alice' }); + const currentRound = makeRound({ id: 'round-1', number: 1 }); + const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' }); + const votes = [ + makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4 }), + makeVote({ id: 'v2', song_id: 'song-a', member_id: 'carol', rating: 3 }), + ]; + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [currentRound], error: null }; + mockData.songs = { data: [song], error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members[0].totalScore).toBe(7); // 4 + 3 + }); + + it('should calculate avgReceived as mean of all ratings', async () => { + const alice = makeMember({ id: 'alice' }); + const currentRound = makeRound({ id: 'round-1', number: 1 }); + const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' }); + const votes = [ + makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4 }), + makeVote({ id: 'v2', song_id: 'song-a', member_id: 'carol', rating: 2 }), + ]; + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [currentRound], error: null }; + mockData.songs = { data: [song], error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members[0].avgReceived).toBe(3); // (4 + 2) / 2 + }); + + it('should count songsAdded per member', async () => { + const alice = makeMember({ id: 'alice' }); + const currentRound = makeRound({ id: 'round-1', number: 1 }); + const songs = [ + makeSong({ id: 'song-1', member_id: 'alice', round_id: 'round-1' }), + makeSong({ id: 'song-2', member_id: 'alice', round_id: 'round-1' }), + ]; + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [currentRound], error: null }; + mockData.songs = { data: songs, error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members[0].songsAdded).toBe(2); + }); + + it('should sort members by totalScore descending', async () => { + const alice = makeMember({ id: 'alice', name: 'Alice' }); + const bob = makeMember({ id: 'bob', name: 'Bob' }); + const round = makeRound({ id: 'round-1', number: 1 }); + const songs = [ + makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' }), + makeSong({ id: 'song-b', member_id: 'bob', round_id: 'round-1' }), + ]; + const votes = [ + makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 2 }), + makeVote({ id: 'v2', song_id: 'song-b', member_id: 'alice', rating: 5 }), + ]; + + mockData.members = { data: [alice, bob], error: null }; + mockData.rounds = { data: [round], error: null }; + mockData.songs = { data: songs, error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members[0].id).toBe('bob'); // score 5 + expect(result.current.members[1].id).toBe('alice'); // score 2 + }); + + it('should not count current round toward roundsWon', async () => { + const alice = makeMember({ id: 'alice' }); + const currentRound = makeRound({ id: 'round-1', number: 1 }); + const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' }); + const votes = [makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 5 })]; + + mockData.members = { data: [alice], error: null }; + // Most recent round (index 0) is treated as current + mockData.rounds = { data: [currentRound], error: null }; + mockData.songs = { data: [song], error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.members[0].roundsWon).toBe(0); + }); + + it('should count past round wins correctly', async () => { + const alice = makeMember({ id: 'alice' }); + const bob = makeMember({ id: 'bob' }); + // Descending: round-2 is current, round-1 is past + const currentRound = makeRound({ id: 'round-2', number: 2 }); + const pastRound = makeRound({ id: 'round-1', number: 1 }); + const songs = [ + makeSong({ id: 'alice-s1', member_id: 'alice', round_id: 'round-1' }), + makeSong({ id: 'bob-s1', member_id: 'bob', round_id: 'round-1' }), + ]; + const votes = [ + // Alice wins round 1 (higher avg) + makeVote({ id: 'v1', song_id: 'alice-s1', member_id: 'bob', rating: 5 }), + makeVote({ id: 'v2', song_id: 'bob-s1', member_id: 'alice', rating: 2 }), + ]; + + mockData.members = { data: [alice, bob], error: null }; + mockData.rounds = { data: [currentRound, pastRound], error: null }; + mockData.songs = { data: songs, error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const aliceStats = result.current.members.find((m) => m.id === 'alice')!; + expect(aliceStats.roundsWon).toBe(1); + }); + + it('should build pastRounds summary excluding current round', async () => { + const alice = makeMember({ id: 'alice', name: 'Alice' }); + const currentRound = makeRound({ id: 'round-2', number: 2 }); + const pastRound = makeRound({ id: 'round-1', number: 1 }); + const song = makeSong({ + id: 'song-a', + member_id: 'alice', + round_id: 'round-1', + title: 'Bohemian Rhapsody', + artist: 'Queen', + }); + const votes = [makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4.5 })]; + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [currentRound, pastRound], error: null }; + mockData.songs = { data: [song], error: null }; + mockData.votes = { data: votes, error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.pastRounds).toHaveLength(1); + expect(result.current.pastRounds[0]).toEqual({ + number: 1, + winner: 'Alice', + topSong: 'Bohemian Rhapsody', + topArtist: 'Queen', + topScore: 4.5, + }); + }); + + it('should set error when members fetch fails', async () => { + mockData.members = { data: null, error: { message: 'connection refused' } }; + mockData.rounds = { data: [], error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toContain('Failed to fetch members'); + }); + + it('should return empty pastRounds when there is only current round', async () => { + const alice = makeMember({ id: 'alice' }); + const currentRound = makeRound({ id: 'round-1', number: 1 }); + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [currentRound], error: null }; + mockData.songs = { data: [], error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useLeaderboard('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.pastRounds).toEqual([]); + }); +}); diff --git a/src/hooks/__tests__/useMember.test.ts b/src/hooks/__tests__/useMember.test.ts new file mode 100644 index 0000000..5a28236 --- /dev/null +++ b/src/hooks/__tests__/useMember.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useMember } from '../useMember'; +import type { Member } from '../../lib/types'; + +// --- Mocks at boundaries --- + +const mockMaybeSingle = vi.fn(); +const mockEq = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); +const mockSelect = vi.fn(() => ({ eq: mockEq })); +const mockFrom = vi.fn(() => ({ select: mockSelect })); + +vi.mock('../../lib/supabase', () => ({ + supabase: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +vi.mock('../../lib/storage', () => ({ + getMemberId: vi.fn(), +})); + +import { getMemberId } from '../../lib/storage'; + +const VALID_UUID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; + +function makeMember(overrides: Partial = {}): Member { + return { + id: VALID_UUID, + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('useMember', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null member when no member ID is stored', async () => { + vi.mocked(getMemberId).mockReturnValue(null); + + const { result } = renderHook(() => useMember('test-group')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.member).toBeNull(); + }); + + it('should fetch and return member when valid ID is stored', async () => { + const member = makeMember(); + vi.mocked(getMemberId).mockReturnValue(VALID_UUID); + mockEq.mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: member, error: null }), + }); + + const { result } = renderHook(() => useMember('test-group')); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.member).toEqual(member); + }); + + it('should return null member when supabase returns error', async () => { + vi.mocked(getMemberId).mockReturnValue(VALID_UUID); + mockEq.mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'not found' }, + }), + }); + + const { result } = renderHook(() => useMember('test-group')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.member).toBeNull(); + }); + + it('should return null member when supabase returns null data', async () => { + vi.mocked(getMemberId).mockReturnValue(VALID_UUID); + mockEq.mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }); + + const { result } = renderHook(() => useMember('test-group')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.member).toBeNull(); + }); + + it('should call getMemberId with the slug', async () => { + vi.mocked(getMemberId).mockReturnValue(null); + + renderHook(() => useMember('my-cool-group')); + + await waitFor(() => { + expect(getMemberId).toHaveBeenCalledWith('my-cool-group'); + }); + }); + + it('should query supabase members table with the stored ID', async () => { + vi.mocked(getMemberId).mockReturnValue(VALID_UUID); + mockEq.mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: makeMember(), error: null }), + }); + + renderHook(() => useMember('test-group')); + + await waitFor(() => { + expect(mockFrom).toHaveBeenCalledWith('members'); + }); + }); +}); From 62e1ed4426fb8c52310b1ea012bbf23a54528a86 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:55:17 +0100 Subject: [PATCH 4/9] feat(security): add rate limiting and response size guard to resolve endpoint Apply 10 req/min per-IP rate limiting to GET /api/resolve using the sliding window rate limiter. Extract client IP from X-Forwarded-For (Netlify). Return 429 with Retry-After header when exceeded. Also guard against oversized upstream responses (>512 KB). Tests updated with rate limiter mock and new assertions for 429 and size guard. --- src/pages/api/__tests__/resolve.test.ts | 101 ++++++++++++++++++++++-- src/pages/api/resolve.ts | 42 +++++++++- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/pages/api/__tests__/resolve.test.ts b/src/pages/api/__tests__/resolve.test.ts index ea72d5b..365f79d 100644 --- a/src/pages/api/__tests__/resolve.test.ts +++ b/src/pages/api/__tests__/resolve.test.ts @@ -1,18 +1,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { GET } from '../resolve'; + +// Mock the rate limiter module before importing the handler. +// This lets us control rate limiting behavior in tests without dealing +// with the module-level singleton state. +const mockCheck = vi.fn(() => true); +vi.mock('../../../lib/rate-limiter', () => ({ + createRateLimiter: () => ({ check: mockCheck, size: () => 0 }), +})); + +// Import after mock setup +const { GET } = await import('../resolve'); // --- Helpers --- -/** Build a minimal Astro APIContext with the given search params */ -function makeContext(params?: Record) { +/** Build a minimal Astro APIContext with search params and request headers */ +function makeContext(params?: Record, headers?: Record) { const url = new URL('http://localhost:4321/api/resolve'); if (params) { for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } } - // Cast to the shape expected by the APIRoute handler - return { url } as Parameters[0]; + const request = new Request(url, { + headers: new Headers(headers), + }); + return { url, request } as Parameters[0]; } /** Parse the JSON body and status from a Response */ @@ -24,10 +36,74 @@ async function parseResponse(response: Response) { describe('GET /api/resolve', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); + mockCheck.mockReturnValue(true); }); afterEach(() => { vi.unstubAllGlobals(); + mockCheck.mockReset(); + mockCheck.mockReturnValue(true); + }); + + // --- Rate limiting --- + + describe('rate limiting', () => { + it('should return 429 when rate limit is exceeded', async () => { + mockCheck.mockReturnValue(false); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(429); + expect(body.error).toContain('Too many requests'); + expect(response.headers.get('Retry-After')).toBe('60'); + }); + + it('should allow requests when under rate limit', async () => { + mockCheck.mockReturnValue(true); + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + + expect(response.status).toBe(200); + }); + + it('should extract client IP from X-Forwarded-For header', async () => { + mockCheck.mockReturnValue(true); + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + await GET( + makeContext( + { url: 'https://open.spotify.com/track/abc123' }, + { 'x-forwarded-for': '203.0.113.50, 70.41.3.18' }, + ), + ); + + // The rate limiter check should be called with the first IP + expect(mockCheck).toHaveBeenCalledWith('203.0.113.50'); + }); + + it('should use "unknown" when no IP headers are present', async () => { + mockCheck.mockReturnValue(true); + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + + expect(mockCheck).toHaveBeenCalledWith('unknown'); + }); + + it('should check rate limit before processing the request', async () => { + mockCheck.mockReturnValue(false); + const mockFetch = vi.mocked(fetch); + + await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + + // fetch should NOT have been called since rate limit was hit first + expect(mockFetch).not.toHaveBeenCalled(); + }); }); // --- Missing / invalid URL parameter --- @@ -146,6 +222,21 @@ describe('GET /api/resolve', () => { expect(response.status).toBe(500); }); + // --- Response size guard --- + + it('should return 502 when Odesli response exceeds size limit', async () => { + const mockFetch = vi.mocked(fetch); + // Create a response body larger than 512 KB + const largeBody = 'x'.repeat(512 * 1024 + 1); + mockFetch.mockResolvedValue(new Response(largeBody, { status: 200 })); + + const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' })); + const { status, body } = await parseResponse(response); + + expect(status).toBe(502); + expect(body.error).toBe('Response too large'); + }); + // --- Network errors --- it('should return 502 when fetch to Odesli API fails with network error', async () => { diff --git a/src/pages/api/resolve.ts b/src/pages/api/resolve.ts index 031f660..6bebd3a 100644 --- a/src/pages/api/resolve.ts +++ b/src/pages/api/resolve.ts @@ -1,7 +1,11 @@ import type { APIRoute } from 'astro'; +import { createRateLimiter } from '../../lib/rate-limiter'; const ODESLI_API = 'https://api.song.link/v1-alpha.1/links'; +/** Max response body size from Odesli API (500 KB) to prevent memory abuse */ +const MAX_RESPONSE_BYTES = 512 * 1024; + /** Allowed music platform hostnames (mirrors the client-side whitelist in odesli.ts) */ const ALLOWED_HOSTS = new Set([ 'open.spotify.com', @@ -24,13 +28,41 @@ const ALLOWED_HOSTS = new Set([ 'odesli.co', ]); +/** In-memory rate limiter — 10 requests per minute per IP */ +const rateLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60_000 }); + +/** + * Extract client IP from the request. Netlify sets X-Forwarded-For; + * take only the first address (leftmost = original client). + */ +function getClientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + const first = forwarded.split(',')[0].trim(); + if (first) return first; + } + return 'unknown'; +} + /** * Server-side proxy for the Odesli API. * Avoids CORS issues since the browser calls our own origin. * * GET /api/resolve?url= */ -export const GET: APIRoute = async ({ url }) => { +export const GET: APIRoute = async ({ url, request }) => { + // Rate limiting + const clientIp = getClientIp(request); + if (!rateLimiter.check(clientIp)) { + return new Response(JSON.stringify({ error: 'Too many requests. Try again in a minute.' }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + }); + } + const musicUrl = url.searchParams.get('url'); if (!musicUrl) { @@ -62,6 +94,14 @@ export const GET: APIRoute = async ({ url }) => { const body = await response.text(); + // Guard against unexpectedly large responses from the upstream API + if (body.length > MAX_RESPONSE_BYTES) { + return new Response(JSON.stringify({ error: 'Response too large' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(body, { status: response.status, headers: { 'Content-Type': 'application/json' }, From a2ecc08235ffd355450ebd0439b3cc94795021ef Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:57:36 +0100 Subject: [PATCH 5/9] test(components): add tests for StarRating, SongCard, AddSong, JoinGroup StarRating: read-only vs interactive mode, half-star precision, aria labels, button click callbacks for all 0.5-5.0 increments. SongCard: title/artist rendering, added-by display, thumbnail HTTPS enforcement, own-track vs other-track rating, average rating display, platform links, album art alt text. AddSong: input enable/disable, submit with trimmed URL, clear on success, error feedback, resolving state, whitespace-only rejection. JoinGroup: member selection flow, create member form, name validation, emoji picker, back navigation, member ID storage on selection. --- .../ui/__tests__/StarRating.test.tsx | 113 ++++++++++++ src/features/group/__tests__/AddSong.test.tsx | 165 +++++++++++++++++ .../group/__tests__/SongCard.test.tsx | 173 +++++++++++++++++ .../join/__tests__/JoinGroup.test.tsx | 174 ++++++++++++++++++ 4 files changed, 625 insertions(+) create mode 100644 src/components/ui/__tests__/StarRating.test.tsx create mode 100644 src/features/group/__tests__/AddSong.test.tsx create mode 100644 src/features/group/__tests__/SongCard.test.tsx create mode 100644 src/features/join/__tests__/JoinGroup.test.tsx diff --git a/src/components/ui/__tests__/StarRating.test.tsx b/src/components/ui/__tests__/StarRating.test.tsx new file mode 100644 index 0000000..977bd33 --- /dev/null +++ b/src/components/ui/__tests__/StarRating.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { StarRating } from '../StarRating'; + +describe('StarRating', () => { + // --- Read-only display --- + + it('should render with img role when not interactive', () => { + render(); + + const container = screen.getByRole('img'); + expect(container).toBeDefined(); + }); + + it('should display rating in aria-label', () => { + render(); + + const container = screen.getByRole('img'); + expect(container.getAttribute('aria-label')).toBe('Rating: 3.5 out of 5 stars'); + }); + + it('should display 0 rating when rating is null', () => { + render(); + + const container = screen.getByRole('img'); + expect(container.getAttribute('aria-label')).toBe('Rating: 0 out of 5 stars'); + }); + + it('should not render interactive buttons when onRate is not provided', () => { + render(); + + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); + }); + + // --- Interactive mode --- + + it('should render with radiogroup role when interactive', () => { + render( {}} />); + + const container = screen.getByRole('radiogroup'); + expect(container).toBeDefined(); + }); + + it('should render 10 interactive buttons when onRate is provided', () => { + render( {}} />); + + // 5 stars x 2 halves = 10 buttons + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(10); + }); + + it('should call onRate with full star value when right half is clicked', () => { + const onRate = vi.fn(); + render(); + + // "3 stars" button is the right half of the 3rd star + const button = screen.getByLabelText('3 stars'); + fireEvent.click(button); + + expect(onRate).toHaveBeenCalledWith(3); + }); + + it('should call onRate with half star value when left half is clicked', () => { + const onRate = vi.fn(); + render(); + + // "2.5 stars" button is the left half of the 3rd star + const button = screen.getByLabelText('2.5 stars'); + fireEvent.click(button); + + expect(onRate).toHaveBeenCalledWith(2.5); + }); + + it('should call onRate with 0.5 when left half of first star is clicked', () => { + const onRate = vi.fn(); + render(); + + const button = screen.getByLabelText('0.5 stars'); + fireEvent.click(button); + + expect(onRate).toHaveBeenCalledWith(0.5); + }); + + it('should call onRate with 5 when right half of last star is clicked', () => { + const onRate = vi.fn(); + render(); + + const button = screen.getByLabelText('5 stars'); + fireEvent.click(button); + + expect(onRate).toHaveBeenCalledWith(5); + }); + + it('should have accessible labels for all half-star increments', () => { + render( {}} />); + + const expectedLabels = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5]; + for (const label of expectedLabels) { + expect(screen.getByLabelText(`${label} stars`)).toBeDefined(); + } + }); + + // --- SVG rendering --- + + it('should render exactly 5 star SVGs', () => { + const { container } = render(); + + const svgs = container.querySelectorAll('svg'); + expect(svgs).toHaveLength(5); + }); +}); diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx new file mode 100644 index 0000000..049054c --- /dev/null +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AddSong } from '../AddSong'; + +describe('AddSong', () => { + let mockOnAddSong: ReturnType; + + beforeEach(() => { + mockOnAddSong = vi.fn(); + }); + + it('should render input and submit button', () => { + render(); + + expect(screen.getByPlaceholderText('Paste a Spotify or YouTube link...')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDefined(); + }); + + it('should disable submit button when input is empty', () => { + render(); + + const button = screen.getByRole('button', { name: 'Add' }); + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('should enable submit button when input has value', () => { + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const button = screen.getByRole('button', { name: 'Add' }); + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('should call onAddSong with trimmed URL on submit', async () => { + mockOnAddSong.mockResolvedValue(undefined); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: ' https://open.spotify.com/track/abc ' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc'); + }); + }); + + it('should clear input on successful submission', async () => { + mockOnAddSong.mockResolvedValue(undefined); + render(); + + const input = screen.getByPlaceholderText( + 'Paste a Spotify or YouTube link...', + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(input.value).toBe(''); + }); + }); + + it('should show error message when onAddSong rejects', async () => { + mockOnAddSong.mockRejectedValue(new Error('Song not found')); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/notfound' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert.textContent).toBe('Song not found'); + }); + }); + + it('should show "Unknown error" when onAddSong rejects with non-Error', async () => { + mockOnAddSong.mockRejectedValue('string error'); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert.textContent).toBe('Unknown error'); + }); + }); + + it('should show "Resolving..." button text while submitting', async () => { + // Never resolve so we can see the loading state + mockOnAddSong.mockReturnValue(new Promise(() => {})); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Resolving...' })).toBeDefined(); + }); + }); + + it('should disable input while resolving', async () => { + mockOnAddSong.mockReturnValue(new Promise(() => {})); + render(); + + const input = screen.getByPlaceholderText( + 'Paste a Spotify or YouTube link...', + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(input.disabled).toBe(true); + }); + }); + + it('should clear error when input value changes after error', async () => { + mockOnAddSong.mockRejectedValue(new Error('fail')); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeDefined(); + }); + + // Change input to clear error + fireEvent.change(input, { target: { value: 'new value' } }); + + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('should not call onAddSong when input is only whitespace', () => { + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: ' ' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + expect(mockOnAddSong).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/group/__tests__/SongCard.test.tsx b/src/features/group/__tests__/SongCard.test.tsx new file mode 100644 index 0000000..66d8e57 --- /dev/null +++ b/src/features/group/__tests__/SongCard.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SongCard } from '../SongCard'; +import type { Member, SongWithVotes, Vote } from '../../../lib/types'; + +// --- Factories --- + +function makeSong(overrides: Partial = {}): SongWithVotes { + return { + id: 'song-1', + round_id: 'round-1', + member_id: 'member-1', + title: 'Bohemian Rhapsody', + artist: 'Queen', + album: 'A Night at the Opera', + thumbnail_url: 'https://i.scdn.co/image/abc', + platform_links: [{ platform: 'spotify', url: 'https://open.spotify.com/track/abc' }], + odesli_page_url: 'https://song.link/s/abc', + created_at: '2026-01-01T00:00:00Z', + votes: [], + avgRating: 0, + totalVotes: 0, + ...overrides, + }; +} + +function makeMember(overrides: Partial = {}): Member { + return { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeVote(overrides: Partial = {}): Vote { + return { + id: 'vote-1', + song_id: 'song-1', + member_id: 'member-2', + rating: 4, + created_at: '2026-01-02T00:00:00Z', + ...overrides, + }; +} + +const defaultProps = { + memberId: 'member-2', + members: [makeMember({ id: 'member-1', name: 'Alice' })], + onRate: vi.fn(), + locale: 'en' as const, +}; + +describe('SongCard', () => { + it('should render song title and artist', () => { + render(); + + expect(screen.getByText('Bohemian Rhapsody')).toBeDefined(); + expect(screen.getByText('Queen')).toBeDefined(); + }); + + it('should display who added the song', () => { + render(); + + expect(screen.getByText('Alice')).toBeDefined(); + }); + + it('should display "?" when song author is not in members list', () => { + const song = makeSong({ member_id: 'unknown-member' }); + render(); + + expect(screen.getByText('?')).toBeDefined(); + }); + + it('should render album art when thumbnail_url is valid HTTPS', () => { + render(); + + const img = screen.getByRole('img'); + expect(img.getAttribute('src')).toBe('https://i.scdn.co/image/abc'); + }); + + it('should not render img when thumbnail_url is null', () => { + const song = makeSong({ thumbnail_url: null }); + render(); + + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('should not render img when thumbnail_url uses HTTP', () => { + const song = makeSong({ thumbnail_url: 'http://insecure.com/image.jpg' }); + render(); + + // The safeThumbUrl function should strip non-https URLs + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('should show "Your track" when song belongs to current member', () => { + const song = makeSong({ member_id: 'member-2' }); + render(); + + expect(screen.getByText('Your track')).toBeDefined(); + }); + + it('should show star rating when song does not belong to current member', () => { + const song = makeSong({ member_id: 'member-1' }); + render(); + + // Should show interactive star rating (radiogroup) + expect(screen.getByRole('radiogroup')).toBeDefined(); + }); + + it('should not show interactive stars when song belongs to current member', () => { + const song = makeSong({ member_id: 'member-2' }); + render(); + + expect(screen.queryByRole('radiogroup')).toBeNull(); + }); + + it('should display average rating', () => { + const song = makeSong({ avgRating: 3.5, totalVotes: 4 }); + render(); + + expect(screen.getByText('3.5')).toBeDefined(); + expect(screen.getByText('(4)')).toBeDefined(); + }); + + it('should display the current user vote rating when they have voted', () => { + const vote = makeVote({ member_id: 'member-2', rating: 4.5 }); + const song = makeSong({ + votes: [vote], + avgRating: 4.5, + totalVotes: 1, + }); + render(); + + // Both the user's rating and the group average show "4.5" + const elements = screen.getAllByText('4.5'); + expect(elements).toHaveLength(2); + }); + + it('should render platform links', () => { + const song = makeSong({ + platform_links: [ + { platform: 'spotify', url: 'https://open.spotify.com/track/abc' }, + { platform: 'youtubeMusic', url: 'https://music.youtube.com/watch?v=abc' }, + ], + }); + render(); + + expect(screen.getByText('Spotify')).toBeDefined(); + expect(screen.getByText('YT Music')).toBeDefined(); + }); + + it('should render album cover alt text with album name when available', () => { + const song = makeSong({ album: 'A Night at the Opera' }); + render(); + + const img = screen.getByRole('img'); + expect(img.getAttribute('alt')).toBe('A Night at the Opera cover'); + }); + + it('should use title in alt text when album is null', () => { + const song = makeSong({ album: null }); + render(); + + const img = screen.getByRole('img'); + expect(img.getAttribute('alt')).toBe('Bohemian Rhapsody cover'); + }); +}); diff --git a/src/features/join/__tests__/JoinGroup.test.tsx b/src/features/join/__tests__/JoinGroup.test.tsx new file mode 100644 index 0000000..65ca473 --- /dev/null +++ b/src/features/join/__tests__/JoinGroup.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { JoinGroup } from '../JoinGroup'; +import type { Member } from '../../../lib/types'; + +// --- Mocks at boundaries --- + +const mockSetMemberId = vi.fn(); +vi.mock('../../../lib/storage', () => ({ + setMemberId: (...args: unknown[]) => mockSetMemberId(...args), +})); + +const mockSupabaseInsert = vi.fn(); +const mockSupabaseSelect = vi.fn(); + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + from: () => ({ + select: () => ({ + eq: () => ({ + order: () => Promise.resolve({ data: [], error: null }), + single: () => mockSupabaseSelect(), + }), + }), + insert: () => ({ + select: () => ({ + single: () => mockSupabaseInsert(), + }), + }), + }), + }, +})); + +// --- Factories --- + +function makeMember(overrides: Partial = {}): Member { + return { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +// Mock window.location.reload +const mockReload = vi.fn(); +Object.defineProperty(window, 'location', { + value: { reload: mockReload }, + writable: true, +}); + +describe('JoinGroup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // --- Member selection step --- + + it('should show member selection when members are provided', () => { + const members = [ + makeMember({ id: 'm1', name: 'Alice' }), + makeMember({ id: 'm2', name: 'Bob' }), + ]; + + render(); + + expect(screen.getByText('Who are you?')).toBeDefined(); + expect(screen.getByText('Alice')).toBeDefined(); + expect(screen.getByText('Bob')).toBeDefined(); + }); + + it('should show create form when no members exist', () => { + render(); + + expect(screen.getByPlaceholderText('Your name')).toBeDefined(); + expect(screen.getByText('Join group')).toBeDefined(); + }); + + it('should save member ID and reload when selecting existing member', () => { + const members = [makeMember({ id: 'm1', name: 'Alice' })]; + + render(); + + const aliceButton = screen.getByRole('option', { name: /Alice/ }); + fireEvent.click(aliceButton); + + expect(mockSetMemberId).toHaveBeenCalledWith('test-group', 'm1'); + expect(mockReload).toHaveBeenCalled(); + }); + + it('should switch to create form when "I\'m new here" is clicked', () => { + const members = [makeMember({ id: 'm1', name: 'Alice' })]; + + render(); + + fireEvent.click(screen.getByText("I'm new here")); + + expect(screen.getByPlaceholderText('Your name')).toBeDefined(); + }); + + // --- Create member step --- + + it('should show error when name is too short', async () => { + render(); + + const input = screen.getByPlaceholderText('Your name'); + fireEvent.change(input, { target: { value: 'A' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert.textContent).toBe('Name is required'); + }); + }); + + it('should disable submit button when name is empty', () => { + render(); + + const button = screen.getByRole('button', { name: 'Join group' }); + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('should render emoji picker for avatar selection', () => { + render(); + + expect(screen.getByText('Pick your avatar')).toBeDefined(); + expect(screen.getByRole('radiogroup')).toBeDefined(); + }); + + it('should show back button to member selection when members exist', () => { + const members = [makeMember({ id: 'm1', name: 'Alice' })]; + + render(); + + // Switch to create step + fireEvent.click(screen.getByText("I'm new here")); + + // Should show a back button + const backButton = screen.getByText('Who are you?'); + expect(backButton).toBeDefined(); + }); + + it('should render join title', () => { + render(); + + expect(screen.getByText('Join this group')).toBeDefined(); + }); + + it('should render listbox for member selection', () => { + const members = [makeMember({ id: 'm1', name: 'Alice' })]; + + render(); + + expect(screen.getByRole('listbox')).toBeDefined(); + }); + + it('should display member avatars in the selection list', () => { + const members = [ + makeMember({ id: 'm1', name: 'Alice', avatar: '🎵' }), + makeMember({ id: 'm2', name: 'Bob', avatar: '🎸' }), + ]; + + render(); + + expect(screen.getByText('🎵')).toBeDefined(); + expect(screen.getByText('🎸')).toBeDefined(); + }); +}); From 1ed0c1094519dff30d52421773618cd9fc2242a6 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:58:31 +0100 Subject: [PATCH 6/9] test(security): add XSS and malicious URL security tests Cover storage XSS prevention (script tags, URL-encoded payloads, path traversal, HTML entities, SQL injection in member IDs), URL validation (javascript:/data:/file: protocols, HTTP rejection, non-music domains, embedded credentials), title truncation to prevent storage abuse, HTTPS-only enforcement for platform links and thumbnails. --- src/lib/__tests__/security.test.ts | 250 +++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/lib/__tests__/security.test.ts diff --git a/src/lib/__tests__/security.test.ts b/src/lib/__tests__/security.test.ts new file mode 100644 index 0000000..ca72b3c --- /dev/null +++ b/src/lib/__tests__/security.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getMemberId, setMemberId } from '../storage'; +import { resolveSongLink } from '../odesli'; + +// --- Mocks --- + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Security: storage XSS prevention', () => { + const mockStorage = new Map(); + + beforeEach(() => { + mockStorage.clear(); + + const mockLocalStorage = { + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + removeItem: (key: string) => mockStorage.delete(key), + }; + + vi.stubGlobal('window', { localStorage: mockLocalStorage }); + vi.stubGlobal('localStorage', mockLocalStorage); + }); + + it('should reject slug containing script tags', () => { + const result = getMemberId(''); + expect(result).toBeNull(); + }); + + it('should reject slug with URL-encoded XSS payload', () => { + const result = getMemberId('%3Cscript%3Ealert(1)%3C/script%3E'); + expect(result).toBeNull(); + }); + + it('should reject slug with special characters', () => { + const result = getMemberId('../../etc/passwd'); + expect(result).toBeNull(); + }); + + it('should reject slug with HTML entities', () => { + const result = getMemberId('test&group'); + expect(result).toBeNull(); + }); + + it('should reject slug with quotes', () => { + const result = getMemberId("test'group"); + expect(result).toBeNull(); + }); + + it('should reject member ID with script payload', () => { + setMemberId('my-group', ''); + expect(mockStorage.has('aux:members:my-group')).toBe(false); + }); + + it('should reject member ID with SQL injection attempt', () => { + setMemberId('my-group', "'; DROP TABLE members; --"); + expect(mockStorage.has('aux:members:my-group')).toBe(false); + }); + + it('should sanitize slug in storage key to prevent key injection', () => { + // Even if someone bypasses the SLUG_RE check externally, + // the storage key function strips non-alphanumeric chars + const validUuid = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; + setMemberId('a', validUuid); + + // The storage key should be clean + expect(mockStorage.get('aux:members:a')).toBe(validUuid); + }); +}); + +describe('Security: URL validation in odesli', () => { + it('should reject javascript: protocol URLs', async () => { + await expect(resolveSongLink('javascript:alert(document.cookie)')).rejects.toThrow( + 'URL must use HTTPS', + ); + }); + + it('should reject data: protocol URLs', async () => { + await expect(resolveSongLink('data:text/html,')).rejects.toThrow( + 'URL must use HTTPS', + ); + }); + + it('should reject URLs with embedded credentials', async () => { + await expect(resolveSongLink('https://evil@open.spotify.com/track/abc')).rejects.toThrow(); + }); + + it('should reject HTTP URLs to prevent MITM', async () => { + await expect(resolveSongLink('http://open.spotify.com/track/abc')).rejects.toThrow( + 'URL must use HTTPS', + ); + }); + + it('should reject URLs from non-music domains', async () => { + await expect(resolveSongLink('https://evil.com/track/abc')).rejects.toThrow( + 'URL must be from a supported music platform', + ); + }); + + it('should reject URLs with XSS payloads in path', async () => { + await expect(resolveSongLink('https://evil.com/')).rejects.toThrow( + 'URL must be from a supported music platform', + ); + }); + + it('should reject file: protocol URLs', async () => { + await expect(resolveSongLink('file:///etc/passwd')).rejects.toThrow('URL must use HTTPS'); + }); + + it('should truncate excessively long titles to prevent storage abuse', async () => { + const mockFetch = vi.mocked(fetch); + const longTitle = 'A'.repeat(500); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + entityUniqueId: 'SPOTIFY_SONG::abc', + pageUrl: 'https://song.link/s/abc', + entitiesByUniqueId: { + 'SPOTIFY_SONG::abc': { + id: 'abc', + type: 'song', + title: longTitle, + artistName: 'Artist', + thumbnailUrl: 'https://i.scdn.co/image/abc', + platforms: ['spotify'], + }, + }, + linksByPlatform: { + spotify: { + entityUniqueId: 'SPOTIFY_SONG::abc', + url: 'https://open.spotify.com/track/abc', + }, + }, + }), + } as unknown as Response); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + + // Title should be truncated to 300 characters + expect(result.title.length).toBeLessThanOrEqual(300); + }); + + it('should only allow HTTPS URLs in platform links', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + entityUniqueId: 'SPOTIFY_SONG::abc', + pageUrl: 'https://song.link/s/abc', + entitiesByUniqueId: { + 'SPOTIFY_SONG::abc': { + id: 'abc', + type: 'song', + title: 'Test', + artistName: 'Artist', + thumbnailUrl: 'https://i.scdn.co/image/abc', + platforms: ['spotify'], + }, + }, + linksByPlatform: { + spotify: { + entityUniqueId: 'SPOTIFY_SONG::abc', + url: 'https://open.spotify.com/track/abc', + }, + malicious: { + entityUniqueId: 'MAL::abc', + url: 'javascript:alert(1)', + }, + httpLink: { + entityUniqueId: 'HTTP::abc', + url: 'http://insecure.com/track/abc', + }, + }, + }), + } as unknown as Response); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + + // Only HTTPS links should be included + const urls = result.platformLinks.map((l) => l.url); + for (const url of urls) { + expect(url.startsWith('https://')).toBe(true); + } + }); + + it('should return null thumbnail for non-HTTPS thumbnail URLs', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + entityUniqueId: 'SPOTIFY_SONG::abc', + pageUrl: 'https://song.link/s/abc', + entitiesByUniqueId: { + 'SPOTIFY_SONG::abc': { + id: 'abc', + type: 'song', + title: 'Test', + artistName: 'Artist', + thumbnailUrl: 'http://insecure.com/image.jpg', + platforms: ['spotify'], + }, + }, + linksByPlatform: { + spotify: { + entityUniqueId: 'SPOTIFY_SONG::abc', + url: 'https://open.spotify.com/track/abc', + }, + }, + }), + } as unknown as Response); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + + expect(result.thumbnailUrl).toBeNull(); + }); +}); + +describe('Security: API resolve endpoint URL validation', () => { + // These tests exercise the server-side resolve handler imported directly + // The resolve endpoint tests already cover this, but these focus on attack vectors + + it('should not send requests for non-HTTPS URLs', async () => { + const mockFetch = vi.mocked(fetch); + + // Attempting to resolve an HTTP URL should throw before ever calling fetch + await expect(resolveSongLink('http://open.spotify.com/track/abc')).rejects.toThrow(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should not send requests for non-music-platform URLs', async () => { + const mockFetch = vi.mocked(fetch); + + await expect(resolveSongLink('https://evil.com/track/abc')).rejects.toThrow(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); From 853c2e5109eb8ad4d3030540928e88a2043b8402 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 20:58:46 +0100 Subject: [PATCH 7/9] chore(deps): add testing-library/react, jest-dom, and jsdom Required for component and hook tests with jsdom environment. --- package.json | 3 + pnpm-lock.yaml | 433 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 434 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e75d2b6..0e15b35 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,13 @@ "@astrojs/check": "^0.9.6", "@capacitor/assets": "^3.0.5", "@capacitor/cli": "^8.1.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "eslint": "^10.0.2", "eslint-plugin-astro": "^1.6.0", + "jsdom": "^28.1.0", "lefthook": "^2.1.1", "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcda115..4047757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,12 @@ importers: '@capacitor/cli': specifier: ^8.1.0 version: 8.1.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@typescript-eslint/eslint-plugin': specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) @@ -78,6 +84,9 @@ importers: eslint-plugin-astro: specifier: ^1.6.0 version: 1.6.0(eslint@10.0.2(jiti@2.6.1)) + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@1.8.0) lefthook: specifier: ^2.1.1 version: 2.1.1 @@ -92,10 +101,26 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(yaml@2.8.2) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/check@0.9.6': resolution: {integrity: sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==} hasBin: true @@ -222,6 +247,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -234,6 +263,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@capacitor/android@8.1.0': resolution: {integrity: sha512-z0acTPxj5DCy/U2FU7w+GA93oC+wdyKnsOcRg5rutDmSYa8Do1tzYqApKgf+hnuTNPbtrCTHp0Zy1cLiK/4MEw==} peerDependencies: @@ -284,6 +317,37 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.28': + resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -817,6 +881,15 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.14.1': + resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -1692,6 +1765,29 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trapezedev/gradle-parse@7.1.3': resolution: {integrity: sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw==} @@ -1710,6 +1806,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2053,6 +2152,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2079,6 +2182,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2234,6 +2340,9 @@ packages: peerDependencies: ajv: 4.11.8 - 8 + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -2625,6 +2734,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2637,6 +2749,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.1.0: + resolution: {integrity: sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2648,6 +2764,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2692,6 +2812,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2826,6 +2949,12 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -3490,6 +3619,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -3499,6 +3632,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3706,6 +3843,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3810,6 +3950,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4124,6 +4273,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4692,6 +4845,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4830,6 +4986,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -4885,6 +5045,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -5115,6 +5278,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5354,6 +5521,9 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5439,6 +5609,13 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -5456,9 +5633,17 @@ packages: tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5592,6 +5777,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -5937,6 +6126,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -5947,6 +6140,18 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6045,6 +6250,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -6061,6 +6270,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xpath@0.0.27: resolution: {integrity: sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==} engines: {node: '>=0.6.0'} @@ -6185,6 +6397,28 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/check@0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3)': dependencies: '@astrojs/language-server': 2.16.3(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3) @@ -6436,6 +6670,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -6459,6 +6695,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.1.0 + '@capacitor/android@8.1.0(@capacitor/core@8.1.0)': dependencies: '@capacitor/core': 8.1.0 @@ -6558,6 +6798,28 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.28': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -6866,6 +7128,10 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@exodus/bytes@1.14.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@fastify/accept-negotiator@2.0.1': {} '@fastify/busboy@3.2.0': {} @@ -7879,6 +8145,36 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@trapezedev/gradle-parse@7.1.3': {} '@trapezedev/project@7.1.3(@types/node@25.3.0)(typescript@5.9.3)': @@ -7923,6 +8219,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -8387,6 +8685,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -8423,6 +8723,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8662,6 +8966,10 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} bindings@1.5.0: @@ -9091,6 +9399,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssfilter@0.0.10: {} @@ -9099,12 +9409,26 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.1.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.28 + css-tree: 3.1.0 + lru-cache: 11.2.6 + csstype@3.2.3: {} dargs@7.0.0: {} data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -9144,6 +9468,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -9278,6 +9604,10 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -10168,12 +10498,25 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} http-cache-semantics@4.2.0: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-shutdown@1.2.2: {} https-proxy-agent@7.0.6: @@ -10373,6 +10716,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10466,6 +10811,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0(@noble/hashes@1.8.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0) + cssstyle: 6.1.0 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10741,6 +11113,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11502,6 +11876,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-browserify@1.0.1: {} path-exists@3.0.0: {} @@ -11635,6 +12013,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -11677,6 +12061,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.4: {} @@ -12011,6 +12397,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@5.7.2: {} @@ -12325,6 +12715,8 @@ snapshots: picocolors: 1.1.1 sax: 1.4.4 + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -12440,6 +12832,12 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -12454,8 +12852,16 @@ snapshots: tomlify-j0.4@3.0.0: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -12575,6 +12981,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.22.0: {} + unicorn-magic@0.1.0: {} unified@11.0.5: @@ -12733,7 +13141,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) @@ -12758,6 +13166,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.3.0 + jsdom: 28.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -12868,12 +13277,28 @@ snapshots: vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -13003,6 +13428,8 @@ snapshots: dependencies: sax: 1.4.4 + xml-name-validator@5.0.0: {} + xml2js@0.5.0: dependencies: sax: 1.4.4 @@ -13017,6 +13444,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xpath@0.0.27: {} xpath@0.0.32: {} From f82a599d00a05d02283e4ca47e1cba54fddd2e46 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 21:01:04 +0100 Subject: [PATCH 8/9] feat(groups): reduce songs per round limit from 5 to 3 --- src/features/group/useGroup.ts | 2 +- src/features/join/CreateGroup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/group/useGroup.ts b/src/features/group/useGroup.ts index 307ba01..f6dd2f9 100644 --- a/src/features/group/useGroup.ts +++ b/src/features/group/useGroup.ts @@ -35,7 +35,7 @@ function enrichSong(song: Song, votes: Vote[]): SongWithVotes { export function useGroup( groupId: string, memberId: string | null, - songsPerRound: number = 5, + songsPerRound: number = 3, initialMembers: Member[] = [], ): UseGroupResult { const [round, setRound] = useState(null); diff --git a/src/features/join/CreateGroup.tsx b/src/features/join/CreateGroup.tsx index 9e2c3bc..f29b5ca 100644 --- a/src/features/join/CreateGroup.tsx +++ b/src/features/join/CreateGroup.tsx @@ -45,7 +45,7 @@ export function CreateGroup({ locale }: CreateGroupProps) { const slug = generateCode(); const { error: groupError } = await supabase .from('groups') - .insert({ name: trimmed, slug, songs_per_round: 5 }) + .insert({ name: trimmed, slug, songs_per_round: 3 }) .select() .single(); From 8716bd5673d01a3b021c9b15875fda1a1c74d3f7 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Sun, 1 Mar 2026 21:05:30 +0100 Subject: [PATCH 9/9] fix(tests): correct mock type signatures for strict type checking --- src/features/group/__tests__/AddSong.test.tsx | 4 ++-- src/hooks/__tests__/useMember.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx index 049054c..dc30aad 100644 --- a/src/features/group/__tests__/AddSong.test.tsx +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -4,10 +4,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { AddSong } from '../AddSong'; describe('AddSong', () => { - let mockOnAddSong: ReturnType; + let mockOnAddSong: ReturnType Promise>>; beforeEach(() => { - mockOnAddSong = vi.fn(); + mockOnAddSong = vi.fn<(url: string) => Promise>(); }); it('should render input and submit button', () => { diff --git a/src/hooks/__tests__/useMember.test.ts b/src/hooks/__tests__/useMember.test.ts index 5a28236..69f1487 100644 --- a/src/hooks/__tests__/useMember.test.ts +++ b/src/hooks/__tests__/useMember.test.ts @@ -9,11 +9,11 @@ import type { Member } from '../../lib/types'; const mockMaybeSingle = vi.fn(); const mockEq = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); const mockSelect = vi.fn(() => ({ eq: mockEq })); -const mockFrom = vi.fn(() => ({ select: mockSelect })); +const mockFrom = vi.fn((_table: string) => ({ select: mockSelect })); vi.mock('../../lib/supabase', () => ({ supabase: { - from: (...args: unknown[]) => mockFrom(...args), + from: (table: string) => mockFrom(table), }, }));