123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import { ApiError } from '$lib/api/client';
|
|
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
/*
|
|
* `loadEventResults` exists to contain the blast radius of one bad event, so what
|
|
* matters here is the failure behaviour: a broken event must not take the others
|
|
* with it, while an abort or a 401 must stop everything.
|
|
*/
|
|
|
|
// Typed so the assertions below read the call arguments without falling back to `any`.
|
|
const getResults = vi.hoisted(() =>
|
|
vi.fn<(ids: number[], options?: { signal?: AbortSignal }) => Promise<TournamentsResultDTO>>()
|
|
);
|
|
vi.mock('$lib/api/tournaments', () => ({ getResults }));
|
|
|
|
const { loadEventResults } = await import('./load');
|
|
|
|
function events(count: number): EventDTO[] {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: i + 1,
|
|
name: `Event ${i + 1}`,
|
|
date: undefined
|
|
}));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
getResults.mockReset();
|
|
});
|
|
|
|
describe('loadEventResults', () => {
|
|
it('requests one event at a time and returns them all', async () => {
|
|
getResults.mockImplementation((ids: number[]) =>
|
|
Promise.resolve({ results: [{ player: `P${ids[0]}` }] })
|
|
);
|
|
|
|
const outcome = await loadEventResults(events(3));
|
|
|
|
expect(outcome.loaded).toHaveLength(3);
|
|
expect(outcome.failed).toEqual([]);
|
|
// One id per call — batching is what this module exists to avoid.
|
|
expect(getResults.mock.calls.map((c) => c[0])).toEqual([[1], [2], [3]]);
|
|
});
|
|
|
|
it('skips events with no id rather than requesting undefined', async () => {
|
|
getResults.mockResolvedValue({ results: [] });
|
|
await loadEventResults([{ id: 1, name: 'A' }, { name: 'B' }]);
|
|
expect(getResults).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('isolates a failing event and reports it, keeping the rest', async () => {
|
|
getResults.mockImplementation((ids: number[]) =>
|
|
ids[0] === 2
|
|
? Promise.reject(new ApiError(500, 'Bracket has no rank-1 row'))
|
|
: Promise.resolve({ results: [] })
|
|
);
|
|
|
|
const outcome = await loadEventResults(events(3));
|
|
|
|
expect(outcome.loaded).toHaveLength(2);
|
|
expect(outcome.failed).toHaveLength(1);
|
|
expect(outcome.failed[0].event.id).toBe(2);
|
|
expect(outcome.failed[0].message).toBe('Bracket has no rank-1 row');
|
|
});
|
|
|
|
it('stops everything on a 401 — retrying N times would just burn requests', async () => {
|
|
getResults.mockRejectedValue(new ApiError(401, 'Unauthorized'));
|
|
await expect(loadEventResults(events(3))).rejects.toThrow(ApiError);
|
|
});
|
|
|
|
it('propagates an abort rather than recording it as a failed event', async () => {
|
|
getResults.mockRejectedValue(new DOMException('Aborted', 'AbortError'));
|
|
await expect(loadEventResults(events(2))).rejects.toThrow(DOMException);
|
|
});
|
|
|
|
it('makes no request at all when the signal is already aborted', async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const outcome = await loadEventResults(events(3), { signal: controller.signal });
|
|
|
|
expect(getResults).not.toHaveBeenCalled();
|
|
expect(outcome.loaded).toEqual([]);
|
|
});
|
|
|
|
it('reports progress once per settled event, failures included', async () => {
|
|
getResults.mockImplementation((ids: number[]) =>
|
|
ids[0] === 2 ? Promise.reject(new ApiError(500, 'boom')) : Promise.resolve({ results: [] })
|
|
);
|
|
|
|
const seen: number[] = [];
|
|
await loadEventResults(events(3), { onProgress: (done, total) => {
|
|
expect(total).toBe(3);
|
|
seen.push(done);
|
|
} });
|
|
|
|
expect(seen).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('never runs more requests at once than the concurrency allows', async () => {
|
|
let inFlight = 0;
|
|
let peak = 0;
|
|
getResults.mockImplementation(async () => {
|
|
inFlight++;
|
|
peak = Math.max(peak, inFlight);
|
|
await Promise.resolve();
|
|
inFlight--;
|
|
return { results: [] };
|
|
});
|
|
|
|
await loadEventResults(events(10), { concurrency: 3 });
|
|
|
|
expect(peak).toBeLessThanOrEqual(3);
|
|
expect(getResults).toHaveBeenCalledTimes(10);
|
|
});
|
|
|
|
it('handles an empty scope without spawning workers', async () => {
|
|
const outcome = await loadEventResults([]);
|
|
expect(outcome).toEqual({ loaded: [], failed: [] });
|
|
expect(getResults).not.toHaveBeenCalled();
|
|
});
|
|
});
|