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.
 
 
 
 
 
 

429 lines
14 KiB

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