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