Files
haopengzhan 95a7dcb93b feat(admin): add ephemeral staging DB, read-only posts overview, and dynamic search
- Implement ephemeral staging mode (APP_ENV=staging) to isolate test/dev DB writes in /tmp
- Add read-only posts management dashboard under /guanli/posts with live links and comment counters
- Add hot re-indexing endpoint (/guanli/reindex) to refresh Markdown without restarting server
- Replace static recommended search config with dynamic frequency-based trending searches
- Remove early scaffold mockup file (dropbox/zh.md) and clean legacy mock test queries
- Add native Makefile with dev, dev-persistent, test, lint, and docker targets
- Update test suite and documentation for development workflows
2026-09-16 23:09:14 -07:00

259 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 bilingualPost = contentService.getPost('ai-can-change-coding-for-kids');
assert(bilingualPost, 'Default bilingual post must exist');
assert.strictEqual(bilingualPost.lang, 'en');
assert(bilingualPost.cover, 'Post must have cover image');
assert(bilingualPost.availableLanguages.includes('zh'), 'Post must report zh translation available');
const bilingualZh = contentService.getPost('ai-can-change-coding-for-kids', 'zh');
assert(bilingualZh, 'Chinese variant of post must exist');
assert.strictEqual(bilingualZh.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 = 'category:tech "strace"';
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, 'troubleshooting-gke-pods-in-crashloopbackoff-how-to-start-with-strace');
assert(res1.results[0].snippet.includes('<mark>'), 'Snippet must highlight matched terms');
// Verify search logging to SQLite
dbService.logSearch(complexQuery, 1);
const topRecs = dbService.getTopSearchQueries(5);
assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs');
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 &amp; 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 Read-Only Posts Admin Dashboard (/guanli/posts)
const postsAdminRes = await request(`/guanli/posts?key=${adminKey}`);
assert.strictEqual(postsAdminRes.statusCode, 200);
assert(postsAdminRes.body.includes('Published Markdown Posts'), 'Posts overview page must render');
assert(postsAdminRes.body.includes('ai-can-change-coding-for-kids'), 'Must list authentic posts');
// Verify Hot Re-index Endpoint (/guanli/reindex)
const reindexRes = await request(`/guanli/reindex?key=${adminKey}`, 'POST', {
'Content-Type': 'application/json',
'Accept': 'application/json'
});
assert.strictEqual(reindexRes.statusCode, 200);
const reindexData = JSON.parse(reindexRes.body);
assert.strictEqual(reindexData.success, true);
assert(reindexData.totalPosts >= 15, 'Re-indexed post count must match');
console.log(' ✓ Admin moderation dashboard (/guanli), posts overview, hot reindex, 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);
});