-
🍎 Daily Notification Plugin - iOS Test
+
🍎 TimeSafari Daily Notification - iOS Test
Ready
-
-
-
-
+
+
+
+
+
diff --git a/test-apps/ios-test/src/index.ts b/test-apps/ios-test/src/index.ts
index c192c43..20cb10f 100644
--- a/test-apps/ios-test/src/index.ts
+++ b/test-apps/ios-test/src/index.ts
@@ -1,76 +1,47 @@
import { Capacitor } from '@capacitor/core';
+import { ConfigLoader, MockDailyNotificationService, TestLogger } from '../shared/config-loader';
-// Mock plugin for development
-const DailyNotification = {
- async configure(options: any) {
- console.log('Configure called:', options);
- return Promise.resolve();
- },
- async scheduleDailyNotification(options: any) {
- console.log('Schedule called:', options);
- return Promise.resolve();
- },
- async maintainRollingWindow() {
- console.log('Maintain rolling window called');
- return Promise.resolve();
- },
- async getRollingWindowStats() {
- return Promise.resolve({
- stats: '64 pending notifications, 20 daily limit',
- maintenanceNeeded: false,
- timeUntilNextMaintenance: 900000
- });
- },
- async getPerformanceMetrics() {
- return Promise.resolve({
- overallScore: 88,
- databasePerformance: 92,
- memoryEfficiency: 85,
- batteryEfficiency: 90,
- objectPoolEfficiency: 88,
- totalDatabaseQueries: 120,
- averageMemoryUsage: 22.3,
- objectPoolHits: 38,
- backgroundCpuUsage: 1.8,
- totalNetworkRequests: 8,
- recommendations: ['Enable background tasks', 'Optimize memory usage']
- });
- }
-};
-
-// Test interface
-class TestApp {
+// Test interface for TimeSafari iOS integration
+class TimeSafariIOSTestApp {
private statusElement: HTMLElement;
private logElement: HTMLElement;
+ private configLoader: ConfigLoader;
+ private notificationService: MockDailyNotificationService;
+ private logger: TestLogger;
constructor() {
this.statusElement = document.getElementById('status')!;
this.logElement = document.getElementById('log')!;
+ this.configLoader = ConfigLoader.getInstance();
+ this.logger = new TestLogger('debug');
+ this.notificationService = new MockDailyNotificationService(this.configLoader.getConfig());
this.setupEventListeners();
- this.log('iOS Test app initialized');
+ this.log('TimeSafari iOS Test app initialized');
}
private setupEventListeners() {
document.getElementById('configure')?.addEventListener('click', () => this.testConfigure());
document.getElementById('schedule')?.addEventListener('click', () => this.testSchedule());
document.getElementById('rolling-window')?.addEventListener('click', () => this.testRollingWindow());
- document.getElementById('window-stats')?.addEventListener('click', () => this.testWindowStats());
+ document.getElementById('endorser-api')?.addEventListener('click', () => this.testEndorserAPI());
+ document.getElementById('callbacks')?.addEventListener('click', () => this.testCallbacks());
document.getElementById('performance')?.addEventListener('click', () => this.testPerformance());
document.getElementById('clear-log')?.addEventListener('click', () => this.clearLog());
}
private async testConfigure() {
try {
- this.log('Testing iOS configuration...');
- await DailyNotification.configure({
- storage: 'shared',
- ttlSeconds: 1800,
- prefetchLeadMinutes: 15,
- enableETagSupport: true,
- enableErrorHandling: true,
- enablePerformanceOptimization: true
+ this.log('Testing TimeSafari iOS configuration...');
+ await this.configLoader.loadConfig();
+ const config = this.configLoader.getConfig();
+
+ await this.notificationService.initialize();
+
+ this.log('✅ TimeSafari iOS configuration successful', {
+ appId: config.timesafari.appId,
+ appName: config.timesafari.appName,
+ version: config.timesafari.version
});
- this.log('✅ iOS Configuration successful');
this.updateStatus('Configured');
} catch (error) {
this.log(`❌ Configuration failed: ${error}`);
@@ -79,53 +50,277 @@ class TestApp {
private async testSchedule() {
try {
- this.log('Testing iOS notification scheduling...');
- await DailyNotification.scheduleDailyNotification({
- url: 'https://api.example.com/daily-content',
- time: '09:00',
- title: 'Daily iOS Test Notification',
- body: 'This is a test notification from the iOS test app'
- });
- this.log('✅ iOS Notification scheduled successfully');
+ this.log('Testing TimeSafari iOS community notification scheduling...');
+ const config = this.configLoader.getConfig();
+
+ const dualConfig = {
+ contentFetch: {
+ enabled: true,
+ schedule: config.scheduling.contentFetch.schedule,
+ url: this.configLoader.getEndorserUrl('notificationsBundle'),
+ headers: this.configLoader.getAuthHeaders(),
+ ttlSeconds: 3600,
+ timeout: 30000,
+ retryAttempts: 3,
+ retryDelay: 5000,
+ callbacks: {
+ onSuccess: async (data: any) => {
+ this.log('✅ Content fetch successful', data);
+ await this.processEndorserNotificationBundle(data);
+ },
+ onError: async (error: any) => {
+ this.log('❌ Content fetch failed', error);
+ }
+ }
+ },
+ userNotification: {
+ enabled: true,
+ schedule: config.scheduling.userNotification.schedule,
+ title: 'TimeSafari Community Update',
+ body: 'New offers, projects, people, and items await your attention!',
+ sound: true,
+ vibration: true,
+ priority: 'high',
+ 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' }
+ ]
+ },
+ relationship: {
+ autoLink: true,
+ contentTimeout: 300000,
+ fallbackBehavior: 'show_default'
+ }
+ };
+
+ await this.notificationService.scheduleDualNotification(dualConfig);
+ this.log('✅ iOS community notification scheduled successfully');
this.updateStatus('Scheduled');
} catch (error) {
- this.log(`❌ iOS Scheduling failed: ${error}`);
+ this.log(`❌ iOS scheduling failed: ${error}`);
}
}
private async testRollingWindow() {
try {
this.log('Testing iOS rolling window maintenance...');
- await DailyNotification.maintainRollingWindow();
- this.log('✅ Rolling window maintenance completed');
+ // Simulate rolling window maintenance
+ const stats = {
+ stats: '64 pending notifications, 20 daily limit',
+ maintenanceNeeded: false,
+ timeUntilNextMaintenance: 900000
+ };
+
+ this.log('✅ Rolling window maintenance completed', stats);
this.updateStatus('Rolling Window Maintained');
} catch (error) {
this.log(`❌ Rolling window maintenance failed: ${error}`);
}
}
- private async testWindowStats() {
+ private async testEndorserAPI() {
try {
- this.log('Testing iOS rolling window stats...');
- const stats = await DailyNotification.getRollingWindowStats();
- this.log(`📊 Rolling Window Stats:`, stats);
- this.updateStatus(`Window: ${stats.maintenanceNeeded ? 'Needs Maintenance' : 'OK'}`);
+ this.log('Testing Endorser.ch API integration on iOS...');
+ const config = this.configLoader.getConfig();
+ const testData = config.testData;
+
+ // Test parallel API requests pattern
+ const requests = [
+ // Offers to person
+ fetch(`${this.configLoader.getEndorserUrl('offers')}?recipientId=${testData.userDid}&afterId=${testData.lastKnownOfferId}`, {
+ headers: this.configLoader.getAuthHeaders()
+ }),
+
+ // Offers to user's projects
+ fetch(`${this.configLoader.getEndorserUrl('offersToPlans')}?afterId=${testData.lastKnownOfferId}`, {
+ headers: this.configLoader.getAuthHeaders()
+ }),
+
+ // Changes to starred projects
+ fetch(this.configLoader.getEndorserUrl('plansLastUpdated'), {
+ method: 'POST',
+ headers: this.configLoader.getAuthHeaders(),
+ body: JSON.stringify({
+ planIds: testData.starredPlanIds,
+ afterId: testData.lastKnownPlanId
+ })
+ })
+ ];
+
+ const [offersToPerson, offersToProjects, starredChanges] = await Promise.all(requests);
+
+ const notificationData = {
+ offersToPerson: await offersToPerson.json(),
+ offersToProjects: await offersToProjects.json(),
+ starredChanges: await starredChanges.json()
+ };
+
+ this.log('✅ Endorser.ch API integration successful on iOS', {
+ offersToPerson: notificationData.offersToPerson.data?.length || 0,
+ offersToProjects: notificationData.offersToProjects.data?.length || 0,
+ starredChanges: notificationData.starredChanges.data?.length || 0
+ });
+
+ this.updateStatus('API Connected');
} catch (error) {
- this.log(`❌ Window stats check failed: ${error}`);
+ this.log(`❌ Endorser.ch API test failed: ${error}`);
+ }
+ }
+
+ private async testCallbacks() {
+ try {
+ this.log('Testing TimeSafari iOS notification callbacks...');
+ const config = this.configLoader.getConfig();
+
+ // Register offers callback
+ await this.notificationService.registerCallback('offers', async (event: any) => {
+ this.log('📨 iOS Offers callback triggered', event);
+ await this.handleOffersNotification(event);
+ });
+
+ // Register projects callback
+ await this.notificationService.registerCallback('projects', async (event: any) => {
+ this.log('📨 iOS Projects callback triggered', event);
+ await this.handleProjectsNotification(event);
+ });
+
+ // Register people callback
+ await this.notificationService.registerCallback('people', async (event: any) => {
+ this.log('📨 iOS People callback triggered', event);
+ await this.handlePeopleNotification(event);
+ });
+
+ // Register items callback
+ await this.notificationService.registerCallback('items', async (event: any) => {
+ this.log('📨 iOS Items callback triggered', event);
+ await this.handleItemsNotification(event);
+ });
+
+ this.log('✅ All iOS callbacks registered successfully');
+ this.updateStatus('Callbacks Registered');
+ } catch (error) {
+ this.log(`❌ iOS callback registration failed: ${error}`);
}
}
private async testPerformance() {
try {
this.log('Testing iOS performance metrics...');
- const metrics = await DailyNotification.getPerformanceMetrics();
- this.log(`📊 iOS Performance Metrics:`, metrics);
+ const metrics = {
+ overallScore: 88,
+ databasePerformance: 92,
+ memoryEfficiency: 85,
+ batteryEfficiency: 90,
+ objectPoolEfficiency: 88,
+ totalDatabaseQueries: 120,
+ averageMemoryUsage: 22.3,
+ objectPoolHits: 38,
+ backgroundCpuUsage: 1.8,
+ totalNetworkRequests: 8,
+ recommendations: ['Enable background tasks', 'Optimize memory usage']
+ };
+
+ this.log('📊 iOS Performance Metrics:', metrics);
this.updateStatus(`Performance: ${metrics.overallScore}/100`);
} catch (error) {
this.log(`❌ Performance check failed: ${error}`);
}
}
+ /**
+ * Process Endorser.ch notification bundle using parallel API requests
+ */
+ private async processEndorserNotificationBundle(data: any): Promise
{
+ try {
+ this.log('Processing Endorser.ch notification bundle on iOS...');
+
+ // Process each notification type
+ if (data.offersToPerson?.data?.length > 0) {
+ await this.handleOffersNotification(data.offersToPerson);
+ }
+
+ if (data.starredChanges?.data?.length > 0) {
+ await this.handleProjectsNotification(data.starredChanges);
+ }
+
+ this.log('✅ iOS notification bundle processed successfully');
+ } catch (error) {
+ this.log(`❌ iOS bundle processing failed: ${error}`);
+ }
+ }
+
+ /**
+ * Handle offers notification events from Endorser.ch API
+ */
+ private async handleOffersNotification(event: any): Promise {
+ this.log('Handling iOS offers notification:', event);
+
+ if (event.data && event.data.length > 0) {
+ // Process OfferSummaryArrayMaybeMoreBody format
+ event.data.forEach((offer: any) => {
+ this.log('Processing iOS offer:', {
+ jwtId: offer.jwtId,
+ handleId: offer.handleId,
+ offeredByDid: offer.offeredByDid,
+ recipientDid: offer.recipientDid,
+ objectDescription: offer.objectDescription
+ });
+ });
+
+ // Check if there are more offers to fetch
+ if (event.hitLimit) {
+ const lastOffer = event.data[event.data.length - 1];
+ this.log('More offers available, last JWT ID:', lastOffer.jwtId);
+ }
+ }
+ }
+
+ /**
+ * Handle projects notification events from Endorser.ch API
+ */
+ private async handleProjectsNotification(event: any): Promise {
+ this.log('Handling iOS projects notification:', event);
+
+ if (event.data && event.data.length > 0) {
+ // Process PlanSummaryAndPreviousClaimArrayMaybeMore format
+ event.data.forEach((planData: any) => {
+ const { plan, wrappedClaimBefore } = planData;
+ this.log('Processing iOS project change:', {
+ jwtId: plan.jwtId,
+ handleId: plan.handleId,
+ name: plan.name,
+ issuerDid: plan.issuerDid,
+ hasPreviousClaim: !!wrappedClaimBefore
+ });
+ });
+
+ // Check if there are more project changes to fetch
+ if (event.hitLimit) {
+ const lastPlan = event.data[event.data.length - 1];
+ this.log('More project changes available, last JWT ID:', lastPlan.plan.jwtId);
+ }
+ }
+ }
+
+ /**
+ * Handle people notification events
+ */
+ private async handlePeopleNotification(event: any): Promise {
+ this.log('Handling iOS people notification:', event);
+ // Implementation would process people data and update local state
+ }
+
+ /**
+ * Handle items notification events
+ */
+ private async handleItemsNotification(event: any): Promise {
+ this.log('Handling iOS items notification:', event);
+ // Implementation would process items data and update local state
+ }
+
private log(message: string, data?: any) {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
@@ -149,5 +344,5 @@ class TestApp {
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
- new TestApp();
+ new TimeSafariIOSTestApp();
});
diff --git a/test-apps/shared/config-loader.ts b/test-apps/shared/config-loader.ts
new file mode 100644
index 0000000..8e9dae6
--- /dev/null
+++ b/test-apps/shared/config-loader.ts
@@ -0,0 +1,522 @@
+/**
+ * Configuration loader for TimeSafari test apps
+ *
+ * Loads configuration from JSON files and provides typed access
+ * to TimeSafari-specific settings, Endorser.ch API endpoints,
+ * and test data.
+ *
+ * @author Matthew Raymer
+ * @version 1.0.0
+ */
+
+export interface TimeSafariConfig {
+ timesafari: {
+ appId: string;
+ appName: string;
+ version: string;
+ description: string;
+ };
+ endorser: {
+ baseUrl: string;
+ apiVersion: string;
+ endpoints: {
+ offers: string;
+ offersToPlans: string;
+ plansLastUpdated: string;
+ notificationsBundle: string;
+ };
+ authentication: {
+ type: string;
+ token: string;
+ headers: Record;
+ };
+ pagination: {
+ defaultLimit: number;
+ maxLimit: number;
+ hitLimitThreshold: number;
+ };
+ };
+ notificationTypes: {
+ offers: {
+ enabled: boolean;
+ types: string[];
+ };
+ projects: {
+ enabled: boolean;
+ types: string[];
+ };
+ people: {
+ enabled: boolean;
+ types: string[];
+ };
+ items: {
+ enabled: boolean;
+ types: string[];
+ };
+ };
+ scheduling: {
+ contentFetch: {
+ schedule: string;
+ time: string;
+ description: string;
+ };
+ userNotification: {
+ schedule: string;
+ time: string;
+ description: string;
+ };
+ };
+ testData: {
+ userDid: string;
+ starredPlanIds: string[];
+ lastKnownOfferId: string;
+ lastKnownPlanId: string;
+ mockOffers: any[];
+ mockProjects: any[];
+ };
+ callbacks: {
+ offers: {
+ enabled: boolean;
+ localHandler: string;
+ };
+ projects: {
+ enabled: boolean;
+ localHandler: string;
+ };
+ people: {
+ enabled: boolean;
+ localHandler: string;
+ };
+ items: {
+ enabled: boolean;
+ localHandler: string;
+ };
+ communityAnalytics: {
+ enabled: boolean;
+ endpoint: string;
+ headers: Record;
+ };
+ };
+ observability: {
+ enableLogging: boolean;
+ logLevel: string;
+ enableMetrics: boolean;
+ enableHealthChecks: boolean;
+ };
+}
+
+/**
+ * Configuration loader class
+ */
+export class ConfigLoader {
+ private static instance: ConfigLoader;
+ private config: TimeSafariConfig | null = null;
+
+ private constructor() {}
+
+ /**
+ * Get singleton instance
+ */
+ public static getInstance(): ConfigLoader {
+ if (!ConfigLoader.instance) {
+ ConfigLoader.instance = new ConfigLoader();
+ }
+ return ConfigLoader.instance;
+ }
+
+ /**
+ * Load configuration from JSON file
+ */
+ public async loadConfig(): Promise {
+ if (this.config) {
+ return this.config;
+ }
+
+ try {
+ // In a real app, this would fetch from a config file
+ // For test apps, we'll use a hardcoded config
+ this.config = {
+ timesafari: {
+ appId: "app.timesafari.test",
+ appName: "TimeSafari Test",
+ version: "1.0.0",
+ description: "Test app for TimeSafari Daily Notification Plugin integration"
+ },
+ endorser: {
+ baseUrl: "http://localhost:3001",
+ apiVersion: "v2",
+ endpoints: {
+ offers: "/api/v2/report/offers",
+ offersToPlans: "/api/v2/report/offersToPlansOwnedByMe",
+ plansLastUpdated: "/api/v2/report/plansLastUpdatedBetween",
+ notificationsBundle: "/api/v2/report/notifications/bundle"
+ },
+ authentication: {
+ type: "Bearer",
+ token: "test-jwt-token-12345",
+ headers: {
+ "Authorization": "Bearer test-jwt-token-12345",
+ "Content-Type": "application/json",
+ "X-Privacy-Level": "user-controlled"
+ }
+ },
+ pagination: {
+ defaultLimit: 50,
+ maxLimit: 100,
+ hitLimitThreshold: 50
+ }
+ },
+ notificationTypes: {
+ offers: {
+ enabled: true,
+ types: [
+ "new_to_me",
+ "changed_to_me",
+ "new_to_projects",
+ "changed_to_projects",
+ "new_to_favorites",
+ "changed_to_favorites"
+ ]
+ },
+ projects: {
+ enabled: true,
+ types: [
+ "local_new",
+ "local_changed",
+ "content_interest_new",
+ "favorited_changed"
+ ]
+ },
+ people: {
+ enabled: true,
+ types: [
+ "local_new",
+ "local_changed",
+ "content_interest_new",
+ "favorited_changed",
+ "contacts_changed"
+ ]
+ },
+ items: {
+ enabled: true,
+ types: [
+ "local_new",
+ "local_changed",
+ "favorited_changed"
+ ]
+ }
+ },
+ scheduling: {
+ contentFetch: {
+ schedule: "0 8 * * *",
+ time: "08:00",
+ description: "8 AM daily - fetch community updates"
+ },
+ userNotification: {
+ schedule: "0 9 * * *",
+ time: "09:00",
+ description: "9 AM daily - notify users of community updates"
+ }
+ },
+ testData: {
+ userDid: "did:example:testuser123",
+ starredPlanIds: [
+ "plan-community-garden",
+ "plan-local-food",
+ "plan-sustainability"
+ ],
+ lastKnownOfferId: "01HSE3R9MAC0FT3P3KZ382TWV7",
+ lastKnownPlanId: "01HSE3R9MAC0FT3P3KZ382TWV8",
+ mockOffers: [
+ {
+ jwtId: "01HSE3R9MAC0FT3P3KZ382TWV7",
+ handleId: "offer-web-dev-001",
+ offeredByDid: "did:example:offerer123",
+ recipientDid: "did:example:testuser123",
+ objectDescription: "Web development services for community project",
+ unit: "USD",
+ amount: 1000,
+ amountGiven: 500,
+ amountGivenConfirmed: 250
+ }
+ ],
+ mockProjects: [
+ {
+ plan: {
+ jwtId: "01HSE3R9MAC0FT3P3KZ382TWV8",
+ handleId: "plan-community-garden",
+ name: "Community Garden Project",
+ description: "Building a community garden for local food production",
+ issuerDid: "did:example:issuer123",
+ agentDid: "did:example:agent123"
+ },
+ wrappedClaimBefore: null
+ }
+ ]
+ },
+ callbacks: {
+ offers: {
+ enabled: true,
+ localHandler: "handleOffersNotification"
+ },
+ projects: {
+ enabled: true,
+ localHandler: "handleProjectsNotification"
+ },
+ people: {
+ enabled: true,
+ localHandler: "handlePeopleNotification"
+ },
+ items: {
+ enabled: true,
+ localHandler: "handleItemsNotification"
+ },
+ communityAnalytics: {
+ enabled: true,
+ endpoint: "http://localhost:3001/api/analytics/community-events",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Privacy-Level": "aggregated"
+ }
+ }
+ },
+ observability: {
+ enableLogging: true,
+ logLevel: "debug",
+ enableMetrics: true,
+ enableHealthChecks: true
+ }
+ };
+
+ return this.config;
+ } catch (error) {
+ console.error('Failed to load configuration:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get configuration
+ */
+ public getConfig(): TimeSafariConfig {
+ if (!this.config) {
+ throw new Error('Configuration not loaded. Call loadConfig() first.');
+ }
+ return this.config;
+ }
+
+ /**
+ * Get Endorser.ch API URL for a specific endpoint
+ */
+ public getEndorserUrl(endpoint: keyof TimeSafariConfig['endorser']['endpoints']): string {
+ const config = this.getConfig();
+ return `${config.endorser.baseUrl}${config.endorser.endpoints[endpoint]}`;
+ }
+
+ /**
+ * Get authentication headers
+ */
+ public getAuthHeaders(): Record {
+ const config = this.getConfig();
+ return config.endorser.authentication.headers;
+ }
+
+ /**
+ * Get test data
+ */
+ public getTestData() {
+ const config = this.getConfig();
+ return config.testData;
+ }
+
+ /**
+ * Get notification types for a specific category
+ */
+ public getNotificationTypes(category: keyof TimeSafariConfig['notificationTypes']) {
+ const config = this.getConfig();
+ return config.notificationTypes[category];
+ }
+}
+
+/**
+ * Logger utility for test apps
+ */
+export class TestLogger {
+ private logLevel: string;
+
+ constructor(logLevel: string = 'debug') {
+ this.logLevel = logLevel;
+ }
+
+ private shouldLog(level: string): boolean {
+ const levels = ['error', 'warn', 'info', 'debug'];
+ return levels.indexOf(level) <= levels.indexOf(this.logLevel);
+ }
+
+ public debug(message: string, data?: any) {
+ if (this.shouldLog('debug')) {
+ console.log(`[DEBUG] ${message}`, data || '');
+ }
+ }
+
+ public info(message: string, data?: any) {
+ if (this.shouldLog('info')) {
+ console.log(`[INFO] ${message}`, data || '');
+ }
+ }
+
+ public warn(message: string, data?: any) {
+ if (this.shouldLog('warn')) {
+ console.warn(`[WARN] ${message}`, data || '');
+ }
+ }
+
+ public error(message: string, data?: any) {
+ if (this.shouldLog('error')) {
+ console.error(`[ERROR] ${message}`, data || '');
+ }
+ }
+}
+
+/**
+ * Mock DailyNotificationService for test apps
+ */
+export class MockDailyNotificationService {
+ private config: TimeSafariConfig;
+ private logger: TestLogger;
+ private isInitialized = false;
+
+ constructor(config: TimeSafariConfig) {
+ this.config = config;
+ this.logger = new TestLogger(config.observability.logLevel);
+ }
+
+ /**
+ * Initialize the service
+ */
+ public async initialize(): Promise {
+ this.logger.info('Initializing Mock DailyNotificationService');
+ this.isInitialized = true;
+ this.logger.info('Mock DailyNotificationService initialized successfully');
+ }
+
+ /**
+ * Schedule dual notification (content fetch + user notification)
+ */
+ public async scheduleDualNotification(config: any): Promise {
+ if (!this.isInitialized) {
+ throw new Error('Service not initialized');
+ }
+
+ this.logger.info('Scheduling dual notification', config);
+
+ // Simulate content fetch
+ if (config.contentFetch?.enabled) {
+ await this.simulateContentFetch(config.contentFetch);
+ }
+
+ // Simulate user notification
+ if (config.userNotification?.enabled) {
+ await this.simulateUserNotification(config.userNotification);
+ }
+
+ this.logger.info('Dual notification scheduled successfully');
+ }
+
+ /**
+ * Register callback
+ */
+ public async registerCallback(name: string, callback: Function): Promise {
+ this.logger.info(`Registering callback: ${name}`);
+ // In a real implementation, this would register the callback
+ this.logger.info(`Callback ${name} registered successfully`);
+ }
+
+ /**
+ * Get dual schedule status
+ */
+ public async getDualScheduleStatus(): Promise {
+ return {
+ contentFetch: {
+ enabled: true,
+ lastFetch: new Date().toISOString(),
+ nextFetch: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
+ },
+ userNotification: {
+ enabled: true,
+ lastNotification: new Date().toISOString(),
+ nextNotification: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
+ }
+ };
+ }
+
+ /**
+ * Cancel all notifications
+ */
+ public async cancelAllNotifications(): Promise {
+ this.logger.info('Cancelling all notifications');
+ this.logger.info('All notifications cancelled successfully');
+ }
+
+ /**
+ * Simulate content fetch using Endorser.ch API patterns
+ */
+ private async simulateContentFetch(config: any): Promise {
+ this.logger.info('Simulating content fetch from Endorser.ch API');
+
+ try {
+ // Simulate parallel API requests
+ const testData = this.config.testData;
+
+ // Mock offers to person
+ const offersToPerson = {
+ data: testData.mockOffers,
+ hitLimit: false
+ };
+
+ // Mock offers to projects
+ const offersToProjects = {
+ data: [],
+ hitLimit: false
+ };
+
+ // Mock starred project changes
+ const starredChanges = {
+ data: testData.mockProjects,
+ hitLimit: false
+ };
+
+ this.logger.info('Content fetch simulation completed', {
+ offersToPerson: offersToPerson.data.length,
+ offersToProjects: offersToProjects.data.length,
+ starredChanges: starredChanges.data.length
+ });
+
+ // Call success callback if provided
+ if (config.callbacks?.onSuccess) {
+ await config.callbacks.onSuccess({
+ offersToPerson,
+ offersToProjects,
+ starredChanges
+ });
+ }
+ } catch (error) {
+ this.logger.error('Content fetch simulation failed', error);
+ if (config.callbacks?.onError) {
+ await config.callbacks.onError(error);
+ }
+ }
+ }
+
+ /**
+ * Simulate user notification
+ */
+ private async simulateUserNotification(config: any): Promise {
+ this.logger.info('Simulating user notification', {
+ title: config.title,
+ body: config.body,
+ time: config.schedule
+ });
+ this.logger.info('User notification simulation completed');
+ }
+}
diff --git a/test-apps/test-api/server.js b/test-apps/test-api/server.js
index ac3d6c1..0861339 100644
--- a/test-apps/test-api/server.js
+++ b/test-apps/test-api/server.js
@@ -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')}"`;
-}
-
-/**
- * Store content with ETag
- * @param {string} slotId - Slot identifier
- * @param {Object} content - Content object
- * @param {string} etag - ETag value
- */
-function storeContent(slotId, content, etag) {
- contentStore.set(slotId, content);
- etagStore.set(slotId, etag);
+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
+ };
}
/**
- * Get stored content and ETag
- * @param {string} slotId - Slot identifier
- * @returns {Object} { content, etag } or null
+ * Generate mock notification bundle for TimeSafari
+ * @param {Object} params - Request parameters
+ * @returns {Object} Mock notification bundle
*/
-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);
});