#!/usr/bin/env node /** * Test API Server for TimeSafari Daily Notification Plugin * * Simulates Endorser.ch API endpoints for testing the plugin's * network fetching, pagination, and TimeSafari-specific functionality. * * @author Matthew Raymer * @version 2.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(); let offersStore = new Map(); let projectsStore = new Map(); /** * Generate mock offer data for TimeSafari testing * @param {string} recipientDid - DID of the recipient * @param {string} afterId - JWT ID for pagination * @returns {Object} Mock offer data */ function generateMockOffers(recipientDid, afterId) { const offers = []; const offerCount = Math.floor(Math.random() * 5) + 1; // 1-5 offers for (let i = 0; i < offerCount; i++) { const jwtId = `01HSE3R9MAC0FT3P3KZ382TWV${7 + i}`; const handleId = `offer-${crypto.randomUUID().substring(0, 8)}`; offers.push({ jwtId: jwtId, handleId: handleId, issuedAt: new Date().toISOString(), offeredByDid: `did:example:offerer${i + 1}`, recipientDid: recipientDid, unit: 'USD', amount: Math.floor(Math.random() * 5000) + 500, amountGiven: Math.floor(Math.random() * 2000) + 200, amountGivenConfirmed: Math.floor(Math.random() * 1000) + 100, objectDescription: `Community service offer ${i + 1}`, validThrough: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), fullClaim: { type: 'Offer', issuer: `did:example:offerer${i + 1}`, recipient: recipientDid, object: { description: `Community service offer ${i + 1}`, amount: Math.floor(Math.random() * 5000) + 500, unit: 'USD' } } }); } return { data: offers, hitLimit: offers.length >= 3 // Simulate hit limit }; } /** * Generate mock project data for TimeSafari testing * @param {Array} planIds - Array of plan IDs * @param {string} afterId - JWT ID for pagination * @returns {Object} Mock project data */ function generateMockProjects(planIds, afterId) { const projects = []; planIds.forEach((planId, index) => { const jwtId = `01HSE3R9MAC0FT3P3KZ382TWV${8 + index}`; projects.push({ plan: { jwtId: jwtId, handleId: planId, name: `Community Project ${index + 1}`, description: `Description for ${planId}`, issuerDid: `did:example:issuer${index + 1}`, agentDid: `did:example:agent${index + 1}`, startTime: new Date().toISOString(), endTime: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), locLat: 40.7128 + (Math.random() - 0.5) * 0.1, locLon: -74.0060 + (Math.random() - 0.5) * 0.1, url: `https://timesafari.com/projects/${planId}`, category: 'community', status: 'active' }, wrappedClaimBefore: null // Simulate no previous claim }); }); return { data: projects, hitLimit: projects.length >= 2 // Simulate hit limit }; } /** * Generate mock notification bundle for TimeSafari * @param {Object} params - Request parameters * @returns {Object} Mock notification bundle */ function generateNotificationBundle(params) { const { userDid, starredPlanIds, lastKnownOfferId, lastKnownPlanId } = params; return { offersToPerson: generateMockOffers(userDid, lastKnownOfferId), offersToProjects: { data: [], hitLimit: false }, starredChanges: generateMockProjects(starredPlanIds, lastKnownPlanId), timestamp: new Date().toISOString(), bundleId: crypto.randomUUID() }; } // Routes /** * Health check endpoint */ app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: Date.now(), version: '2.0.0', service: 'TimeSafari Test API', endpoints: { health: '/health', offers: '/api/v2/report/offers', offersToPlans: '/api/v2/report/offersToPlansOwnedByMe', plansLastUpdated: '/api/v2/report/plansLastUpdatedBetween', notificationsBundle: '/api/v2/report/notifications/bundle', analytics: '/api/analytics/community-events', metrics: '/api/metrics' } }); }); /** * Endorser.ch API: Get offers to person */ app.get('/api/v2/report/offers', (req, res) => { const { recipientId, afterId } = req.query; console.log(`[${new Date().toISOString()}] GET /api/v2/report/offers`); console.log(` recipientId: ${recipientId}, afterId: ${afterId || 'none'}`); if (!recipientId) { return res.status(400).json({ error: 'recipientId parameter is required' }); } const offers = generateMockOffers(recipientId, afterId); console.log(` → 200 OK (${offers.data.length} offers, hitLimit: ${offers.hitLimit})`); res.json(offers); }); /** * Endorser.ch API: Get offers to user's projects */ app.get('/api/v2/report/offersToPlansOwnedByMe', (req, res) => { const { afterId } = req.query; console.log(`[${new Date().toISOString()}] GET /api/v2/report/offersToPlansOwnedByMe`); console.log(` afterId: ${afterId || 'none'}`); const offers = { data: [], // Simulate no offers to user's projects hitLimit: false }; console.log(` → 200 OK (${offers.data.length} offers, hitLimit: ${offers.hitLimit})`); res.json(offers); }); /** * Endorser.ch API: Get changes to starred projects */ app.post('/api/v2/report/plansLastUpdatedBetween', (req, res) => { const { planIds, afterId } = req.body; console.log(`[${new Date().toISOString()}] POST /api/v2/report/plansLastUpdatedBetween`); console.log(` planIds: ${JSON.stringify(planIds)}, afterId: ${afterId || 'none'}`); if (!planIds || !Array.isArray(planIds)) { return res.status(400).json({ error: 'planIds array is required' }); } const projects = generateMockProjects(planIds, afterId); console.log(` → 200 OK (${projects.data.length} projects, hitLimit: ${projects.hitLimit})`); res.json(projects); }); /** * TimeSafari API: Get notification bundle */ app.get('/api/v2/report/notifications/bundle', (req, res) => { const { userDid, starredPlanIds, lastKnownOfferId, lastKnownPlanId } = req.query; console.log(`[${new Date().toISOString()}] GET /api/v2/report/notifications/bundle`); console.log(` userDid: ${userDid}, starredPlanIds: ${starredPlanIds}`); if (!userDid) { return res.status(400).json({ error: 'userDid parameter is required' }); } const bundle = generateNotificationBundle({ userDid, starredPlanIds: starredPlanIds ? JSON.parse(starredPlanIds) : [], lastKnownOfferId, lastKnownPlanId }); console.log(` → 200 OK (bundle generated)`); res.json(bundle); }); /** * TimeSafari Analytics: Community events */ app.post('/api/analytics/community-events', (req, res) => { const { client_id, events } = req.body; console.log(`[${new Date().toISOString()}] POST /api/analytics/community-events`); console.log(` client_id: ${client_id}, events: ${events?.length || 0}`); // Simulate analytics processing res.json({ status: 'success', processed: events?.length || 0, timestamp: new Date().toISOString() }); }); /** * Legacy content endpoint (for backward compatibility) */ 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 = contentStore.get(slotId); const etag = etagStore.get(slotId); if (stored && etag && ifNoneMatch === 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 = { id: crypto.randomUUID().substring(0, 8), slotId: slotId, title: `TimeSafari Community Update - ${slotId.split('-')[1]}`, body: `Your personalized TimeSafari content for ${slotId.split('-')[1]}`, timestamp: timestamp, priority: 'high', category: 'community', actions: [ { id: 'view_offers', title: 'View Offers' }, { id: 'view_projects', title: 'See Projects' }, { id: 'view_people', title: 'Check People' }, { id: 'view_items', title: 'Browse Items' }, { id: 'dismiss', title: 'Dismiss' } ], metadata: { source: 'timesafari-test-api', version: '2.0.0', generated: new Date(timestamp).toISOString() } }; const newEtag = `"${crypto.createHash('md5').update(JSON.stringify(content)).digest('hex')}"`; // Store for future ETag checks contentStore.set(slotId, content); etagStore.set(slotId, newEtag); // Set ETag header res.set('ETag', newEtag); res.set('Cache-Control', 'no-cache'); res.set('Last-Modified', new Date(timestamp).toUTCString()); console.log(` → 200 OK (new content, ETag: ${newEtag})`); res.json(content); }); /** * API metrics endpoint */ app.get('/api/metrics', (req, res) => { const metrics = { timestamp: Date.now(), service: 'TimeSafari Test API', version: '2.0.0', contentStore: { size: contentStore.size, slots: Array.from(contentStore.keys()) }, etagStore: { size: etagStore.size, etags: Array.from(etagStore.entries()) }, uptime: process.uptime(), memory: process.memoryUsage(), endpoints: { total: 8, active: 8, health: '/health', endorser: { offers: '/api/v2/report/offers', offersToPlans: '/api/v2/report/offersToPlansOwnedByMe', plansLastUpdated: '/api/v2/report/plansLastUpdatedBetween' }, timesafari: { notificationsBundle: '/api/v2/report/notifications/bundle', analytics: '/api/analytics/community-events' } } }; 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() }); }); // 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(`🚀 TimeSafari Test API Server running on port ${PORT}`); console.log(`📋 Available endpoints:`); console.log(` GET /health - Health check`); console.log(` GET /api/v2/report/offers - Get offers to person`); console.log(` GET /api/v2/report/offersToPlansOwnedByMe - Get offers to user's projects`); console.log(` POST /api/v2/report/plansLastUpdatedBetween - Get changes to starred projects`); console.log(` GET /api/v2/report/notifications/bundle - Get TimeSafari notification bundle`); console.log(` POST /api/analytics/community-events - Send community analytics`); console.log(` GET /api/content/:slotId - Legacy content endpoint`); 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/v2/report/offers?recipientId=did:example:testuser123&afterId=01HSE3R9MAC0FT3P3KZ382TWV7"`); console.log(` curl -X POST http://localhost:${PORT}/api/v2/report/plansLastUpdatedBetween -H "Content-Type: application/json" -d '{"planIds":["plan-123","plan-456"],"afterId":"01HSE3R9MAC0FT3P3KZ382TWV8"}'`); console.log(` curl "http://localhost:${PORT}/api/v2/report/notifications/bundle?userDid=did:example:testuser123&starredPlanIds=[\"plan-123\",\"plan-456\"]"`); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\n🛑 Shutting down TimeSafari Test API Server...'); process.exit(0); }); process.on('SIGTERM', () => { console.log('\n🛑 Shutting down TimeSafari Test API Server...'); process.exit(0); });