#!/usr/bin/env node /** * Test API Server for Daily Notification Plugin * * Provides mock content endpoints for testing the plugin's * network fetching, ETag support, and error handling capabilities. * * @author Matthew Raymer * @version 1.0.0 */ const express = require('express'); const cors = require('cors'); const crypto = require('crypto'); const app = express(); const PORT = process.env.PORT || 3001; // Middleware app.use(cors()); app.use(express.json()); // In-memory storage for testing let contentStore = new Map(); let etagStore = new Map(); /** * Generate mock notification content for a given slot * @param {string} slotId - The notification slot identifier * @param {number} timestamp - Current timestamp * @returns {Object} Mock notification content */ function generateMockContent(slotId, timestamp) { const slotTime = slotId.split('-')[1] || '08:00'; const contentId = crypto.randomUUID().substring(0, 8); return { id: contentId, slotId: slotId, title: `Daily Update - ${slotTime}`, body: `Your personalized content for ${slotTime}. Content ID: ${contentId}`, timestamp: timestamp, priority: 'high', category: 'daily', actions: [ { id: 'view', title: 'View Details' }, { id: 'dismiss', title: 'Dismiss' } ], metadata: { source: 'test-api', version: '1.0.0', generated: new Date(timestamp).toISOString() } }; } /** * Generate ETag for content * @param {Object} content - Content object * @returns {string} ETag value */ function generateETag(content) { const contentString = JSON.stringify(content); return `"${crypto.createHash('md5').update(contentString).digest('hex')}"`; } /** * Store content with ETag * @param {string} slotId - Slot identifier * @param {Object} content - Content object * @param {string} etag - ETag value */ function storeContent(slotId, content, etag) { contentStore.set(slotId, content); etagStore.set(slotId, etag); } /** * Get stored content and ETag * @param {string} slotId - Slot identifier * @returns {Object} { content, etag } or null */ function getStoredContent(slotId) { const content = contentStore.get(slotId); const etag = etagStore.get(slotId); return content && etag ? { content, etag } : null; } // Routes /** * Health check endpoint */ app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: Date.now(), version: '1.0.0', endpoints: { content: '/api/content/:slotId', health: '/health', metrics: '/api/metrics', error: '/api/error/:type' } }); }); /** * Get notification content for a specific slot * Supports ETag conditional requests */ app.get('/api/content/:slotId', (req, res) => { const { slotId } = req.params; const ifNoneMatch = req.headers['if-none-match']; const timestamp = Date.now(); console.log(`[${new Date().toISOString()}] GET /api/content/${slotId}`); console.log(` If-None-Match: ${ifNoneMatch || 'none'}`); // Validate slotId format if (!slotId || !slotId.match(/^slot-\d{2}:\d{2}$/)) { return res.status(400).json({ error: 'Invalid slotId format. Expected: slot-HH:MM', provided: slotId }); } // Check if we have stored content const stored = getStoredContent(slotId); if (stored && ifNoneMatch === stored.etag) { // Content hasn't changed, return 304 Not Modified console.log(` → 304 Not Modified (ETag match)`); return res.status(304).end(); } // Generate new content const content = generateMockContent(slotId, timestamp); const etag = generateETag(content); // Store for future ETag checks storeContent(slotId, content, etag); // Set ETag header res.set('ETag', etag); res.set('Cache-Control', 'no-cache'); res.set('Last-Modified', new Date(timestamp).toUTCString()); console.log(` → 200 OK (new content, ETag: ${etag})`); res.json(content); }); /** * Simulate network errors for testing error handling */ app.get('/api/error/:type', (req, res) => { const { type } = req.params; console.log(`[${new Date().toISOString()}] GET /api/error/${type}`); switch (type) { case 'timeout': // Simulate timeout by not responding setTimeout(() => { res.status(408).json({ error: 'Request timeout' }); }, 15000); // 15 second timeout break; case 'server-error': res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR', timestamp: Date.now() }); break; case 'not-found': res.status(404).json({ error: 'Content not found', code: 'NOT_FOUND', slotId: req.query.slotId || 'unknown' }); break; case 'rate-limit': res.status(429).json({ error: 'Rate limit exceeded', code: 'RATE_LIMIT', retryAfter: 60 }); break; case 'unauthorized': res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }); break; default: res.status(400).json({ error: 'Unknown error type', available: ['timeout', 'server-error', 'not-found', 'rate-limit', 'unauthorized'] }); } }); /** * API metrics endpoint */ app.get('/api/metrics', (req, res) => { const metrics = { timestamp: Date.now(), contentStore: { size: contentStore.size, slots: Array.from(contentStore.keys()) }, etagStore: { size: etagStore.size, etags: Array.from(etagStore.entries()) }, uptime: process.uptime(), memory: process.memoryUsage() }; res.json(metrics); }); /** * Clear stored content (for testing) */ app.delete('/api/content', (req, res) => { contentStore.clear(); etagStore.clear(); res.json({ message: 'All stored content cleared', timestamp: Date.now() }); }); /** * Update content for a specific slot (for testing content changes) */ app.put('/api/content/:slotId', (req, res) => { const { slotId } = req.params; const { content } = req.body; if (!content) { return res.status(400).json({ error: 'Content is required' }); } const timestamp = Date.now(); const etag = generateETag(content); storeContent(slotId, content, etag); res.set('ETag', etag); res.json({ message: 'Content updated', slotId, etag, timestamp }); }); // Error handling middleware app.use((err, req, res, next) => { console.error(`[${new Date().toISOString()}] Error:`, err); res.status(500).json({ error: 'Internal server error', message: err.message, timestamp: Date.now() }); }); // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Endpoint not found', path: req.path, method: req.method, timestamp: Date.now() }); }); // Start server app.listen(PORT, () => { console.log(`šŸš€ Test API Server running on port ${PORT}`); console.log(`šŸ“‹ Available endpoints:`); console.log(` GET /health - Health check`); console.log(` GET /api/content/:slotId - Get notification content`); console.log(` PUT /api/content/:slotId - Update content`); console.log(` DELETE /api/content - Clear all content`); console.log(` GET /api/error/:type - Simulate errors`); console.log(` GET /api/metrics - API metrics`); console.log(``); console.log(`šŸ”§ Environment:`); console.log(` NODE_ENV: ${process.env.NODE_ENV || 'development'}`); console.log(` PORT: ${PORT}`); console.log(``); console.log(`šŸ“ Usage examples:`); console.log(` curl http://localhost:${PORT}/health`); console.log(` curl http://localhost:${PORT}/api/content/slot-08:00`); console.log(` curl -H "If-None-Match: \\"abc123\\"" http://localhost:${PORT}/api/content/slot-08:00`); console.log(` curl http://localhost:${PORT}/api/error/timeout`); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\nšŸ›‘ Shutting down Test API Server...'); process.exit(0); }); process.on('SIGTERM', () => { console.log('\nšŸ›‘ Shutting down Test API Server...'); process.exit(0); });