diff --git a/web-player/src/lib/player/listenTracker.test.ts b/web-player/src/lib/player/listenTracker.test.ts new file mode 100644 index 0000000..7449436 --- /dev/null +++ b/web-player/src/lib/player/listenTracker.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ListenTracker } from './listenTracker'; + +let fetchMock: ReturnType; + +function sentSeconds(call: number): number { + const init = fetchMock.mock.calls[call][1] as RequestInit; + return JSON.parse(init.body as string).playedSeconds; +} + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function startedTracker(id = 'l1') { + const tracker = new ListenTracker(); + tracker.begin(id); + tracker.tick(0); + return tracker; +} + +describe('ListenTracker', () => { + it('sums small forward advances', () => { + const tracker = startedTracker(); + tracker.tick(1); + tracker.tick(2); + tracker.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sentSeconds(0)).toBe(2); + }); + + it('ignores seeks forward and backwards', () => { + const tracker = startedTracker(); + tracker.tick(1); + tracker.tick(60); + tracker.tick(10); + tracker.tick(11); + tracker.flush(); + expect(sentSeconds(0)).toBe(2); + }); + + it('does not count the tick after interrupt()', () => { + const tracker = startedTracker(); + tracker.tick(1); + tracker.interrupt(); + tracker.tick(2); + tracker.tick(3); + tracker.flush(); + expect(sentSeconds(0)).toBe(2); + }); + + it('sends after 30 seconds of wall time', () => { + const tracker = startedTracker(); + tracker.tick(1); + expect(fetchMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(30_000); + tracker.tick(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sentSeconds(0)).toBe(2); + }); + + it('does not resend without new progress', () => { + const tracker = startedTracker(); + tracker.tick(1); + tracker.flush(); + tracker.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + tracker.tick(2); + tracker.flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(sentSeconds(1)).toBe(2); + }); + + it('sends nothing when there is no listen id', () => { + const tracker = new ListenTracker(); + tracker.begin(null); + tracker.tick(0); + tracker.tick(1); + tracker.flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('resends the total after a failed request', async () => { + fetchMock.mockRejectedValueOnce(new Error('offline')); + const tracker = startedTracker(); + tracker.tick(1); + tracker.flush(); + await vi.runAllTimersAsync(); + tracker.flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(sentSeconds(1)).toBe(1); + }); + + it('flushes the previous listen when a new one begins', () => { + const tracker = startedTracker('old'); + tracker.tick(1); + tracker.begin('new'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/api/listens/old/progress'); + tracker.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-player/src/lib/state/liked.test.ts b/web-player/src/lib/state/liked.test.ts new file mode 100644 index 0000000..273c4bc --- /dev/null +++ b/web-player/src/lib/state/liked.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { LikedTrack, Track } from '$lib/types'; + +function track(id: string): Track { + return { + id, + title: id, + artist: 'Artist', + duration: 100, + explicit: false, + listensCount: 0 + } as unknown as Track; +} + +function likedTrack(id: string): LikedTrack { + return { ...track(id), likedAt: 1 } as LikedTrack; +} + +async function freshStore() { + vi.resetModules(); + return (await import('./liked.svelte')).liked; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('liked store', () => { + it('hydrates only once', async () => { + const liked = await freshStore(); + liked.hydrate([likedTrack('a')]); + liked.hydrate([likedTrack('b')]); + expect(liked.isLiked('a')).toBe(true); + expect(liked.isLiked('b')).toBe(false); + expect(liked.count).toBe(1); + }); + + it('likes and unlikes a track', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + const liked = await freshStore(); + await liked.toggle(track('a')); + expect(liked.isLiked('a')).toBe(true); + await liked.toggle(track('a')); + expect(liked.isLiked('a')).toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('reverts a failed like', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + const liked = await freshStore(); + await liked.toggle(track('a')); + expect(liked.isLiked('a')).toBe(false); + }); + + it('reverts a failed unlike', async () => { + const liked = await freshStore(); + liked.hydrate([likedTrack('a')]); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + await liked.toggle(track('a')); + expect(liked.isLiked('a')).toBe(true); + }); + + it('keeps changes made to other tracks while a request fails', async () => { + const liked = await freshStore(); + let rejectFirst: (reason: Error) => void = () => {}; + const fetchMock = vi + .fn() + .mockImplementationOnce(() => new Promise((_, reject) => (rejectFirst = reject))) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + const first = liked.toggle(track('a')); + await liked.toggle(track('b')); + rejectFirst(new Error('offline')); + await first; + + expect(liked.isLiked('a')).toBe(false); + expect(liked.isLiked('b')).toBe(true); + }); +}); diff --git a/web-player/src/lib/state/pagedList.test.ts b/web-player/src/lib/state/pagedList.test.ts new file mode 100644 index 0000000..ccbc4a2 --- /dev/null +++ b/web-player/src/lib/state/pagedList.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Paged } from '$lib/types'; +import { createPagedList } from './pagedList.svelte'; + +interface Item { + id: number; +} + +function page(ids: number[], pageNumber: number, hasNextPage: boolean): Paged { + return { items: ids.map((id) => ({ id })), pageNumber, hasNextPage, totalItemCount: 99 }; +} + +describe('createPagedList', () => { + it('appends the next page without duplicates and updates hasMore', async () => { + const fetchPage = vi.fn().mockResolvedValue(page([2, 3], 2, false)); + const list = createPagedList(fetchPage, (item) => item.id); + list.reset(page([1, 2], 1, true)); + expect(list.hasMore).toBe(true); + + await list.loadMore(); + + expect(fetchPage).toHaveBeenCalledWith(2); + expect(list.items.map((item) => item.id)).toEqual([1, 2, 3]); + expect(list.hasMore).toBe(false); + }); + + it('ignores loadMore while another is in flight', async () => { + let resolve: (value: Paged) => void = () => {}; + const fetchPage = vi.fn().mockImplementation( + () => + new Promise>((r) => { + resolve = r; + }) + ); + const list = createPagedList(fetchPage, (item) => item.id); + list.reset(page([1], 1, true)); + + const first = list.loadMore(); + expect(list.loading).toBe(true); + await list.loadMore(); + expect(fetchPage).toHaveBeenCalledTimes(1); + + resolve(page([2], 2, false)); + await first; + expect(list.loading).toBe(false); + }); + + it('flags an error and keeps the items when the fetch fails', async () => { + const fetchPage = vi.fn().mockRejectedValue(new Error('boom')); + const list = createPagedList(fetchPage, (item) => item.id); + list.reset(page([1, 2], 1, true)); + + await list.loadMore(); + + expect(list.error).toBe(true); + expect(list.loading).toBe(false); + expect(list.items).toHaveLength(2); + }); + + it('reset clears the error', async () => { + const fetchPage = vi.fn().mockRejectedValue(new Error('boom')); + const list = createPagedList(fetchPage, (item) => item.id); + list.reset(page([1], 1, true)); + await list.loadMore(); + expect(list.error).toBe(true); + + list.reset(page([5], 1, false)); + + expect(list.error).toBe(false); + expect(list.items).toEqual([{ id: 5 }]); + expect(list.hasMore).toBe(false); + }); +}); diff --git a/web-player/src/routes/api/likes/server.test.ts b/web-player/src/routes/api/likes/server.test.ts new file mode 100644 index 0000000..d5e50fd --- /dev/null +++ b/web-player/src/routes/api/likes/server.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } })); + +import { POST } from './+server'; + +type PostEvent = Parameters[0]; + +function event(body: unknown, options: { token?: string | null; api?: Response } = {}) { + const { token = 'token', api = Response.json(true) } = options; + const fetchFn = vi.fn().mockResolvedValue(api); + const request = new Request('http://x/api/likes', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body) + }); + return { + event: { + request, + locals: { accessToken: token, user: null }, + fetch: fetchFn + } as unknown as PostEvent, + fetchFn + }; +} + +describe('POST /api/likes', () => { + it('returns 401 without a token', async () => { + const { event: e, fetchFn } = event({ trackId: 't1' }, { token: null }); + expect((await POST(e)).status).toBe(401); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it.each([[{}], [{ trackId: '' }], [{ trackId: 5 }], ['not json']])( + 'returns 400 for an invalid body %j', + async (body) => { + const { event: e, fetchFn } = event(body); + expect((await POST(e)).status).toBe(400); + expect(fetchFn).not.toHaveBeenCalled(); + } + ); + + it('returns the liked state from the API', async () => { + const { event: e } = event({ trackId: 't1' }); + const res = await POST(e); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ liked: true }); + }); + + it('returns 502 when the API fails', async () => { + const { event: e } = event( + { trackId: 't1' }, + { api: Response.json({ title: 'boom' }, { status: 500 }) } + ); + expect((await POST(e)).status).toBe(502); + }); +}); diff --git a/web-player/src/routes/api/listens/[id]/progress/server.test.ts b/web-player/src/routes/api/listens/[id]/progress/server.test.ts new file mode 100644 index 0000000..7ae6cfb --- /dev/null +++ b/web-player/src/routes/api/listens/[id]/progress/server.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } })); + +import { PUT } from './+server'; + +type PutEvent = Parameters[0]; + +function event(body: string, api: Response = new Response(null, { status: 204 })) { + const fetchFn = vi.fn().mockResolvedValue(api); + const request = new Request('http://x/api/listens/l1/progress', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body + }); + return { + event: { + request, + params: { id: 'l1' }, + locals: { accessToken: 'token', user: null }, + fetch: fetchFn + } as unknown as PutEvent, + fetchFn + }; +} + +describe('PUT /api/listens/[id]/progress', () => { + it('returns 401 without a token', async () => { + const { event: e } = event('{"playedSeconds":5}'); + (e.locals as { accessToken: string | null }).accessToken = null; + expect((await PUT(e)).status).toBe(401); + }); + + it.each([ + ['invalid JSON', 'nope'], + ['negative seconds', '{"playedSeconds":-1}'], + ['string seconds', '{"playedSeconds":"5"}'], + ['missing seconds', '{}'], + ['null seconds', '{"playedSeconds":null}'] + ])('returns 400 for %s', async (_name, body) => { + const { event: e, fetchFn } = event(body); + expect((await PUT(e)).status).toBe(400); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('returns 204 when the API accepts the progress', async () => { + const { event: e, fetchFn } = event('{"playedSeconds":12.5}'); + expect((await PUT(e)).status).toBe(204); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('returns 404 when the API returns 404', async () => { + const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 404 })); + expect((await PUT(e)).status).toBe(404); + }); + + it('returns 502 for other API failures', async () => { + const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 500 })); + expect((await PUT(e)).status).toBe(502); + }); +}); diff --git a/web-player/src/routes/api/tracks/server.test.ts b/web-player/src/routes/api/tracks/server.test.ts new file mode 100644 index 0000000..5635e0f --- /dev/null +++ b/web-player/src/routes/api/tracks/server.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config'; + +vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } })); + +import { GET } from './+server'; + +type GetEvent = Parameters[0]; + +async function requestedQuery(search: string) { + const fetchFn = vi + .fn() + .mockResolvedValue( + Response.json({ items: [], pageNumber: 1, hasNextPage: false, totalItemCount: 0 }) + ); + const res = await GET({ + url: new URL(`http://x/api/tracks${search}`), + locals: { accessToken: null, user: null }, + fetch: fetchFn + } as unknown as GetEvent); + expect(res.status).toBe(200); + const request = fetchFn.mock.calls[0][0] as Request; + return new URL(request.url).searchParams; +} + +describe('GET /api/tracks', () => { + it('caps pageSize at MAX_PAGE_SIZE', async () => { + const query = await requestedQuery('?pageSize=100000'); + expect(query.get('pageSize')).toBe(String(MAX_PAGE_SIZE)); + }); + + it('uses the default page size for invalid values', async () => { + expect((await requestedQuery('?pageSize=abc')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE)); + expect((await requestedQuery('')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE)); + }); + + it('resets an invalid pageNumber to 1', async () => { + expect((await requestedQuery('?pageNumber=-3')).get('pageNumber')).toBe('1'); + expect((await requestedQuery('?pageNumber=abc')).get('pageNumber')).toBe('1'); + expect((await requestedQuery('?pageNumber=4')).get('pageNumber')).toBe('4'); + }); +});