api: unit-test the export labels and schema refinements
The export serializers, then applies a map, and the Zod bounds were only exercised through the database suite, so a broken escape or a drifted label would slip past the fast tier. These pure tests pin the CSV round-trip, each label map, and the mood, sleep, time, activation, day, and impact refinements without touching the DB
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago
82d1a5f43667612a93eade471c7491c813da6c42 Browse filesParents
3292568Changed files
4
Additions
+328
Deletions
0
/dev/null → api/tests/exports/descriptors.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { Insight, MedicineRegimen, SleepRecord, User } from '@prisma/client'
3 +
import {
4 +
insightTable,
5 +
medicineRegimenTable,
6 +
profileTable,
7 +
sleepRecordTable,
8 +
} from '@app/services/export/table.descriptors'
9 +
import { CsvTable } from '@app/services/export/types'
10 +
11 +
const columnValue = <Row>(table: CsvTable<Row>, header: string) => {
12 +
const column = table.columns.find((column) => column.header === header)
13 +
14 +
if (!column) throw new Error(`a tabela não tem a coluna ${header}`)
15 +
16 +
return column.value
17 +
}
18 +
19 +
describe('boolean descriptor values', () => {
20 +
it('renders true as Sim and false as Não', () => {
21 +
const user = { active: true, pushEnabled: false } as User
22 +
23 +
expect(columnValue(profileTable, 'Ativo')(user)).toBe('Sim')
24 +
expect(columnValue(profileTable, 'Notificações por push')(user)).toBe('Não')
25 +
})
26 +
27 +
it('renders a regimen active flag the same way', () => {
28 +
const regimen = { active: false } as MedicineRegimen
29 +
30 +
expect(columnValue(medicineRegimenTable, 'Ativo')(regimen)).toBe('Não')
31 +
})
32 +
})
33 +
34 +
describe('json descriptor values', () => {
35 +
it('serializes metadata to a JSON string', () => {
36 +
const insight = { metadata: { delta: -1, avgFirst: 5 } } as unknown as Insight
37 +
38 +
expect(columnValue(insightTable, 'Metadados')(insight)).toBe(
39 +
'{"delta":-1,"avgFirst":5}',
40 +
)
41 +
})
42 +
43 +
it('keeps null metadata empty instead of the string "null"', () => {
44 +
const insight = { metadata: null } as Insight
45 +
46 +
expect(columnValue(insightTable, 'Metadados')(insight)).toBeNull()
47 +
})
48 +
})
49 +
50 +
describe('date-only descriptor values', () => {
51 +
it('drops the time from a sleep record date', () => {
52 +
const record = { date: new Date('2026-10-17T15:45:30.000Z') } as SleepRecord
53 +
54 +
expect(columnValue(sleepRecordTable, 'Data')(record)).toBe('2026-10-17')
55 +
})
56 +
})
api/tests/exports/format.unit.test.ts
1 1
import { describe, it, expect } from 'vitest'
2 2
import { serializeCsv } from '@app/services/export/csv.serializer'
3 3
import { buildManifest } from '@app/services/export/manifest.builder'
4 4
import { profileTable } from '@app/services/export/table.descriptors'
5 +
import { CsvTable } from '@app/services/export/types'
5 6
6 7
describe('serializeCsv', () => {
7 8
it('keeps the header row and a BOM when the table has no rows', () => {
8 9
const file = serializeCsv(profileTable, [])
9 10
const text = file.content.toString('utf8')
13 14
14 15
const lines = text.slice(1).split('\r\n').filter(Boolean)
15 16
expect(lines).toHaveLength(1)
16 17
expect(lines[0]).toContain('Email')
17 18
})
19 +
20 +
it('round-trips commas, double quotes, and newlines through escaped fields', () => {
21 +
const table: CsvTable<{ text: string }> = {
22 +
filename: 'notes.csv',
23 +
columns: [{ header: 'Texto', value: (row) => row.text }],
24 +
}
25 +
26 +
const values = ['a,b', 'he said "hi"', 'line one\nline two']
27 +
const file = serializeCsv(
28 +
table,
29 +
values.map((text) => ({ text })),
30 +
)
31 +
const body = file.content.toString('utf8').slice(1)
32 +
33 +
// RFC 4180: every string field is quoted and any inner quote doubled, so
34 +
// reading the quoted fields back and undoubling the quotes recovers the
35 +
// original values byte-for-byte.
36 +
const fields = [...body.matchAll(/"((?:[^"]|"")*)"/g)].map((match) =>
37 +
match[1].replaceAll('""', '"'),
38 +
)
39 +
40 +
expect(fields).toEqual(['Texto', ...values])
41 +
expect(file.rows).toBe(3)
42 +
})
18 43
})
19 44
20 45
describe('buildManifest', () => {
21 46
it('states the generation time and lists each file with its row count', () => {
22 47
const manifest = buildManifest(
27 52
28 53
expect(manifest.filename).toBe('leia-me.md')
29 54
expect(text).toContain('2026-10-04T12:00:00.000Z')
30 55
expect(text).toContain('`profile.csv` — 0 registros')
31 56
expect(text).toContain('_id')
57 +
})
58 +
59 +
it('lists every file and singularizes a count of one', () => {
60 +
const manifest = buildManifest(
61 +
[
62 +
{ filename: 'moods.csv', content: Buffer.from(''), rows: 1 },
63 +
{ filename: 'triggers.csv', content: Buffer.from(''), rows: 3 },
64 +
],
65 +
new Date('2026-10-04T12:00:00.000Z'),
66 +
)
67 +
const text = manifest.content.toString('utf8')
68 +
69 +
expect(text).toContain('`moods.csv` — 1 registro')
70 +
expect(text).toContain('`triggers.csv` — 3 registros')
32 71
})
33 72
})
/dev/null → api/tests/exports/label-map.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
activityCategoryLabel,
4 +
appointmentTypeLabel,
5 +
baseMoodLabel,
6 +
careActionTypeLabel,
7 +
insightPeriodLabel,
8 +
insightTypeLabel,
9 +
intensityLabel,
10 +
medicinePeriodicityLabel,
11 +
moodComponentLabel,
12 +
notificationKindLabel,
13 +
triggerTypeLabel,
14 +
} from '@app/services/export/label.map'
15 +
16 +
describe('export label maps', () => {
17 +
it('renders the base mood labels in pt-BR', () => {
18 +
expect(baseMoodLabel).toEqual({
19 +
SAD: 'Triste',
20 +
NEUTRAL: 'Neutro',
21 +
GOOD: 'Bem',
22 +
GREAT: 'Ótimo',
23 +
ANGRY: 'Irritado',
24 +
})
25 +
})
26 +
27 +
it('renders the mood component labels in pt-BR', () => {
28 +
expect(moodComponentLabel).toEqual({
29 +
JOY: 'Alegria',
30 +
ANGER: 'Raiva',
31 +
SADNESS: 'Tristeza',
32 +
FEAR: 'Medo',
33 +
GUILT: 'Culpa',
34 +
FRUSTRATION: 'Frustração',
35 +
CALM: 'Calmo',
36 +
MOTIVATED: 'Motivado',
37 +
TIREDNESS: 'Cansaço',
38 +
GRATITUDE: 'Gratidão',
39 +
FOCUS: 'Foco',
40 +
RESTLESS: 'Inquieto',
41 +
RELAXED: 'Relaxado',
42 +
OVERWHELMED: 'Sobrecarga',
43 +
})
44 +
})
45 +
46 +
it('renders the intensity labels in pt-BR', () => {
47 +
expect(intensityLabel).toEqual({
48 +
LIGHT: 'Suave',
49 +
MODERATE: 'Moderada',
50 +
HIGH: 'Intensa',
51 +
})
52 +
})
53 +
54 +
it('renders the trigger type labels in pt-BR', () => {
55 +
expect(triggerTypeLabel).toEqual({
56 +
SOCIAL: 'Social',
57 +
WORK: 'Trabalho',
58 +
HEALTH: 'Saúde',
59 +
PHYSICAL: 'Físico',
60 +
FAMILY: 'Família',
61 +
THERAPY: 'Terapia',
62 +
INTERNAL: 'Interno',
63 +
OTHER: 'Outro',
64 +
})
65 +
})
66 +
67 +
it('renders the care action type labels in pt-BR', () => {
68 +
expect(careActionTypeLabel).toEqual({
69 +
MEDICINE: 'Medicação',
70 +
APPOINTMENT: 'Consulta',
71 +
ACTIVITY: 'Atividade',
72 +
})
73 +
})
74 +
75 +
it('renders the appointment type labels in pt-BR', () => {
76 +
expect(appointmentTypeLabel).toEqual({
77 +
ANALYST: 'Analista',
78 +
PSYCHIATRIST: 'Psiquiatra',
79 +
GP: 'Clínico geral',
80 +
NUTRITIONIST: 'Nutricionista',
81 +
PHYSIOTHERAPIST: 'Fisioterapeuta',
82 +
OTHER: 'Consulta',
83 +
})
84 +
})
85 +
86 +
it('renders the activity category labels in pt-BR', () => {
87 +
expect(activityCategoryLabel).toEqual({
88 +
WALK: 'Caminhada',
89 +
YOGA: 'Yoga',
90 +
GYM: 'Academia',
91 +
MEDITATION: 'Meditação',
92 +
SOCIAL: 'Momento social',
93 +
CREATIVE: 'Atividade criativa',
94 +
OTHER: 'Atividade',
95 +
})
96 +
})
97 +
98 +
it('renders the medicine periodicity labels in pt-BR', () => {
99 +
expect(medicinePeriodicityLabel).toEqual({
100 +
ONCE: 'Uma vez',
101 +
DAILY: 'Diário',
102 +
TWICE_DAILY: '2x ao dia',
103 +
THREE_TIMES_DAILY: '3x ao dia',
104 +
WEEKLY: 'Semanal',
105 +
BIWEEKLY: 'Quinzenal',
106 +
MONTHLY: 'Mensal',
107 +
})
108 +
})
109 +
110 +
it('renders the insight type labels in pt-BR', () => {
111 +
expect(insightTypeLabel).toEqual({
112 +
MOOD_TREND: 'Tendência de Humor',
113 +
ENERGY_SLEEP_CORRELATION: 'Sono e Energia',
114 +
TRIGGER_PATTERN: 'Padrão de Gatilho',
115 +
WEEKLY_SUMMARY: 'Resumo Semanal',
116 +
STREAK: 'Sequência',
117 +
DAILY_ENERGY: 'Energia do Dia',
118 +
DAILY_SLEEP: 'Sono',
119 +
})
120 +
})
121 +
122 +
it('renders the insight period labels in pt-BR', () => {
123 +
expect(insightPeriodLabel).toEqual({
124 +
DAILY: 'Diário',
125 +
WEEKLY: 'Semanal',
126 +
MONTHLY: 'Mensal',
127 +
})
128 +
})
129 +
130 +
it('renders the notification kind labels in pt-BR', () => {
131 +
expect(notificationKindLabel).toEqual({
132 +
DAILY_REMINDER: 'Lembrete diário',
133 +
MEDICINE_REMINDER: 'Lembrete de medicação',
134 +
INSIGHT_ALERT: 'Alerta de indicador',
135 +
})
136 +
})
137 +
})
/dev/null → api/tests/schemas/refinements.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { CreateMoodSchema } from '@app/schemas/mood.schema'
3 +
import { CreateSleepRecordSchema } from '@app/schemas/sleepRecord.schema'
4 +
import { UpdateUserPreferencesSchema } from '@app/schemas/user.schema'
5 +
import { ActivateUserSchema } from '@app/schemas/auth.schema'
6 +
import { InsightsQuerySchema } from '@app/schemas/insight.schema'
7 +
import { LinkMoodSchema } from '@app/schemas/triggerMoodLink.schema'
8 +
9 +
const moodEntry = {
10 +
moment: '2026-10-17T12:00:00.000Z',
11 +
selectedMood: 'GOOD',
12 +
anxietyLevel: 5,
13 +
stressLevel: 5,
14 +
energyLevel: 5,
15 +
moodComponents: [{ component: 'JOY', intensity: 'LIGHT' }],
16 +
}
17 +
18 +
const sleepRecord = { date: '2026-10-17T00:00:00.000Z', average: 7 }
19 +
20 +
describe('mood level bounds', () => {
21 +
it('accepts zero and ten on every level', () => {
22 +
for (const level of ['anxietyLevel', 'stressLevel', 'energyLevel'] as const) {
23 +
expect(CreateMoodSchema.safeParse({ ...moodEntry, [level]: 0 }).success).toBe(true)
24 +
expect(CreateMoodSchema.safeParse({ ...moodEntry, [level]: 10 }).success).toBe(true)
25 +
}
26 +
})
27 +
28 +
it('rejects a level above ten or below zero', () => {
29 +
expect(CreateMoodSchema.safeParse({ ...moodEntry, energyLevel: 11 }).success).toBe(false)
30 +
expect(CreateMoodSchema.safeParse({ ...moodEntry, energyLevel: -1 }).success).toBe(false)
31 +
})
32 +
})
33 +
34 +
describe('sleep record average bounds', () => {
35 +
it('accepts zero and fifteen hours', () => {
36 +
expect(CreateSleepRecordSchema.safeParse({ ...sleepRecord, average: 0 }).success).toBe(true)
37 +
expect(CreateSleepRecordSchema.safeParse({ ...sleepRecord, average: 15 }).success).toBe(true)
38 +
})
39 +
40 +
it('rejects an average outside the range', () => {
41 +
expect(CreateSleepRecordSchema.safeParse({ ...sleepRecord, average: -0.5 }).success).toBe(false)
42 +
expect(CreateSleepRecordSchema.safeParse({ ...sleepRecord, average: 15.5 }).success).toBe(false)
43 +
})
44 +
})
45 +
46 +
describe('daily reminder time format', () => {
47 +
it('accepts a two-digit hour and minute, and null to clear it', () => {
48 +
expect(UpdateUserPreferencesSchema.safeParse({ dailyReminderTime: '08:30' }).success).toBe(true)
49 +
expect(UpdateUserPreferencesSchema.safeParse({ dailyReminderTime: null }).success).toBe(true)
50 +
})
51 +
52 +
it('rejects a one-digit hour or a trailing seconds segment', () => {
53 +
expect(UpdateUserPreferencesSchema.safeParse({ dailyReminderTime: '8:30' }).success).toBe(false)
54 +
expect(
55 +
UpdateUserPreferencesSchema.safeParse({ dailyReminderTime: '08:30:00' })
56 +
.success,
57 +
).toBe(false)
58 +
})
59 +
})
60 +
61 +
describe('activation code format', () => {
62 +
it('accepts exactly six digits', () => {
63 +
expect(ActivateUserSchema.safeParse({ code: '000123' }).success).toBe(true)
64 +
})
65 +
66 +
it('rejects the wrong length or non-digits', () => {
67 +
for (const code of ['12345', '1234567', '12345a', 'abcdef']) {
68 +
expect(ActivateUserSchema.safeParse({ code }).success).toBe(false)
69 +
}
70 +
})
71 +
})
72 +
73 +
describe('insights day string', () => {
74 +
it('accepts a real yyyy-MM-dd day', () => {
75 +
expect(InsightsQuerySchema.safeParse({ day: '2026-10-17' }).success).toBe(true)
76 +
})
77 +
78 +
it('rejects an unpadded, month-out-of-range, or impossible day', () => {
79 +
expect(InsightsQuerySchema.safeParse({ day: '2026-10-7' }).success).toBe(false)
80 +
expect(InsightsQuerySchema.safeParse({ day: '2026-13-01' }).success).toBe(false)
81 +
expect(InsightsQuerySchema.safeParse({ day: '2026-02-30' }).success).toBe(false)
82 +
})
83 +
})
84 +
85 +
describe('trigger link perceived impact', () => {
86 +
it('accepts the one-to-five boundaries', () => {
87 +
expect(LinkMoodSchema.safeParse({ moodId: 1, perceivedImpact: 1 }).success).toBe(true)
88 +
expect(LinkMoodSchema.safeParse({ moodId: 1, perceivedImpact: 5 }).success).toBe(true)
89 +
})
90 +
91 +
it('rejects an impact off the scale or a non-positive mood', () => {
92 +
expect(LinkMoodSchema.safeParse({ moodId: 1, perceivedImpact: 0 }).success).toBe(false)
93 +
expect(LinkMoodSchema.safeParse({ moodId: 1, perceivedImpact: 6 }).success).toBe(false)
94 +
expect(LinkMoodSchema.safeParse({ moodId: 0, perceivedImpact: 3 }).success).toBe(false)
95 +
})
96 +
})