From 2a7e76a0647a19135608e5779b3efb3d1e9fae54 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 5 Aug 2026 21:48:49 +0545 Subject: [PATCH 1/4] test(OUT-3988): add useFakeClock fake-timer wrapper + safety net Wraps vitest fake timers so a test can freeze/advance "now" with no real waiting. toFake is scoped to Date + timer fns (never nextTick/microtask) and advancing is async so pending sleep() calls flush. Adds a unit-only afterEach(vi.useRealTimers()) net so a forgotten restore can't leak. Co-Authored-By: Claude Opus 4.8 --- test/time/fakeClock.test.ts | 53 +++++++++++++++++++++++++++++++++++++ test/time/fakeClock.ts | 38 ++++++++++++++++++++++++++ vitest.setup.ts | 8 ++++++ 3 files changed, 99 insertions(+) create mode 100644 test/time/fakeClock.test.ts create mode 100644 test/time/fakeClock.ts diff --git a/test/time/fakeClock.test.ts b/test/time/fakeClock.test.ts new file mode 100644 index 0000000..0de28a0 --- /dev/null +++ b/test/time/fakeClock.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { sleep } from '@/utils/sleep' +import { useFakeClock } from './fakeClock' + +afterEach(() => vi.useRealTimers()) + +describe('useFakeClock', () => { + it('freezes now at the given instant', () => { + const clock = useFakeClock('2026-03-01T12:00:00.000Z') + expect(new Date().toISOString()).toBe('2026-03-01T12:00:00.000Z') + expect(Date.now()).toBe(new Date('2026-03-01T12:00:00.000Z').getTime()) + clock.restore() + }) + + it('advances deterministically with advanceByMinutes', async () => { + const clock = useFakeClock('2026-03-01T12:00:00.000Z') + await clock.advanceByMinutes(5) + expect(new Date().toISOString()).toBe('2026-03-01T12:05:00.000Z') + clock.restore() + }) + + it('advances hours', async () => { + const clock = useFakeClock('2026-03-01T12:00:00.000Z') + await clock.advanceByHours(2) + expect(new Date().toISOString()).toBe('2026-03-01T14:00:00.000Z') + clock.restore() + }) + + it('flushes a real sleep() without really waiting', async () => { + const clock = useFakeClock() + let resolved = false + const p = sleep(5000).then(() => { + resolved = true + }) + await clock.advanceBy(5000) + await p + expect(resolved).toBe(true) + clock.restore() + }) + + it('setNow jumps to an absolute instant', () => { + const clock = useFakeClock() + clock.setNow(new Date('2030-01-01T00:00:00.000Z')) + expect(new Date().getFullYear()).toBe(2030) + clock.restore() + }) + + it('restore returns to real time', () => { + const clock = useFakeClock('2000-01-01T00:00:00.000Z') + clock.restore() + expect(new Date().getFullYear()).toBeGreaterThan(2020) + }) +}) diff --git a/test/time/fakeClock.ts b/test/time/fakeClock.ts new file mode 100644 index 0000000..c617457 --- /dev/null +++ b/test/time/fakeClock.ts @@ -0,0 +1,38 @@ +import { vi } from 'vitest' + +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 + +// Faked sources exclude nextTick/queueMicrotask so promises still resolve. +const FAKED = ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] as const + +export interface FakeClock { + advanceBy(ms: number): Promise + advanceByMinutes(n: number): Promise + advanceByHours(n: number): Promise + setNow(date: Date): void + restore(): void +} + +// Freezes time for one test. Advancing is async so pending sleep() calls flush. +export function useFakeClock(iso = '2026-01-01T00:00:00.000Z'): FakeClock { + vi.useFakeTimers({ toFake: [...FAKED] }) + vi.setSystemTime(new Date(iso)) + return { + async advanceBy(ms) { + await vi.advanceTimersByTimeAsync(ms) + }, + async advanceByMinutes(n) { + await vi.advanceTimersByTimeAsync(n * MINUTE_MS) + }, + async advanceByHours(n) { + await vi.advanceTimersByTimeAsync(n * HOUR_MS) + }, + setNow(date) { + vi.setSystemTime(date) + }, + restore() { + vi.useRealTimers() + }, + } +} diff --git a/vitest.setup.ts b/vitest.setup.ts index d9f0a5f..1db7007 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,3 +1,4 @@ +import { afterEach, vi } from 'vitest' import { applyPlaceholderServerEnv } from './test/support/placeholder-env' // Provide placeholder env vars so server-only modules that validate via Zod @@ -5,3 +6,10 @@ import { applyPlaceholderServerEnv } from './test/support/placeholder-env' // values for behavioral assertions — they exist solely to satisfy schema parse. applyPlaceholderServerEnv() process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test' + +// Safety net: if a test froze time via useFakeClock() and forgot to restore(), +// reset to real timers so it can't leak into the next test. A no-op when timers +// were never faked. +afterEach(() => { + vi.useRealTimers() +}) From 523b4c3a1418728f3c72758c64c099982387940f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 5 Aug 2026 21:48:51 +0545 Subject: [PATCH 2/4] test(OUT-3988): add relative-time offset helpers Pure Date-math helpers (msAgo/secondsAgo/minutesAgo/hoursAgo/daysAgo/fromNow) off the live Date.now(). Value-agnostic so they work under a frozen clock (exact) and against real Postgres NOW() (for SQL-side boundary seeding). Co-Authored-By: Claude Opus 4.8 --- test/time/offsets.test.ts | 28 ++++++++++++++++++++++++++++ test/time/offsets.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 test/time/offsets.test.ts create mode 100644 test/time/offsets.ts diff --git a/test/time/offsets.test.ts b/test/time/offsets.test.ts new file mode 100644 index 0000000..9fa31d5 --- /dev/null +++ b/test/time/offsets.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useFakeClock } from './fakeClock' +import { daysAgo, fromNow, hoursAgo, minutesAgo, msAgo, secondsAgo } from './offsets' + +afterEach(() => vi.useRealTimers()) + +describe('offset helpers (real time)', () => { + it('minutesAgo returns a time before now', () => { + expect(minutesAgo(5).getTime()).toBeLessThan(Date.now()) + }) + + it('fromNow returns a time after now', () => { + expect(fromNow(10_000).getTime()).toBeGreaterThan(Date.now()) + }) +}) + +describe('offset helpers (frozen clock — exact)', () => { + it('are exact relative to frozen now', () => { + const clock = useFakeClock('2026-06-01T00:00:00.000Z') + expect(msAgo(500).toISOString()).toBe('2026-05-31T23:59:59.500Z') + expect(secondsAgo(30).toISOString()).toBe('2026-05-31T23:59:30.000Z') + expect(minutesAgo(5).toISOString()).toBe('2026-05-31T23:55:00.000Z') + expect(hoursAgo(2).toISOString()).toBe('2026-05-31T22:00:00.000Z') + expect(daysAgo(1).toISOString()).toBe('2026-05-31T00:00:00.000Z') + expect(fromNow(60_000).toISOString()).toBe('2026-06-01T00:01:00.000Z') + clock.restore() + }) +}) diff --git a/test/time/offsets.ts b/test/time/offsets.ts new file mode 100644 index 0000000..d410320 --- /dev/null +++ b/test/time/offsets.ts @@ -0,0 +1,30 @@ +// Date-math off the live Date.now(). Freeze with useFakeClock() for exact +// values; against real Postgres it tracks real NOW() for SQL-side tests. +const SECOND_MS = 1_000 +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +export function msAgo(n: number): Date { + return new Date(Date.now() - n) +} + +export function secondsAgo(n: number): Date { + return new Date(Date.now() - n * SECOND_MS) +} + +export function minutesAgo(n: number): Date { + return new Date(Date.now() - n * MINUTE_MS) +} + +export function hoursAgo(n: number): Date { + return new Date(Date.now() - n * HOUR_MS) +} + +export function daysAgo(n: number): Date { + return new Date(Date.now() - n * DAY_MS) +} + +export function fromNow(ms: number): Date { + return new Date(Date.now() + ms) +} From e29e215c8fa45fcce9a537872724ee58a313286f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 5 Aug 2026 21:48:52 +0545 Subject: [PATCH 3/4] test(OUT-3988): add mockSleepInstant + time-toolkit barrel mockSleepInstant() swaps sleep() for an instant spy so a test can assert the intended wait (toHaveBeenCalledWith) with no delay. Clears call history since clearMocks is off in the unit project. Adds the test/time barrel. Co-Authored-By: Claude Opus 4.8 --- test/time/index.ts | 3 +++ test/time/sleep.test.ts | 22 ++++++++++++++++++++++ test/time/sleep.ts | 12 ++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 test/time/index.ts create mode 100644 test/time/sleep.test.ts create mode 100644 test/time/sleep.ts diff --git a/test/time/index.ts b/test/time/index.ts new file mode 100644 index 0000000..d9a3be1 --- /dev/null +++ b/test/time/index.ts @@ -0,0 +1,3 @@ +export * from './fakeClock' +export * from './offsets' +export * from './sleep' diff --git a/test/time/sleep.test.ts b/test/time/sleep.test.ts new file mode 100644 index 0000000..3238abc --- /dev/null +++ b/test/time/sleep.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { sleep } from '@/utils/sleep' +import { mockSleepInstant } from './sleep' + +// Hoisted: auto-mocks @/utils/sleep for this whole file. +vi.mock('@/utils/sleep') + +describe('mockSleepInstant', () => { + it('resolves immediately and records the requested duration', async () => { + const spy = mockSleepInstant() + await sleep(5000) + expect(spy).toHaveBeenCalledWith(5000) + }) + + it('records multiple calls in order without waiting', async () => { + const spy = mockSleepInstant() + await sleep(800) + await sleep(5000) + expect(spy).toHaveBeenNthCalledWith(1, 800) + expect(spy).toHaveBeenNthCalledWith(2, 5000) + }) +}) diff --git a/test/time/sleep.ts b/test/time/sleep.ts new file mode 100644 index 0000000..44f821c --- /dev/null +++ b/test/time/sleep.ts @@ -0,0 +1,12 @@ +import { type Mock, vi } from 'vitest' +import { sleep } from '@/utils/sleep' + +// Instant spy for sleep() — assert the wait (e.g. toHaveBeenCalledWith(5000)) +// with no real delay. Caller's file must hoist vi.mock('@/utils/sleep') first. +export function mockSleepInstant(): Mock { + const spy = vi.mocked(sleep) + // Reset call history — clearMocks is off in this project. + spy.mockClear() + spy.mockImplementation(() => Promise.resolve()) + return spy +} From 3ad402a6649a6ebd93324b39437e0e55c9c6885f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 5 Aug 2026 21:48:54 +0545 Subject: [PATCH 4/4] test(OUT-3988): add two-world boundary examples Executable docs for the JS-world (frozen-clock debounce boundary) and SQL-world (offset-seed template, real assertion deferred to L2.3) usage. Co-Authored-By: Claude Opus 4.8 --- test/time/boundary-examples.test.ts | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 test/time/boundary-examples.test.ts diff --git a/test/time/boundary-examples.test.ts b/test/time/boundary-examples.test.ts new file mode 100644 index 0000000..64c3b41 --- /dev/null +++ b/test/time/boundary-examples.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useFakeClock } from './fakeClock' +import { minutesAgo } from './offsets' + +afterEach(() => vi.useRealTimers()) + +// JS world: mirrors the debounce comparison at +// src/features/webhook/dropbox/lib/webhook.service.ts:42, reproduced purely +// (no DB, no prod import — DEBOUNCE_WINDOW_MS is module-local in prod). +describe('JS-world boundary example: webhook debounce', () => { + const DEBOUNCE_WINDOW_MS = 5 * 60 * 1000 // documentation copy of the prod window + + const isRecentlySynced = (lastStartedAt: Date) => + lastStartedAt >= new Date(Date.now() - DEBOUNCE_WINDOW_MS) + + it('a stamp 4 minutes ago is inside the window (debounced → defer to cron)', () => { + const clock = useFakeClock() + expect(isRecentlySynced(minutesAgo(4))).toBe(true) + clock.restore() + }) + + it('a stamp 6 minutes ago is outside the window (sync triggers)', () => { + const clock = useFakeClock() + expect(isRecentlySynced(minutesAgo(6))).toBe(false) + clock.restore() + }) +}) + +// SQL world: the resync backoff compares in Postgres (NOW()), which JS cannot +// fake. This documents the seed-offset shape; the real assertion against real +// Postgres lands in L2.3, not here. +describe('SQL-world boundary template: resync backoff', () => { + it('documents the past-backoff seed offset (real assertion in L2.3)', () => { + // Integration usage: + // await fileSyncSeeder.create( + // pendingCreate(PendingActionTarget.DROPBOX), + // { pendingActionLastAttemptAt: minutesAgo(6) }, // past 5min × 1 attempt + // ) + // // then assert findFailedSyncs() (SQL NOW()) INCLUDES the row. + const seededAt = minutesAgo(6) + expect(seededAt.getTime()).toBeLessThan(Date.now()) + }) +})