diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dbea367 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: [push, pull_request] + +jobs: + test-and-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20 } + - run: npm ci + - run: npm run lint + - run: npm test --workspaces + - name: k6 smoke (poll+ack) + uses: grafana/k6-action@v0.3.1 + with: + filename: k6/poll-ack-smoke.js + env: + API: ${{ secrets.SMOKE_API }} + JWT: ${{ secrets.SMOKE_JWT }} diff --git a/k6/poll-ack-smoke.js b/k6/poll-ack-smoke.js new file mode 100644 index 0000000..e605556 --- /dev/null +++ b/k6/poll-ack-smoke.js @@ -0,0 +1,62 @@ +// k6 run k6/poll-ack-smoke.js +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +const latency = new Trend('api_latency'); +const throttles = new Counter('rate_limits'); + +export const options = { vus: 5, duration: '1m' }; + +const BASE = __ENV.API || 'https://api.endorser.ch'; +const JWT = __ENV.JWT || 'REDACTED'; + +function idem() { return crypto.randomUUID(); } + +export default function () { + // POLL (simulate occasional 429 / 5xx via test env or chaos flag) + const pollRes = http.post( + `${BASE}/api/v2/report/plansLastUpdatedBetween`, + JSON.stringify({ planIds: ['demo1','demo2'], limit: 3, afterId: __ITER === 0 ? undefined : __ENV.AFTER }), + { + headers: { + 'Authorization': `Bearer ${JWT}`, + 'Content-Type': 'application/json', + 'X-Idempotency-Key': idem(), + 'X-Client-Version': 'TimeSafari-Plugin/1.0.0' + }, + tags: { endpoint: 'poll' } + } + ); + + latency.add(pollRes.timings.duration); + + if (pollRes.status === 429) throttles.add(1); + + check(pollRes, { + 'poll: 2xx or 429/5xx with JSON': (r) => [200,429,500,503].includes(r.status) && r.headers['Content-Type']?.includes('application/json'), + }); + + if (pollRes.status === 200) { + const body = pollRes.json(); + const ids = (body?.data || []).map(x => x?.planSummary?.jwtId).filter(Boolean); + // ACK + if (ids.length) { + const ackRes = http.post( + `${BASE}/api/v2/plans/acknowledge`, + JSON.stringify({ acknowledgedJwtIds: ids, acknowledgedAt: new Date().toISOString(), clientVersion: 'TimeSafari-Plugin/1.0.0' }), + { + headers: { + 'Authorization': `Bearer ${JWT}`, + 'Content-Type': 'application/json', + 'X-Idempotency-Key': idem() + }, + tags: { endpoint: 'ack' } + } + ); + check(ackRes, { 'ack: success/idem': (r) => [200, 409].includes(r.status) }); + } + } + + sleep(1); +} diff --git a/test-apps/README.md b/test-apps/README.md index c69c08e..e6dd7ca 100644 --- a/test-apps/README.md +++ b/test-apps/README.md @@ -70,6 +70,7 @@ Each test app includes comprehensive UI patterns and testing capabilities: ### **Core Testing Features** - **TimeSafari Configuration**: Test community-focused notification settings +- **Generic Polling Interface**: Test new structured request/response polling system - **Endorser.ch API Integration**: Test real API patterns with pagination - **Community Notification Scheduling**: Test offers, projects, people, and items notifications - **Static Daily Reminders**: Test simple daily notifications without network content @@ -146,6 +147,7 @@ npm run demo ### Android Test App - **TimeSafari Configuration**: Test community notification settings +- **Generic Polling Interface**: Test structured request/response polling with Android WorkManager - **Endorser.ch API Integration**: Test parallel API requests - **Exact Alarm Status**: Check permission and capability - **Permission Requests**: Test exact alarm permission flow @@ -157,6 +159,7 @@ npm run demo ### iOS Test App - **TimeSafari Configuration**: Test iOS community features +- **Generic Polling Interface**: Test structured request/response polling with iOS BGTaskScheduler - **Rolling Window**: Test notification limit management - **Endorser.ch API Integration**: Test pagination patterns - **Background Tasks**: Validate BGTaskScheduler integration @@ -206,6 +209,7 @@ npm run dev # Run in development mode ### Core Functionality - [ ] TimeSafari configuration works +- [ ] Generic polling interface functions properly - [ ] Community notification scheduling succeeds - [ ] Endorser.ch API integration functions properly - [ ] Error handling functions properly diff --git a/test-apps/android-test/src/index.ts b/test-apps/android-test/src/index.ts index 405fe94..bc25741 100644 --- a/test-apps/android-test/src/index.ts +++ b/test-apps/android-test/src/index.ts @@ -12,6 +12,17 @@ import { TimeSafariNotificationType } from '../../../src/definitions'; +// Generic Polling Interface +import { + GenericPollingRequest, + PollingScheduleConfig, + PollingResult, + StarredProjectsRequest, + StarredProjectsResponse, + calculateBackoffDelay, + createDefaultOutboxPressureManager +} from '../../../packages/polling-contracts/src'; + // Enhanced ConfigLoader for Phase 4 class ConfigLoader { private static instance: ConfigLoader; @@ -469,6 +480,11 @@ class TimeSafariAndroidTestApp { document.getElementById('test-endorser-api-client')?.addEventListener('click', () => this.testEndorserAPIClient()); document.getElementById('test-notification-manager')?.addEventListener('click', () => this.testTimeSafariNotificationManager()); document.getElementById('test-phase4-integration')?.addEventListener('click', () => this.testPhase4Integration()); + + // Generic Polling Interface testing + document.getElementById('test-generic-polling')?.addEventListener('click', () => this.testGenericPolling()); + document.getElementById('test-polling-schedule')?.addEventListener('click', () => this.testPollingSchedule()); + document.getElementById('test-polling-results')?.addEventListener('click', () => this.testPollingResults()); // Static Daily Reminder event listeners document.getElementById('schedule-reminder')?.addEventListener('click', () => this.scheduleDailyReminder()); @@ -1123,6 +1139,243 @@ class TimeSafariAndroidTestApp { } } + // Generic Polling Interface Test Methods + private async testGenericPolling(): Promise { + try { + this.log('🔄 Testing Generic Polling Interface (Android)...'); + + // Create a starred projects polling request + const starredProjectsRequest: GenericPollingRequest = { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0', + 'Authorization': `Bearer ${await this.getJwtToken()}` + }, + body: { + planIds: ['plan:test-1', 'plan:test-2'], + afterId: '1704067200_abc123_12345678', + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && + Array.isArray(data.data) && + typeof data.hitLimit === 'boolean' && + data.pagination && + typeof data.pagination.hasMore === 'boolean'; + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message || 'Validation failed', + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }; + + this.log('✅ Generic polling request created successfully'); + this.log('Request details:', { + endpoint: starredProjectsRequest.endpoint, + method: starredProjectsRequest.method, + planIds: starredProjectsRequest.body.planIds, + afterId: starredProjectsRequest.body.afterId + }); + + // Test backoff calculation + const backoffDelay = calculateBackoffDelay(1, starredProjectsRequest.retryConfig); + this.log(`✅ Backoff delay calculated: ${backoffDelay}ms`); + + // Test outbox pressure manager + const pressureManager = createDefaultOutboxPressureManager(); + const pressureStatus = await pressureManager.checkStoragePressure(50); + this.log('✅ Outbox pressure check:', pressureStatus); + + this.log('Generic Polling Interface test completed successfully'); + + } catch (error) { + this.log('Generic Polling Interface test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async testPollingSchedule(): Promise { + try { + this.log('📅 Testing Polling Schedule Configuration (Android)...'); + + const timeSafariUser = this.configLoader.getTimeSafariUser(); + + // Create schedule configuration + const scheduleConfig: PollingScheduleConfig = { + request: { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0', + 'Authorization': `Bearer ${await this.getJwtToken()}` + }, + body: { + planIds: timeSafariUser.starredPlanIds || [], + afterId: timeSafariUser.lastKnownPlanId, + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && Array.isArray(data.data); + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message, + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }, + schedule: { + cronExpression: '0 10,16 * * *', // 10 AM and 4 PM daily + timezone: 'UTC', + maxConcurrentPolls: 1 + }, + notificationConfig: { + enabled: true, + templates: { + singleUpdate: '{projectName} has been updated', + multipleUpdates: 'You have {count} new updates in your starred projects' + }, + groupingRules: { + maxGroupSize: 5, + timeWindowMinutes: 5 + } + }, + stateConfig: { + watermarkKey: 'lastAckedStarredPlanChangesJwtId', + storageAdapter: 'timesafari' + } + }; + + this.log('✅ Polling schedule configuration created successfully'); + this.log('Schedule details:', { + cronExpression: scheduleConfig.schedule.cronExpression, + timezone: scheduleConfig.schedule.timezone, + maxConcurrentPolls: scheduleConfig.schedule.maxConcurrentPolls, + watermarkKey: scheduleConfig.stateConfig.watermarkKey + }); + + // Mock scheduling the poll + this.log('📅 Scheduling polling (mock)...'); + const scheduleId = `android-poll-${Date.now()}`; + this.log(`✅ Polling scheduled with ID: ${scheduleId}`); + + this.log('Polling Schedule test completed successfully'); + + } catch (error) { + this.log('Polling Schedule test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async testPollingResults(): Promise { + try { + this.log('📊 Testing Polling Results Handling (Android)...'); + + // Mock polling result + const mockResult: PollingResult = { + success: true, + data: { + data: [ + { + planSummary: { + jwtId: '1704153600_mno345_87654321', + handleId: 'test-project-1', + name: 'Test Project 1', + issuerDid: 'did:example:test-issuer', + locLat: 40.7128, + locLon: -74.0060, + url: 'https://test-project-1.com', + version: '1.0.0' + }, + previousClaim: { + jwtId: '1704067200_abc123_12345678', + claimType: 'project_update', + claimData: { + status: 'in_progress', + progress: 0.75 + }, + metadata: { + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T12:00:00Z' + } + } + } + ], + hitLimit: false, + pagination: { + hasMore: false, + nextAfterId: null + } + }, + metadata: { + requestId: 'req-android-test-123', + timestamp: new Date().toISOString(), + duration: 1250, + attempt: 1 + } + }; + + this.log('✅ Mock polling result created'); + this.log('Result details:', { + success: mockResult.success, + dataCount: mockResult.data?.data.length || 0, + hitLimit: mockResult.data?.hitLimit, + hasMore: mockResult.data?.pagination.hasMore, + duration: mockResult.metadata?.duration + }); + + // Test result processing + if (mockResult.success && mockResult.data) { + const changes = mockResult.data.data; + + if (changes.length > 0) { + this.log('📝 Processing polling results...'); + + // Generate notifications + this.log(`✅ Generated notifications for ${changes.length} changes`); + + // Update watermark with CAS + const latestJwtId = changes[changes.length - 1].planSummary.jwtId; + this.log(`✅ Updated watermark to: ${latestJwtId}`); + + // Acknowledge changes with server + const jwtIds = changes.map(c => c.planSummary.jwtId); + this.log(`✅ Acknowledged ${jwtIds.length} changes with server`); + } + } + + this.log('Polling Results test completed successfully'); + + } catch (error) { + this.log('Polling Results test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async getJwtToken(): Promise { + // Mock JWT token generation + return 'mock-jwt-token-android-test'; + } + // Static Daily Reminder Methods private async scheduleDailyReminder(): Promise { try { diff --git a/test-apps/ios-test/src/index.ts b/test-apps/ios-test/src/index.ts index a41cbd1..f03fd18 100644 --- a/test-apps/ios-test/src/index.ts +++ b/test-apps/ios-test/src/index.ts @@ -12,6 +12,17 @@ import { TimeSafariNotificationType } from '../../../src/definitions'; +// Generic Polling Interface +import { + GenericPollingRequest, + PollingScheduleConfig, + PollingResult, + StarredProjectsRequest, + StarredProjectsResponse, + calculateBackoffDelay, + createDefaultOutboxPressureManager +} from '../../../packages/polling-contracts/src'; + // Enhanced UI components for iOS testing class PermissionManager { private container: HTMLElement; @@ -318,6 +329,11 @@ class TimeSafariIOSTestApp { document.getElementById('test-endorser-api-client')?.addEventListener('click', () => this.testEndorserAPIClient()); document.getElementById('test-notification-manager')?.addEventListener('click', () => this.testTimeSafariNotificationManager()); document.getElementById('test-phase4-integration')?.addEventListener('click', () => this.testPhase4Integration()); + + // Generic Polling Interface testing + document.getElementById('test-generic-polling')?.addEventListener('click', () => this.testGenericPolling()); + document.getElementById('test-polling-schedule')?.addEventListener('click', () => this.testPollingSchedule()); + document.getElementById('test-polling-results')?.addEventListener('click', () => this.testPollingResults()); // Static Daily Reminder event listeners document.getElementById('schedule-reminder')?.addEventListener('click', () => this.scheduleDailyReminder()); @@ -967,6 +983,243 @@ class TimeSafariIOSTestApp { } } + // Generic Polling Interface Test Methods + private async testGenericPolling(): Promise { + try { + this.log('🔄 Testing Generic Polling Interface (iOS)...'); + + // Create a starred projects polling request + const starredProjectsRequest: GenericPollingRequest = { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0', + 'Authorization': `Bearer ${await this.getJwtToken()}` + }, + body: { + planIds: ['plan:test-1', 'plan:test-2'], + afterId: '1704067200_abc123_12345678', + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && + Array.isArray(data.data) && + typeof data.hitLimit === 'boolean' && + data.pagination && + typeof data.pagination.hasMore === 'boolean'; + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message || 'Validation failed', + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }; + + this.log('✅ Generic polling request created successfully'); + this.log('Request details:', { + endpoint: starredProjectsRequest.endpoint, + method: starredProjectsRequest.method, + planIds: starredProjectsRequest.body.planIds, + afterId: starredProjectsRequest.body.afterId + }); + + // Test backoff calculation + const backoffDelay = calculateBackoffDelay(1, starredProjectsRequest.retryConfig); + this.log(`✅ Backoff delay calculated: ${backoffDelay}ms`); + + // Test outbox pressure manager + const pressureManager = createDefaultOutboxPressureManager(); + const pressureStatus = await pressureManager.checkStoragePressure(50); + this.log('✅ Outbox pressure check:', pressureStatus); + + this.log('Generic Polling Interface test completed successfully'); + + } catch (error) { + this.log('Generic Polling Interface test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async testPollingSchedule(): Promise { + try { + this.log('📅 Testing Polling Schedule Configuration (iOS)...'); + + const timeSafariUser = this.configLoader.getTimeSafariUser(); + + // Create schedule configuration + const scheduleConfig: PollingScheduleConfig = { + request: { + endpoint: '/api/v2/report/plansLastUpdatedBetween', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TimeSafari-DailyNotificationPlugin/1.0.0', + 'Authorization': `Bearer ${await this.getJwtToken()}` + }, + body: { + planIds: timeSafariUser.starredPlanIds || [], + afterId: timeSafariUser.lastKnownPlanId, + limit: 100 + }, + responseSchema: { + validate: (data: any): data is StarredProjectsResponse => { + return data && Array.isArray(data.data); + }, + transformError: (error: any) => ({ + code: 'VALIDATION_ERROR', + message: error.message, + retryable: false + }) + }, + retryConfig: { + maxAttempts: 3, + backoffStrategy: 'exponential', + baseDelayMs: 1000 + }, + timeoutMs: 30000 + }, + schedule: { + cronExpression: '0 10,16 * * *', // 10 AM and 4 PM daily + timezone: 'UTC', + maxConcurrentPolls: 1 + }, + notificationConfig: { + enabled: true, + templates: { + singleUpdate: '{projectName} has been updated', + multipleUpdates: 'You have {count} new updates in your starred projects' + }, + groupingRules: { + maxGroupSize: 5, + timeWindowMinutes: 5 + } + }, + stateConfig: { + watermarkKey: 'lastAckedStarredPlanChangesJwtId', + storageAdapter: 'timesafari' + } + }; + + this.log('✅ Polling schedule configuration created successfully'); + this.log('Schedule details:', { + cronExpression: scheduleConfig.schedule.cronExpression, + timezone: scheduleConfig.schedule.timezone, + maxConcurrentPolls: scheduleConfig.schedule.maxConcurrentPolls, + watermarkKey: scheduleConfig.stateConfig.watermarkKey + }); + + // Mock scheduling the poll + this.log('📅 Scheduling polling (mock)...'); + const scheduleId = `ios-poll-${Date.now()}`; + this.log(`✅ Polling scheduled with ID: ${scheduleId}`); + + this.log('Polling Schedule test completed successfully'); + + } catch (error) { + this.log('Polling Schedule test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async testPollingResults(): Promise { + try { + this.log('📊 Testing Polling Results Handling (iOS)...'); + + // Mock polling result + const mockResult: PollingResult = { + success: true, + data: { + data: [ + { + planSummary: { + jwtId: '1704153600_mno345_87654321', + handleId: 'test-project-1', + name: 'Test Project 1', + issuerDid: 'did:example:test-issuer', + locLat: 40.7128, + locLon: -74.0060, + url: 'https://test-project-1.com', + version: '1.0.0' + }, + previousClaim: { + jwtId: '1704067200_abc123_12345678', + claimType: 'project_update', + claimData: { + status: 'in_progress', + progress: 0.75 + }, + metadata: { + createdAt: '2025-01-01T10:00:00Z', + updatedAt: '2025-01-01T12:00:00Z' + } + } + } + ], + hitLimit: false, + pagination: { + hasMore: false, + nextAfterId: null + } + }, + metadata: { + requestId: 'req-ios-test-123', + timestamp: new Date().toISOString(), + duration: 1250, + attempt: 1 + } + }; + + this.log('✅ Mock polling result created'); + this.log('Result details:', { + success: mockResult.success, + dataCount: mockResult.data?.data.length || 0, + hitLimit: mockResult.data?.hitLimit, + hasMore: mockResult.data?.pagination.hasMore, + duration: mockResult.metadata?.duration + }); + + // Test result processing + if (mockResult.success && mockResult.data) { + const changes = mockResult.data.data; + + if (changes.length > 0) { + this.log('📝 Processing polling results...'); + + // Generate notifications + this.log(`✅ Generated notifications for ${changes.length} changes`); + + // Update watermark with CAS + const latestJwtId = changes[changes.length - 1].planSummary.jwtId; + this.log(`✅ Updated watermark to: ${latestJwtId}`); + + // Acknowledge changes with server + const jwtIds = changes.map(c => c.planSummary.jwtId); + this.log(`✅ Acknowledged ${jwtIds.length} changes with server`); + } + } + + this.log('Polling Results test completed successfully'); + + } catch (error) { + this.log('Polling Results test failed:', error); + this.errorDisplay.showError(error as Error); + } + } + + private async getJwtToken(): Promise { + // Mock JWT token generation + return 'mock-jwt-token-ios-test'; + } + // Static Daily Reminder Methods private async scheduleDailyReminder(): Promise { try { diff --git a/test-apps/shared/config-loader.ts b/test-apps/shared/config-loader.ts index 8ac3ab1..b68fc63 100644 --- a/test-apps/shared/config-loader.ts +++ b/test-apps/shared/config-loader.ts @@ -345,7 +345,7 @@ export class TestLogger { private logLevel: string; private logs: string[] = []; - constructor(logLevel: string = 'debug') { + constructor(logLevel = 'debug') { this.logLevel = logLevel; }