diff --git a/test/admin/access.test.mjs b/test/admin/access.test.mjs index 37ce5a9..10f5293 100644 --- a/test/admin/access.test.mjs +++ b/test/admin/access.test.mjs @@ -9,6 +9,7 @@ const env = { ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience', }; + async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, @@ -22,6 +23,7 @@ async function token({ .setExpirationTime(expires) .sign(privateKey); } + function request(jwt, host = env.ADMIN_HOSTNAME) { return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {}, @@ -32,17 +34,18 @@ test('validates Access signature, issuer, audience, expiration and configured ho assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test', }); + for (const options of [ { audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }, ]) assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null); - assert.equal( - await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), - null, - ); + + assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null); + const wrong = await generateKeyPair('RS256'); + assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null); }); diff --git a/test/admin/log.test.mjs b/test/admin/log.test.mjs index a08193e..6f2f429 100644 --- a/test/admin/log.test.mjs +++ b/test/admin/log.test.mjs @@ -4,12 +4,16 @@ import { RunLog, logMessage } from '../../src/app/log.js'; test('bounds retained logs, redacts secrets, and preserves normal console output', async () => { const consoleLog = mock.method(console, 'info', () => {}); + try { const capture = new RunLog(['secret-value']); await capture.run(async () => { - for (let i = 0; i < 205; i++) logMessage('info', `line ${i}`); + for (let i = 0; i < 205; i++) + logMessage('info', `line ${i}`); + logMessage('info', 'secret-value Bearer sensitive-token'); }); + assert.equal(capture.snapshot.lines.length, 200); assert.equal(capture.snapshot.omitted, 6); assert.equal(capture.snapshot.lines.at(-1).text, '[redacted] Bearer [redacted]'); @@ -20,9 +24,11 @@ test('bounds retained logs, redacts secrets, and preserves normal console output }); test('concurrent run contexts do not capture each other or unrelated messages', async () => { mock.method(console, 'info', () => {}); + try { const a = new RunLog(), b = new RunLog(); + await Promise.all([ a.run(async () => { await Promise.resolve(); @@ -33,12 +39,13 @@ test('concurrent run contexts do not capture each other or unrelated messages', }), ]); logMessage('info', 'outside'); + assert.deepEqual( - a.snapshot.lines.map((l) => l.text), + a.snapshot.lines.map(l => l.text), ['a'], ); assert.deepEqual( - b.snapshot.lines.map((l) => l.text), + b.snapshot.lines.map(l => l.text), ['b'], ); } finally { diff --git a/test/dataRefresh.test.js b/test/dataRefresh.test.js index 3364a0f..c1fd49b 100644 --- a/test/dataRefresh.test.js +++ b/test/dataRefresh.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { nextDataRefreshAt } from '../src/common/dataRefresh.js'; const at = time => Date.parse(`2026-09-07T${time}Z`); + test('a late hourly publication is picked up again without waiting another hour', () => { assert.equal(nextDataRefreshAt(at('10:00:10'), 0), at('10:00:25')); assert.equal(nextDataRefreshAt(at('10:00:40'), 0), at('10:01:25')); diff --git a/test/fetch.test.js b/test/fetch.test.js index 0426503..083954c 100644 --- a/test/fetch.test.js +++ b/test/fetch.test.js @@ -4,9 +4,15 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { fetchWithTimeout } from '../src/common/fetch.js'; test('aborts a stalled request at its deadline', async () => { - mock.method(globalThis, 'fetch', (input, { signal }) => new Promise((resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - })); + mock.method( + globalThis, + 'fetch', + (input, { signal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ); + try { await Promise.all([ assert.rejects(fetchWithTimeout('https://example.test', {}, 5), { name: 'TimeoutError' }), @@ -18,13 +24,24 @@ test('aborts a stalled request at its deadline', async () => { }); test('keeps the deadline active while reading the response body', async () => { - mock.method(globalThis, 'fetch', async (input, { signal }) => new Response(new ReadableStream({ - start(controller) { - signal.addEventListener('abort', () => controller.error(signal.reason), { once: true }); - }, - }))); + mock.method( + globalThis, + 'fetch', + async (input, { signal }) => + new Response( + new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => controller.error(signal.reason), { + once: true, + }); + }, + }), + ), + ); + try { const response = await fetchWithTimeout('https://example.test', {}, 5); + await Promise.all([assert.rejects(response.text(), { name: 'TimeoutError' }), sleep(20)]); } finally { mock.restoreAll(); @@ -33,11 +50,19 @@ test('keeps the deadline active while reading the response body', async () => { test('preserves caller cancellation', async () => { let signal; - mock.method(globalThis, 'fetch', async (input, init) => { signal = init.signal; return new Response('ok'); }); + + mock.method(globalThis, 'fetch', async (input, init) => { + signal = init.signal; + + return new Response('ok'); + }); + try { - const controller = new AbortController; + const controller = new AbortController(); + await fetchWithTimeout('https://example.test', { signal: controller.signal }); controller.abort(); + assert.equal(signal.aborted, true); } finally { mock.restoreAll(); diff --git a/test/screenshots/node.test.js b/test/screenshots/node.test.js index 0f3356b..d89b1e7 100644 --- a/test/screenshots/node.test.js +++ b/test/screenshots/node.test.js @@ -5,6 +5,7 @@ import PuppeteerRenderer from '../../src/app/screenshots/PuppeteerRenderer.js'; import { withScreenshots } from '../../src/app/screenshots/node.js'; const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + afterEach(() => mock.restoreAll()); test('local browser uses page readiness and closes on success or failure', async () => { @@ -12,51 +13,75 @@ test('local browser uses page readiness and closes on success or failure', async let fail = false; let ready; let page = { - setDefaultTimeout(timeout) { assert.equal(timeout, 10_000); }, + setDefaultTimeout(timeout) { + assert.equal(timeout, 10_000); + }, async setViewport() {}, async setCacheEnabled() {}, async goto(url, options) { assert.equal(url, 'http://localhost:8080/screenshots.html'); assert.equal(options.waitUntil, 'domcontentloaded'); + return { ok: () => true }; }, async waitForSelector(selector) { ready = selector; + if (fail) throw new Error('Page not ready'); }, async screenshot(options) { assert.equal(options.type, 'png'); + return PNG; }, }; + mock.method(puppeteer, 'launch', async () => ({ newPage: async () => page, - close: async () => { closed++; }, + close: async () => { + closed++; + }, })); - let renderer = new PuppeteerRenderer; - let options = { url: 'http://localhost:8080/screenshots.html', viewport: {}, readySelector: '[data-screenshot-ready="true"]' }; + + let renderer = new PuppeteerRenderer(); + let options = { + url: 'http://localhost:8080/screenshots.html', + viewport: {}, + readySelector: '[data-screenshot-ready="true"]', + }; + assert.deepEqual(await renderer.capture(options), PNG); assert.equal(ready, options.readySelector); + fail = true; + await assert.rejects(renderer.capture(options), /Page not ready/); assert.equal(closed, 2); }); test('Node can select Cloudflare for a direct URL without SITE_URL or social data', async () => { let requests = []; + mock.method(globalThis, 'fetch', async (url, init) => { requests.push(JSON.parse(init.body)); + return new Response(PNG); }); + let previous = { ...process.env }; + try { process.env.CLOUDFLARE_ACCOUNT_ID = 'account'; process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; + let url = 'https://dev.example.test/screenshots.html#/schedules/3600'; let result = await withScreenshots(screenshots => screenshots.capture({ url }), { - provider: 'cloudflare', siteUrl: '', url, + provider: 'cloudflare', + siteUrl: '', + url, }); + assert.deepEqual(result.image, PNG); assert.equal(requests[0].url, url); } finally { @@ -65,8 +90,14 @@ test('Node can select Cloudflare for a direct URL without SITE_URL or social dat }); test('provider selection is explicit and Cloudflare requires a reachable target', async () => { - await assert.rejects(withScreenshots(() => {}, { provider: 'unknown' }), /SCREENSHOT_PROVIDER/); - await assert.rejects(withScreenshots(() => {}, { provider: 'cloudflare', siteUrl: '' }), /SITE_URL or --url/); + await assert.rejects( + withScreenshots(() => {}, { provider: 'unknown' }), + /SCREENSHOT_PROVIDER/, + ); + await assert.rejects( + withScreenshots(() => {}, { provider: 'cloudflare', siteUrl: '' }), + /SITE_URL or --url/, + ); }); test('temporary dist server is loopback-only and closes when capture fails', async () => { @@ -76,18 +107,32 @@ test('temporary dist server is loopback-only and closes when capture fails', asy let directory = await mkdtemp(join(tmpdir(), 'screenshot-server-')); let originalDirectory = process.cwd(); let siteUrl; + try { await mkdir(join(directory, 'dist')); await writeFile(join(directory, 'dist/screenshots.html'), 'local fixture'); process.chdir(directory); - mock.method(PuppeteerRenderer.prototype, 'capture', async () => { throw new Error('Capture failed'); }); - await assert.rejects(withScreenshots(async screenshots => { - siteUrl = screenshots.siteUrl; - assert.equal(new URL(siteUrl).hostname, '127.0.0.1'); - let response = await fetch(new URL('/screenshots.html', siteUrl)); - assert.match(await response.text(), /local fixture/); - return screenshots.capture({ hash: '/schedules/3600' }); - }, { provider: 'puppeteer', siteUrl: '' }), /Capture failed/); + mock.method(PuppeteerRenderer.prototype, 'capture', async () => { + throw new Error('Capture failed'); + }); + + await assert.rejects( + withScreenshots( + async screenshots => { + siteUrl = screenshots.siteUrl; + + assert.equal(new URL(siteUrl).hostname, '127.0.0.1'); + + let response = await fetch(new URL('/screenshots.html', siteUrl)); + + assert.match(await response.text(), /local fixture/); + + return screenshots.capture({ hash: '/schedules/3600' }); + }, + { provider: 'puppeteer', siteUrl: '' }, + ), + /Capture failed/, + ); await assert.rejects(fetch(siteUrl)); } finally { process.chdir(originalDirectory); diff --git a/test/screenshots/readiness.test.js b/test/screenshots/readiness.test.js index 4515055..b0a6298 100644 --- a/test/screenshots/readiness.test.js +++ b/test/screenshots/readiness.test.js @@ -4,45 +4,90 @@ import { markScreenshotReady } from '../../src/common/screenshot.js'; function deferred() { let resolve; - const promise = new Promise(done => { resolve = done; }); + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; } test('waits for data, Vue, fonts, images and two layout frames before signaling readiness', async () => { - const data = deferred(), vue = deferred(), fonts = deferred(), image = deferred(); + const data = deferred(), + vue = deferred(), + fonts = deferred(), + image = deferred(); const steps = []; const document = { - documentElement: { removeAttribute: () => steps.push('reset'), setAttribute: (name, value) => steps.push([name, value]) }, + documentElement: { + removeAttribute: () => steps.push('reset'), + setAttribute: (name, value) => steps.push([name, value]), + }, fonts: { ready: fonts.promise }, - images: [{ decode: () => { steps.push('image'); return image.promise; } }], + images: [ + { + decode: () => { + steps.push('image'); + + return image.promise; + }, + }, + ], }; const ready = markScreenshotReady({ - document, loadData: () => data.promise, - nextTick: () => { steps.push('vue'); return vue.promise; }, - requestAnimationFrame: callback => { steps.push('frame'); callback(); }, + document, + loadData: () => data.promise, + nextTick: () => { + steps.push('vue'); + + return vue.promise; + }, + requestAnimationFrame: callback => { + steps.push('frame'); + callback(); + }, }); + assert.deepEqual(steps, ['reset']); + data.resolve([{ status: 'fulfilled' }]); await Promise.resolve(); + assert.deepEqual(steps, ['reset', 'vue']); + vue.resolve(); fonts.resolve(); - await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(steps, ['reset', 'vue', 'image']); + image.resolve(); await ready; + assert.deepEqual(steps.slice(-3), ['frame', 'frame', ['data-screenshot-ready', 'true']]); }); test('failed data and superseded routes never mark a page ready', async () => { let marked = false; const document = { - documentElement: { removeAttribute() {}, setAttribute() { marked = true; } }, - fonts: { ready: Promise.resolve() }, images: [], + documentElement: { + removeAttribute() {}, + setAttribute() { + marked = true; + }, + }, + fonts: { ready: Promise.resolve() }, + images: [], }; const options = { document, nextTick: async () => {}, requestAnimationFrame: callback => callback() }; - await assert.rejects(markScreenshotReady({ ...options, loadData: async () => [{ status: 'rejected' }] }), /data failed/); + + await assert.rejects( + markScreenshotReady({ ...options, loadData: async () => [{ status: 'rejected' }] }), + /data failed/, + ); assert.equal(marked, false); + await markScreenshotReady({ ...options, loadData: async () => [], isCurrent: () => false }); + assert.equal(marked, false); }); diff --git a/test/screenshots/screenshots.test.js b/test/screenshots/screenshots.test.js index 9543104..64e1ab4 100644 --- a/test/screenshots/screenshots.test.js +++ b/test/screenshots/screenshots.test.js @@ -4,20 +4,28 @@ import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js'; import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js'; function screenshots() { - return new ScreenshotGenerator(new BrowserRunClient({ - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, - }), process.env.SITE_URL); + return new ScreenshotGenerator( + new BrowserRunClient({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }), + process.env.SITE_URL, + ); } const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); -function fakeBrowserRendering(respond = () => new Response(PNG, { headers: { 'content-type': 'image/png' } })) { +function fakeBrowserRendering( + respond = () => new Response(PNG, { headers: { 'content-type': 'image/png' } }), +) { const requests = []; + mock.method(globalThis, 'fetch', async (input, init) => { requests.push({ url: new URL(input), headers: new Headers(init.headers), body: JSON.parse(init.body) }); + return respond(); }); + return requests; } @@ -33,8 +41,13 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie const result = await screenshots().capture({ hash: '/schedules/3600' }); assert.equal(requests.length, 1); + const [{ url, headers, body }] = requests; - assert.equal(url.href, 'https://api.cloudflare.com/client/v4/accounts/acct/browser-rendering/screenshot?cacheTTL=0'); + + assert.equal( + url.href, + 'https://api.cloudflare.com/client/v4/accounts/acct/browser-rendering/screenshot?cacheTTL=0', + ); assert.equal(headers.get('authorization'), 'Bearer token'); assert.equal(body.url, 'https://example.test/screenshots.html#/schedules/3600'); assert.deepEqual(body.viewport, { width: 1216, height: 684, deviceScaleFactor: 2 }); @@ -49,42 +62,62 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie test('the new-weapon screenshot grows with the number of weapons, and splatfest passes its regions', async () => { const requests = fakeBrowserRendering(); const tall = await screenshots().captureNewWeaponScreenshot(3600, 9); + assert.equal(requests[0].body.viewport.height, 3 * 320 + 60); assert.equal(tall.height, (3 * 320 + 60) * 2); + const short = await screenshots().captureNewWeaponScreenshot(3600, 1); + assert.equal(requests[1].body.viewport.height, 700); assert.equal(short.width, 2432); await screenshots().captureSplatfestScreenshot('na', 3600, ['na', 'eu']); - assert.equal(requests[2].body.url, 'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu'); + + assert.equal( + requests[2].body.url, + 'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu', + ); }); test('reports API errors with Cloudflare\'s message', async () => { fakeBrowserRendering(() => Response.json({ errors: [{ message: 'Invalid token' }] }, { status: 401 })); - await assert.rejects(screenshots().capture({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/); + + await assert.rejects( + screenshots().capture({ hash: '/x' }), + /Browser Rendering screenshot failed \(401\): Invalid token/, + ); }); test('fails clearly when configuration is missing', async () => { delete process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN; - await assert.rejects(screenshots().capture({ hash: '/x' }), /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/); + + await assert.rejects( + screenshots().capture({ hash: '/x' }), + /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/, + ); }); function immediateBackoff() { const delays = []; + mock.method(globalThis, 'setTimeout', (callback, delay) => { delays.push(delay); queueMicrotask(callback); }); + return delays; } test('retries timeout responses and succeeds with the same screenshot request', async () => { const delays = immediateBackoff(); let attempt = 0; - const requests = fakeBrowserRendering(() => ++attempt < 3 - ? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 }) - : new Response(PNG)); + const requests = fakeBrowserRendering(() => + ++attempt < 3 + ? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 }) + : new Response(PNG), + ); const result = await screenshots().capture({ hash: '/schedules/3600' }); + assert.deepEqual(result.image, PNG); assert.equal(requests.length, 3); assert.deepEqual(requests[0].body, requests[2].body); @@ -94,6 +127,7 @@ test('retries timeout responses and succeeds with the same screenshot request', test('stops after three retries and preserves the final error', async () => { const delays = immediateBackoff(); const requests = fakeBrowserRendering(() => new Response('upstream unavailable', { status: 503 })); + await assert.rejects(screenshots().capture({ hash: '/x' }), /503.*upstream unavailable/); assert.equal(requests.length, 4); assert.deepEqual(delays, [500, 1000, 2000]); @@ -101,26 +135,48 @@ test('stops after three retries and preserves the final error', async () => { test('does not retry authentication, rate limits or non-timeout validation errors', async () => { const delays = immediateBackoff(); + for (const status of [401, 403, 429, 422]) { let count = 0; - const requests = fakeBrowserRendering(() => { count++; return new Response('invalid request', { status }); }); + const requests = fakeBrowserRendering(() => { + count++; + + return new Response('invalid request', { status }); + }); + await assert.rejects(screenshots().capture({ hash: '/x' }), new RegExp(String(status))); assert.equal(count, 1); assert.equal(requests.length, 1); } + assert.deepEqual(delays, []); }); test('retries network and client deadline failures, including while reading the image', async () => { immediateBackoff(); + let attempt = 0; const requests = fakeBrowserRendering(() => { attempt++; - if (attempt === 1) throw new TypeError('fetch failed'); - if (attempt === 2) throw new DOMException('request timed out', 'TimeoutError'); - if (attempt === 3) return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('connection reset')); } })); + + if (attempt === 1) + throw new TypeError('fetch failed'); + + if (attempt === 2) + throw new DOMException('request timed out', 'TimeoutError'); + + if (attempt === 3) + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('connection reset')); + }, + }), + ); + return new Response(PNG); }); + assert.deepEqual((await screenshots().capture({ hash: '/x' })).image, PNG); assert.equal(requests.length, 4); }); diff --git a/test/sentry.test.js b/test/sentry.test.js index 5c03cd9..d6fd5cc 100644 --- a/test/sentry.test.js +++ b/test/sentry.test.js @@ -8,9 +8,14 @@ import { captureException } from '@sentry/core'; // core: Sentry keys its global carrier by version, so a version split silently drops events. test('captureException from @sentry/core reaches the client @sentry/node initialized', async () => { const seen = []; + SentryNode.init({ dsn: 'https://public@example.ingest.sentry.io/1', - beforeSend: event => { seen.push(event.exception.values[0].value); return null; }, + beforeSend: event => { + seen.push(event.exception.values[0].value); + + return null; + }, }); captureException(new Error('routed through core')); diff --git a/test/social/BlueskyClient.test.js b/test/social/BlueskyClient.test.js index 664f2a1..6052089 100644 --- a/test/social/BlueskyClient.test.js +++ b/test/social/BlueskyClient.test.js @@ -4,10 +4,17 @@ import BlueskyClient from '../../src/app/social/clients/BlueskyClient.js'; function fakeAgent() { const calls = { uploads: [], posts: [] }; + return { calls, - uploadBlob: async (bytes, options) => { calls.uploads.push({ bytes, options }); return { data: { blob: { ref: `blob-${calls.uploads.length}` } } }; }, - post: async record => { calls.posts.push(record); }, + uploadBlob: async (bytes, options) => { + calls.uploads.push({ bytes, options }); + + return { data: { blob: { ref: `blob-${calls.uploads.length}` } } }; + }, + post: async record => { + calls.posts.push(record); + }, // RichText.detectFacets resolves handles through the agent; none in our text resolveHandle: async () => ({ data: { did: 'did:example' } }), }; @@ -18,23 +25,38 @@ test('uploads the media as given and posts with an image embed and aspect ratio' const client = new BlueskyClient({ agent }); const jpeg = new Uint8Array([0xff, 0xd8, 0xff]); - await client.send({ status: 'Hello #splatoon2', media: [{ file: jpeg, type: 'image/jpeg', width: 2432, height: 1368 }] }); + await client.send({ + status: 'Hello #splatoon2', + media: [{ file: jpeg, type: 'image/jpeg', width: 2432, height: 1368 }], + }); assert.equal(client.mediaType, 'image/jpeg'); assert.deepEqual(agent.calls.uploads, [{ bytes: jpeg, options: { encoding: 'image/jpeg' } }]); + const [post] = agent.calls.posts; + assert.equal(post.text, 'Hello #splatoon2'); assert.ok(post.facets.some(f => f.features[0].$type === 'app.bsky.richtext.facet#tag')); - assert.deepEqual(post.embed, { $type: 'app.bsky.embed.images', images: [{ image: { ref: 'blob-1' }, alt: '', aspectRatio: { width: 2432, height: 1368 } }] }); + assert.deepEqual(post.embed, { + $type: 'app.bsky.embed.images', + images: [{ image: { ref: 'blob-1' }, alt: '', aspectRatio: { width: 2432, height: 1368 } }], + }); }); test('omits the aspect ratio when the size is unknown', async () => { const agent = fakeAgent(); - await new BlueskyClient({ agent }).send({ status: 'x', media: [{ file: new Uint8Array([1]), type: 'image/png' }] }); + + await new BlueskyClient({ agent }).send({ + status: 'x', + media: [{ file: new Uint8Array([1]), type: 'image/png' }], + }); + assert.deepEqual(Object.keys(agent.calls.posts[0].embed.images[0]), ['image', 'alt']); }); test('cannot send without credentials', async () => { - for (const name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; - assert.ok(!await new BlueskyClient().canSend()); + for (const name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) + delete process.env[name]; + + assert.ok(!(await new BlueskyClient().canSend())); }); diff --git a/test/social/SocialPostBase.test.js b/test/social/SocialPostBase.test.js index d5d7146..e65a97f 100644 --- a/test/social/SocialPostBase.test.js +++ b/test/social/SocialPostBase.test.js @@ -9,41 +9,72 @@ const IMAGE = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); class HourlyPost extends SocialPostBase { constructor(s, clients, { data = { id: 1 }, time = 3600, publicImage = 'hourly.png' } = {}) { super(s, clients); - this.data = data; this.time = time; this.publicImage = publicImage; this.images = 0; + this.data = data; + this.time = time; + this.publicImage = publicImage; + this.images = 0; + } + getKey() { + return 'hourly'; + } + getName() { + return 'Hourly'; + } + async getDataTime() { + return this.time; + } + async getData() { + return this.data; + } + async getImage() { + this.images++; + + return IMAGE; + } + getPublicImageFilename() { + return this.publicImage; + } + async getText(data) { + return `Post ${data.id}`; } - getKey() { return 'hourly'; } - getName() { return 'Hourly'; } - async getDataTime() { return this.time; } - async getData() { return this.data; } - async getImage() { this.images++; return IMAGE; } - getPublicImageFilename() { return this.publicImage; } - async getText(data) { return `Post ${data.id}`; } } let s, bluesky, other; -beforeEach(() => { s = storage(); bluesky = fakeClient('bluesky'); other = fakeClient('other'); }); + +beforeEach(() => { + s = storage(); + bluesky = fakeClient('bluesky'); + other = fakeClient('other'); +}); test('posts to every client, saves the public image, and records the time per client', async () => { const post = new HourlyPost(s, [bluesky, other]); + assert.equal((await post.maybePost()).ok, true); assert.deepEqual(bluesky.sent, [{ status: 'Post 1', media: [{ file: IMAGE, type: 'image/png' }] }]); assert.deepEqual(other.sent, bluesky.sent); assert.equal(post.images, 1); - assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), IMAGE); + assert.deepEqual( + new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), + IMAGE, + ); assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 3600 }); assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 }); }); test('does not post the same data time twice, and posts again when it moves on', async () => { await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600, other: 99 }); + const post = new HourlyPost(s, [bluesky]); + assert.equal(await post.maybePost(), false); assert.deepEqual(bluesky.sent, []); assert.equal(post.images, 0); post.time = 7200; await post.maybePost(); + assert.equal(bluesky.sent.length, 1); assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 7200, other: 99 }); }); @@ -51,6 +82,7 @@ test('does not post the same data time twice, and posts again when it moves on', test('a client that already posted is skipped while another still posts', async () => { await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600 }); await new HourlyPost(s, [bluesky, other]).maybePost(); + assert.deepEqual(bluesky.sent, []); assert.equal(other.sent.length, 1); }); @@ -58,44 +90,67 @@ test('a client that already posted is skipped while another still posts', async test('a failing client does not record a post time and does not block the other client', async () => { const broken = fakeClient('bluesky', { fail: true }); const result = await new HourlyPost(s, [broken, other]).maybePost(); + assert.equal(result.ok, false); - assert.deepEqual(result.clients.map(c => c.ok), [false, true]); + assert.deepEqual( + result.clients.map(c => c.ok), + [false, true], + ); assert.equal(other.sent.length, 1); assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null); assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 }); }); test('captures one PNG and converts it once for JPEG clients, preserving dimensions', async () => { - const png = await sharp({ create: { width: 2, height: 1, channels: 3, background: '#ff0000' } }).png().toBuffer(); + const png = await sharp({ create: { width: 2, height: 1, channels: 3, background: '#ff0000' } }) + .png() + .toBuffer(); class ScreenshotPost extends HourlyPost { async getImage() { this.images++; + return { image: png, type: 'image/png', width: 2, height: 1 }; } } bluesky.mediaType = 'image/jpeg'; - const secondJpeg = fakeClient('second'); secondJpeg.mediaType = 'image/jpeg'; + + const secondJpeg = fakeClient('second'); + secondJpeg.mediaType = 'image/jpeg'; + const post = new ScreenshotPost(s, [bluesky, other, secondJpeg]); + assert.equal((await post.maybePost()).ok, true); assert.equal(post.images, 1); + const jpeg = bluesky.sent[0].media[0]; + assert.equal(jpeg.type, 'image/jpeg'); assert.equal(jpeg.width, 2); assert.equal(jpeg.height, 1); + const decoded = await sharp(jpeg.file).metadata(); + assert.equal(decoded.format, 'jpeg'); assert.equal(decoded.width, 2); assert.equal(decoded.height, 1); assert.equal(secondJpeg.sent[0].media[0], jpeg); assert.deepEqual(other.sent[0].media[0].file, png); - assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array(png)); + assert.deepEqual( + new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), + new Uint8Array(png), + ); }); test('conversion failure preserves the PNG and does not checkpoint or send the JPEG post', async () => { bluesky.mediaType = 'image/jpeg'; + const post = new HourlyPost(s, [bluesky, other]); - post.convertMedia = async () => { throw new Error('Images quota exceeded'); }; + post.convertMedia = async () => { + throw new Error('Images quota exceeded'); + }; + const result = await post.maybePost(); + assert.equal(result.ok, false); assert.match(result.clients[0].error, /Images quota exceeded/); assert.equal(bluesky.sent.length, 0); @@ -107,13 +162,16 @@ test('conversion failure preserves the PNG and does not checkpoint or send the J test('with no data there is nothing to post', async () => { const post = new HourlyPost(s, [bluesky], { data: null }); + assert.equal(await post.maybePost(), false); assert.equal(post.images, 0); }); test('with no client able to send, the public image is still saved', async () => { const offline = fakeClient('bluesky', { canSend: false }); + await new HourlyPost(s, [offline]).maybePost(); + assert.deepEqual(offline.sent, []); assert.ok(await s.publicBucket.get('twitter-images/hourly.png')); }); @@ -121,19 +179,28 @@ test('with no client able to send, the public image is still saved', async () => test('with no client able to send and no public image, it gives up', async () => { const offline = fakeClient('bluesky', { canSend: false }); const post = new HourlyPost(s, [offline], { publicImage: null }); + assert.equal(await post.maybePost(), false); assert.equal(post.images, 0); }); test('test screenshots go to public storage', async () => { await new HourlyPost(s, []).saveTestScreenshot(); - assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('test-screenshots/hourly.png')).arrayBuffer()), IMAGE); + + assert.deepEqual( + new Uint8Array(await (await s.publicBucket.get('test-screenshots/hourly.png')).arrayBuffer()), + IMAGE, + ); }); test('screenshot failure is reported and does not advance post state', async () => { const post = new HourlyPost(s, [bluesky]); - post.getImage = async () => { throw new Error('render failed'); }; + post.getImage = async () => { + throw new Error('render failed'); + }; + const result = await post.maybePost(); + assert.equal(result.ok, false); assert.match(result.error, /render failed/); assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null); @@ -142,10 +209,13 @@ test('screenshot failure is reported and does not advance post state', async () test('retry only sends the previously failed client, using a fresh storage view', async () => { const broken = fakeClient('other', { fail: true }); const first = await new HourlyPost(s, [bluesky, broken]).maybePost(); + assert.equal(first.ok, false); + const recovered = fakeClient('other'); const fresh = { ...s, privateStorage: new s.privateStorage.constructor(s.privateBucket) }; const second = await new HourlyPost(fresh, [bluesky, recovered]).maybePost(); + assert.equal(second.ok, true); assert.equal(bluesky.sent.length, 1); assert.equal(recovered.sent.length, 1); diff --git a/test/social/posts.test.js b/test/social/posts.test.js index cffd9d5..0826056 100644 --- a/test/social/posts.test.js +++ b/test/social/posts.test.js @@ -9,92 +9,173 @@ import { storage, nextRun, fakeClient, json, seed } from './support.js'; const NOW = 3600 * 1000; // an hour boundary, as getTopOfCurrentHour returns const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` }); -const rotation = (start, a, b, rule) => ({ start_time: start, end_time: start + 7200, stage_a: a, stage_b: b, rule: { key: rule, name: rule } }); +const rotation = (start, a, b, rule) => ({ + start_time: start, + end_time: start + 7200, + stage_a: a, + stage_b: b, + rule: { key: rule, name: rule }, +}); let s, client; + beforeEach(async () => { s = storage(); client = fakeClient('bluesky'); - await seed(s.publicBucket, 'data/festivals.json', { na: { festivals: [], results: [] }, eu: { festivals: [], results: [] }, jp: { festivals: [], results: [] } }); + await seed(s.publicBucket, 'data/festivals.json', { + na: { festivals: [], results: [] }, + eu: { festivals: [], results: [] }, + jp: { festivals: [], results: [] }, + }); }); function pinTime(post, time) { post.getDataTime = async () => time; post.getImage = async () => new Uint8Array([1]); + return post; } test('createPosts builds every post against the same storage and clients', () => { const screenshots = {}; const posts = createPosts(s, [client], screenshots); - assert.deepEqual(posts.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']); - assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots)); + + assert.deepEqual( + posts.map(t => t.getKey()), + ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp'], + ); + assert.ok( + posts.every( + t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots, + ), + ); }); test('schedule: posts the rotation for the current hour, with new-stage wording when the stage is new', async () => { - const reef = stage('0', 'The Reef'), skipper = stage('22', 'Skipper Pavilion'); + const reef = stage('0', 'The Reef'), + skipper = stage('22', 'Skipper Pavilion'); + await seed(s.publicBucket, 'data/schedules.json', { - regular: [rotation(NOW, reef, skipper, 'Turf War')], gachi: [rotation(NOW, reef, skipper, 'Splat Zones')], league: [rotation(NOW, reef, skipper, 'Rainmaker')], + regular: [rotation(NOW, reef, skipper, 'Turf War')], + gachi: [rotation(NOW, reef, skipper, 'Splat Zones')], + league: [rotation(NOW, reef, skipper, 'Rainmaker')], }); - await seed(s.privateBucket, 'stages.json', [{ ...reef, first_seen: -1, first_available: -1 }, { ...skipper, first_seen: NOW - 7200, first_available: NOW }]); + await seed(s.privateBucket, 'stages.json', [ + { ...reef, first_seen: -1, first_available: -1 }, + { ...skipper, first_seen: NOW - 7200, first_available: NOW }, + ]); const post = pinTime(new SchedulePost(s, [client]), NOW); await post.maybePost(); + assert.equal(client.sent[0].status, 'NEW STAGE: Skipper Pavilion is now open! #maprotation #splatoon2'); assert.ok(await s.publicBucket.get('twitter-images/schedule.png')); const later = pinTime(new SchedulePost(nextRun(s), [client]), NOW + 3600); + assert.equal(await later.maybePost(), false); // no rotation starts at that hour }); test('schedule: the plain rotation text names the ranked and league rules', async () => { - const reef = stage('0', 'The Reef'), fitness = stage('1', 'Musselforge Fitness'); + const reef = stage('0', 'The Reef'), + fitness = stage('1', 'Musselforge Fitness'); + await seed(s.publicBucket, 'data/schedules.json', { - regular: [rotation(NOW, reef, fitness, 'Turf War')], gachi: [rotation(NOW, reef, fitness, 'Splat Zones')], league: [rotation(NOW, reef, fitness, 'Rainmaker')], + regular: [rotation(NOW, reef, fitness, 'Turf War')], + gachi: [rotation(NOW, reef, fitness, 'Splat Zones')], + league: [rotation(NOW, reef, fitness, 'Rainmaker')], }); + const post = pinTime(new SchedulePost(s, [client]), NOW); - assert.equal(await post.getText(await post.getData()), 'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation'); + + assert.equal( + await post.getText(await post.getData()), + 'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation', + ); }); test('salmon run: posts when a shift opens, remembers it, and posts again when it closes', async () => { - const shift = { start_time: NOW, end_time: NOW + 3600 * 36, stage: { name: 'Spawning Grounds' }, weapons: [{ id: '0', weapon: { name: 'Splattershot' } }] }; + const shift = { + start_time: NOW, + end_time: NOW + 3600 * 36, + stage: { name: 'Spawning Grounds' }, + weapons: [{ id: '0', weapon: { name: 'Splattershot' } }], + }; + await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [shift], details: [shift] }); await seed(s.publicBucket, 'data/timeline.json', { coop: { reward_gear: { gear: { name: 'Cap' } } } }); await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW).maybePost(); - assert.equal(client.sent[0].status, 'Salmon Run is now open on Spawning Grounds! Current reward gear is the Cap. #salmonrun #splatoon2'); + + assert.equal( + client.sent[0].status, + 'Salmon Run is now open on Spawning Grounds! Current reward gear is the Cap. #salmonrun #splatoon2', + ); assert.deepEqual(await json(s.privateBucket, 'salmonrun-previousSchedule.json'), shift); assert.equal(await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600).maybePost(), false); // mid-shift, not a 12-hour mark await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 12).maybePost(); + assert.match(client.sent[1].status, /is still open on Spawning Grounds/); const next = { start_time: NOW + 3600 * 40, end_time: NOW + 3600 * 76 }; + await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [next], details: [] }); await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 36).maybePost(); - assert.equal(client.sent[2].status, 'Salmon Run is now closed. The next shift starts in 4 hours! #salmonrun #splatoon2'); + + assert.equal( + client.sent[2].status, + 'Salmon Run is now closed. The next shift starts in 4 hours! #salmonrun #splatoon2', + ); }); test('new stage: posts the mirrored stage image the hour the stage was first seen', async () => { - await seed(s.privateBucket, 'stages.json', [{ id: '22', name: 'Skipper Pavilion', image: '/images/stage/22.png', first_seen: NOW, first_available: NOW + 7200 }]); + await seed(s.privateBucket, 'stages.json', [ + { + id: '22', + name: 'Skipper Pavilion', + image: '/images/stage/22.png', + first_seen: NOW, + first_available: NOW + 7200, + }, + ]); await s.publicBucket.put('assets/splatnet/images/stage/22.png', new Uint8Array([7])); + const post = new NewStagePost(s, [client]); post.getDataTime = async () => NOW; await post.maybePost(); - assert.equal(client.sent[0].status, 'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this post is 2 hours old. #splatoon2'); + + assert.equal( + client.sent[0].status, + 'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this post is 2 hours old. #splatoon2', + ); assert.deepEqual(client.sent[0].media[0].file, new Uint8Array([7])); }); test('splatfest: a global fest posts once, from the first region', async () => { - const fest = { festival_id: 5, names: { alpha_short: 'A', bravo_short: 'B' }, times: { announce: 0, start: NOW, end: NOW + 3600, result: NOW + 7200 } }; - await seed(s.publicBucket, 'data/festivals.json', { na: { festivals: [fest], results: [] }, eu: { festivals: [fest], results: [] }, jp: { festivals: [fest], results: [] } }); + const fest = { + festival_id: 5, + names: { alpha_short: 'A', bravo_short: 'B' }, + times: { announce: 0, start: NOW, end: NOW + 3600, result: NOW + 7200 }, + }; + + await seed(s.publicBucket, 'data/festivals.json', { + na: { festivals: [fest], results: [] }, + eu: { festivals: [fest], results: [] }, + jp: { festivals: [fest], results: [] }, + }); const sentBy = []; + for (const region of ['na', 'eu', 'jp']) { const c = fakeClient('bluesky'); + await pinTime(new SplatfestPost(region, s, [c]), NOW).maybePost(); - if (c.sent.length) sentBy.push([region, c.sent[0].status]); + + if (c.sent.length) + sentBy.push([region, c.sent[0].status]); } + assert.deepEqual(sentBy, [['na', 'The global Splatfest is now open! #splatfest #splatoon2']]); }); diff --git a/test/social/support.js b/test/social/support.js index 35b6c87..384bc75 100644 --- a/test/social/support.js +++ b/test/social/support.js @@ -1,8 +1,9 @@ import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js'; export function storage() { - const publicBucket = new MemoryBucket; - const privateBucket = new MemoryBucket; + const publicBucket = new MemoryBucket(); + const privateBucket = new MemoryBucket(); + return { publicBucket, privateBucket, @@ -13,12 +14,16 @@ export function storage() { /** A fresh per-run view over the same buckets, as each real run gets (the cache is per run). */ export function nextRun(s) { - return { publicStorage: new BucketStorage(s.publicBucket), privateStorage: new BucketStorage(s.privateBucket) }; + return { + publicStorage: new BucketStorage(s.publicBucket), + privateStorage: new BucketStorage(s.privateBucket), + }; } /** A social client that records what it is asked to send. */ export function fakeClient(key, { canSend = true, fail = false } = {}) { const sent = []; + return { key, name: key[0].toUpperCase() + key.slice(1), @@ -27,6 +32,7 @@ export function fakeClient(key, { canSend = true, fail = false } = {}) { send: async status => { if (fail) throw new Error(`${key} is down`); + sent.push(status); }, }; @@ -34,6 +40,8 @@ export function fakeClient(key, { canSend = true, fail = false } = {}) { export async function json(bucket, key) { const object = await bucket.get(key); + return object ? object.json() : null; } + export const seed = (bucket, key, value) => bucket.put(key, JSON.stringify(value)); diff --git a/test/storage/BucketStorage.test.js b/test/storage/BucketStorage.test.js index 40f4fce..591ddcd 100644 --- a/test/storage/BucketStorage.test.js +++ b/test/storage/BucketStorage.test.js @@ -3,9 +3,10 @@ import assert from 'node:assert/strict'; import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js'; test('exists lists a directory once and remembers what it wrote', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); await bucket.put('assets/splatnet/images/stage/a.png', 'a'); await bucket.put('assets/splatnet/images/weapon/w.png', 'w'); + const list = mock.method(bucket, 'list'); const head = mock.method(bucket, 'head'); const storage = new BucketStorage(bucket); @@ -13,22 +14,28 @@ test('exists lists a directory once and remembers what it wrote', async () => { assert.equal(await storage.exists('assets/splatnet/images/stage/a.png'), true); assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), false); assert.equal(await storage.exists('assets/splatnet/images/weapon/w.png'), true); - assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/stage/', 'assets/splatnet/images/weapon/']); + assert.deepEqual( + list.mock.calls.map(c => c.arguments[0].prefix), + ['assets/splatnet/images/stage/', 'assets/splatnet/images/weapon/'], + ); assert.equal(head.mock.calls.length, 0); await storage.writeBytes('assets/splatnet/images/stage/b.png', new Uint8Array([1])); + assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), true); assert.equal(list.mock.calls.length, 2); }); test('readJson parses once and returns the same object, null for missing keys', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); await bucket.put('data/x.json', JSON.stringify({ a: 1 })); + const get = mock.method(bucket, 'get'); const storage = new BucketStorage(bucket); const first = await storage.readJson('data/x.json'); const second = await storage.readJson('data/x.json'); + assert.deepEqual(first, { a: 1 }); assert.equal(first, second); assert.equal(await storage.readJson('data/missing.json'), null); @@ -37,19 +44,24 @@ test('readJson parses once and returns the same object, null for missing keys', }); test('writeJson skips unchanged content, writes changed content with metadata, and updates the cache', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); await bucket.put('data/x.json', JSON.stringify({ a: 1 })); + const put = mock.method(bucket, 'put'); const storage = new BucketStorage(bucket); const doc = await storage.readJson('data/x.json'); + assert.equal(await storage.writeJson('data/x.json', { a: 1 }), false); assert.equal(put.mock.calls.length, 0); doc.a = 2; // mutate the shared object, as the localization processor does assert.equal(await storage.writeJson('data/x.json', doc, { cacheControl: 'no-cache' }), true); assert.equal(put.mock.calls.length, 1); - assert.deepEqual((await bucket.get('data/x.json')).httpMetadata, { contentType: 'application/json', cacheControl: 'no-cache' }); + assert.deepEqual((await bucket.get('data/x.json')).httpMetadata, { + contentType: 'application/json', + cacheControl: 'no-cache', + }); assert.deepEqual(await (await bucket.get('data/x.json')).json(), { a: 2 }); assert.equal(await storage.writeJson('data/x.json', { a: 2 }), false); @@ -59,11 +71,17 @@ test('writeJson skips unchanged content, writes changed content with metadata, a }); test('writeText and readBytes go straight through with content types by extension', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); const storage = new BucketStorage(bucket); await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR', { cacheControl: 'no-cache' }); - assert.deepEqual((await bucket.get('data/cal.ics')).httpMetadata, { contentType: 'text/calendar', cacheControl: 'no-cache' }); + + assert.deepEqual((await bucket.get('data/cal.ics')).httpMetadata, { + contentType: 'text/calendar', + cacheControl: 'no-cache', + }); + await storage.writeBytes('assets/a.png', new Uint8Array([9, 9])); + assert.equal((await bucket.get('assets/a.png')).httpMetadata.contentType, 'image/png'); assert.deepEqual(await storage.readBytes('assets/a.png'), new Uint8Array([9, 9])); assert.equal(await storage.readBytes('assets/none.png'), null); diff --git a/test/storage/MemoryBucket.test.js b/test/storage/MemoryBucket.test.js index d50fe6b..58ac5d6 100644 --- a/test/storage/MemoryBucket.test.js +++ b/test/storage/MemoryBucket.test.js @@ -4,23 +4,37 @@ import { MemoryBucket } from '../../src/common/storage/index.js'; // MemoryBucket stands in for the R2 binding in tests, so its list() must paginate like R2's. test('list filters by prefix, sorts, and paginates with cursors', async () => { - const bucket = new MemoryBucket; - for (const key of ['assets/images/stage/b.png', 'assets/images/stage/a.png', 'assets/images/stage/deep/c.png', 'assets/images/weapon/w.png', 'data/x.json']) + const bucket = new MemoryBucket(); + + for (const key of [ + 'assets/images/stage/b.png', + 'assets/images/stage/a.png', + 'assets/images/stage/deep/c.png', + 'assets/images/weapon/w.png', + 'data/x.json', + ]) await bucket.put(key, 'x'); const all = await bucket.list({ prefix: 'assets/images/stage/' }); - assert.deepEqual(all.objects.map(o => o.key), ['assets/images/stage/a.png', 'assets/images/stage/b.png', 'assets/images/stage/deep/c.png']); + + assert.deepEqual( + all.objects.map(o => o.key), + ['assets/images/stage/a.png', 'assets/images/stage/b.png', 'assets/images/stage/deep/c.png'], + ); assert.equal(all.truncated, false); const first = await bucket.list({ prefix: 'assets/', limit: 2 }); + assert.equal(first.truncated, true); + const second = await bucket.list({ prefix: 'assets/', limit: 2, cursor: first.cursor }); + assert.deepEqual([...first.objects, ...second.objects].map(o => o.key).length, 4); assert.equal(second.truncated, false); }); test('put and get round-trip strings, bytes, and streams with metadata', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); await bucket.put('a.json', '{"a":1}', { httpMetadata: { contentType: 'application/json' } }); await bucket.put('b.png', new Uint8Array([1, 2])); await bucket.put('c.png', new Response(new Uint8Array([3])).body); @@ -30,6 +44,8 @@ test('put and get round-trip strings, bytes, and streams with metadata', async ( assert.deepEqual(new Uint8Array(await (await bucket.get('b.png')).arrayBuffer()), new Uint8Array([1, 2])); assert.deepEqual(new Uint8Array(await (await bucket.get('c.png')).arrayBuffer()), new Uint8Array([3])); assert.equal(await bucket.get('missing'), null); + await bucket.delete('a.json'); + assert.equal(await bucket.head('a.json'), null); }); diff --git a/test/storage/storage-contract.test.js b/test/storage/storage-contract.test.js index 80540b4..d382ba7 100644 --- a/test/storage/storage-contract.test.js +++ b/test/storage/storage-contract.test.js @@ -9,7 +9,7 @@ import FilesystemStorage from '../../src/common/storage/FilesystemStorage.js'; // One contract for the storage interface the updaters use, run against every implementation. const implementations = { BucketStorage: { - create: async () => new BucketStorage(new MemoryBucket), + create: async () => new BucketStorage(new MemoryBucket()), destroy: async () => {}, }, FilesystemStorage: { @@ -21,7 +21,10 @@ const implementations = { for (const [name, { create, destroy }] of Object.entries(implementations)) { describe(name, () => { let storage; - beforeEach(async () => { storage = await create(); }); + + beforeEach(async () => { + storage = await create(); + }); afterEach(() => destroy(storage)); test('missing keys do not exist and read as null', async () => { @@ -32,19 +35,23 @@ for (const [name, { create, destroy }] of Object.entries(implementations)) { test('json round-trips and becomes visible to exists', async () => { await storage.writeJson('data/nested/a.json', { hello: 'world' }); + assert.equal(await storage.exists('data/nested/a.json'), true); assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'world' }); await storage.writeJson('data/nested/a.json', { hello: 'again' }); + assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'again' }); }); test('bytes and text round-trip', async () => { await storage.writeBytes('assets/x.png', new Uint8Array([1, 2, 3])); + assert.deepEqual(await storage.readBytes('assets/x.png'), new Uint8Array([1, 2, 3])); assert.equal(await storage.exists('assets/x.png'), true); await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR'); + assert.equal(await storage.exists('data/cal.ics'), true); assert.equal(new TextDecoder().decode(await storage.readBytes('data/cal.ics')), 'BEGIN:VCALENDAR'); }); diff --git a/test/updater/LocalizationProcessor.test.js b/test/updater/LocalizationProcessor.test.js index 854f1b3..9e324ac 100644 --- a/test/updater/LocalizationProcessor.test.js +++ b/test/updater/LocalizationProcessor.test.js @@ -10,44 +10,68 @@ const rules = { name: 'rules', entities: '$..rule', id: 'key', values: ['name', const gear = { name: 'gear', entities: '$..gear', id: ['kind', 'id'], values: 'name' }; const data = { - regular: [{ stage_a: { id: '0', name: 'The Reef' }, stage_b: { id: '22', name: 'Skipper Pavilion' }, rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' } }], + regular: [ + { + stage_a: { id: '0', name: 'The Reef' }, + stage_b: { id: '22', name: 'Skipper Pavilion' }, + rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' }, + }, + ], }; const json = async (bucket, key) => (await bucket.get(key)).json(); test('records localized values under name/ids/value and reports what is missing', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' } } })); + const storage = new BucketStorage(bucket); const stageProcessor = new LocalizationProcessor(stages, en, storage); + assert.equal(await stageProcessor.hasLocalizations(data), false); // stage 22 unknown await stageProcessor.updateLocalizations(data); + assert.equal(await stageProcessor.hasLocalizations(data), true); const ruleProcessor = new LocalizationProcessor(rules, en, storage); + assert.equal(await ruleProcessor.hasLocalizations(data), false); + await ruleProcessor.updateLocalizations(data); assert.deepEqual(await json(bucket, 'data/locale/en.json'), { stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } }, rules: { turf_war: { name: 'Turf War', multiline_name: 'Turf\nWar' } }, }); - assert.equal((await bucket.head('data/locale/en.json')).httpMetadata.cacheControl, 'no-cache, stale-while-revalidate=5, stale-if-error=86400'); + assert.equal( + (await bucket.head('data/locale/en.json')).httpMetadata.cacheControl, + 'no-cache, stale-while-revalidate=5, stale-if-error=86400', + ); }); test('supports multi-part ids and creates a missing document', async () => { - const bucket = new MemoryBucket; + const bucket = new MemoryBucket(); const processor = new LocalizationProcessor(gear, en, new BucketStorage(bucket)); - await processor.updateLocalizations({ merchandises: [{ gear: { kind: 'head', id: '5', name: 'Fake Hat' } }] }); - assert.deepEqual(await json(bucket, 'data/locale/en.json'), { gear: { head: { 5: { name: 'Fake Hat' } } } }); + await processor.updateLocalizations({ + merchandises: [{ gear: { kind: 'head', id: '5', name: 'Fake Hat' } }], + }); + + assert.deepEqual(await json(bucket, 'data/locale/en.json'), { + gear: { head: { 5: { name: 'Fake Hat' } } }, + }); }); test('does not rewrite a document that already has every string', async () => { - const bucket = new MemoryBucket; - await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } } })); + const bucket = new MemoryBucket(); + await bucket.put( + 'data/locale/en.json', + JSON.stringify({ stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } } }), + ); + const puts = mock.method(bucket, 'put'); await new LocalizationProcessor(stages, en, new BucketStorage(bucket)).updateLocalizations(data); + assert.equal(puts.mock.calls.length, 0); }); diff --git a/test/updater/Updater.test.js b/test/updater/Updater.test.js index 51f3b12..a15bd8a 100644 --- a/test/updater/Updater.test.js +++ b/test/updater/Updater.test.js @@ -12,33 +12,51 @@ const NAMES = { function thingsFor(language) { const names = NAMES[language] ?? NAMES.en; - return { things: [{ id: '1', name: names.thing, image: '/images/things/one.png' }, { id: '2', name: names.other, image: '/images/things/two.png' }] }; + + return { + things: [ + { id: '1', name: names.thing, image: '/images/things/one.png' }, + { id: '2', name: names.other, image: '/images/things/two.png' }, + ], + }; } class ThingsUpdater extends Updater { constructor(buckets, options = {}) { - super({ - name: 'Things', - filename: 'things.json', - calendarFilename: 'things.ics', - request: splatnet => splatnet.getResponse('things'), - imagePaths: ['$..image'], - localization: [{ name: 'things', entities: '$.things[*]', id: 'id', values: 'name' }], - ...options, - }, buckets); + super( + { + name: 'Things', + filename: 'things.json', + calendarFilename: 'things.ics', + request: splatnet => splatnet.getResponse('things'), + imagePaths: ['$..image'], + localization: [{ name: 'things', entities: '$.things[*]', id: 'id', values: 'name' }], + ...options, + }, + buckets, + ); } getCalendarEntries(data) { - return data.things.map(thing => ({ id: `thing-${thing.id}`, title: thing.name, start_time: 3600, end_time: 7200 })); + return data.things.map(thing => ({ + id: `thing-${thing.id}`, + title: thing.name, + start_time: 3600, + end_time: 7200, + })); } } let b; -beforeEach(() => { b = buckets(); }); + +beforeEach(() => { + b = buckets(); +}); afterEach(() => mock.restoreAll()); test('publishes data, locale document, and calendar with the right metadata', async () => { const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) }); + await new ThingsUpdater(b).update(); assert.deepEqual(await json(b.publicBucket, 'data/things.json'), thingsFor('en')); @@ -47,10 +65,15 @@ test('publishes data, locale document, and calendar with the right metadata', as cacheControl: 'no-cache, stale-while-revalidate=5, stale-if-error=86400', }); - assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 1: { name: 'Widget' }, 2: { name: 'Gadget' } } }); - assert.deepEqual(await json(b.publicBucket, 'data/locale/ja.json'), { things: { 1: { name: 'ウィジェット' }, 2: { name: 'ガジェット' } } }); + assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { + things: { 1: { name: 'Widget' }, 2: { name: 'Gadget' } }, + }); + assert.deepEqual(await json(b.publicBucket, 'data/locale/ja.json'), { + things: { 1: { name: 'ウィジェット' }, 2: { name: 'ガジェット' } }, + }); const ics = await (await b.publicBucket.get('data/things.ics')).text(); + assert.match(ics, /SUMMARY:Widget/); assert.match(ics, /X-WR-CALNAME:Things/); assert.equal((await b.publicBucket.head('data/things.ics')).httpMetadata.contentType, 'text/calendar'); @@ -61,55 +84,94 @@ test('publishes data, locale document, and calendar with the right metadata', as test('fetches each language with missing strings using that region\'s session, then stops needing to', async () => { const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) }); + await new ThingsUpdater(b).update(); - assert.deepEqual(splatnet.api.map(r => `${r.region}/${r.language}`), [ - 'NA/en', 'EU/es', 'NA/es-MX', 'EU/fr', 'NA/fr-CA', 'EU/de', 'EU/nl', 'EU/it', 'EU/ru', 'JP/ja', - ]); + assert.deepEqual( + splatnet.api.map(r => `${r.region}/${r.language}`), + ['NA/en', 'EU/es', 'NA/es-MX', 'EU/fr', 'NA/fr-CA', 'EU/de', 'EU/nl', 'EU/it', 'EU/ru', 'JP/ja'], + ); const second = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) }); const puts = mock.method(b.publicBucket, 'put'); + await new ThingsUpdater(b).update(); - assert.deepEqual(second.api.map(r => r.language), ['en']); - assert.deepEqual(puts.mock.calls.map(c => c.arguments[0]).filter(k => k.startsWith('data/locale/')), []); + + assert.deepEqual( + second.api.map(r => r.language), + ['en'], + ); + assert.deepEqual( + puts.mock.calls.map(c => c.arguments[0]).filter(k => k.startsWith('data/locale/')), + [], + ); }); test('mirrors only images that are missing, using one list per directory', async () => { await b.publicBucket.put('assets/splatnet/images/things/one.png', 'existing'); + const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) }); const list = mock.method(b.publicBucket, 'list'); const head = mock.method(b.publicBucket, 'head'); + await new ThingsUpdater(b).update(); - assert.deepEqual(splatnet.images.map(r => r.path), ['/images/things/two.png']); + assert.deepEqual( + splatnet.images.map(r => r.path), + ['/images/things/two.png'], + ); assert.equal(splatnet.images[0].cookie, null); assert.equal(await (await b.publicBucket.get('assets/splatnet/images/things/one.png')).text(), 'existing'); - assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/things/']); + assert.deepEqual( + list.mock.calls.map(c => c.arguments[0].prefix), + ['assets/splatnet/images/things/'], + ); assert.equal(head.mock.calls.length, 0); - assert.equal((await b.publicBucket.get('assets/splatnet/images/things/two.png')).httpMetadata.contentType, 'image/png'); + assert.equal( + (await b.publicBucket.get('assets/splatnet/images/things/two.png')).httpMetadata.contentType, + 'image/png', + ); }); test('uses the embedded backup for the image SplatNet no longer serves', async () => { const { cdnBackup } = await import('#cdn-images'); const missing = '/images/skill/53c62995f9d2dc4a60f3850c5dbdd2323f1eef87.png'; - const splatnet = fakeSplatNet({ '/api/things': () => ({ things: [{ id: '1', name: 'Widget', image: missing }] }) }); + const splatnet = fakeSplatNet({ + '/api/things': () => ({ things: [{ id: '1', name: 'Widget', image: missing }] }), + }); + await new ThingsUpdater(b).update(); assert.deepEqual(splatnet.images, []); + const written = new Uint8Array(await (await b.publicBucket.get(`assets/splatnet${missing}`)).arrayBuffer()); + assert.deepEqual(written, await cdnBackup(missing)); assert.deepEqual(written.slice(0, 4), new Uint8Array([0x89, 0x50, 0x4e, 0x47])); // a real PNG assert.equal(written.byteLength, 8440); }); test('filters root keys, dropping hidden timeline items', async () => { - fakeSplatNet({ '/api/things': () => ({ keep: { importance: 1, x: 1 }, hidden: { importance: -1 }, extra: 'dropped' }) }); - await new ThingsUpdater(b, { rootKeys: ['keep', 'hidden', 'absent'], localization: undefined, imagePaths: undefined, calendarFilename: undefined }).update(); - assert.deepEqual(await json(b.publicBucket, 'data/things.json'), { keep: { importance: 1, x: 1 }, hidden: null, absent: null }); + fakeSplatNet({ + '/api/things': () => ({ keep: { importance: 1, x: 1 }, hidden: { importance: -1 }, extra: 'dropped' }), + }); + await new ThingsUpdater(b, { + rootKeys: ['keep', 'hidden', 'absent'], + localization: undefined, + imagePaths: undefined, + calendarFilename: undefined, + }).update(); + + assert.deepEqual(await json(b.publicBucket, 'data/things.json'), { + keep: { importance: 1, x: 1 }, + hidden: null, + absent: null, + }); }); test('fails the run and writes nothing when SplatNet rejects the session', async () => { fakeSplatNet({ '/api/things': () => new Response('forbidden', { status: 403 }) }); + await assert.rejects(new ThingsUpdater(b).update(), /status 403/); assert.deepEqual(keys(b.publicBucket), []); }); @@ -117,6 +179,7 @@ test('fails the run and writes nothing when SplatNet rejects the session', async test('leaves the existing locale document untouched when the update fails', async () => { await b.publicBucket.put('data/locale/en.json', JSON.stringify({ things: { 9: { name: 'Old' } } })); fakeSplatNet({ '/api/things': () => new Response('down', { status: 503 }) }); + await assert.rejects(new ThingsUpdater(b).update()); assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 9: { name: 'Old' } } }); }); diff --git a/test/updater/support.js b/test/updater/support.js index 415c847..b35b1f3 100644 --- a/test/updater/support.js +++ b/test/updater/support.js @@ -17,26 +17,39 @@ export function setSessionEnvironment() { */ export function fakeSplatNet(routes = {}) { const requests = []; + mock.method(globalThis, 'fetch', async (input, init = {}) => { const url = new URL(input); const headers = new Headers(init.headers); const cookie = headers.get('Cookie'); const region = Object.entries(SESSIONS).find(([, id]) => cookie === `iksm_session=${id}`)?.[0] ?? null; const request = { path: url.pathname, language: headers.get('Accept-Language'), cookie, region }; + requests.push(request); if (url.pathname.startsWith('/images/')) - return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } }); + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + headers: { 'content-type': 'image/png' }, + }); + const route = routes[url.pathname]; + if (!route) return new Response('not found', { status: 404 }); + const result = await route(request); + return result instanceof Response ? result : Response.json(result); }); + return { requests, - get api() { return requests.filter(r => r.path.startsWith('/api/')); }, - get images() { return requests.filter(r => r.path.startsWith('/images/')); }, + get api() { + return requests.filter(r => r.path.startsWith('/api/')); + }, + get images() { + return requests.filter(r => r.path.startsWith('/images/')); + }, }; } @@ -45,12 +58,20 @@ export function fakeSplatNet(routes = {}) { * updaters' storage argument and as a handle on the raw buckets for assertions. */ export function buckets() { - const publicBucket = new MemoryBucket, privateBucket = new MemoryBucket; - return { publicBucket, privateBucket, publicStorage: new BucketStorage(publicBucket), privateStorage: new BucketStorage(privateBucket) }; + const publicBucket = new MemoryBucket(), + privateBucket = new MemoryBucket(); + + return { + publicBucket, + privateBucket, + publicStorage: new BucketStorage(publicBucket), + privateStorage: new BucketStorage(privateBucket), + }; } export async function json(bucket, key) { const object = await bucket.get(key); + return object ? object.json() : null; } diff --git a/test/updater/updaters.test.js b/test/updater/updaters.test.js index 587a3b7..f32632e 100644 --- a/test/updater/updaters.test.js +++ b/test/updater/updaters.test.js @@ -15,32 +15,58 @@ import { fakeSplatNet, buckets, json, keys, setSessionEnvironment } from './supp setSessionEnvironment(); let b; -beforeEach(() => { b = buckets(); }); + +beforeEach(() => { + b = buckets(); +}); afterEach(() => mock.restoreAll()); const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` }); const rotation = (start, a, c, rule = 'turf_war') => ({ - id: start, start_time: start, end_time: start + 7200, - stage_a: a, stage_b: c, + id: start, + start_time: start, + end_time: start + 7200, + stage_a: a, + stage_b: c, game_mode: { key: 'regular', name: 'Regular Battle' }, rule: { key: rule, name: 'Turf War', multiline_name: 'Turf\nWar' }, }); test('createUpdaters builds every updater against the given buckets', () => { const updaters = createUpdaters(b); - assert.deepEqual(updaters.map(u => u.options.name), [ - 'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises', - ]); - assert.ok(updaters.every(u => u.publicStorage === b.publicStorage && u.privateStorage === b.privateStorage)); + + assert.deepEqual( + updaters.map(u => u.options.name), + [ + 'Original Gear', + 'Schedules', + 'Co-op Schedules', + 'Timeline', + 'Festivals NA', + 'Festivals EU', + 'Festivals JP', + 'Merchandises', + ], + ); + assert.ok( + updaters.every(u => u.publicStorage === b.publicStorage && u.privateStorage === b.privateStorage), + ); }); test('schedules: seeds the known stages from SplatNet and records new stages once', async () => { - const reef = stage('0', 'The Reef'), fitness = stage('1', 'Musselforge Fitness'), skipper = stage('22', 'Skipper Pavilion'); - const schedules = { regular: [rotation(7200, skipper, reef), rotation(3600, reef, fitness)], gachi: [rotation(3600, reef, fitness)], league: [rotation(3600, fitness, reef)] }; + const reef = stage('0', 'The Reef'), + fitness = stage('1', 'Musselforge Fitness'), + skipper = stage('22', 'Skipper Pavilion'); + const schedules = { + regular: [rotation(7200, skipper, reef), rotation(3600, reef, fitness)], + gachi: [rotation(3600, reef, fitness)], + league: [rotation(3600, fitness, reef)], + }; const splatnet = fakeSplatNet({ '/api/schedules': () => schedules, '/api/data/stages': () => ({ stages: [reef, fitness] }), }); + await new SchedulesUpdater(b).update(); assert.deepEqual(await json(b.publicBucket, 'data/schedules.json'), schedules); @@ -53,20 +79,46 @@ test('schedules: seeds the known stages from SplatNet and records new stages onc const second = fakeSplatNet({ '/api/schedules': () => schedules }); const puts = mock.method(b.privateBucket, 'put'); + await new SchedulesUpdater(b).update(); + assert.equal(second.api.filter(r => r.path === '/api/data/stages').length, 0); assert.equal(puts.mock.calls.length, 0); }); test('coop: publishes the calendar with stage and weapon details', async () => { const coop = { - schedules: [{ start_time: 3600, end_time: 7200 }, { start_time: 90000, end_time: 93600 }], - details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [{ id: '0', weapon: { id: '0', name: 'Splattershot', image: '/images/weapon/0.png', sub: { id: '1', name: 'Bomb' }, special: { id: '2', name: 'Special' } } }, null] }], + schedules: [ + { start_time: 3600, end_time: 7200 }, + { start_time: 90000, end_time: 93600 }, + ], + details: [ + { + start_time: 3600, + end_time: 7200, + stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, + weapons: [ + { + id: '0', + weapon: { + id: '0', + name: 'Splattershot', + image: '/images/weapon/0.png', + sub: { id: '1', name: 'Bomb' }, + special: { id: '2', name: 'Special' }, + }, + }, + null, + ], + }, + ], }; + fakeSplatNet({ '/api/coop_schedules': () => coop }); await new CoopSchedulesUpdater(b).update(); const ics = await (await b.publicBucket.get('data/coop-schedules.ics')).text(); + assert.match(ics, /SUMMARY:Salmon Run on Spawning Grounds/); assert.match(ics, /Splattershot/); assert.match(ics, /Random/); @@ -75,25 +127,58 @@ test('coop: publishes the calendar with stage and weapon details', async () => { }); test('timeline: keeps only coop and weapon availability, dropping hidden items', async () => { - fakeSplatNet({ '/api/timeline': () => ({ coop: { importance: -1 }, weapon_availability: { importance: 1, availabilities: [] }, stats: { ignored: true } }) }); + fakeSplatNet({ + '/api/timeline': () => ({ + coop: { importance: -1 }, + weapon_availability: { importance: 1, availabilities: [] }, + stats: { ignored: true }, + }), + }); await new TimelineUpdater(b).update(); - assert.deepEqual(await json(b.publicBucket, 'data/timeline.json'), { coop: null, weapon_availability: { importance: 1, availabilities: [] } }); + + assert.deepEqual(await json(b.publicBucket, 'data/timeline.json'), { + coop: null, + weapon_availability: { importance: 1, availabilities: [] }, + }); }); test('merchandises: attaches original gear from the bundled data without its brand', async () => { const original = gearData.head[0]; - const brand = { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }; + const brand = { + id: '0', + name: 'Brand', + image: '/images/brand/0.png', + frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' }, + }; const skill = { id: '0', name: 'Skill', image: '/images/skill/0.png' }; - fakeSplatNet({ '/api/onlineshop/merchandises': () => ({ - merchandises: [ - { end_time: 7200, gear: { kind: 'head', id: '1', name: original.name.toUpperCase(), image: '/images/gear/1.png', brand }, skill }, - { end_time: 7200, gear: { kind: 'head', id: '2', name: 'No Such Gear', image: '/images/gear/2.png', brand }, skill }, - ], - extra: 'dropped', - }) }); + + fakeSplatNet({ + '/api/onlineshop/merchandises': () => ({ + merchandises: [ + { + end_time: 7200, + gear: { + kind: 'head', + id: '1', + name: original.name.toUpperCase(), + image: '/images/gear/1.png', + brand, + }, + skill, + }, + { + end_time: 7200, + gear: { kind: 'head', id: '2', name: 'No Such Gear', image: '/images/gear/2.png', brand }, + skill, + }, + ], + extra: 'dropped', + }), + }); await new MerchandisesUpdater(b).update(); const data = await json(b.publicBucket, 'data/merchandises.json'); + assert.deepEqual(Object.keys(data), ['merchandises']); assert.equal(data.merchandises[0].original_gear.name, original.name); assert.equal('brand' in data.merchandises[0].original_gear, false); @@ -104,34 +189,66 @@ test('merchandises: attaches original gear from the bundled data without its bra test('festivals: merges one region into the shared file, fetches missing rankings, uses regional languages', async () => { await b.publicBucket.put('data/festivals.json', JSON.stringify({ eu: { festivals: [], results: [] } })); await b.publicBucket.put('data/festivals/na-1-rankings.json', JSON.stringify({ existing: true })); - const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: { id: '100', name: 'Shifty Station', image: '/images/stage/100.png' } }); + + const festival = id => ({ + festival_id: id, + names: { alpha_short: 'A', bravo_short: 'B' }, + times: { start: 3600, end: 7200 }, + images: { + alpha: '/images/festival/a.png', + bravo: '/images/festival/b.png', + panel: '/images/festival/p.png', + }, + special_stage: { id: '100', name: 'Shifty Station', image: '/images/stage/100.png' }, + }); const splatnet = fakeSplatNet({ '/api/festivals/active': () => ({ festivals: [festival(2)] }), - '/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }, { festival_id: 2 }] }), + '/api/festivals/pasts': () => ({ + festivals: [festival(1)], + results: [{ festival_id: 1 }, { festival_id: 2 }], + }), '/api/festivals/2/rankings': () => ({ rankings: 'two' }), }); + await new FestivalsUpdater('NA', b).update(); const data = await json(b.publicBucket, 'data/festivals.json'); + assert.deepEqual(Object.keys(data).sort(), ['eu', 'na']); - assert.deepEqual(data.na.festivals.map(f => f.festival_id), [2, 1]); + assert.deepEqual( + data.na.festivals.map(f => f.festival_id), + [2, 1], + ); assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-1-rankings.json'), { existing: true }); assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-2-rankings.json'), { rankings: 'two' }); - assert.deepEqual(splatnet.api.filter(r => r.path.endsWith('/rankings')).map(r => r.path), ['/api/festivals/2/rankings']); + assert.deepEqual( + splatnet.api.filter(r => r.path.endsWith('/rankings')).map(r => r.path), + ['/api/festivals/2/rankings'], + ); // Only this region's languages are localized (NA: en, es-MX, fr-CA), all with the NA session const localized = splatnet.api.filter(r => r.path === '/api/festivals/active'); - assert.deepEqual(localized.map(r => `${r.region}/${r.language}`), ['NA/en', 'NA/es-MX', 'NA/fr-CA']); + + assert.deepEqual( + localized.map(r => `${r.region}/${r.language}`), + ['NA/en', 'NA/es-MX', 'NA/fr-CA'], + ); const ics = await (await b.publicBucket.get('data/festivals-na.ics')).text(); + assert.match(ics, /X-WR-CALNAME:Splatfests \(NA\)/); assert.match(ics, /SUMMARY:NA Splatfest: A vs. B/); }); test('original gear: mirrors skill images from the bundled skills data', async () => { - const images = Object.values(skills).map(s => s.image).filter(Boolean); + const images = Object.values(skills) + .map(s => s.image) + .filter(Boolean); + await b.publicBucket.put(`assets/splatnet${images[0]}`, 'existing'); + const splatnet = fakeSplatNet(); + await new OriginalGearImageUpdater(b).update(); assert.equal(splatnet.images.length, images.length - 2); // one already present, one from the embedded backup diff --git a/test/web/data.test.js b/test/web/data.test.js index 67beb12..f4afd55 100644 --- a/test/web/data.test.js +++ b/test/web/data.test.js @@ -9,24 +9,35 @@ let page; let originalDocument; const context = { rootGetters: { 'splatoon/languages/selectedLanguage': null }, - dispatch(name) { return actions[name](context); }, - commit(name, value) { commits.push({ name, value }); }, + dispatch(name) { + return actions[name](context); + }, + commit(name, value) { + commits.push({ name, value }); + }, }; beforeEach(() => { originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); - page = new EventTarget; + page = new EventTarget(); page.visibilityState = 'visible'; Object.defineProperty(globalThis, 'document', { configurable: true, value: page }); - timers = new Map; + timers = new Map(); commits = []; + let id = 0; - mock.method(globalThis, 'setTimeout', (callback, ms) => { timers.set(++id, { callback, ms }); return id; }); + + mock.method(globalThis, 'setTimeout', (callback, ms) => { + timers.set(++id, { callback, ms }); + + return id; + }); mock.method(globalThis, 'clearTimeout', id => timers.delete(id)); }); afterEach(() => { actions.stopUpdatingData(); mock.restoreAll(); + if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument); else @@ -34,18 +45,33 @@ afterEach(() => { }); test('a stalled body times out and leaves a future refresh scheduled', async () => { - mock.method(globalThis, 'fetch', async (url, { signal }) => new Response(new ReadableStream({ - start(controller) { - signal.addEventListener('abort', () => controller.error(new Error('aborted')), { once: true }); - }, - }))); + mock.method( + globalThis, + 'fetch', + async (url, { signal }) => + new Response( + new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => controller.error(new Error('aborted')), { + once: true, + }); + }, + }), + ), + ); + let refreshing = actions.startUpdatingData(context); + assert.equal(timers.size, 5); + for (let timer of [...timers.values()]) { assert.equal(timer.ms, 30_000); + timer.callback(); } + await refreshing; + assert.equal(commits.length, 0); assert.equal(timers.size, 1); assert.ok([...timers.values()][0].ms > 0); @@ -54,22 +80,38 @@ test('a stalled body times out and leaves a future refresh scheduled', async () test('a failed fetch retains old data and does not stop the refresh loop', async () => { mock.method(globalThis, 'fetch', async () => new Response('down', { status: 503 })); await actions.startUpdatingData(context); + assert.equal(commits.length, 0); assert.equal(timers.size, 1); + actions.stopUpdatingData(); + assert.equal(timers.size, 0); }); test('stop and restart while an old refresh finishes creates only one timer', async () => { let release; - let pending = new Promise(resolve => { release = resolve; }); - mock.method(globalThis, 'fetch', async () => { await pending; return Response.json({ current: true }); }); + let pending = new Promise(resolve => { + release = resolve; + }); + + mock.method(globalThis, 'fetch', async () => { + await pending; + + return Response.json({ current: true }); + }); + let first = actions.startUpdatingData(context); + actions.stopUpdatingData(); + let second = actions.startUpdatingData(context); + release(); await Promise.all([first, second]); + assert.equal(timers.size, 1); + await actions.startUpdatingData(context); // Already running: no extra fetch or timer assert.equal(timers.size, 1); }); @@ -81,55 +123,85 @@ function visibility(state) { test('returning to an old tab refreshes immediately, with a cooldown and one timer', async () => { let now = Date.parse('2026-09-07T10:10:00Z'); + mock.method(Date, 'now', () => now); + const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true })); + await actions.startUpdatingData(context); + assert.equal(fetching.mock.callCount(), 5); + now += 60_000; visibility('hidden'); await setImmediate(); + assert.equal(fetching.mock.callCount(), 5); + visibility('visible'); await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); assert.equal(timers.size, 1); + visibility('hidden'); visibility('visible'); await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); + actions.stopUpdatingData(); now += 60_000; visibility('visible'); await setImmediate(); + assert.equal(fetching.mock.callCount(), 10); assert.equal(timers.size, 0); }); test('activation does not overlap an in-flight refresh or leave a timer after stopping', async () => { let now = Date.now(); + mock.method(Date, 'now', () => now); + let release; - let pending = new Promise(resolve => { release = resolve; }); - const fetching = mock.method(globalThis, 'fetch', async () => { await pending; return Response.json({ current: true }); }); + let pending = new Promise(resolve => { + release = resolve; + }); + const fetching = mock.method(globalThis, 'fetch', async () => { + await pending; + + return Response.json({ current: true }); + }); let refreshing = actions.startUpdatingData(context); + now += 60_000; visibility('visible'); + assert.equal(fetching.mock.callCount(), 5); + actions.stopUpdatingData(); release(); await refreshing; + assert.equal(timers.size, 0); }); test('activation cooldown does not suppress the scheduled top-of-hour refresh', async () => { let now = Date.parse('2026-09-07T10:59:50Z'); + mock.method(Date, 'now', () => now); + const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true })); + await actions.startUpdatingData(context); + const [id, scheduled] = [...timers.entries()][0]; + timers.delete(id); // A real timeout is removed before its callback runs. now += 40_000; await scheduled.callback(); + assert.equal(fetching.mock.callCount(), 10); assert.equal(timers.size, 1); }); diff --git a/workers/updater/Scheduler.spec.mjs b/workers/updater/Scheduler.spec.mjs index 1979547..615eea8 100644 --- a/workers/updater/Scheduler.spec.mjs +++ b/workers/updater/Scheduler.spec.mjs @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createExecutionContext, createScheduledController, env, runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test'; +import { + createExecutionContext, + createScheduledController, + env, + runDurableObjectAlarm, + runInDurableObject, +} from 'cloudflare:test'; import worker from './src/index.mjs'; import { nextRunAt, GRACE_MS, HOUR_MS } from './src/schedule.mjs'; import { fakeSplatNet, setSessionEnvironment } from './fakeSplatNet.mjs'; @@ -11,25 +17,36 @@ function stub() { async function runAlarmUntil(scheduler, done) { await runDurableObjectAlarm(scheduler); await vi.waitFor(async () => expect(done(await scheduler.status())).toBe(true), { timeout: 5000 }); + return scheduler.status(); } function network({ down = false, renderFails = false, beforeRequest } = {}) { let splatnet = fakeSplatNet(); let renders = []; + vi.stubGlobal('fetch', async (input, init) => { const url = new URL(input); + if (url.hostname === 'site.test') { let object = await env.ASSETS.get(url.pathname.slice(1)); + return object ? new Response(object.body) : new Response('missing', { status: 404 }); } + if (url.hostname === 'api.cloudflare.com') { renders.push(JSON.parse(init.body)); - return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), { status: renderFails ? 503 : 200 }); + + return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), { + status: renderFails ? 503 : 200, + }); } + await beforeRequest?.(); + return down ? new Response('down', { status: 503 }) : splatnet(input, init); }); + return renders; } @@ -38,6 +55,7 @@ beforeEach(() => { process.env.SITE_URL = 'https://site.test'; process.env.CLOUDFLARE_ACCOUNT_ID = 'test'; process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'test'; + for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; }); @@ -51,6 +69,7 @@ describe('Scheduler', () => { let scheduler = stub(); let before = Date.now(); let first = await scheduler.ensureArmed(); + expect(first.armed).toBe(true); expect(first.hourlyAt).toBeGreaterThanOrEqual(nextRunAt(before)); expect((first.hourlyAt - GRACE_MS) % HOUR_MS).toBe(0); @@ -59,13 +78,17 @@ describe('Scheduler', () => { it('persists a pause through watchdog calls and only resumes explicitly', async () => { network(); + let scheduler = stub(); await scheduler.ensureArmed(); + expect(await scheduler.pause()).toEqual({ ok: true, paused: true }); expect(await scheduler.ensureArmed()).toEqual({ armed: false, paused: true }); expect((await scheduler.status()).alarmAt).toBeNull(); expect(await scheduler.run()).toMatchObject({ ok: false, paused: true }); + await scheduler.resume(); + expect((await scheduler.status()).alarmAt).not.toBeNull(); expect((await scheduler.status()).paused).toBe(false); }); @@ -73,19 +96,25 @@ describe('Scheduler', () => { it('repairs a missing alarm without moving its due time', async () => { let scheduler = stub(); let initial = await scheduler.ensureArmed(); + await runInDurableObject(scheduler, async (instance, state) => state.storage.deleteAlarm()); + expect(await scheduler.ensureArmed()).toEqual({ ...initial, armed: true }); }); it('converts pending requests from the previous deployment into a full run', async () => { network(); + let scheduler = stub(); let hourlyAt = nextRunAt(); + await runInDurableObject(scheduler, async (instance, state) => { await state.storage.put('state', { hourlyAt, pending: ['updaters', 'posters'] }); }); await scheduler.ensureArmed(); + let status = await runAlarmUntil(scheduler, s => s.lastRun !== null); + expect(status.lastRun.ok).toBe(true); expect(status.hourlyAt).toBe(hourlyAt); expect(status.retryAt).toBeNull(); @@ -93,11 +122,15 @@ describe('Scheduler', () => { it('runs a full hourly pipeline, records completion timing, and schedules the next hour', async () => { network(); + let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); + let status = await runAlarmUntil(scheduler, s => s.hourlyAt !== hourlyAt); + expect(status.lastRun.ok).toBe(true); expect(status.lastRun.updaters.updaters).toHaveLength(8); expect(status.lastRun.social.ok).toBe(true); @@ -109,29 +142,39 @@ describe('Scheduler', () => { let renders = network({ down: true }); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 5); + let status = await runAlarmUntil(scheduler, s => s.retries === 1); + expect(status.lastRun.updaters.ok).toBe(false); expect(status.lastRun.social).toMatchObject({ skipped: true, reason: 'updater-failed' }); expect(renders).toEqual([]); + for (let attempt = 2; attempt <= 3; attempt++) { vi.setSystemTime(status.retryAt); status = await runAlarmUntil(scheduler, s => s.retries === attempt); } + vi.setSystemTime(status.retryAt); status = await runAlarmUntil(scheduler, s => s.retryAt === null); + expect(status.retries).toBe(0); expect(status.alarmAt).toBe(hourlyAt + HOUR_MS); }); it('retries screenshot failures instead of reporting a successful social run', async () => { network({ renderFails: true }); + let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); + let status = await runAlarmUntil(scheduler, s => s.retries === 1); + expect(status.lastRun.updaters.ok).toBe(true); expect(status.lastRun.social.ok).toBe(false); expect(status.lastRun.social.posts.some(post => post.ok === false)).toBe(true); @@ -142,6 +185,7 @@ describe('Scheduler', () => { let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); let result = await scheduler.run({ only: ['Schedules'] }); + expect(result.ok).toBe(true); expect(result.updaters.updaters.map(u => u.name)).toEqual(['Schedules']); expect(result.social.reason).toBe('targeted-update'); @@ -153,29 +197,48 @@ describe('Scheduler', () => { it('rejects overlapping manual work explicitly and preserves an hourly alarm due during it', async () => { let release; - let gate = new Promise(resolve => { release = resolve; }); + let gate = new Promise(resolve => { + release = resolve; + }); let entered = false; - network({ beforeRequest: async () => { entered = true; await gate; } }); + + network({ + beforeRequest: async () => { + entered = true; + await gate; + }, + }); + let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); let first = scheduler.run({ only: ['Schedules'] }); + try { await vi.waitFor(() => expect(entered).toBe(true)); - expect((await scheduler.status()).activeRun.logs.lines.some(line => line.text.includes('Updating data'))).toBe(true); + + expect( + (await scheduler.status()).activeRun.logs.lines.some(line => line.text.includes('Updating data')), + ).toBe(true); expect(await scheduler.run()).toMatchObject({ ok: false, busy: true }); expect(await scheduler.ensureArmed()).toMatchObject({ busy: true }); expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true }); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); await runDurableObjectAlarm(scheduler); + let status = await scheduler.status(); + expect(status.hourlyAt).toBe(hourlyAt); expect(status.alarmAt).not.toBeNull(); } finally { release(); } + await first; + let status = await runAlarmUntil(scheduler, s => s.lastRun !== null); + expect(status.lastRun.updaters.ok).toBe(true); expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS); }); @@ -184,20 +247,33 @@ describe('Scheduler', () => { describe('Background manual runs', () => { it('persists a request, rejects overlap, and runs data without posting', async () => { let release; - const gate = new Promise(resolve => { release = resolve; }); + const gate = new Promise(resolve => { + release = resolve; + }); const renders = network({ beforeRequest: () => gate }); const scheduler = stub(); const { hourlyAt } = await scheduler.ensureArmed(); const result = await scheduler.startManual('data'); + expect(result).toMatchObject({ ok: true, run: { mode: 'data', status: 'queued' } }); + try { expect((await scheduler.status()).pendingManual.id).toBe(result.run.id); expect(await scheduler.startManual('social')).toMatchObject({ ok: false, busy: true }); expect(await scheduler.run()).toMatchObject({ ok: false, busy: true }); expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true }); - } finally { release(); } + } finally { + release(); + } + const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun); - expect(status.lastManualRun).toMatchObject({ id: result.run.id, ok: true, status: 'succeeded', social: { skipped: true } }); + + expect(status.lastManualRun).toMatchObject({ + id: result.run.id, + ok: true, + status: 'succeeded', + social: { skipped: true }, + }); expect(status.pendingManual).toBeNull(); expect(status.activeRun).toBeNull(); expect(status.lastManualRun.logs.lines.some(line => line.text.includes('Done.'))).toBe(true); @@ -206,26 +282,41 @@ describe('Background manual runs', () => { }); it('runs social from the published data without invoking SplatNet again', async () => { network(); + const scheduler = stub(); await scheduler.startManual('data'); await runAlarmUntil(scheduler, s => !!s.lastManualRun); + const requests = vi.fn(); const renders = network({ beforeRequest: requests }); const { run } = await scheduler.startManual('social'); const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === run.id); - expect(status.lastManualRun).toMatchObject({ ok: true, updaters: { skipped: true }, social: { ok: true } }); + + expect(status.lastManualRun).toMatchObject({ + ok: true, + updaters: { skipped: true }, + social: { ok: true }, + }); expect(requests).not.toHaveBeenCalled(); expect(renders.length).toBeGreaterThan(0); }); it('reports an interrupted run instead of automatically replaying an uncertain post', async () => { const scheduler = stub(); const renders = network(); + await scheduler.ensureArmed(); await runInDurableObject(scheduler, async (instance, state) => { - await state.storage.put('pendingManual', { id: 'interrupted', mode: 'social', status: 'running', startedAt: Date.now() - 1000 }); + await state.storage.put('pendingManual', { + id: 'interrupted', + mode: 'social', + status: 'running', + startedAt: Date.now() - 1000, + }); }); await scheduler.ensureArmed(); + const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun); + expect(status.lastManualRun).toMatchObject({ id: 'interrupted', ok: false, status: 'failed' }); expect(status.lastManualRun.error).toContain('interrupted'); expect(renders).toHaveLength(0); @@ -233,22 +324,34 @@ describe('Background manual runs', () => { }); it('still performs an hourly cycle that became due alongside a manual request', async () => { network(); + const scheduler = stub(); const { hourlyAt } = await scheduler.ensureArmed(); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); await runInDurableObject(scheduler, async (instance, state) => { - await state.storage.put('pendingManual', { id: 'due', mode: 'data', status: 'queued', requestedAt: Date.now() }); + await state.storage.put('pendingManual', { + id: 'due', + mode: 'data', + status: 'queued', + requestedAt: Date.now(), + }); }); + const status = await runAlarmUntil(scheduler, s => !!s.lastRun); + expect(status.lastManualRun).toMatchObject({ id: 'due', ok: true, mode: 'data' }); expect(status.lastRun).toMatchObject({ ok: true, mode: 'both', social: { ok: true } }); expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS); }); it('rejects invalid modes and paused scheduling', async () => { const scheduler = stub(); + expect(await scheduler.startManual('anything')).toMatchObject({ ok: false }); + await scheduler.pause(); + expect(await scheduler.startManual('both')).toMatchObject({ ok: false, paused: true }); }); }); @@ -257,22 +360,36 @@ describe('Worker routing', () => { it('cron only calls the watchdog', async () => { const ensureArmed = vi.fn(async () => ({ armed: true })); const fakeEnv = { SCHEDULER: { idFromName: () => 'id', get: () => ({ ensureArmed }) } }; - await worker.scheduled(createScheduledController({ cron: '30 * * * *' }), fakeEnv, createExecutionContext()); + + await worker.scheduled( + createScheduledController({ cron: '30 * * * *' }), + fakeEnv, + createExecutionContext(), + ); + expect(ensureArmed).toHaveBeenCalledOnce(); }); it('authenticates manual runs and sends them through the DO with busy/failure HTTP statuses', async () => { const run = vi.fn(async () => ({ ok: false, busy: true })); const fakeEnv = { RUN_TOKEN: 'test-token', SCHEDULER: { idFromName: () => 'id', get: () => ({ run }) } }; - const request = headers => new Request('https://worker.test/run?only=Schedules,Timeline', { method: 'POST', headers }); + const request = headers => + new Request('https://worker.test/run?only=Schedules,Timeline', { method: 'POST', headers }); + expect((await worker.fetch(request(), fakeEnv, createExecutionContext())).status).toBe(401); expect(run).not.toHaveBeenCalled(); + const headers = { Authorization: 'Bearer test-token' }; + expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(409); expect(run).toHaveBeenCalledWith({ only: ['Schedules', 'Timeline'] }); + run.mockResolvedValue({ ok: false }); + expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(502); + run.mockResolvedValue({ ok: true }); + expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200); }); }); diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs index a941f4d..6104ff9 100644 --- a/workers/updater/admin.spec.mjs +++ b/workers/updater/admin.spec.mjs @@ -3,6 +3,7 @@ import { adminRequest } from './src/admin/routes.mjs'; import { verifyAccess } from './src/admin/access.mjs'; vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() })); afterEach(() => vi.resetAllMocks()); + const url = 'https://admin.example.test'; const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { @@ -13,7 +14,9 @@ const post = (mode = 'both', origin = url) => it('requires Access before exposing the panel, status or actions', async () => { verifyAccess.mockResolvedValue(null); + const get = vi.fn(); + for (const request of [ new Request(url + '/'), new Request(url + '/admin/'), @@ -21,30 +24,42 @@ it('requires Access before exposing the panel, status or actions', async () => { post(), ]) expect((await adminRequest(request, {}, get)).status).toBe(401); + expect(get).not.toHaveBeenCalled(); }); it('rejects cross-origin requests and unknown modes before scheduling work', async () => { verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + const get = vi.fn(); + expect((await adminRequest(post('both', 'https://other.test'), {}, get)).status).toBe(403); expect((await adminRequest(post('force'), {}, get)).status).toBe(400); expect(get).not.toHaveBeenCalled(); }); it('returns an accepted run immediately and reports overlap without starting another', async () => { verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); - const startManual = vi.fn(async (mode) => ({ + + const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' }, })); + expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202); expect(startManual).toHaveBeenCalledWith('social'); + startManual.mockResolvedValue({ ok: false, busy: true }); + expect((await adminRequest(post(), {}, () => ({ startManual }))).status).toBe(409); }); -it.each(['/', '/admin/'])('serves the mobile panel at %s with no-store and a nonce-based content policy', async (path) => { - verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); - const response = await adminRequest(new Request(url + path), {}, vi.fn()); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\''); - expect(await response.text()).not.toContain('__NONCE__'); -}); +it.each(['/', '/admin/'])( + 'serves the mobile panel at %s with no-store and a nonce-based content policy', + async path => { + verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + + const response = await adminRequest(new Request(url + path), {}, vi.fn()); + + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\''); + expect(await response.text()).not.toContain('__NONCE__'); + }, +); diff --git a/workers/updater/fakeSplatNet.mjs b/workers/updater/fakeSplatNet.mjs index 252fb4d..4764bca 100644 --- a/workers/updater/fakeSplatNet.mjs +++ b/workers/updater/fakeSplatNet.mjs @@ -3,41 +3,94 @@ export const SESSIONS = { NA: 'na-session', EU: 'eu-session', JP: 'jp-session' } const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` }); const rotation = start => ({ - id: start, start_time: start, end_time: start + 7200, - stage_a: stage('0', 'The Reef'), stage_b: stage('1', 'Musselforge Fitness'), + id: start, + start_time: start, + end_time: start + 7200, + stage_a: stage('0', 'The Reef'), + stage_b: stage('1', 'Musselforge Fitness'), game_mode: { key: 'regular', name: 'Regular Battle' }, rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' }, }); -const gear = { kind: 'head', id: '1', name: 'Fake Hat', image: '/images/gear/1.png', brand: { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } } }; -const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: stage('100', 'Shifty Station') }); +const gear = { + kind: 'head', + id: '1', + name: 'Fake Hat', + image: '/images/gear/1.png', + brand: { + id: '0', + name: 'Brand', + image: '/images/brand/0.png', + frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' }, + }, +}; +const festival = id => ({ + festival_id: id, + names: { alpha_short: 'A', bravo_short: 'B' }, + times: { start: 3600, end: 7200 }, + images: { + alpha: '/images/festival/a.png', + bravo: '/images/festival/b.png', + panel: '/images/festival/p.png', + }, + special_stage: stage('100', 'Shifty Station'), +}); export const ROUTES = { '/api/schedules': () => ({ regular: [rotation(3600)], gachi: [rotation(3600)], league: [rotation(3600)] }), '/api/data/stages': () => ({ stages: [stage('0', 'The Reef'), stage('1', 'Musselforge Fitness')] }), - '/api/coop_schedules': () => ({ schedules: [{ start_time: 3600, end_time: 7200 }], details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [null] }] }), - '/api/timeline': () => ({ coop: { importance: 1, reward_gear: { available_time: 0, gear } }, weapon_availability: null }), + '/api/coop_schedules': () => ({ + schedules: [{ start_time: 3600, end_time: 7200 }], + details: [ + { + start_time: 3600, + end_time: 7200, + stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, + weapons: [null], + }, + ], + }), + '/api/timeline': () => ({ + coop: { importance: 1, reward_gear: { available_time: 0, gear } }, + weapon_availability: null, + }), '/api/festivals/active': () => ({ festivals: [festival(2)] }), '/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }] }), '/api/festivals/1/rankings': () => ({ rankings: [] }), - '/api/onlineshop/merchandises': () => ({ merchandises: [{ end_time: 7200, gear, skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }] }), + '/api/onlineshop/merchandises': () => ({ + merchandises: [{ end_time: 7200, gear, skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }], + }), }; /** Install on global fetch (e.g. vi.stubGlobal('fetch', fakeSplatNet())). Records every request. */ export function fakeSplatNet(routes = ROUTES) { const requests = []; + const fetch = async (input, init = {}) => { const url = new URL(input); const headers = new Headers(init.headers); - requests.push({ path: url.pathname, language: headers.get('Accept-Language'), cookie: headers.get('Cookie') }); + + requests.push({ + path: url.pathname, + language: headers.get('Accept-Language'), + cookie: headers.get('Cookie'), + }); + if (url.pathname.startsWith('/images/')) - return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } }); + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + headers: { 'content-type': 'image/png' }, + }); + const route = routes[url.pathname]; + if (!route) return new Response('not found', { status: 404 }); + const result = await route(); + return result instanceof Response ? result : Response.json(result); }; fetch.requests = requests; + return fetch; } diff --git a/workers/updater/images.spec.mjs b/workers/updater/images.spec.mjs index c354e9c..788b19b 100644 --- a/workers/updater/images.spec.mjs +++ b/workers/updater/images.spec.mjs @@ -3,10 +3,18 @@ import { env } from 'cloudflare:test'; import { convertToJpeg } from '../../src/app/screenshots/convert.worker.js'; it('converts a PNG through the local Images binding without changing its dimensions', async () => { - const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWP4z8Dwn4EBAAj+Af/KOtJRAAAAAElFTkSuQmCC'), c => c.charCodeAt(0)); + const png = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWP4z8Dwn4EBAAj+Af/KOtJRAAAAAElFTkSuQmCC', + ), + c => c.charCodeAt(0), + ); const jpeg = await convertToJpeg(png); + expect([...jpeg.slice(0, 3)]).toEqual([255, 216, 255]); + const info = await env.IMAGES.info(new Response(jpeg).body); + expect(info.width).toBe(2); expect(info.height).toBe(1); }); @@ -17,7 +25,14 @@ it('passes the original PNG bytes to Images and returns JPEG bytes', async () => let source; const output = vi.fn(async () => ({ response: () => new Response(jpeg) })); const transform = vi.fn(() => ({ output })); - const images = { input: stream => { source = stream; return { transform }; } }; + const images = { + input: stream => { + source = stream; + + return { transform }; + }, + }; + expect(await convertToJpeg(png, images)).toEqual(jpeg); expect(new Uint8Array(await new Response(source).arrayBuffer())).toEqual(png); expect(transform).toHaveBeenCalledWith({ background: '#ffffff' }); @@ -25,6 +40,15 @@ it('passes the original PNG bytes to Images and returns JPEG bytes', async () => }); it('propagates Images failures to the posting pipeline', async () => { - const images = { input: () => ({ transform: () => ({ output: async () => { throw new Error('Images quota exceeded'); } }) }) }; + const images = { + input: () => ({ + transform: () => ({ + output: async () => { + throw new Error('Images quota exceeded'); + }, + }), + }), + }; + await expect(convertToJpeg(new Uint8Array([1]), images)).rejects.toThrow('Images quota exceeded'); }); diff --git a/workers/updater/posters.spec.mjs b/workers/updater/posters.spec.mjs index 2fb96e4..f4b2d9e 100644 --- a/workers/updater/posters.spec.mjs +++ b/workers/updater/posters.spec.mjs @@ -10,34 +10,59 @@ const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); describe('runPosters', () => { let renders; + beforeEach(async () => { process.env.SITE_URL = 'https://example.test'; process.env.CLOUDFLARE_ACCOUNT_ID = 'acct'; process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; + for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; let now = getTopOfCurrentHour(); let rotation = { ...ROUTES['/api/schedules']().regular[0], start_time: now, end_time: now + 7200 }; - await env.ASSETS.put('data/schedules.json', JSON.stringify({ regular: [rotation], gachi: [rotation], league: [rotation] })); - await env.ASSETS.put('data/festivals.json', JSON.stringify({ na: { festivals: [], results: [] }, eu: { festivals: [], results: [] }, jp: { festivals: [], results: [] } })); + + await env.ASSETS.put( + 'data/schedules.json', + JSON.stringify({ regular: [rotation], gachi: [rotation], league: [rotation] }), + ); + await env.ASSETS.put( + 'data/festivals.json', + JSON.stringify({ + na: { festivals: [], results: [] }, + eu: { festivals: [], results: [] }, + jp: { festivals: [], results: [] }, + }), + ); await env.ASSETS.put('data/coop-schedules.json', JSON.stringify({ schedules: [], details: [] })); await env.ASSETS.put('data/timeline.json', JSON.stringify({ coop: null, weapon_availability: null })); - await env.ASSETS.put('data/merchandises.json', JSON.stringify({ merchandises: [{ end_time: now + 3600, gear: { name: 'Hat' }, skill: { name: 'Skill' } }] })); + await env.ASSETS.put( + 'data/merchandises.json', + JSON.stringify({ + merchandises: [{ end_time: now + 3600, gear: { name: 'Hat' }, skill: { name: 'Skill' } }], + }), + ); await env.ASSETS.put('data/locale/en.json', JSON.stringify({})); renders = []; + let splatnet = fakeSplatNet(); + vi.stubGlobal('fetch', async (input, init) => { let url = new URL(input); + if (url.hostname === 'example.test') { let object = await env.ASSETS.get(url.pathname.slice(1)); + return object ? new Response(object.body) : new Response('missing', { status: 404 }); } + if (url.hostname === 'api.cloudflare.com') { renders.push(JSON.parse(init.body)); + return new Response(PNG, { headers: { 'content-type': 'image/png' } }); } + return splatnet(input, init); }); }); @@ -61,6 +86,7 @@ describe('runPosters', () => { it('rejects a rendering site serving different data before making screenshots', async () => { vi.stubGlobal('fetch', async () => Response.json({ stale: true })); + await expect(runPosters(env)).rejects.toThrow('Rendering site data does not match'); expect(renders).toEqual([]); }); diff --git a/workers/updater/schedule.spec.mjs b/workers/updater/schedule.spec.mjs index c8d3e67..749961d 100644 --- a/workers/updater/schedule.spec.mjs +++ b/workers/updater/schedule.spec.mjs @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { nextRunAt, HOUR_MS, GRACE_MS } from './src/schedule.mjs'; const HOUR = Date.UTC(2026, 8, 4, 14, 0, 0); // 14:00:00 UTC -const t = (seconds) => HOUR + seconds * 1000; +const t = seconds => HOUR + seconds * 1000; describe('nextRunAt', () => { it('targets :00:10 of the current hour when that is still ahead', () => { diff --git a/workers/updater/updaters.spec.mjs b/workers/updater/updaters.spec.mjs index 98a7a56..5c51ba7 100644 --- a/workers/updater/updaters.spec.mjs +++ b/workers/updater/updaters.spec.mjs @@ -12,7 +12,11 @@ async function keys(bucket, prefix = '') { describe('runUpdaters', () => { let fetch; - beforeEach(() => { fetch = fakeSplatNet(); vi.stubGlobal('fetch', fetch); }); + + beforeEach(() => { + fetch = fakeSplatNet(); + vi.stubGlobal('fetch', fetch); + }); afterEach(() => vi.unstubAllGlobals()); it('runs every updater against the R2 bindings and publishes the site data', async () => { @@ -20,28 +24,61 @@ describe('runUpdaters', () => { expect(summary.ok).toBe(true); expect(summary.updaters.map(u => u.name)).toEqual([ - 'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises', + 'Original Gear', + 'Schedules', + 'Co-op Schedules', + 'Timeline', + 'Festivals NA', + 'Festivals EU', + 'Festivals JP', + 'Merchandises', ]); - expect(await keys(env.ASSETS, 'data/')).toEqual(expect.arrayContaining([ - 'data/schedules.json', 'data/coop-schedules.json', 'data/coop-schedules.ics', 'data/timeline.json', - 'data/festivals.json', 'data/festivals-na.ics', 'data/festivals-eu.ics', 'data/festivals-jp.ics', - 'data/festivals/na-1-rankings.json', 'data/merchandises.json', 'data/locale/en.json', 'data/locale/ja.json', - ])); + expect(await keys(env.ASSETS, 'data/')).toEqual( + expect.arrayContaining([ + 'data/schedules.json', + 'data/coop-schedules.json', + 'data/coop-schedules.ics', + 'data/timeline.json', + 'data/festivals.json', + 'data/festivals-na.ics', + 'data/festivals-eu.ics', + 'data/festivals-jp.ics', + 'data/festivals/na-1-rankings.json', + 'data/merchandises.json', + 'data/locale/en.json', + 'data/locale/ja.json', + ]), + ); expect(await keys(env.PRIVATE)).toEqual(['stages.json']); let schedules = await (await env.ASSETS.get('data/schedules.json')).json(); + expect(schedules.regular[0].stage_a.name).toBe('The Reef'); - expect((await env.ASSETS.head('data/schedules.json')).httpMetadata.cacheControl).toContain('stale-while-revalidate'); - expect(await keys(env.ASSETS, 'assets/splatnet/images/stage/')).toEqual(['assets/splatnet/images/stage/0.png', 'assets/splatnet/images/stage/1.png', 'assets/splatnet/images/stage/100.png']); + expect((await env.ASSETS.head('data/schedules.json')).httpMetadata.cacheControl).toContain( + 'stale-while-revalidate', + ); + expect(await keys(env.ASSETS, 'assets/splatnet/images/stage/')).toEqual([ + 'assets/splatnet/images/stage/0.png', + 'assets/splatnet/images/stage/1.png', + 'assets/splatnet/images/stage/100.png', + ]); let festivals = await (await env.ASSETS.get('data/festivals.json')).json(); + expect(Object.keys(festivals).sort()).toEqual(['eu', 'jp', 'na']); expect(fetch.requests.find(r => r.path === '/api/schedules').cookie).toBe('iksm_session=na-session'); }); it('can run a subset of updaters, and reports a failing one without stopping the rest', async () => { await env.ASSETS.delete(['data/timeline.json', 'data/merchandises.json']); - vi.stubGlobal('fetch', fakeSplatNet({ '/api/timeline': () => new Response('down', { status: 503 }), '/api/onlineshop/merchandises': () => ({ merchandises: [] }) })); + vi.stubGlobal( + 'fetch', + fakeSplatNet({ + '/api/timeline': () => new Response('down', { status: 503 }), + '/api/onlineshop/merchandises': () => ({ merchandises: [] }), + }), + ); + let summary = await runUpdaters(env, { only: ['Timeline', 'Merchandises'] }); expect(summary.ok).toBe(false);