Files
aia-website/www/tools/counters.test.mjs
T

210 lines
8.5 KiB
JavaScript

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));
}
});