diff --git a/packages/contracts/src/ipc-crdt.test.ts b/packages/contracts/src/ipc-crdt.test.ts new file mode 100644 index 000000000..ed3d1ff22 --- /dev/null +++ b/packages/contracts/src/ipc-crdt.test.ts @@ -0,0 +1,140 @@ +/** + * IPC CRDT Contract Tests + * + * Covers the Yjs IPC envelope validators. `update`/`stateVector`/`diff` are + * serialized as number arrays across the IPC boundary (Uint8Array bytes) and + * the schemas clamp each byte to 0..255. + */ + +import { describe, it, expect } from 'vitest' + +import { + CRDT_CHANNELS, + CRDT_EVENTS, + CRDT_FRAGMENT_NAME, + CrdtApplyUpdateSchema, + CrdtCloseDocSchema, + CrdtOpenDocSchema, + CrdtSyncStep1Schema, + CrdtSyncStep2Schema +} from './ipc-crdt' + +describe('CRDT channel constants', () => { + it('exposes the expected command channels', () => { + expect(CRDT_CHANNELS.OPEN_DOC).toBe('crdt:open-doc') + expect(CRDT_CHANNELS.CLOSE_DOC).toBe('crdt:close-doc') + expect(CRDT_CHANNELS.APPLY_UPDATE).toBe('crdt:apply-update') + expect(CRDT_CHANNELS.SYNC_STEP_1).toBe('crdt:sync-step-1') + expect(CRDT_CHANNELS.SYNC_STEP_2).toBe('crdt:sync-step-2') + }) + + it('exposes the expected event channels', () => { + expect(CRDT_EVENTS.STATE_CHANGED).toBe('crdt:state-changed') + expect(CRDT_EVENTS.DOC_LOADED).toBe('crdt:doc-loaded') + expect(CRDT_EVENTS.DOC_ERROR).toBe('crdt:doc-error') + }) + + it('pins the Y.Doc fragment name', () => { + expect(CRDT_FRAGMENT_NAME).toBe('prosemirror') + }) +}) + +describe('CrdtOpenDocSchema / CrdtCloseDocSchema', () => { + it('accepts a noteId', () => { + expect(CrdtOpenDocSchema.safeParse({ noteId: 'note-1' }).success).toBe(true) + expect(CrdtCloseDocSchema.safeParse({ noteId: 'note-1' }).success).toBe(true) + }) + + it('rejects empty noteId', () => { + const result = CrdtOpenDocSchema.safeParse({ noteId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('noteId') + } + }) + + it('rejects missing noteId', () => { + expect(CrdtOpenDocSchema.safeParse({}).success).toBe(false) + expect(CrdtCloseDocSchema.safeParse({}).success).toBe(false) + }) +}) + +describe('CrdtApplyUpdateSchema', () => { + it('accepts serialized Uint8Array payload', () => { + const update = Array.from(new Uint8Array([0, 127, 255])) + expect(CrdtApplyUpdateSchema.safeParse({ noteId: 'note-1', update }).success).toBe(true) + }) + + it('accepts empty update array', () => { + expect( + CrdtApplyUpdateSchema.safeParse({ noteId: 'note-1', update: [] }).success + ).toBe(true) + }) + + it('rejects byte above 255', () => { + const result = CrdtApplyUpdateSchema.safeParse({ + noteId: 'note-1', + update: [256] + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path[0]).toBe('update') + } + }) + + it('rejects negative byte', () => { + expect( + CrdtApplyUpdateSchema.safeParse({ noteId: 'note-1', update: [-1] }).success + ).toBe(false) + }) + + it('rejects non-integer byte', () => { + expect( + CrdtApplyUpdateSchema.safeParse({ noteId: 'note-1', update: [1.5] }).success + ).toBe(false) + }) + + it('rejects missing noteId', () => { + expect(CrdtApplyUpdateSchema.safeParse({ update: [0] }).success).toBe(false) + }) +}) + +describe('CrdtSyncStep1Schema', () => { + it('accepts state vector bytes', () => { + expect( + CrdtSyncStep1Schema.safeParse({ noteId: 'note-1', stateVector: [0, 1, 2] }).success + ).toBe(true) + }) + + it('rejects non-array stateVector', () => { + const result = CrdtSyncStep1Schema.safeParse({ + noteId: 'note-1', + stateVector: 'deadbeef' + }) + expect(result.success).toBe(false) + }) + + it('rejects byte out of range', () => { + expect( + CrdtSyncStep1Schema.safeParse({ noteId: 'note-1', stateVector: [999] }).success + ).toBe(false) + }) +}) + +describe('CrdtSyncStep2Schema', () => { + it('accepts diff bytes', () => { + expect( + CrdtSyncStep2Schema.safeParse({ noteId: 'note-1', diff: [0, 10, 255] }).success + ).toBe(true) + }) + + it('rejects missing diff', () => { + expect(CrdtSyncStep2Schema.safeParse({ noteId: 'note-1' }).success).toBe(false) + }) + + it('rejects diff byte out of range', () => { + expect( + CrdtSyncStep2Schema.safeParse({ noteId: 'note-1', diff: [-1] }).success + ).toBe(false) + }) +}) diff --git a/packages/contracts/src/ipc-events.test.ts b/packages/contracts/src/ipc-events.test.ts new file mode 100644 index 000000000..600a2878c --- /dev/null +++ b/packages/contracts/src/ipc-events.test.ts @@ -0,0 +1,258 @@ +/** + * IPC Events Contract Tests + * + * ipc-events.ts exports the EVENT_CHANNELS constant plus a family of + * TypeScript event payload interfaces. Lock the channel map shape here and + * rely on `satisfies` assertions for compile-time coverage of the payload + * types (no Zod schemas in this module). + */ + +import { describe, it, expect } from 'vitest' + +import type { + AttachmentUploadFailedEvent, + CertificatePinFailedEvent, + ClockSkewWarningEvent, + ConflictDetectedEvent, + DeviceRenamedEvent, + DeviceRevokedEvent, + DownloadProgressEvent, + InitialSyncPhase, + InitialSyncProgressEvent, + ItemCorruptEvent, + ItemRecoveredEvent, + ItemSyncedEvent, + KeyRotationProgressEvent, + LinkingApprovedEvent, + LinkingFinalizedEvent, + LinkingRequestEvent, + OAuthCallbackEvent, + OAuthErrorEvent, + OtpDetectedEvent, + QuarantinedItemInfo, + QueueClearedEvent, + SecurityWarningEvent, + SessionExpiredEvent, + SessionExpiredReason, + SyncPausedEvent, + SyncResumedEvent, + SyncStatusChangedEvent, + UploadProgressEvent +} from './ipc-events' +import { EVENT_CHANNELS } from './ipc-events' + +describe('EVENT_CHANNELS', () => { + it('namespaces every channel under sync:/auth:/crypto:', () => { + const allowed = /^(sync|auth|crypto):/ + for (const value of Object.values(EVENT_CHANNELS)) { + expect(allowed.test(value)).toBe(true) + } + }) + + it('has unique channel values', () => { + const values = Object.values(EVENT_CHANNELS) + expect(new Set(values).size).toBe(values.length) + }) + + it('pins expected well-known channels', () => { + expect(EVENT_CHANNELS.STATUS_CHANGED).toBe('sync:status-changed') + expect(EVENT_CHANNELS.ITEM_SYNCED).toBe('sync:item-synced') + expect(EVENT_CHANNELS.CONFLICT_DETECTED).toBe('sync:conflict-detected') + expect(EVENT_CHANNELS.KEY_ROTATION_PROGRESS).toBe('crypto:key-rotation-progress') + expect(EVENT_CHANNELS.SESSION_EXPIRED).toBe('auth:session-expired') + expect(EVENT_CHANNELS.OTP_DETECTED).toBe('auth:otp-detected') + expect(EVENT_CHANNELS.OAUTH_CALLBACK).toBe('auth:oauth-callback') + expect(EVENT_CHANNELS.OAUTH_ERROR).toBe('auth:oauth-error') + expect(EVENT_CHANNELS.CERTIFICATE_PIN_FAILED).toBe('sync:certificate-pin-failed') + }) +}) + +describe('event payload types (compile-time shape locks)', () => { + it('SyncStatusChangedEvent accepts full shape', () => { + const event: SyncStatusChangedEvent = { + status: 'syncing', + lastSyncAt: 1, + pendingCount: 0, + error: 'x', + errorCategory: 'network_offline', + offlineSince: 2 + } + expect(event.status).toBe('syncing') + }) + + it('ItemSyncedEvent covers push/pull + operation', () => { + const push: ItemSyncedEvent = { itemId: 'id', type: 'task', operation: 'push' } + const pull: ItemSyncedEvent = { + itemId: 'id', + type: 'task', + operation: 'pull', + itemOperation: 'delete' + } + expect([push.operation, pull.operation]).toEqual(['push', 'pull']) + }) + + it('ConflictDetectedEvent carries optional clocks', () => { + const event: ConflictDetectedEvent = { + itemId: 'id', + type: 'task', + localVersion: { title: 'a' }, + remoteVersion: { title: 'b' }, + localClock: { 'd-1': 1 }, + remoteClock: { 'd-2': 1 } + } + expect(event.localClock?.['d-1']).toBe(1) + }) + + it('LinkingRequestEvent + LinkingApprovedEvent hold sessionId', () => { + const req: LinkingRequestEvent = { + sessionId: 's-1', + newDeviceName: 'Laptop', + newDevicePlatform: 'macos' + } + const ok: LinkingApprovedEvent = { sessionId: 's-1' } + expect(req.sessionId).toBe(ok.sessionId) + }) + + it('UploadProgressEvent / DownloadProgressEvent have progress 0..1-style fields', () => { + const up: UploadProgressEvent = { + attachmentId: 'a', + sessionId: 's', + progress: 0.5, + status: 'uploading' + } + const down: DownloadProgressEvent = { + attachmentId: 'a', + progress: 1, + status: 'complete' + } + expect(up.progress + down.progress).toBe(1.5) + }) + + it('InitialSyncProgressEvent locks phase union', () => { + const phases: InitialSyncPhase[] = [ + 'manifest', + 'notes', + 'tasks', + 'attachments', + 'complete' + ] + for (const phase of phases) { + const event: InitialSyncProgressEvent = { + phase, + totalItems: 0, + processedItems: 0 + } + expect(event.phase).toBe(phase) + } + }) + + it('QueueClearedEvent / SyncPausedEvent / SyncResumedEvent', () => { + const cleared: QueueClearedEvent = { itemCount: 3, duration: 100 } + const paused: SyncPausedEvent = { pendingCount: 2 } + const resumed: SyncResumedEvent = { pendingCount: 0 } + expect(cleared.itemCount + paused.pendingCount + resumed.pendingCount).toBe(5) + }) + + it('KeyRotationProgressEvent accepts optional error', () => { + const event: KeyRotationProgressEvent = { + phase: 're-encrypting', + totalItems: 10, + processedItems: 5, + error: undefined + } + expect(event.phase).toBe('re-encrypting') + }) + + it('SessionExpiredEvent reason union', () => { + const reasons: SessionExpiredReason[] = [ + 'token_expired', + 'device_revoked', + 'server_error' + ] + for (const reason of reasons) { + const event: SessionExpiredEvent = { reason } + expect(event.reason).toBe(reason) + } + }) + + it('OtpDetectedEvent + OAuth events', () => { + const otp: OtpDetectedEvent = { code: '123456' } + const cb: OAuthCallbackEvent = { code: 'c', state: 's' } + const err: OAuthErrorEvent = { error: 'denied' } + expect(otp.code).toBe('123456') + expect(cb.state).toBe('s') + expect(err.error).toBe('denied') + }) + + it('ClockSkewWarningEvent carries skew seconds', () => { + const event: ClockSkewWarningEvent = { + localTime: 1000, + serverTime: 1060, + skewSeconds: 60 + } + expect(event.skewSeconds).toBe(60) + }) + + it('AttachmentUploadFailedEvent carries noteId + diskPath', () => { + const event: AttachmentUploadFailedEvent = { + noteId: 'n', + diskPath: '/tmp/x', + error: 'disk full' + } + expect(event.noteId).toBe('n') + }) + + it('DeviceRevokedEvent / DeviceRenamedEvent', () => { + const revoked: DeviceRevokedEvent = { unsyncedCount: 3 } + const renamed: DeviceRenamedEvent = { deviceId: 'd', name: 'Phone' } + expect(revoked.unsyncedCount).toBe(3) + expect(renamed.name).toBe('Phone') + }) + + it('LinkingFinalizedEvent allows either deviceId or error', () => { + const ok: LinkingFinalizedEvent = { deviceId: 'd' } + const bad: LinkingFinalizedEvent = { error: 'mismatch' } + expect(ok.deviceId).toBe('d') + expect(bad.error).toBe('mismatch') + }) + + it('ItemRecoveredEvent / ItemCorruptEvent', () => { + const rec: ItemRecoveredEvent = { itemId: 'i', type: 'note' } + const corrupt: ItemCorruptEvent = { itemId: 'i', type: 'note', error: 'bad sig' } + expect(rec.type).toBe(corrupt.type) + }) + + it('SecurityWarningEvent locks signature_verification_failed literal', () => { + const event: SecurityWarningEvent = { + itemId: 'i', + itemType: 'note', + signerDeviceId: 'd', + reason: 'signature_verification_failed', + attemptCount: 2, + permanent: false + } + expect(event.reason).toBe('signature_verification_failed') + }) + + it('QuarantinedItemInfo carries attempt metadata', () => { + const info: QuarantinedItemInfo = { + itemId: 'i', + itemType: 'note', + signerDeviceId: 'd', + failedAt: 100, + attemptCount: 3, + lastError: 'boom', + permanent: true + } + expect(info.permanent).toBe(true) + }) + + it('CertificatePinFailedEvent captures pin comparison', () => { + const event: CertificatePinFailedEvent = { + hostname: 'api.memry.app', + actualHash: 'sha256/a', + expectedHashes: ['sha256/b', 'sha256/c'] + } + expect(event.expectedHashes.length).toBe(2) + }) +}) diff --git a/packages/contracts/src/ipc-sync-ops.test.ts b/packages/contracts/src/ipc-sync-ops.test.ts new file mode 100644 index 000000000..f34d68922 --- /dev/null +++ b/packages/contracts/src/ipc-sync-ops.test.ts @@ -0,0 +1,118 @@ +/** + * IPC Sync Ops Contract Tests + * + * Covers the two runtime-validated schemas (history pagination + synced-setting + * update) and locks the channel name map used by renderer<->main IPC. + */ + +import { describe, it, expect } from 'vitest' + +import { + GetHistorySchema, + SYNC_OP_CHANNELS, + UpdateSyncedSettingSchema +} from './ipc-sync-ops' + +describe('SYNC_OP_CHANNELS', () => { + it('namespaces every channel under "sync:"', () => { + for (const value of Object.values(SYNC_OP_CHANNELS)) { + expect(value.startsWith('sync:')).toBe(true) + } + }) + + it('has unique channel values', () => { + const values = Object.values(SYNC_OP_CHANNELS) + expect(new Set(values).size).toBe(values.length) + }) + + it('includes expected core operations', () => { + expect(SYNC_OP_CHANNELS.GET_STATUS).toBe('sync:get-status') + expect(SYNC_OP_CHANNELS.TRIGGER_SYNC).toBe('sync:trigger-sync') + expect(SYNC_OP_CHANNELS.PAUSE).toBe('sync:pause') + expect(SYNC_OP_CHANNELS.RESUME).toBe('sync:resume') + expect(SYNC_OP_CHANNELS.EMERGENCY_WIPE).toBe('sync:emergency-wipe') + }) +}) + +describe('GetHistorySchema', () => { + it('accepts empty object (all optional)', () => { + expect(GetHistorySchema.safeParse({}).success).toBe(true) + }) + + it('accepts valid limit + offset', () => { + expect(GetHistorySchema.safeParse({ limit: 50, offset: 0 }).success).toBe(true) + }) + + it('accepts limit at boundaries (1 and 1000)', () => { + expect(GetHistorySchema.safeParse({ limit: 1 }).success).toBe(true) + expect(GetHistorySchema.safeParse({ limit: 1000 }).success).toBe(true) + }) + + it('rejects limit below 1', () => { + const result = GetHistorySchema.safeParse({ limit: 0 }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('limit') + } + }) + + it('rejects limit above 1000', () => { + expect(GetHistorySchema.safeParse({ limit: 1001 }).success).toBe(false) + }) + + it('rejects non-integer limit', () => { + expect(GetHistorySchema.safeParse({ limit: 50.5 }).success).toBe(false) + }) + + it('rejects negative offset', () => { + const result = GetHistorySchema.safeParse({ offset: -1 }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('offset') + } + }) + + it('accepts offset 0', () => { + expect(GetHistorySchema.safeParse({ offset: 0 }).success).toBe(true) + }) +}) + +describe('UpdateSyncedSettingSchema', () => { + it('accepts string value', () => { + expect( + UpdateSyncedSettingSchema.safeParse({ fieldPath: 'ui.theme', value: 'dark' }).success + ).toBe(true) + }) + + it('accepts boolean/number/object/null values (z.unknown)', () => { + const values: unknown[] = [true, 1, { nested: { a: 1 } }, null, []] + for (const value of values) { + expect( + UpdateSyncedSettingSchema.safeParse({ fieldPath: 'x', value }).success + ).toBe(true) + } + }) + + it('rejects missing fieldPath', () => { + const result = UpdateSyncedSettingSchema.safeParse({ value: 'dark' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('fieldPath') + } + }) + + it('rejects empty fieldPath', () => { + expect( + UpdateSyncedSettingSchema.safeParse({ fieldPath: '', value: 'x' }).success + ).toBe(false) + }) + + it('accepts undefined value (z.unknown optional-semantics)', () => { + // z.unknown() accepts undefined. Field is present by key. + const result = UpdateSyncedSettingSchema.safeParse({ + fieldPath: 'x', + value: undefined + }) + expect(result.success).toBe(true) + }) +}) diff --git a/packages/contracts/src/ipc-sync.test.ts b/packages/contracts/src/ipc-sync.test.ts new file mode 100644 index 000000000..237956db7 --- /dev/null +++ b/packages/contracts/src/ipc-sync.test.ts @@ -0,0 +1,101 @@ +/** + * IPC Sync Barrel Tests + * + * ipc-sync.ts is a composition barrel: it re-exports every sync-related IPC + * module and merges their channel maps into SYNC_CHANNELS / SYNC_EVENTS. + * These tests lock the merged shape so new sub-modules can't silently drop + * channels or clobber existing keys. + */ + +import { describe, it, expect } from 'vitest' + +import { + SYNC_CHANNELS, + SYNC_EVENTS, + AUTH_CHANNELS, + CRYPTO_CHANNELS, + SYNC_OP_CHANNELS, + DEVICE_CHANNELS, + ATTACHMENT_CHANNELS, + CRDT_CHANNELS, + EVENT_CHANNELS, + CRDT_EVENTS +} from './ipc-sync' + +describe('SYNC_CHANNELS composition', () => { + it('includes every AUTH_CHANNELS key', () => { + for (const [key, value] of Object.entries(AUTH_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('includes every CRYPTO_CHANNELS key', () => { + for (const [key, value] of Object.entries(CRYPTO_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('includes every SYNC_OP_CHANNELS key', () => { + for (const [key, value] of Object.entries(SYNC_OP_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('includes every DEVICE_CHANNELS key', () => { + for (const [key, value] of Object.entries(DEVICE_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('includes every ATTACHMENT_CHANNELS key', () => { + for (const [key, value] of Object.entries(ATTACHMENT_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('includes every CRDT_CHANNELS key', () => { + for (const [key, value] of Object.entries(CRDT_CHANNELS)) { + expect(SYNC_CHANNELS).toHaveProperty(key, value) + } + }) + + it('has channel values that are unique (no key clobbering)', () => { + const values = Object.values(SYNC_CHANNELS) + expect(new Set(values).size).toBe(values.length) + }) + + it('key count equals sum of source maps', () => { + const total = + Object.keys(AUTH_CHANNELS).length + + Object.keys(CRYPTO_CHANNELS).length + + Object.keys(SYNC_OP_CHANNELS).length + + Object.keys(DEVICE_CHANNELS).length + + Object.keys(ATTACHMENT_CHANNELS).length + + Object.keys(CRDT_CHANNELS).length + expect(Object.keys(SYNC_CHANNELS)).toHaveLength(total) + }) +}) + +describe('SYNC_EVENTS composition', () => { + it('includes every EVENT_CHANNELS key', () => { + for (const [key, value] of Object.entries(EVENT_CHANNELS)) { + expect(SYNC_EVENTS).toHaveProperty(key, value) + } + }) + + it('includes every CRDT_EVENTS key', () => { + for (const [key, value] of Object.entries(CRDT_EVENTS)) { + expect(SYNC_EVENTS).toHaveProperty(key, value) + } + }) + + it('has event values that are unique', () => { + const values = Object.values(SYNC_EVENTS) + expect(new Set(values).size).toBe(values.length) + }) + + it('key count equals sum of source maps', () => { + const total = Object.keys(EVENT_CHANNELS).length + Object.keys(CRDT_EVENTS).length + expect(Object.keys(SYNC_EVENTS)).toHaveLength(total) + }) +}) diff --git a/packages/contracts/src/sync-api.test.ts b/packages/contracts/src/sync-api.test.ts new file mode 100644 index 000000000..64762b9cf --- /dev/null +++ b/packages/contracts/src/sync-api.test.ts @@ -0,0 +1,758 @@ +/** + * Sync API Contract Tests + * + * Zod schema validation coverage for sync push/pull envelopes, manifest, and + * conflict-response shapes plus device/cursor metadata. + */ + +import { describe, it, expect } from 'vitest' + +import { + ChangesResponseSchema, + ConflictResponseSchema, + CursorPositionSchema, + DeviceKeySchema, + DeviceKeysResponseSchema, + DeviceSyncStateSchema, + EncryptedItemPayloadSchema, + FieldClocksSchema, + OFFLINE_CLOCK_DEVICE_ID, + PullItemResponseSchema, + PullRequestSchema, + PullResponseSchema, + PushItemSchema, + PushRequestSchema, + PushResponseSchema, + RecordChangesResponseSchema, + RecordPullItemResponseSchema, + RecordPullResponseSchema, + RecordPushItemSchema, + RecordPushRequestSchema, + RecordSyncItemRefSchema, + RecordSyncManifestSchema, + SignatureMetadataSchema, + SyncItemRefSchema, + SyncItemSchema, + SyncManifestSchema, + SyncQueueItemSchema, + SyncStatusSchema, + VectorClockSchema, + SYNC_ITEM_TYPES, + RECORD_SYNC_ITEM_TYPES, + RECORD_CLOCK_REQUIRED_ITEM_TYPES, + CRDT_SYNC_ITEM_TYPES, + SYNC_OPERATIONS, + ENCRYPTABLE_ITEM_TYPES +} from './sync-api' + +const VALID_UUID = '11111111-1111-4111-8111-111111111111' + +const validEncryptedPayload = () => ({ + encryptedKey: 'ek', + keyNonce: 'kn', + encryptedData: 'ed', + dataNonce: 'dn' +}) + +const validPushItem = (overrides: Record = {}) => ({ + id: 'item-1', + type: 'task' as const, + operation: 'create' as const, + encryptedKey: 'ek', + keyNonce: 'kn', + encryptedData: 'ed', + dataNonce: 'dn', + signature: 'sig', + signerDeviceId: 'device-1', + ...overrides +}) + +describe('constants', () => { + it('exposes expected sync item types', () => { + expect(SYNC_ITEM_TYPES).toContain('note') + expect(SYNC_ITEM_TYPES).toContain('calendar_external_event') + }) + + it('record types exclude attachment', () => { + expect(RECORD_SYNC_ITEM_TYPES).not.toContain('attachment') + }) + + it('record-clock-required excludes settings', () => { + expect(RECORD_CLOCK_REQUIRED_ITEM_TYPES).not.toContain('settings') + }) + + it('CRDT sync list is note-only', () => { + expect(CRDT_SYNC_ITEM_TYPES).toEqual(['note']) + }) + + it('sync operations are create/update/delete', () => { + expect(SYNC_OPERATIONS).toEqual(['create', 'update', 'delete']) + }) + + it('encryptable list excludes attachment', () => { + expect(ENCRYPTABLE_ITEM_TYPES).not.toContain('attachment') + }) + + it('offline device id is stable', () => { + expect(OFFLINE_CLOCK_DEVICE_ID).toBe('_offline') + }) +}) + +describe('VectorClockSchema', () => { + it('accepts empty map', () => { + expect(VectorClockSchema.safeParse({}).success).toBe(true) + }) + + it('accepts device-id keyed ticks', () => { + expect(VectorClockSchema.safeParse({ 'device-a': 1, _offline: 0 }).success).toBe(true) + }) + + it('rejects negative ticks', () => { + const result = VectorClockSchema.safeParse({ 'device-a': -1 }) + expect(result.success).toBe(false) + }) + + it('rejects non-integer ticks', () => { + const result = VectorClockSchema.safeParse({ 'device-a': 1.5 }) + expect(result.success).toBe(false) + }) +}) + +describe('FieldClocksSchema', () => { + it('accepts per-field vector clocks', () => { + const result = FieldClocksSchema.safeParse({ + title: { 'device-a': 1 }, + description: { 'device-a': 2, 'device-b': 1 } + }) + expect(result.success).toBe(true) + }) + + it('rejects non-vector-clock value', () => { + const result = FieldClocksSchema.safeParse({ title: 'not-a-clock' }) + expect(result.success).toBe(false) + }) +}) + +describe('EncryptedItemPayloadSchema', () => { + it('accepts minimal payload', () => { + expect(EncryptedItemPayloadSchema.safeParse(validEncryptedPayload()).success).toBe(true) + }) + + it('rejects empty encryptedKey', () => { + const result = EncryptedItemPayloadSchema.safeParse({ + ...validEncryptedPayload(), + encryptedKey: '' + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('encryptedKey') + } + }) + + it('rejects missing dataNonce', () => { + const { dataNonce: _dataNonce, ...rest } = validEncryptedPayload() + const result = EncryptedItemPayloadSchema.safeParse(rest) + expect(result.success).toBe(false) + }) +}) + +describe('SyncItemSchema', () => { + const base = { + id: VALID_UUID, + userId: 'user-1', + itemType: 'task' as const, + itemId: 'task-1', + blobKey: 'blob/key', + sizeBytes: 128, + contentHash: 'hash', + serverCursor: 5, + signerDeviceId: 'device-1', + signature: 'sig', + createdAt: 1, + updatedAt: 2 + } + + it('accepts minimal sync item with defaults', () => { + const result = SyncItemSchema.safeParse(base) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.version).toBe(1) + expect(result.data.cryptoVersion).toBe(1) + } + }) + + it('accepts full sync item with clock + stateVector + deletedAt', () => { + const result = SyncItemSchema.safeParse({ + ...base, + version: 3, + cryptoVersion: 2, + stateVector: 'sv-base64', + clock: { 'device-1': 3 }, + deletedAt: 999 + }) + expect(result.success).toBe(true) + }) + + it('rejects non-uuid id', () => { + const result = SyncItemSchema.safeParse({ ...base, id: 'not-uuid' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('id') + } + }) + + it('rejects negative sizeBytes', () => { + expect(SyncItemSchema.safeParse({ ...base, sizeBytes: -1 }).success).toBe(false) + }) + + it('rejects version below 1', () => { + expect(SyncItemSchema.safeParse({ ...base, version: 0 }).success).toBe(false) + }) + + it('rejects unknown itemType', () => { + expect(SyncItemSchema.safeParse({ ...base, itemType: 'widget' }).success).toBe(false) + }) +}) + +describe('SyncQueueItemSchema', () => { + const base = { + id: VALID_UUID, + type: 'task' as const, + itemId: 'task-1', + operation: 'update' as const, + payload: '{}', + createdAt: 1 + } + + it('accepts minimal queue item', () => { + const result = SyncQueueItemSchema.safeParse(base) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.priority).toBe(0) + expect(result.data.attempts).toBe(0) + } + }) + + it('accepts full queue item with lastAttempt/errorMessage', () => { + const result = SyncQueueItemSchema.safeParse({ + ...base, + priority: 5, + attempts: 3, + lastAttempt: 10, + errorMessage: 'boom' + }) + expect(result.success).toBe(true) + }) + + it('rejects unknown operation', () => { + expect( + SyncQueueItemSchema.safeParse({ ...base, operation: 'patch' }).success + ).toBe(false) + }) + + it('rejects empty payload', () => { + expect(SyncQueueItemSchema.safeParse({ ...base, payload: '' }).success).toBe(false) + }) +}) + +describe('PushItemSchema', () => { + it('accepts minimal push item', () => { + expect(PushItemSchema.safeParse(validPushItem()).success).toBe(true) + }) + + it('accepts push item with clock + stateVector + deletedAt', () => { + const result = PushItemSchema.safeParse( + validPushItem({ + clock: { 'device-a': 1 }, + stateVector: 'sv', + deletedAt: 123 + }) + ) + expect(result.success).toBe(true) + }) + + it('rejects missing signature', () => { + const { signature: _signature, ...rest } = validPushItem() + expect(PushItemSchema.safeParse(rest).success).toBe(false) + }) + + it('rejects unknown type', () => { + expect(PushItemSchema.safeParse(validPushItem({ type: 'widget' })).success).toBe(false) + }) +}) + +describe('PushRequestSchema', () => { + it('accepts 1..100 items', () => { + const one = PushRequestSchema.safeParse({ items: [validPushItem()] }) + expect(one.success).toBe(true) + + const hundred = PushRequestSchema.safeParse({ + items: Array.from({ length: 100 }, (_, i) => validPushItem({ id: `item-${i}` })) + }) + expect(hundred.success).toBe(true) + }) + + it('rejects empty items array', () => { + expect(PushRequestSchema.safeParse({ items: [] }).success).toBe(false) + }) + + it('rejects over 100 items', () => { + const items = Array.from({ length: 101 }, (_, i) => validPushItem({ id: `item-${i}` })) + expect(PushRequestSchema.safeParse({ items }).success).toBe(false) + }) +}) + +describe('RecordPushItemSchema', () => { + it('accepts record type with clock when required', () => { + const result = RecordPushItemSchema.safeParse( + validPushItem({ type: 'note', clock: { 'device-a': 1 } }) + ) + expect(result.success).toBe(true) + }) + + it('accepts settings without clock (not clock-required)', () => { + const result = RecordPushItemSchema.safeParse( + validPushItem({ type: 'settings' }) + ) + expect(result.success).toBe(true) + }) + + it('rejects record type missing required clock', () => { + const result = RecordPushItemSchema.safeParse(validPushItem({ type: 'task' })) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('clock') + expect(result.error.issues[0].message).toMatch(/requires clock metadata/) + } + }) + + it('rejects attachment type (not in record list)', () => { + const result = RecordPushItemSchema.safeParse(validPushItem({ type: 'attachment' })) + expect(result.success).toBe(false) + }) +}) + +describe('RecordPushRequestSchema', () => { + it('accepts valid batch', () => { + const result = RecordPushRequestSchema.safeParse({ + items: [validPushItem({ type: 'note', clock: { a: 1 } })] + }) + expect(result.success).toBe(true) + }) + + it('rejects empty batch', () => { + expect(RecordPushRequestSchema.safeParse({ items: [] }).success).toBe(false) + }) +}) + +describe('PushResponseSchema', () => { + it('accepts minimal response', () => { + const result = PushResponseSchema.safeParse({ + accepted: ['id-1'], + rejected: [], + serverTime: 1, + maxCursor: 10 + }) + expect(result.success).toBe(true) + }) + + it('accepts response with rejections', () => { + const result = PushResponseSchema.safeParse({ + accepted: [], + rejected: [{ id: 'id-1', reason: 'conflict' }], + serverTime: 1, + maxCursor: 10 + }) + expect(result.success).toBe(true) + }) + + it('rejects negative serverTime', () => { + expect( + PushResponseSchema.safeParse({ + accepted: [], + rejected: [], + serverTime: -1, + maxCursor: 0 + }).success + ).toBe(false) + }) +}) + +describe('PullRequestSchema', () => { + it('accepts 1..100 item ids', () => { + expect(PullRequestSchema.safeParse({ itemIds: ['a'] }).success).toBe(true) + expect( + PullRequestSchema.safeParse({ + itemIds: Array.from({ length: 100 }, (_, i) => `id-${i}`) + }).success + ).toBe(true) + }) + + it('rejects empty itemIds', () => { + expect(PullRequestSchema.safeParse({ itemIds: [] }).success).toBe(false) + }) + + it('rejects over 100 itemIds', () => { + expect( + PullRequestSchema.safeParse({ + itemIds: Array.from({ length: 101 }, (_, i) => `id-${i}`) + }).success + ).toBe(false) + }) +}) + +describe('SyncItemRefSchema', () => { + it('accepts minimal ref', () => { + const result = SyncItemRefSchema.safeParse({ + id: 'id-1', + type: 'task', + version: 1, + modifiedAt: 0, + size: 0 + }) + expect(result.success).toBe(true) + }) + + it('accepts ref with stateVector', () => { + const result = SyncItemRefSchema.safeParse({ + id: 'id-1', + type: 'note', + version: 2, + modifiedAt: 1, + size: 5, + stateVector: 'sv' + }) + expect(result.success).toBe(true) + }) + + it('rejects version below 1', () => { + expect( + SyncItemRefSchema.safeParse({ + id: 'id-1', + type: 'task', + version: 0, + modifiedAt: 0, + size: 0 + }).success + ).toBe(false) + }) +}) + +describe('RecordSyncItemRefSchema', () => { + it('accepts record-type ref without stateVector', () => { + const result = RecordSyncItemRefSchema.safeParse({ + id: 'id-1', + type: 'task', + version: 1, + modifiedAt: 0, + size: 0 + }) + expect(result.success).toBe(true) + }) + + it('rejects attachment type', () => { + const result = RecordSyncItemRefSchema.safeParse({ + id: 'id-1', + type: 'attachment', + version: 1, + modifiedAt: 0, + size: 0 + }) + expect(result.success).toBe(false) + }) +}) + +describe('SyncManifestSchema', () => { + it('accepts empty manifest', () => { + expect(SyncManifestSchema.safeParse({ items: [], serverTime: 0 }).success).toBe(true) + }) + + it('accepts manifest with refs', () => { + const result = SyncManifestSchema.safeParse({ + items: [{ id: 'a', type: 'task', version: 1, modifiedAt: 0, size: 0 }], + serverTime: 1 + }) + expect(result.success).toBe(true) + }) + + it('rejects bad ref inside items', () => { + const result = SyncManifestSchema.safeParse({ + items: [{ id: 'a', type: 'task', version: 0, modifiedAt: 0, size: 0 }], + serverTime: 0 + }) + expect(result.success).toBe(false) + }) +}) + +describe('RecordSyncManifestSchema', () => { + it('accepts record manifest', () => { + const result = RecordSyncManifestSchema.safeParse({ + items: [{ id: 'a', type: 'note', version: 1, modifiedAt: 0, size: 0 }], + serverTime: 0 + }) + expect(result.success).toBe(true) + }) +}) + +describe('ChangesResponseSchema', () => { + it('accepts pagination cursor + hasMore', () => { + const result = ChangesResponseSchema.safeParse({ + items: [], + deleted: ['id-gone'], + hasMore: true, + nextCursor: 42 + }) + expect(result.success).toBe(true) + }) + + it('rejects non-boolean hasMore', () => { + const result = ChangesResponseSchema.safeParse({ + items: [], + deleted: [], + hasMore: 'yes', + nextCursor: 0 + }) + expect(result.success).toBe(false) + }) + + it('rejects negative nextCursor', () => { + const result = ChangesResponseSchema.safeParse({ + items: [], + deleted: [], + hasMore: false, + nextCursor: -1 + }) + expect(result.success).toBe(false) + }) +}) + +describe('RecordChangesResponseSchema', () => { + it('accepts record changes', () => { + expect( + RecordChangesResponseSchema.safeParse({ + items: [], + deleted: [], + hasMore: false, + nextCursor: 0 + }).success + ).toBe(true) + }) +}) + +describe('SyncStatusSchema', () => { + it('accepts connected status without optional fields', () => { + expect( + SyncStatusSchema.safeParse({ connected: true, pendingItems: 0, serverTime: 0 }).success + ).toBe(true) + }) + + it('accepts lastSyncAt timestamp', () => { + expect( + SyncStatusSchema.safeParse({ + connected: false, + lastSyncAt: 999, + pendingItems: 2, + serverTime: 100 + }).success + ).toBe(true) + }) +}) + +describe('ConflictResponseSchema', () => { + it('accepts conflict shape', () => { + const result = ConflictResponseSchema.safeParse({ + conflicts: [ + { + id: 'id-1', + localClock: { 'device-a': 2 }, + serverClock: { 'device-b': 3 }, + serverVersion: validEncryptedPayload() + } + ] + }) + expect(result.success).toBe(true) + }) + + it('rejects missing serverVersion', () => { + const result = ConflictResponseSchema.safeParse({ + conflicts: [{ id: 'id-1', localClock: {}, serverClock: {} }] + }) + expect(result.success).toBe(false) + }) +}) + +describe('DeviceSyncStateSchema', () => { + it('accepts minimal state', () => { + expect( + DeviceSyncStateSchema.safeParse({ + deviceId: 'd', + lastCursorSeen: 0, + updatedAt: 0 + }).success + ).toBe(true) + }) + + it('rejects empty deviceId', () => { + expect( + DeviceSyncStateSchema.safeParse({ + deviceId: '', + lastCursorSeen: 0, + updatedAt: 0 + }).success + ).toBe(false) + }) +}) + +describe('PullItemResponseSchema', () => { + const base = { + id: 'id-1', + type: 'task' as const, + operation: 'update' as const, + signature: 'sig', + signerDeviceId: 'device-1', + blob: validEncryptedPayload() + } + + it('accepts minimal pull item', () => { + expect(PullItemResponseSchema.safeParse(base).success).toBe(true) + }) + + it('accepts pull item with clock + stateVector + cryptoVersion', () => { + const result = PullItemResponseSchema.safeParse({ + ...base, + cryptoVersion: 2, + clock: { 'device-1': 1 }, + stateVector: 'sv', + deletedAt: 99 + }) + expect(result.success).toBe(true) + }) + + it('rejects missing blob', () => { + const { blob: _blob, ...rest } = base + expect(PullItemResponseSchema.safeParse(rest).success).toBe(false) + }) +}) + +describe('RecordPullItemResponseSchema', () => { + it('accepts record type', () => { + const result = RecordPullItemResponseSchema.safeParse({ + id: 'id-1', + type: 'note', + operation: 'create', + signature: 'sig', + signerDeviceId: 'device-1', + blob: validEncryptedPayload() + }) + expect(result.success).toBe(true) + }) + + it('rejects attachment type', () => { + const result = RecordPullItemResponseSchema.safeParse({ + id: 'id-1', + type: 'attachment', + operation: 'create', + signature: 'sig', + signerDeviceId: 'device-1', + blob: validEncryptedPayload() + }) + expect(result.success).toBe(false) + }) +}) + +describe('PullResponseSchema', () => { + it('accepts empty items', () => { + expect(PullResponseSchema.safeParse({ items: [] }).success).toBe(true) + }) +}) + +describe('RecordPullResponseSchema', () => { + it('accepts empty items', () => { + expect(RecordPullResponseSchema.safeParse({ items: [] }).success).toBe(true) + }) +}) + +describe('DeviceKeySchema / DeviceKeysResponseSchema', () => { + it('accepts active device key', () => { + const result = DeviceKeySchema.safeParse({ + id: 'd1', + name: 'Laptop', + platform: 'macos', + signingPublicKey: 'pk', + revokedAt: null + }) + expect(result.success).toBe(true) + }) + + it('accepts revoked device key with timestamp', () => { + const result = DeviceKeySchema.safeParse({ + id: 'd1', + name: 'Laptop', + platform: 'macos', + signingPublicKey: 'pk', + revokedAt: 123 + }) + expect(result.success).toBe(true) + }) + + it('rejects missing revokedAt', () => { + const result = DeviceKeySchema.safeParse({ + id: 'd1', + name: 'Laptop', + platform: 'macos', + signingPublicKey: 'pk' + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('revokedAt') + } + }) + + it('accepts devices response', () => { + const result = DeviceKeysResponseSchema.safeParse({ + devices: [ + { id: 'd1', name: 'A', platform: 'macos', signingPublicKey: 'pk', revokedAt: null } + ] + }) + expect(result.success).toBe(true) + }) +}) + +describe('CursorPositionSchema', () => { + it('accepts minimal cursor', () => { + expect( + CursorPositionSchema.safeParse({ cursor: 0, deviceId: 'd', updatedAt: 0 }).success + ).toBe(true) + }) + + it('rejects negative cursor', () => { + expect( + CursorPositionSchema.safeParse({ cursor: -1, deviceId: 'd', updatedAt: 0 }).success + ).toBe(false) + }) +}) + +describe('SignatureMetadataSchema', () => { + it('accepts ed25519 signature metadata', () => { + const result = SignatureMetadataSchema.safeParse({ + signerDeviceId: 'd1', + signerPublicKey: 'pk', + signedAt: 0, + algorithm: 'ed25519' + }) + expect(result.success).toBe(true) + }) + + it('rejects non-ed25519 algorithm', () => { + const result = SignatureMetadataSchema.safeParse({ + signerDeviceId: 'd1', + signerPublicKey: 'pk', + signedAt: 0, + algorithm: 'rsa' + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('algorithm') + } + }) +}) diff --git a/packages/contracts/src/sync-payloads.test.ts b/packages/contracts/src/sync-payloads.test.ts new file mode 100644 index 000000000..fac790134 --- /dev/null +++ b/packages/contracts/src/sync-payloads.test.ts @@ -0,0 +1,411 @@ +/** + * Sync Payload Contract Tests + * + * Per-item-type encrypted payload shapes consumed by sync item handlers. + * Covers optional-field tolerance, nullable-string handling, and Phase 8 + * field-clock regression (fieldClocks on tasks/projects). + */ + +import { describe, it, expect } from 'vitest' + +import { + CalendarBindingSyncPayloadSchema, + CalendarEventSyncPayloadSchema, + CalendarExternalEventSyncPayloadSchema, + CalendarSourceSyncPayloadSchema, + FilterSyncPayloadSchema, + FolderConfigSyncPayloadSchema, + InboxSyncPayloadSchema, + JournalSyncPayloadSchema, + NoteSyncPayloadSchema, + ProjectSyncPayloadSchema, + StatusSyncSchema, + TagDefinitionSyncPayloadSchema, + TaskSyncPayloadSchema +} from './sync-payloads' + +describe('TaskSyncPayloadSchema', () => { + it('accepts empty payload (all optional)', () => { + expect(TaskSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts full payload with clock + fieldClocks (Phase 8 regression)', () => { + const result = TaskSyncPayloadSchema.safeParse({ + title: 'Write tests', + description: 'Cover all schemas', + projectId: 'proj-1', + statusId: 'status-1', + parentId: null, + priority: 2, + position: 0, + dueDate: '2026-04-20', + dueTime: '14:30', + startDate: '2026-04-18', + repeatConfig: { frequency: 'weekly' }, + repeatFrom: 'due', + sourceNoteId: null, + completedAt: null, + archivedAt: null, + tags: ['work'], + linkedNoteIds: ['note-1'], + clock: { 'device-a': 3 }, + fieldClocks: { + title: { 'device-a': 3 }, + description: { 'device-a': 2, 'device-b': 1 } + }, + createdAt: '2026-04-01T00:00:00Z', + modifiedAt: '2026-04-16T00:00:00Z' + }) + expect(result.success).toBe(true) + }) + + it('accepts null description (nullable)', () => { + expect(TaskSyncPayloadSchema.safeParse({ description: null }).success).toBe(true) + }) + + it('rejects non-string title', () => { + const result = TaskSyncPayloadSchema.safeParse({ title: 42 }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('title') + } + }) + + it('rejects fieldClocks with non-clock value', () => { + const result = TaskSyncPayloadSchema.safeParse({ + fieldClocks: { title: 'not-a-clock' } + }) + expect(result.success).toBe(false) + }) + + it('rejects clock with negative tick', () => { + const result = TaskSyncPayloadSchema.safeParse({ + clock: { 'device-a': -1 } + }) + expect(result.success).toBe(false) + }) + + it('rejects tags containing non-string', () => { + const result = TaskSyncPayloadSchema.safeParse({ tags: ['ok', 1] }) + expect(result.success).toBe(false) + }) +}) + +describe('InboxSyncPayloadSchema', () => { + it('accepts empty payload', () => { + expect(InboxSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts full inbox entry', () => { + const result = InboxSyncPayloadSchema.safeParse({ + title: 'Read later', + content: null, + type: 'url', + metadata: { favicon: 'x' }, + filedAt: null, + filedTo: null, + filedAction: null, + snoozedUntil: '2026-04-20T00:00:00Z', + snoozeReason: 'later', + archivedAt: null, + sourceUrl: 'https://example.com', + sourceTitle: 'Example', + captureSource: 'web-clipper', + clock: { 'device-a': 1 }, + createdAt: '2026-04-16T00:00:00Z', + modifiedAt: '2026-04-16T00:00:00Z' + }) + expect(result.success).toBe(true) + }) + + it('rejects non-string content (not nullable unknown)', () => { + const result = InboxSyncPayloadSchema.safeParse({ content: 123 }) + expect(result.success).toBe(false) + }) +}) + +describe('FilterSyncPayloadSchema', () => { + it('accepts unknown config (unstructured)', () => { + const result = FilterSyncPayloadSchema.safeParse({ + name: 'Today', + config: { any: 'shape' }, + position: 0 + }) + expect(result.success).toBe(true) + }) + + it('rejects non-number position', () => { + const result = FilterSyncPayloadSchema.safeParse({ position: '0' }) + expect(result.success).toBe(false) + }) +}) + +describe('StatusSyncSchema', () => { + const base = { id: 's1', name: 'Todo', color: '#abc', position: 0 } + + it('accepts minimal required fields', () => { + expect(StatusSyncSchema.safeParse(base).success).toBe(true) + }) + + it('accepts optional flags', () => { + const result = StatusSyncSchema.safeParse({ + ...base, + isDefault: true, + isDone: false, + createdAt: '2026-04-16T00:00:00Z' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing name', () => { + const { name: _name, ...rest } = base + expect(StatusSyncSchema.safeParse(rest).success).toBe(false) + }) +}) + +describe('ProjectSyncPayloadSchema', () => { + it('accepts empty payload', () => { + expect(ProjectSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts project with fieldClocks (Phase 8 regression)', () => { + const result = ProjectSyncPayloadSchema.safeParse({ + name: 'Side project', + description: null, + color: '#ff5733', + icon: null, + position: 1, + isInbox: false, + archivedAt: null, + clock: { 'device-a': 1 }, + fieldClocks: { name: { 'device-a': 1 } }, + statuses: [{ id: 's1', name: 'Todo', color: '#abc', position: 0 }] + }) + expect(result.success).toBe(true) + }) + + it('rejects statuses containing invalid entry', () => { + const result = ProjectSyncPayloadSchema.safeParse({ + statuses: [{ id: 's1', name: 'Todo', color: '#abc' }] + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path[0]).toBe('statuses') + } + }) +}) + +describe('NoteSyncPayloadSchema', () => { + it('accepts empty payload', () => { + expect(NoteSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts properties as Record', () => { + const result = NoteSyncPayloadSchema.safeParse({ + title: 'N', + properties: { rating: 5, tags: ['a'] } + }) + expect(result.success).toBe(true) + }) + + it('accepts null properties', () => { + const result = NoteSyncPayloadSchema.safeParse({ properties: null }) + expect(result.success).toBe(true) + }) + + it('accepts all fileType enum values', () => { + const values = ['markdown', 'pdf', 'image', 'audio', 'video'] as const + for (const fileType of values) { + expect(NoteSyncPayloadSchema.safeParse({ fileType }).success).toBe(true) + } + }) + + it('rejects invalid fileType', () => { + const result = NoteSyncPayloadSchema.safeParse({ fileType: 'doc' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('fileType') + } + }) + + it('rejects non-string tag entry', () => { + const result = NoteSyncPayloadSchema.safeParse({ tags: ['ok', 1] }) + expect(result.success).toBe(false) + }) +}) + +describe('JournalSyncPayloadSchema', () => { + it('accepts minimal journal with date', () => { + expect(JournalSyncPayloadSchema.safeParse({ date: '2026-04-16' }).success).toBe(true) + }) + + it('accepts full journal entry', () => { + const result = JournalSyncPayloadSchema.safeParse({ + date: '2026-04-16', + content: 'Today...', + tags: ['personal'], + properties: { mood: 'calm' }, + clock: { 'device-a': 1 }, + createdAt: '2026-04-16T00:00:00Z', + modifiedAt: '2026-04-16T01:00:00Z' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing date', () => { + const result = JournalSyncPayloadSchema.safeParse({ content: 'Today...' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('date') + } + }) +}) + +describe('TagDefinitionSyncPayloadSchema', () => { + it('accepts minimal required fields', () => { + expect( + TagDefinitionSyncPayloadSchema.safeParse({ name: 'work', color: '#abc' }).success + ).toBe(true) + }) + + it('rejects missing color', () => { + expect(TagDefinitionSyncPayloadSchema.safeParse({ name: 'work' }).success).toBe(false) + }) +}) + +describe('FolderConfigSyncPayloadSchema', () => { + it('accepts null icon (required field, nullable)', () => { + expect(FolderConfigSyncPayloadSchema.safeParse({ icon: null }).success).toBe(true) + }) + + it('accepts string icon', () => { + expect(FolderConfigSyncPayloadSchema.safeParse({ icon: 'folder' }).success).toBe(true) + }) + + it('rejects missing icon field', () => { + const result = FolderConfigSyncPayloadSchema.safeParse({}) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('icon') + } + }) +}) + +describe('CalendarEventSyncPayloadSchema', () => { + it('accepts minimal empty payload', () => { + expect(CalendarEventSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts recurrenceRule + exceptions as records', () => { + const result = CalendarEventSyncPayloadSchema.safeParse({ + title: 'Standup', + startAt: '2026-04-16T09:00:00Z', + endAt: '2026-04-16T09:15:00Z', + timezone: 'UTC', + isAllDay: false, + recurrenceRule: { freq: 'DAILY' }, + recurrenceExceptions: [{ date: '2026-04-18' }] + }) + expect(result.success).toBe(true) + }) + + it('rejects non-record recurrenceRule', () => { + const result = CalendarEventSyncPayloadSchema.safeParse({ + recurrenceRule: 'DAILY' + }) + expect(result.success).toBe(false) + }) +}) + +describe('CalendarSourceSyncPayloadSchema', () => { + it('accepts all kind enum values', () => { + expect( + CalendarSourceSyncPayloadSchema.safeParse({ kind: 'account' }).success + ).toBe(true) + expect( + CalendarSourceSyncPayloadSchema.safeParse({ kind: 'calendar' }).success + ).toBe(true) + }) + + it('accepts all syncStatus enum values', () => { + const values = ['idle', 'ok', 'error', 'pending'] as const + for (const syncStatus of values) { + expect(CalendarSourceSyncPayloadSchema.safeParse({ syncStatus }).success).toBe(true) + } + }) + + it('rejects invalid kind', () => { + const result = CalendarSourceSyncPayloadSchema.safeParse({ kind: 'group' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('kind') + } + }) + + it('rejects invalid syncStatus', () => { + const result = CalendarSourceSyncPayloadSchema.safeParse({ syncStatus: 'running' }) + expect(result.success).toBe(false) + }) +}) + +describe('CalendarBindingSyncPayloadSchema', () => { + it('accepts sourceType enum values', () => { + const values = ['event', 'task', 'reminder', 'inbox_snooze'] as const + for (const sourceType of values) { + expect(CalendarBindingSyncPayloadSchema.safeParse({ sourceType }).success).toBe(true) + } + }) + + it('accepts ownershipMode values', () => { + const values = ['memry_managed', 'provider_managed'] as const + for (const ownershipMode of values) { + expect(CalendarBindingSyncPayloadSchema.safeParse({ ownershipMode }).success).toBe(true) + } + }) + + it('accepts writebackMode values', () => { + const values = ['schedule_only', 'time_and_text', 'broad'] as const + for (const writebackMode of values) { + expect(CalendarBindingSyncPayloadSchema.safeParse({ writebackMode }).success).toBe(true) + } + }) + + it('rejects invalid sourceType', () => { + const result = CalendarBindingSyncPayloadSchema.safeParse({ sourceType: 'unknown' }) + expect(result.success).toBe(false) + }) + + it('accepts lastLocalSnapshot as record or null', () => { + expect( + CalendarBindingSyncPayloadSchema.safeParse({ lastLocalSnapshot: { a: 1 } }).success + ).toBe(true) + expect( + CalendarBindingSyncPayloadSchema.safeParse({ lastLocalSnapshot: null }).success + ).toBe(true) + }) +}) + +describe('CalendarExternalEventSyncPayloadSchema', () => { + it('accepts minimal empty payload', () => { + expect(CalendarExternalEventSyncPayloadSchema.safeParse({}).success).toBe(true) + }) + + it('accepts all status enum values', () => { + const values = ['confirmed', 'tentative', 'cancelled'] as const + for (const status of values) { + expect(CalendarExternalEventSyncPayloadSchema.safeParse({ status }).success).toBe(true) + } + }) + + it('rejects invalid status', () => { + const result = CalendarExternalEventSyncPayloadSchema.safeParse({ status: 'draft' }) + expect(result.success).toBe(false) + }) + + it('accepts rawPayload as record', () => { + const result = CalendarExternalEventSyncPayloadSchema.safeParse({ + rawPayload: { vendor: 'google', data: { id: 'x' } } + }) + expect(result.success).toBe(true) + }) +})