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:
@@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
||||
238
packages/polling-contracts/src/__tests__/backoff.test.ts
Normal file
238
packages/polling-contracts/src/__tests__/backoff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
268
packages/polling-contracts/src/__tests__/clock-sync.test.ts
Normal file
268
packages/polling-contracts/src/__tests__/clock-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/polling-contracts/src/__tests__/schemas.test.ts
Normal file
236
packages/polling-contracts/src/__tests__/schemas.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
22
packages/polling-contracts/src/__tests__/setup.ts
Normal file
22
packages/polling-contracts/src/__tests__/setup.ts
Normal 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;
|
||||
});
|
||||
265
packages/polling-contracts/src/__tests__/watermark-cas.test.ts
Normal file
265
packages/polling-contracts/src/__tests__/watermark-cas.test.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user