Add tests for API endpoints, liked store, listen tracker and paged list
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Item> {
|
||||
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<Item>(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<Item>) => void = () => {};
|
||||
const fetchPage = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Paged<Item>>((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
const list = createPagedList<Item>(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<Item>(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<Item>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof POST>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof PUT>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof GET>[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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user