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:
Matthew Raymer
2025-09-24 07:38:53 +00:00
parent 999b824a36
commit fe82fd2147
11 changed files with 3432 additions and 389 deletions

View File

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