You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

321 lines
8.8 KiB

#!/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);
});