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()) + }) +}) 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/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/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) +} 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 +} 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() +})