test(sync-core): add port contract adapter coverage

This commit is contained in:
johannesjo 2026-05-11 23:08:20 +02:00
parent 1c337866d6
commit bc437c7881
6 changed files with 229 additions and 2 deletions

View file

@ -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

View file

@ -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<PersistentAction> = 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 = [

View file

@ -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();

View file

@ -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<Operation> = 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<SyncActionLike> = 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) => {

View file

@ -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();

View file

@ -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<Operation, OperationLogEntry> = 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();