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
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);
|
|
});
|
|
|