eletrotupi / tcc/ commit / 82d1a5f

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 files
Parents 3292568
Changed 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 +
})