/** * Unit tests for js/pleroma-adapter.js. * * The adapter is an IIFE that wraps window.fetch. It is loaded in a vm * context with a mocked window object, so tests can call the wrapped * fetch and inspect how Pleroma API responses are mapped to the * Mastodon format expected by mastodon-timeline.umd.js. */ 'use strict'; const { describe, test } = require('node:test'); const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ADAPTER_PATH = path.join(__dirname, '..', '..', 'js', 'pleroma-adapter.js'); const ADAPTER_SOURCE = fs.readFileSync(ADAPTER_PATH, 'utf8'); const TIMELINE_URL = 'https://pleroma.example/api/v1/timelines/public'; const ACCOUNT_STATUSES_URL = 'https://pleroma.example/api/v1/accounts/42/statuses'; /** * Loads the adapter in an isolated vm context. * * @param {Function} handler Fake backend: receives the URL, returns a Response * @returns {{fetch: Function, warnings: string[]}} The wrapped fetch and logged warnings */ function loadAdapter(handler) { const warnings = []; const windowMock = { fetch: async (url) => handler(url) }; const sandbox = { window: windowMock, Response, console: { log() {}, warn: (...args) => warnings.push(args.join(' ')), error() {} } }; vm.createContext(sandbox); vm.runInContext(ADAPTER_SOURCE, sandbox); return { fetch: windowMock.fetch, warnings }; } function jsonResponse(data, init = {}) { return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init }); } describe('Pleroma adapter', () => { test('adds default meta to timeline attachments missing meta', async () => { const posts = [ { id: '1', content: 'hello', media_attachments: [ { id: 'a1', type: 'image', url: 'https://pleroma.example/img.png' } ] }, { id: '2', content: 'no media' } ]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); const data = await res.json(); assert.strictEqual(res.status, 200); assert.strictEqual(data.length, 2); const attachment = data[0].media_attachments[0]; assert.deepStrictEqual(attachment.meta.original, { width: 1280, height: 720, aspect: 1280 / 720 }); assert.deepStrictEqual(attachment.meta.small, { width: 640, height: 360, aspect: 1280 / 720 }); // Other fields are preserved assert.strictEqual(attachment.url, 'https://pleroma.example/img.png'); assert.strictEqual(data[0].content, 'hello'); assert.strictEqual(data[1].content, 'no media'); assert.strictEqual(data[1].media_attachments, undefined); }); test('uses 1920x1080 for video attachments (pleroma mime_type)', async () => { const posts = [ { id: '9', media_attachments: [ { id: 'v1', pleroma: { mime_type: 'video/mp4' } } ] } ]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(ACCOUNT_STATUSES_URL); const data = await res.json(); const meta = data[0].media_attachments[0].meta; assert.deepStrictEqual(meta.original, { width: 1920, height: 1080, aspect: 1920 / 1080 }); assert.deepStrictEqual(meta.small, { width: 960, height: 540, aspect: 1920 / 1080 }); }); test('uses 1200x800 for image attachments (pleroma mime_type)', async () => { const post = { id: '10', media_attachments: [ { id: 'i1', pleroma: { mime_type: 'image/jpeg' } } ] }; const { fetch } = loadAdapter(() => jsonResponse(post)); const res = await fetch(TIMELINE_URL); const data = await res.json(); const meta = data.media_attachments[0].meta; assert.deepStrictEqual(meta.original, { width: 1200, height: 800, aspect: 1200 / 800 }); assert.deepStrictEqual(meta.small, { width: 600, height: 400, aspect: 1200 / 800 }); }); test('adapts a single (non-array) post object', async () => { const post = { id: 'single', media_attachments: [{ id: 's1' }] }; const { fetch } = loadAdapter(() => jsonResponse(post)); const res = await fetch(TIMELINE_URL); const data = await res.json(); assert.strictEqual(Array.isArray(data), false); assert.strictEqual(data.id, 'single'); assert.deepStrictEqual(data.media_attachments[0].meta.original, { width: 1280, height: 720, aspect: 1280 / 720 }); }); test('leaves attachments with complete meta untouched', async () => { const completeMeta = { original: { width: 640, height: 480, aspect: 640 / 480 }, small: { width: 320, height: 240, aspect: 640 / 480 } }; const posts = [ { id: 'm1', media_attachments: [{ id: 'ok', meta: completeMeta }] } ]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); const data = await res.json(); assert.deepStrictEqual(data[0].media_attachments[0].meta, completeMeta); }); test('keeps existing partial meta.original and fills meta.small', async () => { const posts = [ { id: 'p1', media_attachments: [ { id: 'partial', meta: { original: { width: 640, height: 480, aspect: 640 / 480 } } } ] } ]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); const data = await res.json(); const meta = data[0].media_attachments[0].meta; assert.deepStrictEqual(meta.original, { width: 640, height: 480, aspect: 640 / 480 }); // meta.small is created from the default dimensions assert.deepStrictEqual(meta.small, { width: 640, height: 360, aspect: 1280 / 720 }); }); test('adapts media attachments inside reblogs recursively', async () => { const posts = [ { id: 'r1', reblog: { id: 'r2', media_attachments: [ { id: 'rb', pleroma: { mime_type: 'image/png' } } ] } } ]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); const data = await res.json(); const meta = data[0].reblog.media_attachments[0].meta; assert.deepStrictEqual(meta.original, { width: 1200, height: 800, aspect: 1200 / 800 }); }); test('does not mutate the original payload objects', async () => { const attachment = { id: 'orig' }; const posts = [{ id: 'n1', media_attachments: [attachment] }]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); await res.json(); assert.strictEqual(attachment.meta, undefined); assert.deepStrictEqual(posts[0].media_attachments, [attachment]); }); test('passes non-object array entries through unchanged', async () => { const posts = [null, 'not-a-post', { id: 'real', media_attachments: [{ id: 'x' }] }]; const { fetch } = loadAdapter(() => jsonResponse(posts)); const res = await fetch(TIMELINE_URL); const data = await res.json(); assert.strictEqual(data[0], null); assert.strictEqual(data[1], 'not-a-post'); assert.ok(data[2].media_attachments[0].meta); }); test('intercepts account statuses URLs only when they contain /statuses', async () => { const posts = [{ id: 'a', media_attachments: [{ id: 'x' }] }]; const { fetch } = loadAdapter(() => jsonResponse(posts)); // /api/v1/accounts/42/statuses is adapted const resStatuses = await fetch(ACCOUNT_STATUSES_URL); const dataStatuses = await resStatuses.json(); assert.ok(dataStatuses[0].media_attachments[0].meta); // /api/v1/accounts/42 (no /statuses) is left as-is const resAccount = await fetch('https://pleroma.example/api/v1/accounts/42'); const dataAccount = await resAccount.json(); assert.strictEqual(dataAccount[0].media_attachments[0].meta, undefined); }); test('returns non-API responses untouched (same Response object)', async () => { let original; const { fetch } = loadAdapter(() => { original = jsonResponse({ ok: true }); return original; }); const res = await fetch('https://example.com/about'); assert.strictEqual(res, original); assert.deepStrictEqual(await res.json(), { ok: true }); }); test('falls back to the original response when JSON parsing fails', async () => { let original; const { fetch, warnings } = loadAdapter(() => { original = new Response('not-json', { status: 200 }); return original; }); const res = await fetch(TIMELINE_URL); assert.strictEqual(res, original); assert.strictEqual(await res.text(), 'not-json'); assert.strictEqual(warnings.length, 1); }); test('preserves response status on adapted responses', async () => { const posts = [{ id: 's', media_attachments: [] }]; const { fetch } = loadAdapter(() => jsonResponse(posts, { status: 201 })); const res = await fetch(TIMELINE_URL); assert.strictEqual(res.status, 201); assert.deepStrictEqual(await res.json(), posts); }); });