210 lines
8.5 KiB
JavaScript
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));
|
|
}
|
|
});
|