Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions test/time/boundary-examples.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
53 changes: 53 additions & 0 deletions test/time/fakeClock.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
38 changes: 38 additions & 0 deletions test/time/fakeClock.ts
Original file line number Diff line number Diff line change
@@ -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<void>
advanceByMinutes(n: number): Promise<void>
advanceByHours(n: number): Promise<void>
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()
},
}
}
3 changes: 3 additions & 0 deletions test/time/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './fakeClock'
export * from './offsets'
export * from './sleep'
28 changes: 28 additions & 0 deletions test/time/offsets.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
30 changes: 30 additions & 0 deletions test/time/offsets.ts
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions test/time/sleep.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
12 changes: 12 additions & 0 deletions test/time/sleep.ts
Original file line number Diff line number Diff line change
@@ -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<typeof sleep> {
const spy = vi.mocked(sleep)
// Reset call history — clearMocks is off in this project.
spy.mockClear()
spy.mockImplementation(() => Promise.resolve())
return spy
}
8 changes: 8 additions & 0 deletions vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { afterEach, vi } from 'vitest'
import { applyPlaceholderServerEnv } from './test/support/placeholder-env'

// Provide placeholder env vars so server-only modules that validate via Zod
// at import time don't blow up during tests. Tests must not rely on these
// 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()
})
Loading