- vite assetsInlineLimit 0: fonts-work.css 42.8 KB -> 5.9 KB - Ghost posts fetched with limit=100&page=N instead of limit=all - 404 title uses site.name; drop the duplicate js-class script on /everything/ - deploy/compose.yml: LISTENBRAINZ_USER - HANDOFF and TODO: the site is live and the sources ran Audit findings 2, 6, 7, 18, 19, 21 (docs/audits/2026-11-11-audit.md). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
281 lines
15 KiB
TypeScript
281 lines
15 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { HARDCOVER_QUERY, loadSources, postFromGhost } from '../../src/sources/index.ts';
|
|
|
|
const FIX = join(import.meta.dir, '../../fixtures/sources');
|
|
const read = (n: string) => Bun.file(join(FIX, n)).text();
|
|
|
|
const env = {
|
|
GHOST_URL: 'https://blog.example.test', GHOST_CONTENT_KEY: 'k',
|
|
TRAKT_CLIENT_ID: 'cid', TRAKT_USER: 'lucasw89', HARDCOVER_TOKEN: 'tok', LISTENBRAINZ_USER: 'fixture-user',
|
|
};
|
|
|
|
let dir: string;
|
|
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'sources-')); });
|
|
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
|
|
type Opts = { traktStatus?: number; history?: string; movieRatings?: string; ratingPages?: string[]; seen?: string[]; lbPages?: unknown[][]; lbStatus?: number };
|
|
function fixtureFetch(o: Opts = {}): typeof fetch {
|
|
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
o.seen?.push(url);
|
|
const json = (body: string, status = 200) => new Response(body, { status });
|
|
if (url.includes('/ghost/api/content/posts/')) return json(await read('ghost-posts.json'));
|
|
if (url.includes('api.trakt.tv')) {
|
|
const h = new Headers(init?.headers);
|
|
if (h.get('trakt-api-version') !== '2' || h.get('trakt-api-key') !== 'cid') return json('', 400);
|
|
if (o.traktStatus) return json('', o.traktStatus);
|
|
if (url.endsWith('/history/movies?limit=1')) return json(o.history ?? await read('trakt-history.json'));
|
|
const rp = url.match(/\/ratings\/movies\?page=(\d+)&limit=100$/);
|
|
if (rp) {
|
|
const pages = o.ratingPages ?? [o.movieRatings ?? await read('trakt-movie-ratings.json')];
|
|
const body = pages[Number(rp[1]) - 1] ?? '[]';
|
|
return new Response(body, { status: 200, headers: { 'x-pagination-page-count': String(pages.length) } });
|
|
}
|
|
if (url.endsWith('/ratings?limit=5')) return json(await read('trakt-ratings.json'));
|
|
}
|
|
if (url.startsWith('https://api.listenbrainz.org/1/user/fixture-user/listens?count=1000')) {
|
|
if (o.lbStatus) return json('', o.lbStatus);
|
|
const pages = o.lbPages ?? [JSON.parse(await read('listenbrainz-listens.json')).payload.listens];
|
|
const maxTs = url.match(/&max_ts=(\d+)/)?.[1];
|
|
const i = maxTs ? (o.lbPages ?? []).findIndex((p) => (p as { listened_at: number }[]).every((l) => l.listened_at < Number(maxTs))) : 0;
|
|
return json(JSON.stringify({ payload: { listens: i < 0 ? [] : pages[i] } }));
|
|
}
|
|
if (url === 'https://api.hardcover.app/v1/graphql') {
|
|
if (new Headers(init?.headers).get('authorization') !== 'Bearer tok') return json('', 401);
|
|
return json(await read('hardcover.json'));
|
|
}
|
|
return json('not found', 404);
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
test('ghost posts are fetched page by page until the last page', async () => {
|
|
const post = (slug: string) => ({ slug, title: slug, published_at: '2025-01-01T00:00:00.000+00:00', url: `https://blog.example.test/${slug}/` });
|
|
const pages = [
|
|
{ posts: [post('one'), post('two')], meta: { pagination: { page: 1, pages: 2, next: 2 } } },
|
|
{ posts: [post('three')], meta: { pagination: { page: 2, pages: 2, next: null } } },
|
|
];
|
|
const asked: string[] = [];
|
|
const f = (async (input: RequestInfo | URL) => {
|
|
const url = new URL(String(input));
|
|
if (!url.pathname.endsWith('/ghost/api/content/posts/')) return new Response('', { status: 500 });
|
|
asked.push(`${url.searchParams.get('limit')}/${url.searchParams.get('page')}`);
|
|
return Response.json(pages[Number(url.searchParams.get('page')) - 1] ?? { posts: [] });
|
|
}) as typeof fetch;
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: f });
|
|
expect(s.status.ghost.ok).toBe(true);
|
|
expect(s.posts.map((p) => p.slug)).toEqual(['one', 'two', 'three']);
|
|
expect(asked).toEqual(['100/1', '100/2']);
|
|
});
|
|
|
|
test('ghost tags decide side, kind and ai level', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() });
|
|
expect(s.status.ghost.ok).toBe(true);
|
|
expect(s.posts.map((p) => [p.side, p.kind, p.ai])).toEqual([
|
|
['work', 'post', 'unknown'],
|
|
['off-the-clock', 'essay', 'by-hand'],
|
|
['off-the-clock', 'life', 'some-ai'],
|
|
]);
|
|
expect(s.posts[1]!.date).toBe('2025-05-02');
|
|
expect(s.posts[1]!.tags).not.toContain('hash-internal');
|
|
expect(s.posts[0]!.url).toBe('https://blog.example.test/synthetic-work-post/');
|
|
});
|
|
|
|
test('internal tags never decide anything', () => {
|
|
const p = postFromGhost({
|
|
slug: 'x', title: 'X', published_at: '2025-01-01T00:00:00Z', url: 'u',
|
|
tags: [{ slug: 'hash-off-the-clock', name: '#off-the-clock', visibility: 'internal' }],
|
|
});
|
|
expect(p.side).toBe('work');
|
|
expect(p.tags).toEqual([]);
|
|
});
|
|
|
|
const LAST = { title: 'Synthetic Movie', year: 2019, url: 'https://trakt.tv/movies/synthetic-movie', date: '2025-06-30' };
|
|
|
|
test('trakt maps the last film with its rating, and recent ratings', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() });
|
|
expect(s.trakt.lastFilm).toEqual({ ...LAST, rating: 9 });
|
|
expect(s.trakt.recent[0]).toEqual({ title: 'Synthetic Movie', rating: 9, url: 'https://trakt.tv/movies/synthetic-movie', date: '2025-06-30' });
|
|
});
|
|
|
|
test('a last film not yet rated has no rating', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ movieRatings: '[]' }) });
|
|
expect(s.trakt.lastFilm).toEqual(LAST);
|
|
});
|
|
|
|
test('the rating is matched by slug when the trakt id is missing', async () => {
|
|
const history = JSON.stringify([{ watched_at: '2025-06-30T20:00:00.000Z', type: 'movie', movie: { title: 'Synthetic Movie', year: 2019, ids: { slug: 'synthetic-movie' } } }]);
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ history }) });
|
|
expect(s.trakt.lastFilm?.rating).toBe(9);
|
|
});
|
|
|
|
test('the rating is found on a later page of film ratings, and paging stops there', async () => {
|
|
const other = (n: number) => JSON.stringify([{ rated_at: '2026-01-01T00:00:00.000Z', rating: 5, type: 'movie', movie: { title: `Other ${n}`, ids: { trakt: 100 + n, slug: `other-${n}` } } }]);
|
|
const seen: string[] = [];
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ ratingPages: [other(1), await read('trakt-movie-ratings.json'), other(3)], seen }) });
|
|
expect(s.trakt.lastFilm?.rating).toBe(9);
|
|
expect(seen.filter((u) => u.includes('/ratings/movies')).length).toBe(2);
|
|
});
|
|
|
|
test('a history entry without a date is skipped, not fatal', async () => {
|
|
const history = JSON.stringify([{ type: 'movie', movie: { title: 'No Date', ids: { trakt: 1 } } }]);
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ history }) });
|
|
expect(s.status.trakt.ok).toBe(true);
|
|
expect(s.trakt.lastFilm).toBeNull();
|
|
});
|
|
|
|
test('an empty history means no last film', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ history: '[]' }) });
|
|
expect(s.status.trakt.ok).toBe(true);
|
|
expect(s.trakt.lastFilm).toBeNull();
|
|
expect(s.trakt.recent).toHaveLength(2);
|
|
});
|
|
|
|
test('never asks Trakt what is playing right now', async () => {
|
|
const seen: string[] = [];
|
|
await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ seen }) });
|
|
expect(seen.some((u) => u.includes('api.trakt.tv'))).toBe(true);
|
|
expect(seen.some((u) => u.endsWith('/watching'))).toBe(false);
|
|
});
|
|
|
|
test('a cache written before the last film existed reads as no last film', async () => {
|
|
await Bun.write(join(dir, 'trakt.json'), JSON.stringify({ fetchedAt: '2026-01-01T00:00:00.000Z', data: { watching: null, recent: [] } }));
|
|
const down = (async () => { throw new Error('network down'); }) as unknown as typeof fetch;
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: down });
|
|
expect(s.trakt).toEqual({ lastFilm: null, recent: [] });
|
|
});
|
|
|
|
test('hardcover maps reading and recent ratings', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() });
|
|
expect(s.hardcover.reading).toEqual([{
|
|
title: 'A Synthetic Novel', author: 'Ada Example', progress: 25,
|
|
url: 'https://hardcover.app/books/a-synthetic-novel', cover: 'https://covers.example.test/a-synthetic-novel.jpg',
|
|
}]);
|
|
expect(s.hardcover.recent[0]).toEqual({ title: 'Another Synthetic Book', rating: 4.5, url: 'https://hardcover.app/books/another-synthetic-book', date: '2025-06-25' });
|
|
expect(HARDCOVER_QUERY).toContain('status_id');
|
|
});
|
|
|
|
test('a failing source returns cached data and reports the error', async () => {
|
|
const first = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() });
|
|
const down = (async () => { throw new Error('network down'); }) as unknown as typeof fetch;
|
|
const second = await loadSources({ cacheDir: dir, env, fetch: down });
|
|
expect(second.trakt).toEqual(first.trakt);
|
|
expect(second.posts).toEqual(first.posts);
|
|
expect(second.status.trakt.ok).toBe(false);
|
|
expect(second.status.trakt.error).toContain('network down');
|
|
expect(second.status.trakt.fetchedAt).toBe(first.status.trakt.fetchedAt!);
|
|
});
|
|
|
|
test('a failing source with no cache returns empty data', async () => {
|
|
const down = (async () => { throw new Error('boom'); }) as unknown as typeof fetch;
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: down });
|
|
expect(s.posts).toEqual([]);
|
|
expect(s.trakt).toEqual({ lastFilm: null, recent: [] });
|
|
expect(s.hardcover).toEqual({ reading: [], recent: [] });
|
|
expect(s.status.ghost.ok).toBe(false);
|
|
});
|
|
|
|
test('non-2xx and bad JSON are failures, not throws', async () => {
|
|
const bad = (async (u: RequestInfo | URL) =>
|
|
String(u).includes('ghost') ? new Response('<html>', { status: 200 }) : new Response('', { status: 500 })) as unknown as typeof fetch;
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: bad });
|
|
expect(s.status.ghost.ok).toBe(false);
|
|
expect(s.status.trakt.error).toContain('500');
|
|
expect(s.status.hardcover.ok).toBe(false);
|
|
});
|
|
|
|
test('missing credentials report a clear error, not a throw', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env: {}, fetch: fixtureFetch() });
|
|
expect(s.status.hardcover.error).toContain('HARDCOVER_TOKEN');
|
|
expect(s.status.trakt.error).toContain('TRAKT_CLIENT_ID');
|
|
expect(s.status.ghost.error).toContain('GHOST');
|
|
});
|
|
|
|
test('private trakt profile is reported', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ traktStatus: 403 }) });
|
|
expect(s.status.trakt.ok).toBe(false);
|
|
expect(s.status.trakt.error).toBe('Trakt profile not readable with an API key (private?)');
|
|
});
|
|
|
|
test('SOURCES_FIXTURES reads fixture files instead of fetching', async () => {
|
|
const never = (async () => { throw new Error('network must not be used'); }) as unknown as typeof fetch;
|
|
const before = Date.now();
|
|
const s = await loadSources({ cacheDir: dir, env: { SOURCES_FIXTURES: FIX }, fetch: never });
|
|
for (const k of ['ghost', 'trakt', 'hardcover'] as const) {
|
|
expect(s.status[k].ok).toBe(true);
|
|
expect(Date.parse(s.status[k].fetchedAt!)).toBeGreaterThanOrEqual(before);
|
|
}
|
|
expect(s.posts).toHaveLength(3);
|
|
expect(s.trakt.lastFilm?.title).toBe('Synthetic Movie');
|
|
expect(s.hardcover.reading[0]!.title).toBe('A Synthetic Novel');
|
|
});
|
|
|
|
test('a stalled request times out and falls back to the cache', async () => {
|
|
const first = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch() });
|
|
const stalled = ((_input: RequestInfo | URL, init?: RequestInit) =>
|
|
new Promise<Response>((_res, rej) => {
|
|
init?.signal?.addEventListener('abort', () => rej(init.signal!.reason));
|
|
})) as typeof fetch;
|
|
const second = await loadSources({ cacheDir: dir, env, fetch: stalled, timeoutMs: 50 });
|
|
for (const k of ['ghost', 'trakt', 'hardcover'] as const) expect(second.status[k].ok).toBe(false);
|
|
expect(second.posts).toEqual(first.posts);
|
|
expect(second.trakt).toEqual(first.trakt);
|
|
expect(second.hardcover).toEqual(first.hardcover);
|
|
});
|
|
|
|
/* ---------- ListenBrainz: the week's top track (spec 004) ---------- */
|
|
const NOW = (1751371200 + 3600) * 1000; // an hour after the newest fixture listen
|
|
const lb = (ts: number, artist: string, track: string) => ({ listened_at: ts, track_metadata: { artist_name: artist, track_name: track } });
|
|
|
|
test('listening: the most played track of the last 7 days, variants counted as one', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch(), now: NOW });
|
|
expect(s.status.listenbrainz.ok).toBe(true);
|
|
expect(s.listening.top).toEqual({ artist: 'Fixture Band', title: 'Synthetic Track A', plays: 3, url: 'https://listenbrainz.org/user/fixture-user/' });
|
|
});
|
|
|
|
test('listening: a tie goes to the track played most recently', async () => {
|
|
const t = 1751371200;
|
|
const page = [lb(t, 'B', 'Two'), lb(t - 10, 'A', 'One'), lb(t - 20, 'B', 'Two'), lb(t - 30, 'A', 'One')];
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [page] }), now: NOW });
|
|
expect(s.listening.top?.title).toBe('Two');
|
|
});
|
|
|
|
test('listening: pages back until the window edge, and no further', async () => {
|
|
const t = 1751371200;
|
|
const page1 = Array.from({ length: 1000 }, (_, i) => lb(t - i, 'A', 'One'));
|
|
const page2 = [lb(t - 2000, 'B', 'Two'), lb(t - 9 * 86400, 'C', 'Old')];
|
|
const page3 = [lb(t - 10 * 86400, 'C', 'Old')];
|
|
const seen: string[] = [];
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [page1, page2, page3], seen }), now: NOW });
|
|
expect(s.listening.top).toMatchObject({ title: 'One', plays: 1000 });
|
|
expect(seen.filter((u) => u.includes('listenbrainz')).length).toBe(2);
|
|
});
|
|
|
|
test('listening: nothing in the last 7 days means no top track', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [[lb(1751371200 - 8 * 86400, 'C', 'Old')]] }), now: NOW });
|
|
expect(s.listening.top).toBeNull();
|
|
});
|
|
|
|
test('listening: a failing ListenBrainz falls back like the other sources', async () => {
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbStatus: 503 }), now: NOW });
|
|
expect(s.status.listenbrainz.ok).toBe(false);
|
|
expect(s.status.listenbrainz.error).toContain('503');
|
|
expect(s.listening).toEqual({ top: null });
|
|
});
|
|
|
|
test('listening: a cache from before ListenBrainz reads as no top track', async () => {
|
|
const down = (async () => { throw new Error('network down'); }) as unknown as typeof fetch;
|
|
const s = await loadSources({ cacheDir: dir, env, fetch: down });
|
|
expect(s.listening).toEqual({ top: null });
|
|
});
|
|
|
|
test('listening: without LISTENBRAINZ_USER nothing is fetched', async () => {
|
|
const seen: string[] = [];
|
|
const s = await loadSources({ cacheDir: dir, env: {}, fetch: fixtureFetch({ seen }) });
|
|
expect(seen.some((u) => u.includes('listenbrainz'))).toBe(false);
|
|
expect(s.status.listenbrainz.error).toContain('LISTENBRAINZ_USER is not set');
|
|
expect(s.listening).toEqual({ top: null });
|
|
});
|