import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; import fg from 'fast-glob'; import matter from 'gray-matter'; import { bindLiveCounters, fetchCounterStats, readLikedSlugs, reportArticleView, updateLike, } from '../src/scripts/likes-views.ts'; class FakeClassList { values = new Set(); toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); } } class FakeButton { attributes = new Map([['aria-pressed', 'false']]); classList = new FakeClassList(); disabled = true; listeners = []; setAttribute(name, value) { this.attributes.set(name, value); } getAttribute(name) { return this.attributes.get(name); } addEventListener(name, callback) { if (name === 'click') this.listeners.push(callback); } async click() { for (const callback of this.listeners) await callback(); } } class FakeCounter { constructor(slug, { trackView = false, lang = 'en' } = {}) { this.dataset = { slug, counterMode: 'live', apiBase: 'https://api.example.test', trackView: String(trackView), likeLabel: lang === 'ar' ? 'إعجاب' : 'Like', unlikeLabel: lang === 'ar' ? 'إزالة الإعجاب' : 'Unlike', readyMessage: '', errorMessage: lang === 'ar' ? 'خطأ مؤقت' : 'Temporarily unavailable', }; this.views = { textContent: '—' }; this.likes = { textContent: '—' }; this.status = { textContent: '' }; this.button = new FakeButton(); } querySelector(selector) { return selector === '[data-views]' ? this.views : selector === '[data-likes]' ? this.likes : selector === '[data-like]' ? this.button : selector === '.counter-status' ? this.status : null; } } class FakeRoot { constructor(counters) { this.counters = counters; } querySelectorAll(selector) { return selector === '[data-counter][data-counter-mode="live"]' ? this.counters : []; } } class FakeStorage { values = new Map(); getItem(key) { return this.values.get(key) ?? null; } setItem(key, value) { this.values.set(key, value); } } function response(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } test('counter transport batches unique slugs and never sends credentials', async () => { const calls = []; const fetcher = async (url, options) => { calls.push({ url, options }); return response({ article: { likes: 3, views: 9 } }); }; const result = await fetchCounterStats('https://api.example.test/', ['article', 'article'], fetcher); assert.equal(result.article.views, 9); assert.equal(calls.length, 1); assert.equal(calls[0].url, 'https://api.example.test/stats/batch'); assert.equal(calls[0].options.credentials, 'omit'); assert.equal(calls[0].options.referrerPolicy, 'no-referrer'); assert.deepEqual(JSON.parse(calls[0].options.body), { slugs: ['article'] }); }); test('article views and likes use the existing API contracts', async () => { const calls = []; const fetcher = async (url, options) => { calls.push({ url, options }); return response(url.endsWith('/views') ? { views: 42 } : { count: 8 }); }; assert.equal(await reportArticleView('https://api.example.test', 'article', fetcher), 42); assert.equal(await updateLike('https://api.example.test', 'article', false, fetcher), 8); assert.equal(await updateLike('https://api.example.test', 'article', true, fetcher), 8); assert.deepEqual(JSON.parse(calls[0].options.body), { slug: 'article' }); assert.equal(calls[0].options.keepalive, true); assert.equal(calls[1].options.method, 'POST'); assert.equal(calls[2].options.method, 'DELETE'); assert.equal(calls[2].url, 'https://api.example.test/likes/article'); }); test('one article navigation increments once while cards only retrieve statistics', async () => { const article = new FakeCounter('shared-slug', { trackView: true }); const duplicate = new FakeCounter('shared-slug'); const card = new FakeCounter('other-slug'); const root = new FakeRoot([article, duplicate, card]); const storage = new FakeStorage(); const calls = []; const fetcher = async (url, options) => { calls.push({ url, options }); if (url.endsWith('/stats/batch')) return response({ 'shared-slug': { likes: 4, views: 20 }, 'other-slug': { likes: 1, views: 2 }, }); if (url.endsWith('/views')) return response({ views: 21 }); if (options.method === 'POST') return response({ count: 5 }); return response({ count: 4 }); }; await bindLiveCounters(root, fetcher, storage); await bindLiveCounters(root, fetcher, storage); assert.equal(calls.filter((call) => call.url.endsWith('/stats/batch')).length, 1); assert.equal(calls.filter((call) => call.url.endsWith('/views')).length, 1); assert.equal(article.views.textContent, '21'); assert.equal(duplicate.views.textContent, '21'); assert.equal(card.views.textContent, '2'); await article.button.click(); assert.equal(article.likes.textContent, '5'); assert.equal(duplicate.likes.textContent, '5'); assert.equal(article.button.getAttribute('aria-pressed'), 'true'); assert.deepEqual([...readLikedSlugs(storage)], ['shared-slug']); await duplicate.button.click(); assert.equal(article.likes.textContent, '4'); assert.equal(duplicate.button.getAttribute('aria-pressed'), 'false'); assert.deepEqual([...readLikedSlugs(storage)], []); }); test('API failures preserve rendering and expose localized inline status', async () => { const counter = new FakeCounter('article', { trackView: true, lang: 'ar' }); const root = new FakeRoot([counter]); await bindLiveCounters(root, async () => response({ error: 'offline' }, 503), new FakeStorage()); assert.equal(counter.status.textContent, 'خطأ مؤقت'); assert.equal(counter.button.disabled, false); assert.equal(counter.dataset.viewReported, 'true'); }); test('like persistence rejects corrupt and non-array storage values', () => { const storage = new FakeStorage(); storage.setItem('aia-liked-articles-v1', '{broken'); assert.deepEqual([...readLikedSlugs(storage)], []); storage.setItem('aia-liked-articles-v1', JSON.stringify(['valid', 4, null])); assert.deepEqual([...readLikedSlugs(storage)], ['valid']); }); test('blocked local storage does not duplicate or disable a successful like', async () => { const counter = new FakeCounter('article'); const root = new FakeRoot([counter]); const storage = { getItem() { throw new Error('blocked'); }, setItem() { throw new Error('blocked'); }, }; let writes = 0; const fetcher = async (url) => { if (url.endsWith('/stats/batch')) return response({ article: { likes: 2, views: 3 } }); writes += 1; return response({ count: 3 }); }; await bindLiveCounters(root, fetcher, storage); await counter.button.click(); assert.equal(writes, 1); assert.equal(counter.likes.textContent, '3'); assert.equal(counter.button.getAttribute('aria-pressed'), 'true'); assert.equal(counter.button.disabled, false); }); test('counter requests time out without retrying an ambiguous operation', async () => { let calls = 0; const hanging = (_url, options) => { calls += 1; return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason))); }; await assert.rejects(fetchCounterStats('https://api.example.test', ['article'], hanging, 5)); assert.equal(calls, 1); }); test('only article layouts track views and translated publications retain exact legacy slugs', async () => { const layout = await fs.readFile('src/layouts/BlogPostLayout.astro', 'utf8'); const card = await fs.readFile('src/components/BlogCard.astro', 'utf8'); assert.match(layout, /LikeViewCounter[^>]*trackView/); assert.doesNotMatch(card, /trackView/); const groups = new Map(); for (const file of await fg('src/content/blog/**/*.{md,mdx}')) { const frontmatter = matter(await fs.readFile(file, 'utf8')).data; if (!frontmatter.translationKey || frontmatter.draft) continue; const entries = groups.get(frontmatter.translationKey) ?? []; entries.push({ lang: frontmatter.lang, slug: frontmatter.slug, file: path.basename(file) }); groups.set(frontmatter.translationKey, entries); } const multilingual = [...groups.values()].filter((entries) => entries.length > 1); assert.ok(multilingual.some((entries) => new Set(entries.map(({ lang }) => lang)).size === 3)); for (const entries of multilingual) { assert.equal(new Set(entries.map(({ slug }) => slug)).size, 1, JSON.stringify(entries)); } });