Add tests for API endpoints, liked store, listen tracker and paged list

This commit is contained in:
2026-09-24 14:04:43 +02:00
parent b5c9658d65
commit e5ce63a192
6 changed files with 423 additions and 0 deletions
@@ -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);
});
});
+80
View File
@@ -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);
});
});