From bc437c7881185ce421b63b9903a2425fa371c6a8 Mon Sep 17 00:00:00 2001 From: johannesjo Date: Mon, 11 May 2026 23:08:20 +0200 Subject: [PATCH] test(sync-core): add port contract adapter coverage --- .../sync-core-extraction-plan.md | 19 ++- .../archive-operation-handler.service.spec.ts | 24 ++++ .../apply/hydration-state.service.spec.ts | 19 +++ .../apply/operation-applier.service.spec.ts | 121 ++++++++++++++++++ .../capture/operation-log.effects.spec.ts | 17 +++ .../operation-log-store.service.spec.ts | 31 +++++ 6 files changed, 229 insertions(+), 2 deletions(-) diff --git a/docs/long-term-plans/sync-core-extraction-plan.md b/docs/long-term-plans/sync-core-extraction-plan.md index 775cf28df8..0c012fb397 100644 --- a/docs/long-term-plans/sync-core-extraction-plan.md +++ b/docs/long-term-plans/sync-core-extraction-plan.md @@ -4,8 +4,9 @@ > vector-clock ownership, full-state op classification config, and the PR 3b > pure helper slices are present on this branch. PR 4a port contracts have > started with the app-side replay/operation-store services explicitly satisfying -> sync-core interfaces. Remaining cleanup is PR text alignment plus targeted -> future `SyncLogger` routing for files as they move.** +> sync-core interfaces and adapter contract specs covering the initial ports. +> Remaining cleanup is PR text alignment plus targeted future `SyncLogger` +> routing for files as they move.** **Goal:** Carve the sync engine out of `src/app/op-log/` into a reusable, framework-agnostic, **domain-agnostic** `@sp/sync-core` package, plus a sibling @@ -668,6 +669,20 @@ contracts, and these app services now explicitly satisfy them: This is contract-only: NgRx dispatch, hydration windows, archive IndexedDB handling, and deferred local action processing remain app-side. +App-side adapter specs now exercise the first port set through the sync-core +types: + +- `OperationApplyPort` and `ActionDispatchPort` coverage in + `operation-applier.service.spec.ts`, including action/meta identity, bulk + operation reference preservation, dispatch-yield-before-archive ordering, + remote cooldown/end-window/deferred flush ordering, and local hydration + close-window/deferred flush behavior. +- `RemoteApplyWindowPort` coverage in `hydration-state.service.spec.ts`. +- `ArchiveSideEffectPort` coverage in + `archive-operation-handler.service.spec.ts`. +- `DeferredLocalActionsPort` coverage in `operation-log.effects.spec.ts`. +- `OperationStorePort` coverage in `operation-log-store.service.spec.ts`. + ### Ports - `OperationStorePort` - abstract over op-log persistence. Method names use diff --git a/src/app/op-log/apply/archive-operation-handler.service.spec.ts b/src/app/op-log/apply/archive-operation-handler.service.spec.ts index 89b6229fee..d199f7d7c8 100644 --- a/src/app/op-log/apply/archive-operation-handler.service.spec.ts +++ b/src/app/op-log/apply/archive-operation-handler.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import type { ArchiveSideEffectPort } from '@sp/sync-core'; import { ArchiveOperationHandler, isArchiveAffectingAction, @@ -195,6 +196,29 @@ describe('ArchiveOperationHandler', () => { }); describe('handleOperation', () => { + it('should expose archive side effects through ArchiveSideEffectPort without mutating action meta', async () => { + const port: ArchiveSideEffectPort = service; + const tasks = [createMockTaskWithSubTasks('task-1')]; + const meta: PersistentAction['meta'] = { + isPersistent: true, + isRemote: true, + entityType: 'TASK', + opType: OpType.Update, + }; + const action = { + type: TaskSharedActions.moveToArchive.type, + tasks, + meta, + } as unknown as PersistentAction; + + await port.handleOperation(action); + + expect(action.meta).toBe(meta); + expect( + mockArchiveService.writeTasksToArchiveForRemoteSync, + ).toHaveBeenCalledOnceWith(tasks); + }); + describe('moveToArchive action', () => { it('should write tasks to archive for remote sync', async () => { const tasks = [ diff --git a/src/app/op-log/apply/hydration-state.service.spec.ts b/src/app/op-log/apply/hydration-state.service.spec.ts index 38237f4cb0..51d5240012 100644 --- a/src/app/op-log/apply/hydration-state.service.spec.ts +++ b/src/app/op-log/apply/hydration-state.service.spec.ts @@ -1,4 +1,5 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import type { RemoteApplyWindowPort } from '@sp/sync-core'; import { HydrationStateService } from './hydration-state.service'; import { getIsApplyingRemoteOps } from '../capture/operation-capture.meta-reducer'; @@ -23,6 +24,24 @@ describe('HydrationStateService', () => { }); }); + describe('RemoteApplyWindowPort contract', () => { + it('should expose remote apply window state through the port methods', fakeAsync(() => { + const port: RemoteApplyWindowPort = service; + + port.startApplyingRemoteOps(); + expect(port.isApplyingRemoteOps?.()).toBeTrue(); + expect(service.isInSyncWindow()).toBeTrue(); + + port.startPostSyncCooldown(50); + port.endApplyingRemoteOps(); + expect(port.isApplyingRemoteOps?.()).toBeFalse(); + expect(service.isInSyncWindow()).toBeTrue(); + + tick(100); + expect(service.isInSyncWindow()).toBeFalse(); + })); + }); + describe('startApplyingRemoteOps', () => { it('should set isApplyingRemoteOps to true', () => { service.startApplyingRemoteOps(); diff --git a/src/app/op-log/apply/operation-applier.service.spec.ts b/src/app/op-log/apply/operation-applier.service.spec.ts index 659c04054b..4cab10dd6f 100644 --- a/src/app/op-log/apply/operation-applier.service.spec.ts +++ b/src/app/op-log/apply/operation-applier.service.spec.ts @@ -1,5 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; +import type { + ActionDispatchPort, + OperationApplyPort, + SyncActionLike, +} from '@sp/sync-core'; import { OperationApplierService } from './operation-applier.service'; import { Operation, OpType, EntityType, ActionType } from '../core/operation.types'; import { ArchiveOperationHandler } from './archive-operation-handler.service'; @@ -65,6 +70,36 @@ describe('OperationApplierService', () => { service = TestBed.inject(OperationApplierService); }); + describe('port contracts', () => { + it('should expose operation application through OperationApplyPort', async () => { + const applyPort: OperationApplyPort = service; + const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' }); + + const result = await applyPort.applyOperations([op]); + + expect(result.appliedOps).toEqual([op]); + expect(mockStore.dispatch).toHaveBeenCalledOnceWith( + bulkApplyOperations({ operations: [op] }), + ); + }); + + it('should preserve action object and meta identity through ActionDispatchPort', () => { + const dispatchPort: ActionDispatchPort = mockStore; + const meta = { source: 'remote-sync', nested: { marker: 'keep-reference' } }; + const action: SyncActionLike = { + type: '[Test] Dispatch Port Action', + meta, + }; + + dispatchPort.dispatch(action); + + const dispatchedAction = mockStore.dispatch.calls.first() + .args[0] as unknown as SyncActionLike; + expect(dispatchedAction).toBe(action); + expect(dispatchedAction.meta).toBe(meta); + }); + }); + describe('applyOperations with bulk dispatch', () => { it('should dispatch bulkApplyOperations with single operation', async () => { const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' }); @@ -109,6 +144,23 @@ describe('OperationApplierService', () => { expect(result.failedOp).toBeUndefined(); }); + it('should preserve the operation array and operation object references in the bulk action', async () => { + const payload = { title: 'Reference payload' }; + const op = createMockOperation('op-1', 'TASK', OpType.Update, payload); + const ops = [op]; + + await service.applyOperations(ops); + + const dispatchedAction = mockStore.dispatch.calls.first().args[0] as unknown as { + type: string; + operations: Operation[]; + }; + expect(dispatchedAction.type).toBe(bulkApplyOperations.type); + expect(dispatchedAction.operations).toBe(ops); + expect(dispatchedAction.operations[0]).toBe(op); + expect(dispatchedAction.operations[0].payload).toBe(payload); + }); + it('should handle empty operations array', async () => { const result = await service.applyOperations([]); @@ -256,6 +308,38 @@ describe('OperationApplierService', () => { expect(mockHydrationState.startPostSyncCooldown).toHaveBeenCalledTimes(1); }); + + it('should close the apply window and flush deferred actions for local hydration', async () => { + const callOrder: string[] = []; + const op = createMockOperation('op-1'); + + mockHydrationState.startApplyingRemoteOps.and.callFake(() => { + callOrder.push('startApplyingRemoteOps'); + }); + + mockHydrationState.startPostSyncCooldown.and.callFake(() => { + callOrder.push('startPostSyncCooldown'); + }); + + mockHydrationState.endApplyingRemoteOps.and.callFake(() => { + callOrder.push('endApplyingRemoteOps'); + }); + + mockOperationLogEffects.processDeferredActions.and.callFake(() => { + callOrder.push('processDeferredActions'); + return Promise.resolve(); + }); + + await service.applyOperations([op], { isLocalHydration: true }); + + expect(callOrder).toEqual([ + 'startApplyingRemoteOps', + 'endApplyingRemoteOps', + 'processDeferredActions', + ]); + expect(mockArchiveOperationHandler.handleOperation).not.toHaveBeenCalled(); + expect(mockHydrationState.startPostSyncCooldown).not.toHaveBeenCalled(); + }); }); describe('archive reload trigger', () => { @@ -371,6 +455,43 @@ describe('OperationApplierService', () => { expect(setTimeoutCalledWithZero).toBe(true); }); + it('should wait for the bulk dispatch yield before archive handling starts', async () => { + const archiveCalls: string[] = []; + const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' }); + let releaseDispatchYield: (() => void) | undefined; + const originalSetTimeout = window.setTimeout.bind(window); + + mockArchiveOperationHandler.handleOperation.and.callFake(() => { + archiveCalls.push('archive'); + return Promise.resolve(); + }); + + spyOn(window, 'setTimeout').and.callFake(((fn: () => void, ms?: number) => { + if (ms === 0 && !releaseDispatchYield) { + releaseDispatchYield = fn; + return 0; + } + return originalSetTimeout(fn, ms); + }) as typeof window.setTimeout); + + const applyPromise = service.applyOperations([op]); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + expect(archiveCalls).toEqual([]); + expect(mockArchiveOperationHandler.handleOperation).not.toHaveBeenCalled(); + expect(releaseDispatchYield).toBeDefined(); + + if (!releaseDispatchYield) { + fail('Expected dispatch yield callback to be captured'); + return; + } + + releaseDispatchYield(); + await applyPromise; + + expect(archiveCalls).toEqual(['archive']); + }); + it('should not yield to event loop when no operations are applied', async () => { let setTimeoutCalledWithZero = false; spyOn(window, 'setTimeout').and.callFake(((fn: () => void, ms?: number) => { diff --git a/src/app/op-log/capture/operation-log.effects.spec.ts b/src/app/op-log/capture/operation-log.effects.spec.ts index c61b5d3f94..dffd4b7f71 100644 --- a/src/app/op-log/capture/operation-log.effects.spec.ts +++ b/src/app/op-log/capture/operation-log.effects.spec.ts @@ -1,6 +1,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store } from '@ngrx/store'; +import type { DeferredLocalActionsPort } from '@sp/sync-core'; import { Observable, of } from 'rxjs'; import { OperationLogEffects } from './operation-log.effects'; import { OperationLogStoreService } from '../persistence/operation-log-store.service'; @@ -544,6 +545,22 @@ describe('OperationLogEffects', () => { * sync completes with fresh vector clocks. */ + it('should expose deferred action flushing through DeferredLocalActionsPort', async () => { + const port: DeferredLocalActionsPort = effects; + const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); + bufferDeferredAction(action); + + await port.processDeferredActions(); + + expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith( + jasmine.objectContaining({ + actionType: ActionType.TASK_SHARED_UPDATE, + clientId: 'testClient', + }), + 'local', + ); + }); + it('should do nothing when no deferred actions are buffered', async () => { await effects.processDeferredActions(); diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index 67e4d6cb70..1df2136989 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -1,9 +1,11 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import type { OperationStorePort } from '@sp/sync-core'; import { OperationLogStoreService } from './operation-log-store.service'; import { VectorClockService } from '../sync/vector-clock.service'; import { ActionType, Operation, + OperationLogEntry, OpType, EntityType, VectorClock, @@ -90,6 +92,35 @@ describe('OperationLogStoreService', () => { }); }); + describe('OperationStorePort contract', () => { + it('should expose unsynced, synced, and rejected transitions through the port methods', async () => { + const port: OperationStorePort = service; + const syncedOp = createTestOperation({ entityId: 'synced-task' }); + const rejectedOp = createTestOperation({ entityId: 'rejected-task' }); + + await service.append(syncedOp); + await service.append(rejectedOp); + + const initialUnsynced = await port.getUnsynced(); + expect(initialUnsynced.map((entry) => entry.op.id)).toEqual([ + syncedOp.id, + rejectedOp.id, + ]); + + const syncedEntry = initialUnsynced.find((entry) => entry.op.id === syncedOp.id); + expect(syncedEntry).toBeDefined(); + if (!syncedEntry) { + fail('Expected synced operation entry to exist'); + return; + } + + await port.markSynced([syncedEntry.seq]); + await port.markRejected([rejectedOp.id]); + + expect(await port.getUnsynced()).toEqual([]); + }); + }); + describe('append', () => { it('should append operation to log', async () => { const op = createTestOperation();