From de10e0b52c2953177fbc0730ce1e138ebb5d80c2 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:53 +0330 Subject: [PATCH] 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. --- app/backend/tests/unit-logic.test.mjs | 349 ++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 app/backend/tests/unit-logic.test.mjs diff --git a/app/backend/tests/unit-logic.test.mjs b/app/backend/tests/unit-logic.test.mjs new file mode 100644 index 0000000..d0ec458 --- /dev/null +++ b/app/backend/tests/unit-logic.test.mjs @@ -0,0 +1,349 @@ +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(/]+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('

Hello world

'), '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('

abcdef

', 4), 'abc'); + }); +}); + +describe('internalLinkCount', () => { + it('counts internal links', () => { + assert.equal(internalLinkCount('12'), 2); + }); + it('counts topic/category/series/author links', () => { + assert.equal(internalLinkCount('tcsa'), 4); + }); + it('ignores external links', () => { + assert.equal(internalLinkCount('ext'), 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: '12Real 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 12' }).includes('noPlaceholder')); + }); + it('detects remote images', () => { + assert.ok(publishQualityIssues({ ...baseArticle, content: '12' }).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); + }); +});