api: unit-test the time, token, and validation helpers
Adds focused unit tests for the generic formatting helpers, the app-timezone day-window helpers, the token expiry helpers, and the validation-error formatter, covering the boundaries each one turns on
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago
b44450051fd5637c480a9dec8ae1f93921d1754c Browse filesParents
7d05736Changed files
4
Additions
+192
Deletions
0
/dev/null → api/tests/auth/jwt.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { isTokenExpired, getPasswordResetExpiration } from '@app/lib/jwt'
3 +
4 +
describe('isTokenExpired', () => {
5 +
it('treats a past date as expired', () => {
6 +
expect(isTokenExpired(new Date(Date.now() - 60_000))).toBe(true)
7 +
})
8 +
9 +
it('treats a future date as live', () => {
10 +
expect(isTokenExpired(new Date(Date.now() + 60_000))).toBe(false)
11 +
})
12 +
})
13 +
14 +
describe('getPasswordResetExpiration', () => {
15 +
it('lands one hour ahead of now', () => {
16 +
const oneHour = 60 * 60 * 1000
17 +
const before = Date.now()
18 +
const expiration = getPasswordResetExpiration()
19 +
const after = Date.now()
20 +
21 +
expect(expiration.getTime()).toBeGreaterThanOrEqual(before + oneHour)
22 +
expect(expiration.getTime()).toBeLessThanOrEqual(after + oneHour)
23 +
})
24 +
})
/dev/null → api/tests/errors/validation-error.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { z } from 'zod'
3 +
import { formatValidationError } from '@app/lib/errors/validationError'
4 +
5 +
describe('formatValidationError', () => {
6 +
it('keys a single field by its name', () => {
7 +
const schema = z.object({ email: z.string().email() })
8 +
const parsed = schema.safeParse({ email: 'nope' })
9 +
10 +
if (parsed.success) throw new Error('expected validation to fail')
11 +
12 +
const { error, fields } = formatValidationError(parsed.error)
13 +
14 +
expect(Object.keys(fields)).toEqual(['email'])
15 +
expect(typeof fields.email).toBe('string')
16 +
expect(fields.email.length).toBeGreaterThan(0)
17 +
expect(error).toContain(fields.email)
18 +
})
19 +
20 +
it('joins the segments of a nested path with dots', () => {
21 +
const schema = z.object({ profile: z.object({ age: z.number() }) })
22 +
const parsed = schema.safeParse({ profile: { age: 'x' } })
23 +
24 +
if (parsed.success) throw new Error('expected validation to fail')
25 +
26 +
const { fields } = formatValidationError(parsed.error)
27 +
28 +
expect(Object.keys(fields)).toEqual(['profile.age'])
29 +
})
30 +
31 +
it('keeps only the first message for a field', () => {
32 +
const schema = z.object({ code: z.string().min(5).regex(/^a/) })
33 +
const parsed = schema.safeParse({ code: 'b' })
34 +
35 +
if (parsed.success) throw new Error('expected validation to fail')
36 +
37 +
const { fields } = formatValidationError(parsed.error)
38 +
39 +
expect(parsed.error.issues.length).toBeGreaterThan(1)
40 +
expect(fields.code).toBe(parsed.error.issues[0].message)
41 +
expect(fields.code).not.toBe(parsed.error.issues[1].message)
42 +
})
43 +
})
/dev/null → api/tests/insights/app-day.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
startOfAppDay,
4 +
previousAppDay,
5 +
parseAppDay,
6 +
appDayWindow,
7 +
appDayPeriod,
8 +
} from '@app/lib/queue/processors/insights/utils'
9 +
10 +
// America/Sao_Paulo is UTC-3 all year (Brazil dropped daylight saving in 2019),
11 +
// so the app's local day starts at 03:00Z; the trailing 3 is that offset.
12 +
const saoPauloMidnight = (year: number, month: number, day: number) =>
13 +
Date.UTC(year, month - 1, day, 3, 0, 0)
14 +
15 +
describe('startOfAppDay', () => {
16 +
it('floors a mid-day instant to the start of the local day', () => {
17 +
expect(startOfAppDay(new Date('2026-10-17T12:00:00.000Z')).getTime()).toBe(
18 +
saoPauloMidnight(2026, 10, 17),
19 +
)
20 +
})
21 +
22 +
it('keeps an instant just before local midnight on the same day', () => {
23 +
expect(
24 +
startOfAppDay(new Date('2026-10-18T02:59:59.999Z')).getTime(),
25 +
).toBe(saoPauloMidnight(2026, 10, 17))
26 +
})
27 +
28 +
it('rolls over to the next day at local midnight', () => {
29 +
expect(
30 +
startOfAppDay(new Date('2026-10-18T03:00:00.000Z')).getTime(),
31 +
).toBe(saoPauloMidnight(2026, 10, 18))
32 +
})
33 +
})
34 +
35 +
describe('previousAppDay', () => {
36 +
it('returns the start of the day before', () => {
37 +
expect(previousAppDay(new Date('2026-10-17T12:00:00.000Z')).getTime()).toBe(
38 +
saoPauloMidnight(2026, 10, 16),
39 +
)
40 +
})
41 +
})
42 +
43 +
describe('parseAppDay', () => {
44 +
it('parses a day string at the local midnight', () => {
45 +
expect(parseAppDay('2026-10-17').getTime()).toBe(
46 +
saoPauloMidnight(2026, 10, 17),
47 +
)
48 +
})
49 +
})
50 +
51 +
describe('appDayWindow', () => {
52 +
it('spans from one local day start to the next', () => {
53 +
const { start, end } = appDayWindow(new Date('2026-10-17T12:00:00.000Z'))
54 +
55 +
expect(start.getTime()).toBe(saoPauloMidnight(2026, 10, 17))
56 +
expect(end.getTime()).toBe(saoPauloMidnight(2026, 10, 18))
57 +
})
58 +
59 +
it('rolls the whole window over at local midnight', () => {
60 +
const before = appDayWindow(new Date('2026-10-18T02:59:59.999Z'))
61 +
const after = appDayWindow(new Date('2026-10-18T03:00:00.000Z'))
62 +
63 +
expect(before.start.getTime()).toBe(saoPauloMidnight(2026, 10, 17))
64 +
expect(before.end.getTime()).toBe(saoPauloMidnight(2026, 10, 18))
65 +
expect(after.start.getTime()).toBe(saoPauloMidnight(2026, 10, 18))
66 +
expect(after.end.getTime()).toBe(saoPauloMidnight(2026, 10, 19))
67 +
})
68 +
})
69 +
70 +
describe('appDayPeriod', () => {
71 +
it('serializes the window as offset ISO strings', () => {
72 +
expect(appDayPeriod(new Date('2026-10-17T12:00:00.000Z'))).toEqual({
73 +
periodStart: '2026-10-17T00:00:00.000-03:00',
74 +
periodEnd: '2026-10-18T00:00:00.000-03:00',
75 +
})
76 +
})
77 +
})
/dev/null → api/tests/utils/calc.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { avg, formatHours, formatDiff } from '@app/utils/calc'
3 +
4 +
describe('avg', () => {
5 +
it('averages a list of numbers', () => {
6 +
expect(avg([2, 4, 6])).toBe(4)
7 +
})
8 +
9 +
it('yields NaN for an empty list', () => {
10 +
expect(avg([])).toBeNaN()
11 +
})
12 +
})
13 +
14 +
describe('formatHours', () => {
15 +
it('drops the minutes for a whole hour', () => {
16 +
expect(formatHours(7)).toBe('7h')
17 +
})
18 +
19 +
it('renders the remaining minutes', () => {
20 +
expect(formatHours(7.5)).toBe('7h 30min')
21 +
})
22 +
})
23 +
24 +
describe('formatDiff', () => {
25 +
it('signs a positive whole-hour difference', () => {
26 +
expect(formatDiff(120)).toBe('+2h')
27 +
})
28 +
29 +
it('signs a negative whole-hour difference', () => {
30 +
expect(formatDiff(-120)).toBe('-2h')
31 +
})
32 +
33 +
it('signs a positive difference with hours and minutes', () => {
34 +
expect(formatDiff(90)).toBe('+1h 30min')
35 +
})
36 +
37 +
it('signs a negative difference with hours and minutes', () => {
38 +
expect(formatDiff(-90)).toBe('-1h 30min')
39 +
})
40 +
41 +
it('renders a positive sub-hour difference in minutes only', () => {
42 +
expect(formatDiff(30)).toBe('+30min')
43 +
})
44 +
45 +
it('renders a negative sub-hour difference in minutes only', () => {
46 +
expect(formatDiff(-30)).toBe('-30min')
47 +
})
48 +
})