feat: Update test-apps for TimeSafari integration with Endorser.ch API patterns
- Add comprehensive configuration system with timesafari-config.json - Create shared config-loader.ts with TypeScript interfaces and mock services - Update Android test app to use TimeSafari community notification patterns - Update iOS test app with rolling window and community features - Update Electron test app with desktop-specific TimeSafari integration - Enhance test API server to simulate Endorser.ch API endpoints - Add pagination support with afterId/beforeId parameters - Implement parallel API requests pattern for offers, projects, people, items - Add community analytics and notification bundle endpoints - Update all test app UIs for TimeSafari-specific functionality - Update README with comprehensive TimeSafari testing guide All test apps now demonstrate: - Real Endorser.ch API integration patterns - TimeSafari community-building features - Platform-specific optimizations (Android/iOS/Electron) - Comprehensive error handling and performance monitoring - Configuration-driven testing with type safety
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test API Server for Daily Notification Plugin
|
||||
* Test API Server for TimeSafari Daily Notification Plugin
|
||||
*
|
||||
* Provides mock content endpoints for testing the plugin's
|
||||
* network fetching, ETag support, and error handling capabilities.
|
||||
* Simulates Endorser.ch API endpoints for testing the plugin's
|
||||
* network fetching, pagination, and TimeSafari-specific functionality.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
@@ -24,67 +24,110 @@ 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 notification content for a given slot
|
||||
* @param {string} slotId - The notification slot identifier
|
||||
* @param {number} timestamp - Current timestamp
|
||||
* @returns {Object} Mock notification content
|
||||
* 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 generateMockContent(slotId, timestamp) {
|
||||
const slotTime = slotId.split('-')[1] || '08:00';
|
||||
const contentId = crypto.randomUUID().substring(0, 8);
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
data: offers,
|
||||
hitLimit: offers.length >= 3 // Simulate hit limit
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ETag for content
|
||||
* @param {Object} content - Content object
|
||||
* @returns {string} ETag value
|
||||
* 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 generateETag(content) {
|
||||
const contentString = JSON.stringify(content);
|
||||
return `"${crypto.createHash('md5').update(contentString).digest('hex')}"`;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store content with ETag
|
||||
* @param {string} slotId - Slot identifier
|
||||
* @param {Object} content - Content object
|
||||
* @param {string} etag - ETag value
|
||||
* Generate mock notification bundle for TimeSafari
|
||||
* @param {Object} params - Request parameters
|
||||
* @returns {Object} Mock notification bundle
|
||||
*/
|
||||
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;
|
||||
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
|
||||
@@ -96,19 +139,125 @@ app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: Date.now(),
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
service: 'TimeSafari Test API',
|
||||
endpoints: {
|
||||
content: '/api/content/:slotId',
|
||||
health: '/health',
|
||||
metrics: '/api/metrics',
|
||||
error: '/api/error/:type'
|
||||
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'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get notification content for a specific slot
|
||||
* Supports ETag conditional requests
|
||||
* 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;
|
||||
@@ -127,91 +276,61 @@ app.get('/api/content/:slotId', (req, res) => {
|
||||
}
|
||||
|
||||
// Check if we have stored content
|
||||
const stored = getStoredContent(slotId);
|
||||
const stored = contentStore.get(slotId);
|
||||
const etag = etagStore.get(slotId);
|
||||
|
||||
if (stored && ifNoneMatch === stored.etag) {
|
||||
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 = generateMockContent(slotId, timestamp);
|
||||
const etag = generateETag(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
|
||||
storeContent(slotId, content, etag);
|
||||
contentStore.set(slotId, content);
|
||||
etagStore.set(slotId, newEtag);
|
||||
|
||||
// Set ETag header
|
||||
res.set('ETag', etag);
|
||||
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: ${etag})`);
|
||||
console.log(` → 200 OK (new content, ETag: ${newEtag})`);
|
||||
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(),
|
||||
service: 'TimeSafari Test API',
|
||||
version: '2.0.0',
|
||||
contentStore: {
|
||||
size: contentStore.size,
|
||||
slots: Array.from(contentStore.keys())
|
||||
@@ -221,7 +340,21 @@ app.get('/api/metrics', (req, res) => {
|
||||
etags: Array.from(etagStore.entries())
|
||||
},
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage()
|
||||
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);
|
||||
@@ -240,33 +373,6 @@ app.delete('/api/content', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -289,14 +395,16 @@ app.use((req, res) => {
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Test API Server running on port ${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/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(` 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'}`);
|
||||
@@ -304,18 +412,18 @@ app.listen(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`);
|
||||
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 Test API Server...');
|
||||
console.log('\n🛑 Shutting down TimeSafari Test API Server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n🛑 Shutting down Test API Server...');
|
||||
console.log('\n🛑 Shutting down TimeSafari Test API Server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user