diff --git a/tests/js/countdown-test.js b/tests/js/countdown-test.js new file mode 100644 index 0000000..de53e85 --- /dev/null +++ b/tests/js/countdown-test.js @@ -0,0 +1,190 @@ +/** + * Tests unitaires pour js/countdown.js (classe CountdownTimer). + * + * Le script est chargé dans un contexte vm avec un DOM simulé : + * la date courante est figée (FakeDate) et setInterval est factice, + * ce qui rend les calculs de temps restant déterministes. + */ +'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 COUNTDOWN_PATH = path.join(__dirname, '..', '..', 'js', 'countdown.js'); +const COUNTDOWN_SOURCE = fs.readFileSync(COUNTDOWN_PATH, 'utf8'); + +// Date courante figée pour tous les tests +const FIXED_NOW = new Date('2030-01-01T00:00:00Z').getTime(); + +function makeElements() { + return { + 'countdown-days': { textContent: '' }, + 'countdown-hours': { textContent: '' }, + 'countdown-minutes': { textContent: '' }, + 'countdown-seconds': { textContent: '' } + }; +} + +/** + * Charge countdown.js dans un contexte vm isolé et retourne la classe. + * + * @param {object|null} options.elements Éléments DOM simulés (null = absents) + * @param {number} options.now Timestamp courant simulé + */ +function loadCountdown({ elements = null, now = FIXED_NOW } = {}) { + class FakeDate extends Date { + constructor(...args) { + if (args.length === 0) { + super(now); + } else { + super(...args); + } + } + + static now() { + return now; + } + } + + const windowMock = { + addEventListener() {}, + location: { href: '' } + }; + + // Timers factices : le timer ne tourne jamais tout seul, + // on enregistre simplement les appels pour vérification. + const timers = { intervals: [], cleared: [] }; + let nextIntervalId = 1; + + const sandbox = { + document: { + getElementById: (id) => (elements ? elements[id] || null : null), + querySelectorAll: () => [], + addEventListener() {} + }, + window: windowMock, + Date: FakeDate, + setInterval: (fn, delay) => { + const id = nextIntervalId++; + timers.intervals.push({ id, fn, delay }); + return id; + }, + clearInterval: (id) => { + timers.cleared.push(id); + }, + setTimeout: () => 0, + console + }; + + vm.createContext(sandbox); + const CountdownTimer = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox); + + return { CountdownTimer, window: windowMock, timers }; +} + +describe('CountdownTimer', () => { + test('formatNumber complète les nombres sur deux chiffres', () => { + const { CountdownTimer } = loadCountdown(); + + assert.strictEqual(CountdownTimer.prototype.formatNumber(0), '00'); + assert.strictEqual(CountdownTimer.prototype.formatNumber(5), '05'); + assert.strictEqual(CountdownTimer.prototype.formatNumber(42), '42'); + assert.strictEqual(CountdownTimer.prototype.formatNumber(123), '123'); + }); + + test('calcule correctement le temps restant (jours/heures/minutes/secondes)', () => { + const distance = + 2 * 24 * 60 * 60 * 1000 + // 2 jours + 3 * 60 * 60 * 1000 + // 3 heures + 4 * 60 * 1000 + // 4 minutes + 5 * 1000; // 5 secondes + + const elements = makeElements(); + const { CountdownTimer } = loadCountdown({ elements }); + const timer = new CountdownTimer(FIXED_NOW + distance); + + try { + assert.strictEqual(elements['countdown-days'].textContent, '02'); + assert.strictEqual(elements['countdown-hours'].textContent, '03'); + assert.strictEqual(elements['countdown-minutes'].textContent, '04'); + assert.strictEqual(elements['countdown-seconds'].textContent, '05'); + } finally { + timer.stop(); + } + }); + + test('reporte les heures au-delà de 24h dans les jours', () => { + const distance = 36 * 60 * 60 * 1000; // 36 heures = 1 jour + 12 heures + + const elements = makeElements(); + const { CountdownTimer } = loadCountdown({ elements }); + const timer = new CountdownTimer(FIXED_NOW + distance); + + try { + assert.strictEqual(elements['countdown-days'].textContent, '01'); + assert.strictEqual(elements['countdown-hours'].textContent, '12'); + assert.strictEqual(elements['countdown-minutes'].textContent, '00'); + assert.strictEqual(elements['countdown-seconds'].textContent, '00'); + } finally { + timer.stop(); + } + }); + + test('une date passée arrête le compte à rebours et redirige vers /', () => { + const elements = makeElements(); + const { CountdownTimer, window } = loadCountdown({ elements }); + const timer = new CountdownTimer(FIXED_NOW - 1000); + + try { + // onComplete() redirige vers la page principale + assert.strictEqual(window.location.href, '/'); + + // Les éléments ne sont pas mis à jour quand le compte est terminé + assert.strictEqual(elements['countdown-days'].textContent, ''); + assert.strictEqual(elements['countdown-hours'].textContent, ''); + assert.strictEqual(elements['countdown-minutes'].textContent, ''); + assert.strictEqual(elements['countdown-seconds'].textContent, ''); + } finally { + timer.stop(); + } + }); + + test('une distance nulle affiche zéro partout sans rediriger', () => { + const elements = makeElements(); + const { CountdownTimer, window } = loadCountdown({ elements }); + const timer = new CountdownTimer(FIXED_NOW); + + try { + assert.strictEqual(elements['countdown-days'].textContent, '00'); + assert.strictEqual(elements['countdown-hours'].textContent, '00'); + assert.strictEqual(elements['countdown-minutes'].textContent, '00'); + assert.strictEqual(elements['countdown-seconds'].textContent, '00'); + assert.strictEqual(window.location.href, ''); + } finally { + timer.stop(); + } + }); + + test('fonctionne sans éléments DOM présents', () => { + const { CountdownTimer } = loadCountdown({ elements: null }); + const timer = new CountdownTimer(FIXED_NOW + 60 * 1000); + + // Aucune erreur attendue malgré l'absence des éléments + timer.stop(); + }); + + test('démarre un intervalle d\'une seconde et stop() le nettoie', () => { + const { CountdownTimer, timers } = loadCountdown(); + const timer = new CountdownTimer(FIXED_NOW + 60 * 1000); + + assert.strictEqual(timers.intervals.length, 1); + assert.strictEqual(timers.intervals[0].delay, 1000); + assert.strictEqual(timers.cleared.length, 0); + + timer.stop(); + assert.deepStrictEqual(timers.cleared, [timer.interval]); + }); +}); diff --git a/tests/js/pleroma-adapter-test.js b/tests/js/pleroma-adapter-test.js new file mode 100644 index 0000000..cc75287 --- /dev/null +++ b/tests/js/pleroma-adapter-test.js @@ -0,0 +1,311 @@ +/** + * 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); + }); +}); diff --git a/tests/js/run.js b/tests/js/run.js new file mode 100644 index 0000000..6d67f30 --- /dev/null +++ b/tests/js/run.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * Lanceur des tests unitaires JS. + * + * Utilise le runner natif de Node (node:test), aucune dépendance npm. + * Usage : node tests/js/run.js + */ +'use strict'; + +const { run } = require('node:test'); +const { spec } = require('node:test/reporters'); +const fs = require('node:fs'); +const path = require('node:path'); + +const testDir = __dirname; +const files = fs + .readdirSync(testDir) + .filter((name) => name.endsWith('-test.js')) + .sort() + .map((name) => path.join(testDir, name)); + +if (files.length === 0) { + console.error('Aucun fichier *-test.js trouvé dans ' + testDir); + process.exit(1); +} + +const stream = run({ files }); + +let failures = 0; +stream.on('test:fail', () => { + failures += 1; +}); + +stream.on('error', (err) => { + console.error(err); + process.exitCode = 1; +}); + +stream.compose(spec).pipe(process.stdout); + +stream.on('end', () => { + if (failures > 0) { + process.exitCode = 1; + } +});