- Editorial web platform built with Node.js, Express, EJS, and Vanilla CSS - Markdown content engine with YAML frontmatter, bilingual variants, and hot-reload watcher - Unified SQLite with FTS5 search indexing, query logging, and schema filters - Centered editorial post layout with margin-docked left TOC and mobile bottom sheet - WordPress-grade comment system with moderation dashboard and bulk operations under /guanli - Complete SEO suite (JSON-LD structured data, RSS/Atom feeds, XML sitemap, robots.txt) - CI/CD workflows for linting, security audits (Gitleaks), automated tests, and Docker releases
251 lines
13 KiB
JavaScript
251 lines
13 KiB
JavaScript
const assert = require('assert');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
|
|
const { CONTENT_DIR, loadSiteConfig } = require('../server/config');
|
|
const contentService = require('../server/services/content');
|
|
const searchEngine = require('../server/services/search-engine');
|
|
const commentService = require('../server/services/comments');
|
|
const dbService = require('../server/services/db');
|
|
const pluginManager = require('../server/services/plugin-manager');
|
|
const pageCache = require('../server/middleware/cache');
|
|
const app = require('../server/index');
|
|
|
|
async function runTests() {
|
|
console.log('====================================================');
|
|
console.log(' Comprehensive Test Suite for pengzhan.dev');
|
|
console.log('====================================================');
|
|
|
|
// Test 1: Content Service & Bilingual Loading
|
|
console.log('[Test 1] Testing Content Service & Bilingual Loading...');
|
|
const posts = contentService.getAllPosts();
|
|
assert(posts.length >= 3, `Expected at least 3 posts, got ${posts.length}`);
|
|
|
|
const dropboxPost = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
|
assert(dropboxPost, 'Default dropbox post must exist');
|
|
assert.strictEqual(dropboxPost.lang, 'en');
|
|
assert(dropboxPost.cover, 'Dropbox post must have cover image');
|
|
assert(dropboxPost.availableLanguages.includes('zh'), 'Dropbox post must report zh translation available');
|
|
|
|
const dropboxZh = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox', 'zh');
|
|
assert(dropboxZh, 'Chinese variant of dropbox post must exist');
|
|
assert.strictEqual(dropboxZh.lang, 'zh');
|
|
console.log(' ✓ Content service & bilingual variants verified.');
|
|
|
|
// Test 2: Unified SQLite Database Sync & FTS5
|
|
console.log('[Test 2] Testing Unified SQLite DB (Posts, FTS5 & Search Analytics)...');
|
|
const allComments = dbService.getAllComments();
|
|
assert(Array.isArray(allComments), 'Comments must be an array from SQLite');
|
|
|
|
// Check posts synced to SQLite
|
|
const stmt = dbService.db.prepare('SELECT COUNT(*) as count FROM posts');
|
|
const postCount = stmt.get().count;
|
|
assert(postCount >= 3, `Expected at least 3 posts in SQLite, got ${postCount}`);
|
|
|
|
// Test FTS5 search in SQLite
|
|
const ftsStmt = dbService.db.prepare("SELECT slug FROM posts_fts WHERE posts_fts MATCH 'dropbox'");
|
|
const ftsResults = ftsStmt.all();
|
|
assert(ftsResults.length >= 1, 'SQLite FTS5 must find posts matching dropbox');
|
|
console.log(` ✓ Unified SQLite active: ${postCount} posts in DB, FTS5 indexed.`);
|
|
|
|
// Test 3: Structured Schema Search Engine & Deduplication
|
|
console.log('[Test 3] Testing Structured Schema Search Engine & Deduplication...');
|
|
const complexQuery = 'tag:k8s "container image" category:tech';
|
|
const res1 = searchEngine.search(complexQuery);
|
|
assert.strictEqual(res1.total, 1, `Query '${complexQuery}' should deduplicate to exactly 1 post, got ${res1.total}`);
|
|
assert.strictEqual(res1.results[0].post.slug, 'using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
|
assert(res1.results[0].snippet.includes('<mark>'), 'Snippet must highlight matched terms');
|
|
|
|
// Verify search logging to SQLite
|
|
const topRecs = dbService.getTopSearchRecommendations(5);
|
|
assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs / recommendations');
|
|
console.log(` ✓ Search engine verified. Query logged in SQLite recommendations.`);
|
|
|
|
// Test 4: WordPress-Grade Comment Moderation & Author Whitelist
|
|
console.log('[Test 4] Testing WordPress-Grade Comment Moderation...');
|
|
const testEmail = `ada-${Date.now()}@computing.org`;
|
|
// Initial submission -> Pending (approved: 0)
|
|
const pendingComment = commentService.addComment({
|
|
postSlug: 'using-gemini-cli-and-mcp-server-to-organize-my-dropbox',
|
|
authorName: 'Ada Lovelace',
|
|
authorEmail: testEmail,
|
|
authorWebsite: 'https://ada.org',
|
|
body: 'Fascinating article on MCP servers.'
|
|
});
|
|
assert.strictEqual(pendingComment.approved, 0, 'New author comment must be pending approval (approved: 0)');
|
|
assert.strictEqual(pendingComment.status, 'pending');
|
|
|
|
const pendingList = commentService.getPendingComments();
|
|
const added = pendingList.find(c => c.author_email === testEmail);
|
|
assert(added, 'Comment must be present in pending queue');
|
|
|
|
// Admin approves comment
|
|
commentService.approveComment(added.id);
|
|
const approvedList = commentService.getCommentsForPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
|
assert(approvedList.some(c => c.author_name === 'Ada Lovelace' && c.id === added.id), 'Approved comment must appear in post comments');
|
|
|
|
// WordPress Author Whitelist rule: Subsequent comment by approved author is auto-approved!
|
|
const secondComment = commentService.addComment({
|
|
postSlug: 'using-gemini-cli-and-mcp-server-to-organize-my-dropbox',
|
|
authorName: 'Ada Lovelace',
|
|
authorEmail: testEmail,
|
|
body: 'A follow-up thought.'
|
|
});
|
|
assert.strictEqual(secondComment.approved, 1, 'Previously approved author must be auto-approved (WordPress parity)');
|
|
console.log(' ✓ Comment moderation workflow and author email whitelist verified.');
|
|
|
|
// Test 5: In-Process HTTP Server, SEO, Cache & Admin Tests
|
|
console.log('[Test 5] Testing HTTP Endpoints, SEO Structured Data, and Super Page Cache...');
|
|
|
|
function request(route, method = 'GET', headers = {}, data = null) {
|
|
return new Promise((resolve, reject) => {
|
|
const parsedUrl = new URL(route, 'http://localhost');
|
|
const req = new http.IncomingMessage(null);
|
|
req.method = method;
|
|
req.url = route;
|
|
const normalizedHeaders = {};
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
normalizedHeaders[k.toLowerCase()] = v;
|
|
}
|
|
req.headers = {
|
|
host: 'localhost',
|
|
accept: 'text/html,application/xhtml+xml,application/xml,*/*',
|
|
...normalizedHeaders
|
|
};
|
|
if (data) {
|
|
req.headers['content-length'] = Buffer.byteLength(data);
|
|
req.push(data);
|
|
}
|
|
req.push(null);
|
|
|
|
const res = new http.ServerResponse(req);
|
|
let body = '';
|
|
res.write = function(chunk, encoding, cb) {
|
|
if (chunk) body += chunk.toString();
|
|
if (typeof cb === 'function') cb();
|
|
return true;
|
|
};
|
|
res.end = function(chunk, encoding, cb) {
|
|
if (chunk) body += chunk.toString();
|
|
if (typeof cb === 'function') cb();
|
|
resolve({ statusCode: res.statusCode, headers: res.getHeaders(), body });
|
|
};
|
|
|
|
try {
|
|
app.handle(req, res);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Route Tests
|
|
const homeRes = await request('/');
|
|
assert.strictEqual(homeRes.statusCode, 200);
|
|
assert(homeRes.body.includes('Pengzhan Hao'), 'Home page must have hero title');
|
|
assert(homeRes.body.includes('two-column-grid'), 'Home page must render two-column research sections');
|
|
|
|
// SEO JSON-LD Verification
|
|
assert(homeRes.body.includes('application/ld+json'), 'Page must contain JSON-LD script');
|
|
const jsonLdMatch = homeRes.body.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
|
|
assert(jsonLdMatch, 'JSON-LD tag must exist');
|
|
const parsedJsonLd = JSON.parse(jsonLdMatch[1]);
|
|
assert(parsedJsonLd['@context'] === 'https://schema.org', 'JSON-LD context must be schema.org');
|
|
assert(parsedJsonLd['@graph'].some(node => node['@type'] === 'WebSite'), 'Must include WebSite schema');
|
|
assert(parsedJsonLd['@graph'].some(node => node['@type'] === 'Person'), 'Must include Person schema');
|
|
console.log(' ✓ SEO JSON-LD structured data validated.');
|
|
|
|
// Post Page & Article Schema
|
|
const postRes = await request('/using-gemini-cli-and-mcp-server-to-organize-my-dropbox');
|
|
assert.strictEqual(postRes.statusCode, 200);
|
|
assert(postRes.body.includes('post-title-editorial'), 'Post page must render editorial title');
|
|
assert(postRes.body.includes('desktop-side-toc') || postRes.body.includes('mobile-toc-container'), 'Post page must render TOC (desktop side rail and mobile drawer)');
|
|
const postJsonLdMatch = postRes.body.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
|
|
const postJsonLd = JSON.parse(postJsonLdMatch[1]);
|
|
assert(postJsonLd['@graph'].some(node => node['@type'] === 'BlogPosting'), 'Must include BlogPosting schema');
|
|
console.log(' ✓ Blog post editorial layout & BlogPosting schema validated.');
|
|
|
|
// Consistent Navigation: Categories and Tags both filter in /blogs
|
|
const catFilterRes = await request('/blogs?category=tech');
|
|
assert.strictEqual(catFilterRes.statusCode, 200);
|
|
assert(catFilterRes.body.includes('Filtering by category:'), 'Category filter must display in blogs');
|
|
|
|
const tagFilterRes = await request('/blogs?tag=k8s');
|
|
assert.strictEqual(tagFilterRes.statusCode, 200);
|
|
assert(tagFilterRes.body.includes('Filtering by tag:'), 'Tag filter must display in blogs');
|
|
console.log(' ✓ Unified navigation (category & tag) verified in /blogs.');
|
|
|
|
// Super Page Cache Test
|
|
const cacheKey = '/quick-links';
|
|
const uncached = await request(cacheKey);
|
|
assert.strictEqual(uncached.statusCode, 200);
|
|
const etag = uncached.headers['etag'];
|
|
assert(etag, 'Super Page Cache must emit ETag');
|
|
|
|
// Second request: Cache HIT
|
|
const cachedHit = await request(cacheKey);
|
|
assert.strictEqual(cachedHit.headers['x-cache'], 'HIT', 'Super Page Cache must return HIT on second request');
|
|
|
|
// Conditional request: 304 Not Modified
|
|
const notModified = await request(cacheKey, 'GET', { 'if-none-match': etag });
|
|
assert.strictEqual(notModified.statusCode, 304, 'Conditional request must return 304 Not Modified');
|
|
console.log(' ✓ Super Page Cache micro-caching (HIT, ETag, 304) verified.');
|
|
|
|
// Admin Dashboard & SEO Analyzer Tests (/guanli)
|
|
const adminKey = loadSiteConfig().admin?.secret || 'admin';
|
|
const adminCommentsRes = await request(`/guanli/comments?key=${adminKey}`);
|
|
assert.strictEqual(adminCommentsRes.statusCode, 200);
|
|
assert(adminCommentsRes.body.includes('Comment Moderation'), 'Admin comments dashboard must render');
|
|
assert(adminCommentsRes.body.includes('Pending'), 'Admin comments must render Pending filter');
|
|
|
|
const pendingCommentsRes = await request(`/guanli/comments?status=pending&key=${adminKey}`);
|
|
assert.strictEqual(pendingCommentsRes.statusCode, 200);
|
|
|
|
const commentCounts = dbService.getCommentCounts();
|
|
assert(typeof commentCounts.all === 'number', 'Comment counts must provide numeric all count');
|
|
assert(typeof commentCounts.pending === 'number', 'Comment counts must provide numeric pending count');
|
|
|
|
const paginated = dbService.getPaginatedComments({ status: 'all', page: 1, limit: 10 });
|
|
assert(Array.isArray(paginated.comments), 'Paginated comments must return array');
|
|
|
|
const adminSeoRes = await request(`/guanli/seo?key=${adminKey}`);
|
|
assert.strictEqual(adminSeoRes.statusCode, 200);
|
|
assert(adminSeoRes.body.includes('SEO Analyzer & Content Audit') || adminSeoRes.body.includes('SEO Analyzer'), 'Admin SEO analyzer must render');
|
|
|
|
// Verify /admin returns 404 (does NOT redirect to /guanli, keeping /guanli obscure)
|
|
const adminRes = await request('/admin');
|
|
assert.strictEqual(adminRes.statusCode, 404, '/admin must return 404 to conceal the /guanli admin URL');
|
|
|
|
// Verify Sitemap & Robots Exclusion of Admin/Guanli
|
|
const sitemapRes = await request('/sitemap.xml');
|
|
assert.strictEqual(sitemapRes.statusCode, 200);
|
|
assert(!sitemapRes.body.includes('/guanli'), 'sitemap.xml must NEVER include /guanli or sub-routers');
|
|
assert(!sitemapRes.body.includes('/admin'), 'sitemap.xml must NEVER include /admin or sub-routers');
|
|
|
|
const robotsRes = await request('/robots.txt');
|
|
assert.strictEqual(robotsRes.statusCode, 200);
|
|
assert(!robotsRes.body.includes('/guanli'), 'robots.txt must NOT disclose /guanli to scanners');
|
|
assert(robotsRes.body.includes('Disallow: /admin/'), 'robots.txt must Disallow /admin/');
|
|
|
|
// Verify Batch Comment Moderation Endpoint (/guanli/comments/batch)
|
|
const batchRes = await request(`/guanli/comments/batch?key=${adminKey}`, 'POST', {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
}, JSON.stringify({ action: 'approve', ids: [1] }));
|
|
assert.strictEqual(batchRes.statusCode, 200);
|
|
const batchData = JSON.parse(batchRes.body);
|
|
assert.strictEqual(batchData.success, true);
|
|
assert.strictEqual(batchData.action, 'approve');
|
|
console.log(' ✓ Admin moderation dashboard (/guanli), 404 on /admin, batch actions, and sitemap/robots secrecy verified.');
|
|
|
|
console.log('====================================================');
|
|
console.log('ALL TESTS PASSED WITH 100% SUCCESS! (5/5 suites)');
|
|
console.log('====================================================');
|
|
process.exit(0);
|
|
}
|
|
|
|
runTests().catch(err => {
|
|
console.error('Test failed with error:', err);
|
|
process.exit(1);
|
|
});
|