import { ApplicationRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { createDraftSync, DraftSnapshot } from './draft-sync'; function setup(adapter: Partial) { const navigate = vi.fn().mockResolvedValue(true); TestBed.configureTestingModule({ providers: [ { provide: AanvragenAdapter, useValue: adapter }, { provide: Router, useValue: { navigate } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, ], }); const snap = signal(null); const onResume = vi.fn(); const draftSync = TestBed.runInInjectionContext(() => createDraftSync({ type: 'registratie', snapshot: () => snap(), onResume, enabled: () => true, }), ); TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run return { draftSync, snap, navigate, onResume }; } const tick = () => TestBed.inject(ApplicationRef).tick(); describe('createDraftSync', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => { const create = vi.fn().mockResolvedValue('a1'); const syncDraft = vi.fn().mockResolvedValue(undefined); const { snap } = setup({ create, syncDraft }); snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); // still inside the 600ms debounce window — nothing has synced yet await vi.advanceTimersByTimeAsync(500); expect(syncDraft).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(200); expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three expect(syncDraft).toHaveBeenCalledWith( 'a1', expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins ); }); it('a trailing change after the debounce fires schedules its own sync', async () => { const create = vi.fn().mockResolvedValue('a1'); const syncDraft = vi.fn().mockResolvedValue(undefined); const { snap } = setup({ create, syncDraft }); snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); await vi.advanceTimersByTimeAsync(600); expect(syncDraft).toHaveBeenCalledTimes(1); snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] }); tick(); await vi.advanceTimersByTimeAsync(600); expect(syncDraft).toHaveBeenCalledTimes(2); expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 })); }); describe('submit', () => { it('resolves ok with the server response on success', async () => { const create = vi.fn().mockResolvedValue('a1'); const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true }); const { draftSync } = setup({ create, submit }); const r = await draftSync.submit({}); expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } }); expect(submit).toHaveBeenCalledWith('a1', {}); }); it('folds a rejected submit into a Result error, never throwing', async () => { const create = vi.fn().mockResolvedValue('a1'); const submit = vi.fn().mockRejectedValue(new Error('boom')); const { draftSync } = setup({ create, submit }); const r = await draftSync.submit({}); expect(r.ok).toBe(false); }); it('recovers from a create conflict by adopting the existing Concept', async () => { // Server enforces one Concept per type: a stale/cross-tab create is rejected (409), // and ensureId adopts the existing Concept from the list instead of erroring. const create = vi.fn().mockRejectedValue({ status: 409 }); const list = vi.fn().mockResolvedValue([ { id: 'existing-1', type: 'registratie', status: { tag: 'Concept', stepIndex: 1, stepCount: 3 }, createdAt: '2026-07-23T10:00:00Z', updatedAt: '2026-07-23T10:00:00Z', }, ]); const submit = vi.fn().mockResolvedValue({ id: 'existing-1', autoApprovable: true }); const { draftSync } = setup({ create, list, submit }); const r = await draftSync.submit({}); expect(r.ok).toBe(true); expect(submit).toHaveBeenCalledWith('existing-1', {}); // adopted, not a new id }); }); describe('flushPending (CanDeactivate guard / beforeunload)', () => { it('hasPendingSave reflects an armed debounce timer', () => { const { draftSync, snap } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft: vi.fn().mockResolvedValue(undefined), }); expect(draftSync.hasPendingSave()).toBe(false); snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); // the effect arms the 600ms timer expect(draftSync.hasPendingSave()).toBe(true); }); it('flushPending writes the pending draft immediately, before the debounce fires', async () => { const create = vi.fn().mockResolvedValue('a1'); const syncDraft = vi.fn().mockResolvedValue(undefined); const { draftSync, snap } = setup({ create, syncDraft }); snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); tick(); await draftSync.flushPending(); expect(syncDraft).toHaveBeenCalledTimes(1); // no timer advance needed expect(draftSync.hasPendingSave()).toBe(false); // timer consumed }); it('flushPending is a no-op when nothing is pending', async () => { const syncDraft = vi.fn().mockResolvedValue(undefined); const { draftSync } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft }); await draftSync.flushPending(); expect(syncDraft).not.toHaveBeenCalled(); }); }); });