'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createLimiter, createClient, normalizeMedia, AniListStop, AniListSkip } = require('../discovery/anilist'); const fixture = name => JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'anilist', `${name}.json`), 'utf8')); const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); const noWait = { wait: async () => {} }; function stub(respond) { const calls = []; const fetchImpl = async (url, init) => { calls.push({ url, init, body: JSON.parse(init.body) }); return respond(); }; return { calls, client: createClient({ endpoint: 'http://localhost/graphql', limiter: noWait, fetchImpl, today: () => new Date('2026-10-25') }) }; } const clientFor = respond => stub(respond).client; test('normalizeMedia maps fields and drops novels', () => { const media = fixture('title').data.Media; assert.deepEqual(normalizeMedia(media), { anilistId: 101, title: 'Moon Traveller', altTitles: ['Tsuki no Tabibito', '月の旅人', 'Lunar Wanderer'], format: 'MANGA', isAdult: false, status: 'ongoing', year: 2019, averageScore: 81, popularity: 5400, trending: 37, tags: [ { name: 'Adventure', rank: 100, kind: 'genre', isAdult: false }, { name: 'Fantasy', rank: 100, kind: 'genre', isAdult: false }, { name: 'Travel', rank: 88, kind: 'tag', isAdult: false }, { name: 'Nudity', rank: 40, kind: 'tag', isAdult: true }], authors: [{ staffId: 900, name: 'Aoi Example', role: 'Story & Art' }], coverUrl: 'https://example.invalid/cover/101.jpg', siteUrl: 'https://anilist.co/manga/101' }); assert.equal(normalizeMedia({ ...media, format: 'NOVEL' }), null); assert.equal(normalizeMedia(fixture('novel').data.Media), null); assert.equal(normalizeMedia({ ...media, status: 'HIATUS' }).status, 'unknown'); }); test('normalizeMedia drops authors without an integer staff id', () => { const media = fixture('title').data.Media; const edge = (id, full) => ({ role: 'Story', node: { id, name: { full } } }); const record = normalizeMedia({ ...media, staff: { edges: [edge(5, 'Has Id'), edge(undefined, 'No Id'), edge('6', 'String Id'), edge(1.5, 'Float Id'), edge(null, 'Null Id')] } }); assert.deepEqual(record.authors, [{ staffId: 5, name: 'Has Id', role: 'Story' }]); }); test('title returns a record and sends the fixed headers', async () => { const { client, calls } = stub(() => json(fixture('title'))); const record = await client.title(101); assert.equal(record.anilistId, 101); assert.equal(calls[0].body.variables.id, 101); assert.equal(calls[0].init.headers['user-agent'], 'MangaCircus-Discovery/1.0'); assert.equal(calls[0].init.headers['content-type'], 'application/json'); }); test('recs keeps positive ratings and manga formats', async () => { const out = await clientFor(() => json(fixture('recs'))).recs(101); assert.equal(out.length, 1); assert.equal(out[0].anilistId, 201); assert.equal(out[0].rating, 12); assert.equal(out[0].record.title, 'Sky Town'); }); test('recs query follows spec sort and page size', async () => { const { client, calls } = stub(() => json(fixture('recs'))); await client.recs(101); assert.match(calls[0].body.query, /recommendations\(perPage: 25, sort: RATING_DESC\)/); }); test('creator query follows spec sort and page size', async () => { const { client, calls } = stub(() => json(fixture('creator'))); await client.creator(900); assert.match(calls[0].body.query, /staffMedia\(type: MANGA, perPage: 25, sort: POPULARITY_DESC\)/); }); test('title on a novel resolves to null', async () => { assert.equal(await clientFor(() => json(fixture('novel'))).title(777), null); }); test('requests refuse redirects', async () => { const { client, calls } = stub(() => json(fixture('title'))); await client.title(101); assert.equal(calls[0].init.redirect, 'error'); const redirected = () => { throw new TypeError('fetch failed: unexpected redirect'); }; await assert.rejects(clientFor(redirected).title(1), e => e instanceof AniListSkip && e.reason === 'upstream'); }); test('recs drops recommendations whose media was removed', async () => { const recs = fixture('recs'); recs.data.Media.recommendations.nodes = [recs.data.Media.recommendations.nodes[0], { rating: 10, mediaRecommendation: null }]; const out = await clientFor(() => json(recs)).recs(101); assert.deepEqual(out.map(e => e.anilistId), [201]); }); test('one malformed item invalidates a list response', async () => { const invalid = e => e instanceof AniListSkip && e.reason === 'invalid'; const recs = fixture('recs'); recs.data.Media.recommendations.nodes.push(null); await assert.rejects(clientFor(() => json(recs)).recs(1), invalid); const tagtop = fixture('tagtop'); tagtop.data.Page.media[0].id = 'x'; await assert.rejects(clientFor(() => json(tagtop)).tagTop({ name: 'Mystery', kind: 'genre' }), invalid); for (const patch of [{ genres: 'Drama' }, { tags: [null] }, { title: 'x' }, { staff: { edges: {} } }, { staff: { edges: [null] } }]) { const fresh = fixture('fresh'); Object.assign(fresh.data.Page.media[0], patch); await assert.rejects(clientFor(() => json(fresh)).fresh(), invalid, JSON.stringify(patch)); } await assert.rejects(async () => normalizeMedia({ id: 1, format: 'MANGA', title: 5 }), invalid); }); test('creator returns roles and drops novels', async () => { const out = await clientFor(() => json(fixture('creator'))).creator(900); assert.deepEqual(out.map(e => [e.anilistId, e.role]), [[301, 'Story & Art']]); }); test('fresh query sends startDate two years back', async () => { const { client, calls } = stub(() => json(fixture('fresh'))); const out = await client.fresh(); assert.equal(calls[0].body.variables.startDate_greater, 20241025); assert.equal(calls[0].body.variables.perPage, 50); assert.match(calls[0].body.query, /TRENDING_DESC/); assert.deepEqual(out.map(e => e.anilistId), [501]); }); test('tagTop uses genre_in for genres and tag_in for tags', async () => { const g = stub(() => json(fixture('tagtop'))); const out = await g.client.tagTop({ name: 'Mystery', kind: 'genre' }); assert.deepEqual(g.calls[0].body.variables.genre_in, ['Mystery']); assert.equal(g.calls[0].body.variables.tag_in, undefined); assert.match(g.calls[0].body.query, /genre_in: \$genre_in, popularity_greater: 1000, sort: SCORE_DESC/); assert.match(g.calls[0].body.query, /Page\(perPage: 25\)/); assert.deepEqual(out.map(e => e.anilistId), [401]); const t = stub(() => json(fixture('tagtop'))); await t.client.tagTop({ name: 'Detective', kind: 'tag' }); assert.match(t.calls[0].body.query, /tag_in: \$tag_in, popularity_greater: 1000, sort: SCORE_DESC/); assert.deepEqual(t.calls[0].body.variables.tag_in, ['Detective']); assert.equal(t.calls[0].body.variables.genre_in, undefined); }); test('429 stops, 403 stops, html body is a challenge', async () => { await assert.rejects(clientFor(() => json({}, 429)).title(1), e => e instanceof AniListStop && e.reason === 'rate-limited'); await assert.rejects(clientFor(() => json({}, 403)).title(1), e => e instanceof AniListStop && e.reason === 'forbidden'); await assert.rejects(clientFor(() => json({}, 401)).title(1), e => e instanceof AniListStop && e.reason === 'forbidden'); const html = () => new Response('checking your browser', { status: 200, headers: { 'content-type': 'text/html' } }); await assert.rejects(clientFor(html).title(1), e => e instanceof AniListStop && e.reason === 'challenge'); const garbage = () => new Response('not json', { status: 200 }); await assert.rejects(clientFor(garbage).title(1), e => e instanceof AniListStop && e.reason === 'challenge'); }); test('5xx and timeout skip', async () => { await assert.rejects(clientFor(() => json({}, 503)).title(1), e => e instanceof AniListSkip && e.reason === 'upstream'); // A challenge page served with a 5xx is still a challenge, not an outage. const html503 = () => new Response('just a moment', { status: 503, headers: { 'content-type': 'text/html' } }); await assert.rejects(clientFor(html503).title(1), e => e instanceof AniListStop && e.reason === 'challenge'); const text502 = () => new Response('Bad Gateway', { status: 502 }); await assert.rejects(clientFor(text502).title(1), e => e instanceof AniListStop && e.reason === 'challenge'); const timeout = () => { throw Object.assign(new Error('timed out'), { name: 'TimeoutError' }); }; await assert.rejects(clientFor(timeout).title(1), e => e instanceof AniListSkip && e.reason === 'timeout'); const abort = () => { throw Object.assign(new Error('aborted'), { name: 'AbortError' }); }; await assert.rejects(clientFor(abort).title(1), e => e instanceof AniListSkip && e.reason === 'timeout'); }); test('missing data is invalid', async () => { const invalid = e => e instanceof AniListSkip && e.reason === 'invalid'; await assert.rejects(clientFor(() => json({ data: {} })).title(1), invalid); await assert.rejects(clientFor(() => json({ data: { Page: {} } })).fresh(), invalid); await assert.rejects(clientFor(() => json({ data: {} })).creator(1), invalid); await assert.rejects(clientFor(() => json({ data: { Media: { ...fixture('title').data.Media, id: 'x' } } })).title(1), invalid); }); test('limiter spaces calls 2400 ms apart', async () => { let clock = 1000; const sleeps = []; const limiter = createLimiter({ perMinute: 25, now: () => clock, sleep: async ms => { sleeps.push(ms); clock += ms; } }); await Promise.all([limiter.wait(), limiter.wait(), limiter.wait()]); assert.deepEqual(sleeps, [0, 2400, 2400]); });