import { describe, expect, it } from 'vitest'; import { advanceOneMs, madeForwardProgress, normalisePage, pageIsEmpty, planEmailFetches, planMailboxFetches, updatedPropertiesAreCountsOnly, type ChangesPage, } from '../apply'; import { asChangesState } from '../states'; function page(partial: Partial): ChangesPage { return { oldState: asChangesState('old'), newState: asChangesState('new'), hasMoreChanges: false, created: [], updated: [], destroyed: [], ...partial, }; } describe('normalisePage', () => { it('lets a destroyed id win outright over created and updated', () => { // Fetching an id that is also destroyed spends a request to get `notFound`. const out = normalisePage(page({ created: ['a', 'b'], updated: ['a'], destroyed: ['a'] })); expect(out.created).toEqual(['b']); expect(out.updated).toEqual([]); expect(out.destroyed).toEqual(['a']); }); it('treats an id in both created and updated as a create', () => { // The create path fetches the full envelope tier, which already contains the // updated values - so an extra 3-property fetch would be pure waste. const out = normalisePage(page({ created: ['a'], updated: ['a'] })); expect(out.created).toEqual(['a']); expect(out.updated).toEqual([]); }); it('deduplicates within each bucket', () => { const out = normalisePage(page({ created: ['a', 'a'], destroyed: ['b', 'b'] })); expect(out.created).toEqual(['a']); expect(out.destroyed).toEqual(['b']); }); }); describe('pageIsEmpty', () => { it('is true only when nothing changed', () => { // An empty page STILL has to advance the cursor: skipping it re-requests the // same position forever. expect(pageIsEmpty(page({}))).toBe(true); expect(pageIsEmpty(page({ updated: ['a'] }))).toBe(false); }); }); describe('planEmailFetches', () => { it('drops an updated id we do not hold locally, BEFORE any fetch is issued', () => { // The absent case is an unconditional no-op. Fetching it would need a // `receivedAt` the 3-property response cannot supply and the schema's // NOT NULL would reject. Coverage enumerates CURRENT state, so it will pick // the record up with the updated values anyway. const plan = planEmailFetches(page({ updated: ['have', 'missing'] }), new Set(['have'])); expect(plan.updateIds).toEqual(['have']); }); it('keeps creates unconditional - presence is irrelevant for a create', () => { const plan = planEmailFetches(page({ created: ['new'] }), new Set()); expect(plan.createIds).toEqual(['new']); }); it('never routes an id into both the create and the update fetch', () => { const plan = planEmailFetches(page({ created: ['a'], updated: ['a'] }), new Set(['a'])); expect(plan.createIds).toEqual(['a']); expect(plan.updateIds).toEqual([]); }); }); describe('updatedPropertiesAreCountsOnly', () => { it('is true for the four counters and for an empty list', () => { expect(updatedPropertiesAreCountsOnly(['unreadEmails'])).toBe(true); expect(updatedPropertiesAreCountsOnly(['totalEmails', 'unreadThreads'])).toBe(true); // "nothing but the state token moved" is counts-only vacuously. expect(updatedPropertiesAreCountsOnly([])).toBe(true); }); it('is false when the server will not say what changed', () => { // `null` means "assume everything", so the whole object must be re-fetched. expect(updatedPropertiesAreCountsOnly(null)).toBe(false); expect(updatedPropertiesAreCountsOnly(undefined)).toBe(false); }); it('is false as soon as one non-count property is present', () => { expect(updatedPropertiesAreCountsOnly(['unreadEmails', 'name'])).toBe(false); }); }); describe('planMailboxFetches', () => { it('routes updates to the cheap four-integer patch when only counts moved', () => { const plan = planMailboxFetches( page({ created: ['new'], updated: ['old'], updatedProperties: ['unreadEmails'] }), ); expect(plan.fullIds).toEqual(['new']); expect(plan.countOnlyIds).toEqual(['old']); }); it('re-fetches the whole object when updatedProperties is null', () => { const plan = planMailboxFetches(page({ updated: ['old'], updatedProperties: null })); expect(plan.fullIds).toEqual(['old']); expect(plan.countOnlyIds).toEqual([]); }); }); describe('keyset progress', () => { it('requires STRICTLY greater, because `after` is spec-inclusive', () => { // RFC 8621 s4.4.1: receivedAt "must be the same or after this date-time to // match". So every page re-returns the boundary message, and equality is NOT // progress - treating it as progress would loop on that millisecond forever. expect(madeForwardProgress('2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')).toBe(false); expect(madeForwardProgress('2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z')).toBe(true); expect(madeForwardProgress(null, '2026-01-01T00:00:00.000Z')).toBe(false); expect(madeForwardProgress('2026-01-01T00:00:00.000Z', null)).toBe(true); }); it('advances exactly one millisecond in the last-resort rung', () => { expect(advanceOneMs('2026-01-01T00:00:00.000Z')).toBe('2026-01-01T00:00:00.001Z'); // A malformed value must not become NaN and poison the cursor. expect(advanceOneMs('not-a-date')).toBe('not-a-date'); }); });