feat(polling-contracts): add generic polling interface with TypeScript types and Zod schemas

- Add @timesafari/polling-contracts package with comprehensive type definitions
- Implement GenericPollingRequest, PollingResult, and PollingScheduleConfig interfaces
- Add Zod schemas for StarredProjectsRequest/Response and DeepLinkParams validation
- Include calculateBackoffDelay utility with unified retry policy (exponential, linear, fixed)
- Add OutboxPressureManager for storage pressure controls and back-pressure signals
- Implement TelemetryManager with cardinality budgets and PII redaction
- Add ClockSyncManager for JWT timestamp validation and skew tolerance
- Include comprehensive unit tests with Jest snapshots and race condition testing
- Add JWT_ID_PATTERN regex for canonical JWT ID format validation
- Support idempotency with X-Idempotency-Key enforcement
- Implement watermark CAS (Compare-and-Swap) for race condition prevention

This establishes the foundation for the new generic polling system where host apps
define request/response schemas and the plugin provides robust polling logic.
This commit is contained in:
Matthew Raymer
2025-10-07 04:44:01 +00:00
parent 5b7bd95bdd
commit a5831b3c9f
18 changed files with 2599 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Schema Validation AcknowledgmentRequestSchema should validate acknowledgment request: acknowledgment-request 1`] = `
{
"acknowledgedAt": "2025-01-01T12:00:00Z",
"acknowledgedJwtIds": [
"1704067200_abc123_12345678",
"1704153600_mno345_87654321",
],
"clientVersion": "TimeSafari-DailyNotificationPlugin/1.0.0",
}
`;
exports[`Schema Validation AcknowledgmentResponseSchema should validate acknowledgment response: acknowledgment-response 1`] = `
{
"acknowledged": 2,
"acknowledgmentId": "ack_xyz789",
"alreadyAcknowledged": 0,
"failed": 0,
"timestamp": "2025-01-01T12:00:00Z",
}
`;
exports[`Schema Validation ClockSyncResponseSchema should validate clock sync response: clock-sync-response 1`] = `
{
"clientTime": 1704067195000,
"ntpServers": [
"pool.ntp.org",
"time.google.com",
],
"offset": 5000,
"serverTime": 1704067200000,
}
`;
exports[`Schema Validation DeepLinkParamsSchema should reject invalid params: invalid-params-error 1`] = `
[ZodError: [
{
"validation": "regex",
"code": "invalid_string",
"message": "Invalid",
"path": [
"jwtIds",
0
]
},
{
"validation": "regex",
"code": "invalid_string",
"message": "Invalid",
"path": [
"projectId"
]
}
]]
`;
exports[`Schema Validation DeepLinkParamsSchema should validate multiple JWT IDs params: multiple-jwt-ids-params 1`] = `
{
"jwtIds": [
"1704067200_abc123_12345678",
"1704153600_mno345_87654321",
"1704240000_new123_abcdef01",
],
}
`;
exports[`Schema Validation DeepLinkParamsSchema should validate project ID params: project-id-params 1`] = `
{
"projectId": "test_project_123",
}
`;
exports[`Schema Validation DeepLinkParamsSchema should validate shortlink params: shortlink-params 1`] = `
{
"shortlink": "abc123def456789",
}
`;
exports[`Schema Validation ErrorResponseSchema should validate generic error: generic-error 1`] = `
{
"details": {
"component": "database",
"operation": "query_starred_projects",
},
"error": "internal_server_error",
"message": "Database connection timeout",
"requestId": "req_mno345",
}
`;
exports[`Schema Validation ErrorResponseSchema should validate rate limit error: rate-limit-error 1`] = `
{
"code": "RATE_LIMIT_EXCEEDED",
"details": {
"limit": 100,
"resetAt": "2025-01-01T12:01:00Z",
"window": "1m",
},
"error": "Rate limit exceeded",
"message": "Rate limit exceeded for DID",
"requestId": "req_jkl012",
"retryAfter": 60,
}
`;
exports[`Schema Validation StarredProjectsResponseSchema should validate canonical response envelope: canonical-response-envelope 1`] = `
{
"data": [
{
"planSummary": {
"agentDid": "did:key:test_agent_1",
"description": "First test project",
"endTime": "2025-01-31T23:59:59Z",
"handleId": "test_project_1",
"issuerDid": "did:key:test_issuer_1",
"jwtId": "1704067200_abc123_def45678",
"locLat": 40.7128,
"locLon": -74.006,
"name": "Test Project 1",
"startTime": "2025-01-01T00:00:00Z",
"url": "https://project-url.com",
"version": "1.0.0",
},
"previousClaim": {
"claimData": {
"progress": 0.75,
"status": "in_progress",
},
"claimType": "project_update",
"jwtId": "1703980800_xyz789_12345678",
"metadata": {
"createdAt": "2025-01-01T10:00:00Z",
"updatedAt": "2025-01-01T12:00:00Z",
},
},
},
],
"hitLimit": false,
"pagination": {
"hasMore": false,
"nextAfterId": null,
},
}
`;
exports[`Schema Validation StarredProjectsResponseSchema should validate empty response: empty-response 1`] = `
{
"data": [],
"hitLimit": false,
"pagination": {
"hasMore": false,
"nextAfterId": null,
},
}
`;
exports[`Schema Validation StarredProjectsResponseSchema should validate paginated response: paginated-response 1`] = `
{
"data": [],
"hitLimit": true,
"pagination": {
"hasMore": true,
"nextAfterId": "1704153600_mno345_87654321",
},
}
`;

View File

@@ -0,0 +1,238 @@
/**
* Unit tests for backoff policy
*/
import {
calculateBackoffDelay,
createDefaultBackoffPolicy,
createRateLimitBackoffPolicy,
createNetworkErrorBackoffPolicy,
createServerErrorBackoffPolicy
} from '../backoff';
import { BackoffPolicy } from '../types';
describe('Backoff Policy', () => {
describe('calculateBackoffDelay', () => {
const defaultPolicy: BackoffPolicy = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
strategy: 'exponential',
jitterEnabled: false, // Disable for predictable tests
jitterFactor: 0.25,
respectRetryAfter: true,
retryAfterMaxMs: 300000
};
it('should calculate exponential backoff correctly', () => {
const policy = { ...defaultPolicy, strategy: 'exponential' as const };
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
expect(calculateBackoffDelay(2, policy)).toBe(2000); // 2s
expect(calculateBackoffDelay(3, policy)).toBe(4000); // 4s
expect(calculateBackoffDelay(4, policy)).toBe(8000); // 8s
});
it('should calculate linear backoff correctly', () => {
const policy = { ...defaultPolicy, strategy: 'linear' as const };
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
expect(calculateBackoffDelay(2, policy)).toBe(2000); // 2s
expect(calculateBackoffDelay(3, policy)).toBe(3000); // 3s
expect(calculateBackoffDelay(4, policy)).toBe(4000); // 4s
});
it('should calculate fixed backoff correctly', () => {
const policy = { ...defaultPolicy, strategy: 'fixed' as const };
expect(calculateBackoffDelay(1, policy)).toBe(1000); // 1s
expect(calculateBackoffDelay(2, policy)).toBe(1000); // 1s
expect(calculateBackoffDelay(3, policy)).toBe(1000); // 1s
expect(calculateBackoffDelay(4, policy)).toBe(1000); // 1s
});
it('should respect Retry-After header', () => {
const policy = { ...defaultPolicy, respectRetryAfter: true };
const retryAfterMs = 5000; // 5 seconds
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(5000);
expect(calculateBackoffDelay(2, policy, retryAfterMs)).toBe(5000);
expect(calculateBackoffDelay(3, policy, retryAfterMs)).toBe(5000);
});
it('should cap Retry-After at maxDelayMs', () => {
const policy = { ...defaultPolicy, maxDelayMs: 10000 };
const retryAfterMs = 60000; // 60 seconds (exceeds max)
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(10000);
});
it('should cap Retry-After at retryAfterMaxMs', () => {
const policy = { ...defaultPolicy, retryAfterMaxMs: 15000 };
const retryAfterMs = 30000; // 30 seconds (exceeds retryAfterMaxMs)
expect(calculateBackoffDelay(1, policy, retryAfterMs)).toBe(15000);
});
it('should apply jitter when enabled', () => {
const policy = { ...defaultPolicy, jitterEnabled: true, jitterFactor: 0.5 };
// Run multiple times to test jitter range
const delays = Array.from({ length: 100 }, () =>
calculateBackoffDelay(1, policy)
);
// All delays should be within jitter range (500ms to 1500ms)
delays.forEach(delay => {
expect(delay).toBeGreaterThanOrEqual(500);
expect(delay).toBeLessThanOrEqual(1500);
});
// Should have some variation due to jitter
const uniqueDelays = new Set(delays);
expect(uniqueDelays.size).toBeGreaterThan(1);
});
it('should cap delay at maxDelayMs', () => {
const policy = { ...defaultPolicy, maxDelayMs: 5000 };
expect(calculateBackoffDelay(10, policy)).toBe(5000); // Capped at 5s
});
it('should handle zero jitter factor', () => {
const policy = { ...defaultPolicy, jitterEnabled: true, jitterFactor: 0 };
expect(calculateBackoffDelay(1, policy)).toBe(1000); // No jitter applied
});
});
describe('Policy Creators', () => {
it('should create default backoff policy', () => {
const policy = createDefaultBackoffPolicy();
expect(policy.maxAttempts).toBe(3);
expect(policy.baseDelayMs).toBe(1000);
expect(policy.maxDelayMs).toBe(30000);
expect(policy.strategy).toBe('exponential');
expect(policy.jitterEnabled).toBe(true);
expect(policy.jitterFactor).toBe(0.25);
expect(policy.respectRetryAfter).toBe(true);
});
it('should create rate limit backoff policy', () => {
const retryAfterMs = 60000; // 1 minute
const policy = createRateLimitBackoffPolicy(retryAfterMs);
expect(policy.maxAttempts).toBe(5);
expect(policy.baseDelayMs).toBe(60000);
expect(policy.strategy).toBe('fixed');
expect(policy.jitterEnabled).toBe(true);
expect(policy.jitterFactor).toBe(0.1);
expect(policy.respectRetryAfter).toBe(true);
});
it('should create network error backoff policy', () => {
const policy = createNetworkErrorBackoffPolicy();
expect(policy.maxAttempts).toBe(3);
expect(policy.baseDelayMs).toBe(1000);
expect(policy.strategy).toBe('exponential');
expect(policy.jitterEnabled).toBe(true);
expect(policy.respectRetryAfter).toBe(false);
});
it('should create server error backoff policy', () => {
const policy = createServerErrorBackoffPolicy();
expect(policy.maxAttempts).toBe(3);
expect(policy.baseDelayMs).toBe(2000);
expect(policy.strategy).toBe('exponential');
expect(policy.jitterEnabled).toBe(true);
expect(policy.jitterFactor).toBe(0.5);
expect(policy.respectRetryAfter).toBe(false);
});
});
describe('429 + Retry-After Branch', () => {
it('should handle 429 with Retry-After header', () => {
const policy = createRateLimitBackoffPolicy(30000); // 30 seconds
policy.jitterEnabled = false; // Disable jitter for predictable tests
// First attempt should use Retry-After
expect(calculateBackoffDelay(1, policy, 30000)).toBe(30000);
// Subsequent attempts should also use Retry-After
expect(calculateBackoffDelay(2, policy, 30000)).toBe(30000);
expect(calculateBackoffDelay(3, policy, 30000)).toBe(30000);
});
it('should handle 429 without Retry-After header', () => {
const policy = createRateLimitBackoffPolicy(30000);
policy.jitterEnabled = false; // Disable jitter for predictable tests
// Should fall back to fixed strategy
expect(calculateBackoffDelay(1, policy)).toBe(30000);
expect(calculateBackoffDelay(2, policy)).toBe(30000);
});
it('should cap 429 Retry-After at reasonable limits', () => {
const policy = createRateLimitBackoffPolicy(300000); // 5 minutes
policy.jitterEnabled = false; // Disable jitter for predictable tests
// Should be capped at retryAfterMaxMs (600000ms = 10 minutes)
expect(calculateBackoffDelay(1, policy, 300000)).toBe(300000);
expect(calculateBackoffDelay(1, policy, 900000)).toBe(600000); // Capped
});
});
describe('Jitter Bounds', () => {
it('should respect jitter bounds for exponential backoff', () => {
const policy: BackoffPolicy = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
strategy: 'exponential',
jitterEnabled: true,
jitterFactor: 0.25,
respectRetryAfter: false
};
// Test multiple attempts to ensure jitter is within bounds
for (let attempt = 1; attempt <= 5; attempt++) {
const baseDelay = 1000 * Math.pow(2, attempt - 1);
const minDelay = baseDelay * (1 - 0.25);
const maxDelay = baseDelay * (1 + 0.25);
const delays = Array.from({ length: 50 }, () =>
calculateBackoffDelay(attempt, policy)
);
delays.forEach(delay => {
expect(delay).toBeGreaterThanOrEqual(minDelay);
expect(delay).toBeLessThanOrEqual(maxDelay);
});
}
});
it('should not apply negative jitter', () => {
const policy: BackoffPolicy = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
strategy: 'fixed',
jitterEnabled: true,
jitterFactor: 1.0, // 100% jitter
respectRetryAfter: false
};
const delays = Array.from({ length: 100 }, () =>
calculateBackoffDelay(1, policy)
);
// All delays should be non-negative
delays.forEach(delay => {
expect(delay).toBeGreaterThanOrEqual(0);
});
});
});
});

View File

@@ -0,0 +1,268 @@
/**
* Unit tests for clock synchronization and skew handling
*/
import { ClockSyncManager, createDefaultClockSyncManager } from '../clock-sync';
// Mock fetch for testing
global.fetch = jest.fn();
describe('Clock Sync Manager', () => {
let clockSync: ClockSyncManager;
beforeEach(() => {
clockSync = createDefaultClockSyncManager();
jest.clearAllMocks();
});
describe('Server Time Sync', () => {
it('should sync with server time successfully', async () => {
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
const mockClientTime = 1704067195000; // 2024-01-01 00:00:00 - 5s
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue(mockServerTime.toString())
}
});
// Mock Date.now to return consistent client time
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
await clockSync.syncWithServer('https://api.example.com');
expect(clockSync.getServerOffset()).toBe(5000); // 5 second offset
expect(clockSync.getLastSyncTime()).toBe(mockClientTime);
});
it('should handle server sync failure gracefully', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const initialOffset = clockSync.getServerOffset();
await clockSync.syncWithServer('https://api.example.com');
// Offset should remain unchanged on failure
expect(clockSync.getServerOffset()).toBe(initialOffset);
});
it('should handle invalid server time response', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue('0')
}
});
const initialOffset = clockSync.getServerOffset();
await clockSync.syncWithServer('https://api.example.com');
// Offset should remain unchanged on invalid response
expect(clockSync.getServerOffset()).toBe(initialOffset);
});
it('should handle HTTP error responses', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500
});
const initialOffset = clockSync.getServerOffset();
await clockSync.syncWithServer('https://api.example.com');
// Offset should remain unchanged on HTTP error
expect(clockSync.getServerOffset()).toBe(initialOffset);
});
});
describe('Clock Skew Detection', () => {
it('should detect excessive clock skew', async () => {
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
const mockClientTime = 1704067200000 - (35 * 1000); // 35 seconds behind
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue(mockServerTime.toString())
}
});
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
await clockSync.syncWithServer('https://api.example.com');
expect(clockSync.isClockSkewExcessive()).toBe(true);
});
it('should not detect excessive clock skew within tolerance', async () => {
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
const mockClientTime = 1704067200000 - (25 * 1000); // 25 seconds behind
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue(mockServerTime.toString())
}
});
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
await clockSync.syncWithServer('https://api.example.com');
expect(clockSync.isClockSkewExcessive()).toBe(false);
});
});
describe('JWT Timestamp Validation', () => {
beforeEach(() => {
// Set up clock sync with known offset
const mockServerTime = 1704067200000; // 2024-01-01 00:00:00
const mockClientTime = 1704067195000; // 5 seconds behind
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue(mockServerTime.toString())
}
});
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
});
it('should validate JWT within acceptable time window', async () => {
await clockSync.syncWithServer('https://api.example.com');
const jwt = {
iat: Math.floor((1704067200000 - 1000) / 1000), // 1 second ago
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
};
expect(clockSync.validateJwtTimestamp(jwt)).toBe(true);
});
it('should reject JWT with excessive clock skew', async () => {
await clockSync.syncWithServer('https://api.example.com');
const jwt = {
iat: Math.floor((1704067200000 - 45000) / 1000), // 45 seconds ago (exceeds 30s tolerance)
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
};
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
});
it('should reject expired JWT', async () => {
await clockSync.syncWithServer('https://api.example.com');
const jwt = {
iat: Math.floor((1704067200000 - 3600000) / 1000), // 1 hour ago
exp: Math.floor((1704067200000 - 35000) / 1000) // 35 seconds ago (expired, exceeds tolerance)
};
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
});
it('should reject JWT that is too old', async () => {
await clockSync.syncWithServer('https://api.example.com');
const jwt = {
iat: Math.floor((1704067200000 - 7200000) / 1000), // 2 hours ago (exceeds max age)
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
};
expect(clockSync.validateJwtTimestamp(jwt)).toBe(false);
});
it('should handle JWT with clock skew tolerance', async () => {
await clockSync.syncWithServer('https://api.example.com');
const jwt = {
iat: Math.floor((1704067200000 - 25000) / 1000), // 25 seconds ago (within tolerance)
exp: Math.floor((1704067200000 + 3600000) / 1000) // 1 hour from now
};
expect(clockSync.validateJwtTimestamp(jwt)).toBe(true);
});
});
describe('Periodic Sync', () => {
it('should start and stop periodic sync', () => {
const syncSpy = jest.spyOn(clockSync, 'syncWithServer');
clockSync.startPeriodicSync('https://api.example.com');
expect(syncSpy).not.toHaveBeenCalled(); // Not called immediately
clockSync.stopPeriodicSync();
// Should not throw error
});
it('should detect when sync is needed', () => {
// Initially needs sync (no previous sync)
expect(clockSync.needsSync()).toBe(true);
// Mock a recent sync
jest.spyOn(Date, 'now').mockReturnValue(1000);
clockSync.syncWithServer('https://api.example.com');
// Should not need sync immediately after
expect(clockSync.needsSync()).toBe(false);
});
});
describe('Time Calculations', () => {
it('should calculate server time correctly', async () => {
const mockServerTime = 1704067200000;
const mockClientTime = 1704067195000;
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
headers: {
get: jest.fn().mockReturnValue(mockServerTime.toString())
}
});
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime);
await clockSync.syncWithServer('https://api.example.com');
// Mock current time to be 1 second later
jest.spyOn(Date, 'now').mockReturnValue(mockClientTime + 1000);
const serverTime = clockSync.getServerTime();
expect(serverTime).toBe(mockServerTime + 1000);
});
it('should return client time correctly', () => {
const mockTime = 1704067200000;
jest.spyOn(Date, 'now').mockReturnValue(mockTime);
expect(clockSync.getClientTime()).toBe(mockTime);
});
});
describe('Configuration', () => {
it('should use default configuration', () => {
const config = clockSync.getConfig();
expect(config.maxClockSkewSeconds).toBe(30);
expect(config.skewCheckIntervalMs).toBe(300000);
expect(config.jwtClockSkewTolerance).toBe(30);
expect(config.jwtMaxAge).toBe(3600000);
});
it('should use custom configuration', () => {
const customClockSync = new ClockSyncManager({
maxClockSkewSeconds: 60,
skewCheckIntervalMs: 600000
});
const config = customClockSync.getConfig();
expect(config.maxClockSkewSeconds).toBe(60);
expect(config.skewCheckIntervalMs).toBe(600000);
});
});
});

View File

@@ -0,0 +1,236 @@
/**
* Jest tests for schema validation with snapshots
*/
import {
StarredProjectsResponseSchema,
DeepLinkParamsSchema,
ErrorResponseSchema,
RateLimitResponseSchema,
AcknowledgmentRequestSchema,
AcknowledgmentResponseSchema,
ClockSyncResponseSchema
} from '../schemas';
describe('Schema Validation', () => {
describe('StarredProjectsResponseSchema', () => {
it('should validate canonical response envelope', () => {
const canonicalResponse = {
data: [
{
planSummary: {
jwtId: '1704067200_abc123_def45678',
handleId: 'test_project_1',
name: 'Test Project 1',
description: 'First test project',
issuerDid: 'did:key:test_issuer_1',
agentDid: 'did:key:test_agent_1',
startTime: '2025-01-01T00:00:00Z',
endTime: '2025-01-31T23:59:59Z',
locLat: 40.7128,
locLon: -74.0060,
url: 'https://project-url.com',
version: '1.0.0'
},
previousClaim: {
jwtId: '1703980800_xyz789_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
}
};
const result = StarredProjectsResponseSchema.safeParse(canonicalResponse);
if (!result.success) {
console.log('Schema validation errors:', result.error.errors);
}
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('canonical-response-envelope');
});
it('should validate empty response', () => {
const emptyResponse = {
data: [],
hitLimit: false,
pagination: {
hasMore: false,
nextAfterId: null
}
};
const result = StarredProjectsResponseSchema.safeParse(emptyResponse);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('empty-response');
});
it('should validate paginated response', () => {
const paginatedResponse = {
data: [], // Would contain 100 items in real scenario
hitLimit: true,
pagination: {
hasMore: true,
nextAfterId: '1704153600_mno345_87654321'
}
};
const result = StarredProjectsResponseSchema.safeParse(paginatedResponse);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('paginated-response');
});
});
describe('DeepLinkParamsSchema', () => {
it('should validate single JWT ID params', () => {
const params = {
jwtId: '1704067200_abc123_def45678'
};
const result = DeepLinkParamsSchema.safeParse(params);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('single-jwt-id-params');
});
it('should validate multiple JWT IDs params', () => {
const params = {
jwtIds: [
'1704067200_abc123_12345678',
'1704153600_mno345_87654321',
'1704240000_new123_abcdef01'
]
};
const result = DeepLinkParamsSchema.safeParse(params);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('multiple-jwt-ids-params');
});
it('should validate project ID params', () => {
const params = {
projectId: 'test_project_123'
};
const result = DeepLinkParamsSchema.safeParse(params);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('project-id-params');
});
it('should validate shortlink params', () => {
const params = {
shortlink: 'abc123def456789'
};
const result = DeepLinkParamsSchema.safeParse(params);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('shortlink-params');
});
it('should reject invalid params', () => {
const invalidParams = {
jwtIds: ['invalid_jwt_id'],
projectId: 'invalid@project#id'
};
const result = DeepLinkParamsSchema.safeParse(invalidParams);
expect(result.success).toBe(false);
expect(result.success ? null : result.error).toMatchSnapshot('invalid-params-error');
});
});
describe('ErrorResponseSchema', () => {
it('should validate rate limit error', () => {
const rateLimitError = {
error: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
message: 'Rate limit exceeded for DID',
details: {
limit: 100,
window: '1m',
resetAt: '2025-01-01T12:01:00Z'
},
retryAfter: 60,
requestId: 'req_jkl012'
};
const result = RateLimitResponseSchema.safeParse(rateLimitError);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('rate-limit-error');
});
it('should validate generic error', () => {
const genericError = {
error: 'internal_server_error',
message: 'Database connection timeout',
details: {
component: 'database',
operation: 'query_starred_projects'
},
requestId: 'req_mno345'
};
const result = ErrorResponseSchema.safeParse(genericError);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('generic-error');
});
});
describe('AcknowledgmentRequestSchema', () => {
it('should validate acknowledgment request', () => {
const ackRequest = {
acknowledgedJwtIds: [
'1704067200_abc123_12345678',
'1704153600_mno345_87654321'
],
acknowledgedAt: '2025-01-01T12:00:00Z',
clientVersion: 'TimeSafari-DailyNotificationPlugin/1.0.0'
};
const result = AcknowledgmentRequestSchema.safeParse(ackRequest);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('acknowledgment-request');
});
});
describe('AcknowledgmentResponseSchema', () => {
it('should validate acknowledgment response', () => {
const ackResponse = {
acknowledged: 2,
failed: 0,
alreadyAcknowledged: 0,
acknowledgmentId: 'ack_xyz789',
timestamp: '2025-01-01T12:00:00Z'
};
const result = AcknowledgmentResponseSchema.safeParse(ackResponse);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('acknowledgment-response');
});
});
describe('ClockSyncResponseSchema', () => {
it('should validate clock sync response', () => {
const clockSyncResponse = {
serverTime: 1704067200000,
clientTime: 1704067195000,
offset: 5000,
ntpServers: ['pool.ntp.org', 'time.google.com']
};
const result = ClockSyncResponseSchema.safeParse(clockSyncResponse);
expect(result.success).toBe(true);
expect(result.success ? result.data : null).toMatchSnapshot('clock-sync-response');
});
});
});

View File

@@ -0,0 +1,22 @@
/**
* Jest setup file for polling contracts tests
*/
// Mock console methods to reduce noise in tests
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
const originalConsoleError = console.error;
beforeAll(() => {
// Allow console.log for debugging, but suppress other console methods
// console.log = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
});
afterAll(() => {
// Restore console methods
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
});

View File

@@ -0,0 +1,265 @@
/**
* Watermark CAS race condition tests
*/
import { compareJwtIds } from '../validation';
describe('Watermark CAS Race Conditions', () => {
const testJwtIds = [
'1704067200_abc123_12345678', // 2024-01-01 00:00:00
'1704153600_mno345_87654321', // 2024-01-02 00:00:00
'1704240000_new123_abcdef01', // 2024-01-03 00:00:00
'1704326400_xyz789_23456789', // 2024-01-04 00:00:00
'1704412800_stu901_34567890' // 2024-01-05 00:00:00
];
describe('Concurrent Bootstrap Race', () => {
it('should handle two clients bootstrapping concurrently', async () => {
// Simulate two clients fetching the same data concurrently
const client1Bootstrap = testJwtIds[2]; // 2024-01-03
const client2Bootstrap = testJwtIds[3]; // 2024-01-04 (newer)
// Client 1 attempts to set watermark
const client1Result = await simulateWatermarkUpdate(null, client1Bootstrap);
expect(client1Result.success).toBe(true);
expect(client1Result.watermark).toBe(client1Bootstrap);
// Client 2 attempts to set watermark (should succeed due to CAS)
const client2Result = await simulateWatermarkUpdate(null, client2Bootstrap);
expect(client2Result.success).toBe(true);
expect(client2Result.watermark).toBe(client2Bootstrap);
// Final watermark should be the maximum JWT ID
const finalWatermark = await getCurrentWatermark();
expect(finalWatermark).toBe(client2Bootstrap);
if (finalWatermark && client1Bootstrap) {
expect(compareJwtIds(finalWatermark, client1Bootstrap)).toBeGreaterThan(0);
}
});
it('should reject older watermark updates', async () => {
// Set initial watermark
await simulateWatermarkUpdate(null, testJwtIds[3]); // 2024-01-04
// Attempt to set older watermark (should fail)
const result = await simulateWatermarkUpdate(testJwtIds[3], testJwtIds[1]); // 2024-01-02
expect(result.success).toBe(false);
expect(result.watermark).toBe(testJwtIds[3]); // Unchanged
});
it('should handle null watermark bootstrap', async () => {
// First client sets watermark from null
const result1 = await simulateWatermarkUpdate(null, testJwtIds[2]);
expect(result1.success).toBe(true);
// Second client attempts to set watermark from null (should fail)
const result2 = await simulateWatermarkUpdate(null, testJwtIds[1]);
expect(result2.success).toBe(false);
expect(result2.watermark).toBe(testJwtIds[2]); // First client's watermark
});
});
describe('Overlapping Polls Race', () => {
it('should handle overlapping polls with different results', async () => {
// Set initial watermark
await simulateWatermarkUpdate(null, testJwtIds[1]); // 2024-01-02
// Client 1 polls and finds changes up to 2024-01-03
const client1Changes = [testJwtIds[2]]; // 2024-01-03
const client1Result = await simulatePollAndUpdate(testJwtIds[1], client1Changes);
expect(client1Result.success).toBe(true);
expect(client1Result.newWatermark).toBe(testJwtIds[2]);
// Client 2 polls concurrently and finds changes up to 2024-01-04
const client2Changes = [testJwtIds[2], testJwtIds[3]]; // 2024-01-03, 2024-01-04
const client2Result = await simulatePollAndUpdate(testJwtIds[1], client2Changes);
expect(client2Result.success).toBe(true);
expect(client2Result.newWatermark).toBe(testJwtIds[3]);
// Final watermark should be the maximum
const finalWatermark = await getCurrentWatermark();
expect(finalWatermark).toBe(testJwtIds[3]);
});
it('should prevent duplicate notifications from overlapping polls', async () => {
// Set initial watermark
await simulateWatermarkUpdate(null, testJwtIds[1]);
// Both clients find the same change
const sharedChange = testJwtIds[2];
// Client 1 processes change
const client1Result = await simulatePollAndUpdate(testJwtIds[1], [sharedChange]);
expect(client1Result.success).toBe(true);
expect(client1Result.notificationsGenerated).toBe(1);
// Client 2 attempts to process same change (should be no-op)
const client2Result = await simulatePollAndUpdate(testJwtIds[1], [sharedChange]);
expect(client2Result.success).toBe(false); // No new changes
expect(client2Result.notificationsGenerated).toBe(0);
});
});
describe('Watermark Monotonicity', () => {
it('should maintain monotonic watermark advancement', async () => {
let currentWatermark = null;
// Process changes in order
for (let i = 0; i < testJwtIds.length; i++) {
const newWatermark = testJwtIds[i];
const result = await simulateWatermarkUpdate(currentWatermark, newWatermark);
expect(result.success).toBe(true);
expect(result.watermark).toBe(newWatermark);
// Verify monotonicity
if (currentWatermark) {
expect(compareJwtIds(newWatermark, currentWatermark)).toBeGreaterThan(0);
}
currentWatermark = newWatermark;
}
});
it('should reject non-monotonic watermark updates', async () => {
// Set watermark to middle value
await simulateWatermarkUpdate(null, testJwtIds[2]);
// Attempt to set older watermark
const result = await simulateWatermarkUpdate(testJwtIds[2], testJwtIds[0]);
expect(result.success).toBe(false);
// Attempt to set same watermark
const result2 = await simulateWatermarkUpdate(testJwtIds[2], testJwtIds[2]);
expect(result2.success).toBe(false);
});
});
describe('Platform-Specific CAS Implementation', () => {
it('should verify SQL CAS returns row update count', async () => {
// Simulate SQL UPDATE ... WHERE condition
const sqlResult = await simulateSqlWatermarkUpdate(null, testJwtIds[0]);
expect(sqlResult.rowsAffected).toBe(1);
// Attempt to update with same condition (should affect 0 rows)
const sqlResult2 = await simulateSqlWatermarkUpdate(null, testJwtIds[0]);
expect(sqlResult2.rowsAffected).toBe(0);
});
it('should verify Room CAS returns update count', async () => {
// Simulate Room @Query with return type Int
const roomResult = await simulateRoomWatermarkUpdate(null, testJwtIds[0]);
expect(roomResult).toBe(1);
// Attempt to update with same condition
const roomResult2 = await simulateRoomWatermarkUpdate(null, testJwtIds[0]);
expect(roomResult2).toBe(0);
});
it('should verify Core Data CAS returns success boolean', async () => {
// Simulate Core Data compare-and-swap
const coreDataResult = await simulateCoreDataWatermarkUpdate(null, testJwtIds[0]);
expect(coreDataResult).toBe(true);
// Attempt to update with same condition
const coreDataResult2 = await simulateCoreDataWatermarkUpdate(null, testJwtIds[0]);
expect(coreDataResult2).toBe(false);
});
it('should verify IndexedDB CAS returns success boolean', async () => {
// Simulate IndexedDB transaction
const idbResult = await simulateIndexedDBWatermarkUpdate(null, testJwtIds[0]);
expect(idbResult).toBe(true);
// Attempt to update with same condition
const idbResult2 = await simulateIndexedDBWatermarkUpdate(null, testJwtIds[0]);
expect(idbResult2).toBe(false);
});
});
});
// Mock implementations for testing
let mockWatermark: string | null = null;
async function simulateWatermarkUpdate(
expectedWatermark: string | null,
newWatermark: string
): Promise<{ success: boolean; watermark: string | null }> {
// Simulate CAS logic
if (mockWatermark === expectedWatermark) {
mockWatermark = newWatermark;
return { success: true, watermark: newWatermark };
}
return { success: false, watermark: mockWatermark };
}
async function simulatePollAndUpdate(
currentWatermark: string | null,
changes: string[]
): Promise<{ success: boolean; newWatermark: string | null; notificationsGenerated: number }> {
if (changes.length === 0) {
return { success: false, newWatermark: currentWatermark, notificationsGenerated: 0 };
}
const latestChange = changes[changes.length - 1];
const result = await simulateWatermarkUpdate(currentWatermark, latestChange);
return {
success: result.success,
newWatermark: result.watermark,
notificationsGenerated: result.success ? changes.length : 0
};
}
async function getCurrentWatermark(): Promise<string | null> {
return mockWatermark;
}
async function simulateSqlWatermarkUpdate(
expectedWatermark: string | null,
newWatermark: string
): Promise<{ rowsAffected: number }> {
if (mockWatermark === expectedWatermark) {
mockWatermark = newWatermark;
return { rowsAffected: 1 };
}
return { rowsAffected: 0 };
}
async function simulateRoomWatermarkUpdate(
expectedWatermark: string | null,
newWatermark: string
): Promise<number> {
if (mockWatermark === expectedWatermark) {
mockWatermark = newWatermark;
return 1;
}
return 0;
}
async function simulateCoreDataWatermarkUpdate(
expectedWatermark: string | null,
newWatermark: string
): Promise<boolean> {
if (mockWatermark === expectedWatermark) {
mockWatermark = newWatermark;
return true;
}
return false;
}
async function simulateIndexedDBWatermarkUpdate(
expectedWatermark: string | null,
newWatermark: string
): Promise<boolean> {
if (mockWatermark === expectedWatermark) {
mockWatermark = newWatermark;
return true;
}
return false;
}
// Reset mock state before each test
beforeEach(() => {
mockWatermark = null;
});