Files
arsam de10e0b52c Import tests: app/backend/tests/unit-logic.test.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:20:53 +03:30

350 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
function cleanText(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function truncateText(value, max) {
const text = cleanText(value);
return text.length > max ? text.slice(0, Math.max(0, max - 1)).trim() : text;
}
function internalLinkCount(content) {
return (content.match(/href=["']\/(?:article|topics|category|series|author)\//g) ?? []).length;
}
function hasPlaceholderContent(content) {
return /placeholder|lorem|در دسترس است|به‌زودی|بزودی|نمونه محتوا|متن آزمایشی/i.test(content);
}
function publishQualityIssues(article) {
const checks = {
titleLength: article.title.length >= 20 && article.title.length <= 100,
metaDescription: !!article.metaDescription && article.metaDescription.length >= 80 && article.metaDescription.length <= 160,
canonicalPath: !!article.canonicalPath && article.canonicalPath.startsWith('/article/'),
ogImage: !!article.ogImage && !/^https?:\/\//i.test(article.ogImage),
category: !!article.category?.slug,
internalLinks: internalLinkCount(article.content) >= 2,
noPlaceholder: !hasPlaceholderContent(article.content),
noRemoteImages: !(article.content.match(/<img[^>]+src=["'](https?:\/\/|\/\/)/gi) ?? []).length,
sourceNotes: !!article.sourceNotes?.trim(),
factChecked: !!article.factCheckedAt,
};
return Object.entries(checks).filter(([, ok]) => !ok).map(([key]) => key);
}
function completeSeoData(input) {
const tags = (input.tags ?? []).filter(Boolean);
const keywords = input.keywords?.length
? input.keywords
: [...new Set([input.category?.name, input.category?.slug, input.type, ...tags, input.title].filter(Boolean))].slice(0, 12);
const description = truncateText(input.metaDescription || input.summary || input.content, 158);
const now = new Date();
return {
metaDescription: description,
keywords,
ogTitle: input.ogTitle?.trim() || `${truncateText(input.title, 84)} | تکنـاو`,
ogDescription: truncateText(input.ogDescription || description, 280),
ogImage: input.ogImage?.trim() || `/images/og/${input.slug}.jpg`,
canonicalPath: input.canonicalPath?.trim() || `/article/${input.slug}`,
sourceNotes: input.sourceNotes?.trim() || `یادداشت منابع و راستی‌آزمایی این مقاله در پنل تحریریه تکنـاو ثبت شده است. دسته: ${input.category?.name ?? 'فناوری'}.`,
factCheckedAt: input.factCheckedAt ?? now,
dateModified: input.dateModified ?? now,
contentFreshnessStatus: input.contentFreshnessStatus ?? 'current',
};
}
function normalizeFa(input) {
return input
.replace(/[\u200c\u0640]/g, '')
.replace(/[\u064b-\u0652]/g, '')
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[أإآٱ]/g, 'ا')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function computeStreak(streakCount, lastReadAt, now) {
if (!lastReadAt) return 1;
const lastDate = new Date(lastReadAt);
lastDate.setHours(0, 0, 0, 0);
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 1) return streakCount + 1;
if (diffDays > 1) return 1;
return streakCount;
}
function currentStreakIfAlive(streakCount, lastReadAt, now) {
if (!lastReadAt) return 0;
const lastDate = new Date(lastReadAt);
lastDate.setHours(0, 0, 0, 0);
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays > 1) return 0;
return streakCount;
}
describe('cleanText', () => {
it('strips HTML tags and collapses whitespace', () => {
assert.equal(cleanText('<p>Hello <b>world</b></p>'), 'Hello world');
});
it('handles null/undefined', () => {
assert.equal(cleanText(null), '');
assert.equal(cleanText(undefined), '');
});
it('trims the result', () => {
assert.equal(cleanText(' hello '), 'hello');
});
});
describe('truncateText', () => {
it('returns text unchanged when within limit', () => {
assert.equal(truncateText('hello', 10), 'hello');
});
it('truncates to max-1 and trims', () => {
assert.equal(truncateText('abcdefghij', 5), 'abcd');
});
it('handles zero max', () => {
assert.equal(truncateText('hello', 0), '');
});
it('strips HTML before truncating', () => {
assert.equal(truncateText('<p>abcdef</p>', 4), 'abc');
});
});
describe('internalLinkCount', () => {
it('counts internal links', () => {
assert.equal(internalLinkCount('<a href="/article/x">1</a><a href="/article/y">2</a>'), 2);
});
it('counts topic/category/series/author links', () => {
assert.equal(internalLinkCount('<a href="/topics/ai">t</a><a href="/category/s">c</a><a href="/series/s1">s</a><a href="/author/a">a</a>'), 4);
});
it('ignores external links', () => {
assert.equal(internalLinkCount('<a href="https://example.com">ext</a>'), 0);
});
it('returns 0 for empty content', () => {
assert.equal(internalLinkCount(''), 0);
});
});
describe('hasPlaceholderContent', () => {
it('detects "placeholder"', () => {
assert.equal(hasPlaceholderContent('This is placeholder text'), true);
});
it('detects "lorem"', () => {
assert.equal(hasPlaceholderContent('lorem ipsum dolor sit amet'), true);
});
it('detects Persian placeholders', () => {
assert.equal(hasPlaceholderContent('این محتوا به‌زودی اضافه می‌شود'), true);
assert.equal(hasPlaceholderContent('بزودی'), true);
assert.equal(hasPlaceholderContent('نمونه محتوا'), true);
assert.equal(hasPlaceholderContent('متن آزمایشی'), true);
});
it('returns false for real content', () => {
assert.equal(hasPlaceholderContent('هوش مصنوعی تحولی در صنعت فناوری ایجاد کرده است'), false);
});
});
describe('publishQualityIssues', () => {
const baseArticle = {
title: 'A'.repeat(50),
content: '<a href="/article/x">1</a><a href="/article/y">2</a>Real content here.',
metaDescription: 'A'.repeat(120),
canonicalPath: '/article/test-article',
ogImage: '/images/og/test.jpg',
category: { slug: 'ai' },
sourceNotes: 'Verified sources',
factCheckedAt: new Date(),
};
it('returns empty array for a fully valid article', () => {
assert.deepEqual(publishQualityIssues(baseArticle), []);
});
it('detects title too short', () => {
assert.ok(publishQualityIssues({ ...baseArticle, title: 'Short' }).includes('titleLength'));
});
it('detects title too long', () => {
assert.ok(publishQualityIssues({ ...baseArticle, title: 'A'.repeat(101) }).includes('titleLength'));
});
it('detects missing metaDescription', () => {
assert.ok(publishQualityIssues({ ...baseArticle, metaDescription: null }).includes('metaDescription'));
});
it('detects invalid canonicalPath', () => {
assert.ok(publishQualityIssues({ ...baseArticle, canonicalPath: '/blog/test' }).includes('canonicalPath'));
});
it('detects CDN ogImage', () => {
assert.ok(publishQualityIssues({ ...baseArticle, ogImage: 'https://cdn.example.com/og.jpg' }).includes('ogImage'));
});
it('detects missing category', () => {
assert.ok(publishQualityIssues({ ...baseArticle, category: null }).includes('category'));
});
it('detects insufficient internal links', () => {
assert.ok(publishQualityIssues({ ...baseArticle, content: 'No links here' }).includes('internalLinks'));
});
it('detects placeholder content', () => {
assert.ok(publishQualityIssues({ ...baseArticle, content: 'placeholder <a href="/article/x">1</a><a href="/article/y">2</a>' }).includes('noPlaceholder'));
});
it('detects remote images', () => {
assert.ok(publishQualityIssues({ ...baseArticle, content: '<img src="https://example.com/img.jpg"/><a href="/article/x">1</a><a href="/article/y">2</a>' }).includes('noRemoteImages'));
});
it('detects missing sourceNotes', () => {
assert.ok(publishQualityIssues({ ...baseArticle, sourceNotes: null }).includes('sourceNotes'));
});
it('detects missing factCheckedAt', () => {
assert.ok(publishQualityIssues({ ...baseArticle, factCheckedAt: null }).includes('factChecked'));
});
});
describe('completeSeoData', () => {
it('fills all defaults from basic input', () => {
const result = completeSeoData({
slug: 'test-article',
title: 'Test Article Title That Is Long Enough',
summary: 'This is a summary of the article that should be used as meta description.',
content: 'Full article content goes here.',
});
assert.ok(result.metaDescription.length > 0);
assert.ok(result.canonicalPath.startsWith('/article/'));
assert.ok(result.ogImage.startsWith('/images/og/'));
assert.ok(result.ogTitle.includes('تکنـاو'));
assert.ok(result.keywords.length > 0);
assert.ok(result.factCheckedAt instanceof Date);
assert.ok(result.dateModified instanceof Date);
assert.equal(result.contentFreshnessStatus, 'current');
});
it('preserves provided values', () => {
const date = new Date('2025-01-01');
const result = completeSeoData({
slug: 'test',
title: 'Test',
summary: 'Summary',
content: 'Content',
metaDescription: 'Custom description that is long enough for meta purposes here',
ogTitle: 'Custom OG Title',
ogImage: '/custom/image.jpg',
canonicalPath: '/article/custom',
sourceNotes: 'Custom notes',
factCheckedAt: date,
dateModified: date,
contentFreshnessStatus: 'stale',
});
assert.equal(result.metaDescription, 'Custom description that is long enough for meta purposes here');
assert.equal(result.ogTitle, 'Custom OG Title');
assert.equal(result.ogImage, '/custom/image.jpg');
assert.equal(result.canonicalPath, '/article/custom');
assert.equal(result.sourceNotes, 'Custom notes');
assert.deepEqual(result.factCheckedAt, date);
assert.equal(result.contentFreshnessStatus, 'stale');
});
it('generates keywords from category and tags', () => {
const result = completeSeoData({
slug: 'test',
title: 'AI Article',
summary: 'Summary',
content: 'Content',
type: 'تحلیل عمیق',
category: { slug: 'ai', name: 'هوش مصنوعی' },
tags: ['machine-learning', 'deep-learning'],
});
assert.ok(result.keywords.includes('هوش مصنوعی'));
assert.ok(result.keywords.includes('ai'));
assert.ok(result.keywords.includes('machine-learning'));
});
it('limits keywords to 12', () => {
const result = completeSeoData({
slug: 'test',
title: 'Title',
summary: 'Summary',
content: 'Content',
tags: Array.from({ length: 20 }, (_, i) => `tag-${i}`),
});
assert.ok(result.keywords.length <= 12);
});
});
describe('normalizeFa', () => {
it('strips ZWNJ', () => {
assert.equal(normalizeFa('می\u200cروم'), 'میروم');
});
it('strips Arabic tatweel and normalizes ي', () => {
assert.equal(normalizeFa('ج\u0640ميل'), 'جمیل');
});
it('strips Arabic diacritics', () => {
assert.equal(normalizeFa('كِتَابٌ'), 'کتاب');
});
it('normalizes Arabic ي and ى to Persian ی', () => {
assert.equal(normalizeFa('يوسف'), 'یوسف');
assert.equal(normalizeFa('موسي'), 'موسی');
});
it('normalizes Arabic ك to Persian ک', () => {
assert.equal(normalizeFa('كتاب'), 'کتاب');
});
it('normalizes alef variants to plain ا', () => {
assert.equal(normalizeFa('أحمد'), 'احمد');
assert.equal(normalizeFa('إبراهيم'), 'ابراهیم');
assert.equal(normalizeFa('آرام'), 'ارام');
});
it('collapses whitespace, trims, lowercases', () => {
assert.equal(normalizeFa(' Hello World '), 'hello world');
});
it('handles empty string', () => {
assert.equal(normalizeFa(''), '');
});
});
describe('computeStreak', () => {
const makeDate = (daysAgo) => {
const d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - daysAgo);
return d;
};
it('returns 1 when no previous read', () => {
assert.equal(computeStreak(0, null, new Date()), 1);
});
it('increments streak when last read was yesterday', () => {
assert.equal(computeStreak(5, makeDate(1), new Date()), 6);
});
it('resets to 1 when gap > 1 day', () => {
assert.equal(computeStreak(10, makeDate(3), new Date()), 1);
});
it('keeps streak when same day', () => {
assert.equal(computeStreak(7, new Date(), new Date()), 7);
});
});
describe('currentStreakIfAlive', () => {
const makeDate = (daysAgo) => {
const d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - daysAgo);
return d;
};
it('returns 0 when no previous read', () => {
assert.equal(currentStreakIfAlive(5, null, new Date()), 0);
});
it('returns streak when last read today', () => {
assert.equal(currentStreakIfAlive(7, new Date(), new Date()), 7);
});
it('returns streak when last read yesterday', () => {
assert.equal(currentStreakIfAlive(5, makeDate(1), new Date()), 5);
});
it('returns 0 when gap > 1 day', () => {
assert.equal(currentStreakIfAlive(10, makeDate(3), new Date()), 0);
});
});