eletrotupi / tcc/ api/tests/jobs/export-data.test.ts1230d1819bd28727595923820528cbb25d702eb8
16.7 KB | 477 lines Raw
1
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2
import JSZip from 'jszip'
3
import { exportProcessor } from '@app/lib/queue/processors/exports'
4
import { ExportJobName } from '@app/lib/queue/types'
5
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
6

7
// Intercept all Resend calls
8
vi.mock('@app/lib/mail', () => ({
9
  sendEmail: vi.fn().mockResolvedValue({
10
    data: { id: 'fake-email-id' },
11
    error: null,
12
  }),
13
}))
14

15
import { sendEmail } from '@app/lib/mail'
16
const mockedSendEmail = vi.mocked(sendEmail)
17

18
const db = buildPrisma()
19

20
const buildJob = (data: unknown, overrides: Record<string, unknown> = {}) =>
21
  ({
22
    name: ExportJobName.Data,
23
    data,
24
    attemptsMade: 0,
25
    opts: { attempts: 3 },
26
    ...overrides,
27
  }) as any
28

29
async function attachmentFromLastEmail() {
30
  const calls = mockedSendEmail.mock.calls
31
  const [payload] = calls[calls.length - 1]
32
  const [attachment] = payload.attachments!
33

34
  return { payload, attachment }
35
}
36

37
const csvLines = (csv: string): string[] =>
38
  csv.slice(1).split('\r\n').filter(Boolean)
39

40
afterAll(() => db.$disconnect())
41
beforeEach(async () => {
42
  await cleanupTestDb(db)
43
  mockedSendEmail.mockClear()
44
  mockedSendEmail.mockResolvedValue({ data: { id: 'fake-email-id' }, error: null } as any)
45
})
46

47
describe('exportProcessor', () => {
48
  it('emails a zip with the profile CSV and a manifest', async () => {
49
    const user = await createTestUser(db, {
50
      email: 'export@example.com',
51
      firstName: 'Ana',
52
      lastName: 'Silva',
53
    })
54

55
    await exportProcessor(buildJob({ userId: user.id }))
56

57
    expect(mockedSendEmail).toHaveBeenCalledOnce()
58

59
    const { payload, attachment } = await attachmentFromLastEmail()
60
    expect(payload.to).toBe('export@example.com')
61
    expect(payload.subject).toBe('Seus dados do nexo')
62
    expect(attachment.filename).toMatch(/^nexo-dados-\d{4}-\d{2}-\d{2}\.zip$/)
63

64
    const zip = await JSZip.loadAsync(attachment.content)
65
    expect(Object.keys(zip.files).sort()).toEqual([
66
      'activities.csv',
67
      'appointments.csv',
68
      'care_actions.csv',
69
      'leia-me.md',
70
      'medicine_logs.csv',
71
      'medicine_regimens.csv',
72
      'mood_components.csv',
73
      'moods.csv',
74
      'profile.csv',
75
      'sleep_records.csv',
76
      'trigger_mood_links.csv',
77
      'triggers.csv',
78
    ])
79

80
    const csv = await zip.file('profile.csv')!.async('string')
81
    expect(csv.startsWith('\ufeff')).toBe(true)
82

83
    const lines = csvLines(csv)
84
    expect(lines).toHaveLength(2)
85
    expect(lines[0]).toContain('Nome')
86
    expect(lines[0]).toContain('Email')
87

88
    const [row] = lines.slice(1)
89
    expect(row).toContain('"Ana"')
90
    expect(row).toContain('"Silva"')
91
    expect(row).toContain('"export@example.com"')
92
    expect(row).toContain('"Sim"') // notifications default to enabled
93
    expect(row).toContain('"Não"') // freshly created users are inactive
94

95
    // Secrets must never travel in the export
96
    const lower = csv.toLowerCase()
97
    expect(lower).not.toContain('senha')
98
    expect(lower).not.toContain('token')
99
    expect(lower).not.toContain('push')
100
    expect(lower).not.toContain('avatar')
101

102
    const manifest = await zip.file('leia-me.md')!.async('string')
103
    expect(manifest).toContain('`profile.csv`')
104
    expect(manifest).toContain('1 registro')
105
    expect(manifest).toContain('_id')
106
  })
107

108
  it('exports mood entries and their components with pt-BR labels', async () => {
109
    const user = await createTestUser(db, { email: 'moods@example.com' })
110
    const firstMood = await db.mood.create({
111
      data: {
112
        userId: user.id,
113
        selectedMood: 'GREAT',
114
        anxietyLevel: 1,
115
        stressLevel: 2,
116
        energyLevel: 9,
117
        moment: new Date('2026-03-05T10:15:00.000Z'),
118
        annotation: 'Bom, "ótimo"\ndia',
119
        moodComponents: {
120
          create: [{ component: 'JOY', intensity: 'HIGH' }],
121
        },
122
      },
123
      include: { moodComponents: true },
124
    })
125
    const secondMood = await db.mood.create({
126
      data: {
127
        userId: user.id,
128
        selectedMood: 'SAD',
129
        anxietyLevel: 6,
130
        stressLevel: 7,
131
        energyLevel: 3,
132
        moment: new Date('2026-03-06T21:40:00.000Z'),
133
        moodComponents: {
134
          create: [{ component: 'GRATITUDE', intensity: 'MODERATE' }],
135
        },
136
      },
137
      include: { moodComponents: true },
138
    })
139

140
    await exportProcessor(buildJob({ userId: user.id }))
141

142
    const { attachment } = await attachmentFromLastEmail()
143
    const zip = await JSZip.loadAsync(attachment.content)
144

145
    const moodsCsv = await zip.file('moods.csv')!.async('string')
146
    const moodLines = csvLines(moodsCsv)
147
    expect(moodLines[0]).toContain('Humor')
148
    expect(moodLines[0]).toContain('Anotação')
149
    expect(moodLines[1]).toContain('"Ótimo"')
150
    expect(moodLines[1]).toContain('"Bom, ""ótimo""\ndia"')
151
    expect(moodLines[2]).toContain('"Triste"')
152

153
    const componentsCsv = await zip.file('mood_components.csv')!.async('string')
154
    const componentLines = csvLines(componentsCsv)
155
    expect(componentLines[0]).toContain('Sentimento')
156
    expect(componentLines[0]).toContain('Intensidade')
157

158
    const moodIdFor = new Map(
159
      [...firstMood.moodComponents, ...secondMood.moodComponents].map((component) => [
160
        component.id,
161
        component.moodId,
162
      ]),
163
    )
164
    const header = componentLines[0].split(',').map((field) => field.replace(/"/g, ''))
165
    const idIndex = header.indexOf('id')
166
    const moodIdIndex = header.indexOf('mood_id')
167

168
    const componentRows = componentLines.slice(1)
169
    expect(componentRows).toHaveLength(2)
170
    for (const row of componentRows) {
171
      const fields = row.split(',')
172
      expect(Number(fields[moodIdIndex])).toBe(moodIdFor.get(Number(fields[idIndex])))
173
    }
174
    expect(componentsCsv).toContain('"Alegria"')
175
    expect(componentsCsv).toContain('"Intensa"')
176
    expect(componentsCsv).toContain('"Gratidão"')
177
    expect(componentsCsv).toContain('"Moderada"')
178

179
    const manifest = await zip.file('leia-me.md')!.async('string')
180
    expect(manifest).toContain('`moods.csv` — 2 registros')
181
    expect(manifest).toContain('`mood_components.csv` — 2 registros')
182
  })
183

184
  it('exports sleep records, triggers and their mood links', async () => {
185
    const user = await createTestUser(db, { email: 'timeline@example.com' })
186
    const mood = await db.mood.create({
187
      data: {
188
        userId: user.id,
189
        selectedMood: 'GOOD',
190
        anxietyLevel: 3,
191
        stressLevel: 3,
192
        energyLevel: 6,
193
        moment: new Date('2026-03-05T10:00:00.000Z'),
194
      },
195
    })
196
    await db.sleepRecord.create({
197
      data: {
198
        userId: user.id,
199
        date: new Date('2026-03-05T00:00:00.000Z'),
200
        average: 7.5,
201
        annotations: 'Dormi bem, "profundo"',
202
      },
203
    })
204
    const trigger = await db.trigger.create({
205
      data: {
206
        userId: user.id,
207
        category: 'WORK',
208
        moment: new Date('2026-03-05T14:30:00.000Z'),
209
        comment: 'Reunião difícil',
210
      },
211
    })
212
    await db.trigger.create({
213
      data: {
214
        userId: user.id,
215
        category: 'HEALTH',
216
        moment: new Date('2026-03-05T18:00:00.000Z'),
217
        comment: 'Consulta',
218
      },
219
    })
220
    await db.triggerMoodLink.create({
221
      data: {
222
        triggerId: trigger.id,
223
        moodId: mood.id,
224
        perceivedImpact: 4,
225
        linkedAt: new Date('2026-03-05T15:00:00.000Z'),
226
      },
227
    })
228

229
    await exportProcessor(buildJob({ userId: user.id }))
230

231
    const { attachment } = await attachmentFromLastEmail()
232
    const zip = await JSZip.loadAsync(attachment.content)
233

234
    const sleepCsv = await zip.file('sleep_records.csv')!.async('string')
235
    const sleepLines = csvLines(sleepCsv)
236
    expect(sleepLines[0]).toContain('Data')
237
    expect(sleepLines[0]).toContain('Média')
238
    expect(sleepLines[0]).toContain('Anotações')
239
    expect(sleepLines[1]).toContain('"2026-03-05"')
240
    expect(sleepLines[1]).toContain('7.5')
241
    expect(sleepLines[1]).toContain('"Dormi bem, ""profundo"""')
242

243
    const triggersCsv = await zip.file('triggers.csv')!.async('string')
244
    const triggerLines = csvLines(triggersCsv)
245
    expect(triggerLines[0]).toContain('Categoria')
246
    expect(triggerLines[0]).toContain('Comentário')
247
    expect(triggerLines[1]).toContain('"Trabalho"')
248
    expect(triggerLines[1]).toContain('"Reunião difícil"')
249
    expect(triggerLines[2]).toContain('"Saúde"')
250

251
    const linksCsv = await zip.file('trigger_mood_links.csv')!.async('string')
252
    const linkLines = csvLines(linksCsv)
253
    expect(linkLines[0]).toContain('Impacto percebido')
254
    expect(linkLines[0]).toContain('Vinculado em')
255

256
    const header = linkLines[0].split(',').map((field) => field.replace(/"/g, ''))
257
    const fields = linkLines[1].split(',')
258
    expect(Number(fields[header.indexOf('trigger_id')])).toBe(trigger.id)
259
    expect(Number(fields[header.indexOf('mood_id')])).toBe(mood.id)
260

261
    const manifest = await zip.file('leia-me.md')!.async('string')
262
    expect(manifest).toContain('`sleep_records.csv` — 1 registro')
263
    expect(manifest).toContain('`triggers.csv` — 2 registros')
264
    expect(manifest).toContain('`trigger_mood_links.csv` — 1 registro')
265
  })
266

267
  it('exports care actions, their subtypes and medicine regimens', async () => {
268
    const user = await createTestUser(db, { email: 'care@example.com' })
269
    const mood = await db.mood.create({
270
      data: {
271
        userId: user.id,
272
        selectedMood: 'GOOD',
273
        anxietyLevel: 3,
274
        stressLevel: 3,
275
        energyLevel: 6,
276
        moment: new Date('2026-03-05T10:00:00.000Z'),
277
      },
278
    })
279
    const trigger = await db.trigger.create({
280
      data: {
281
        userId: user.id,
282
        category: 'WORK',
283
        moment: new Date('2026-03-05T14:00:00.000Z'),
284
      },
285
    })
286
    const regimen = await db.medicineRegimen.create({
287
      data: {
288
        userId: user.id,
289
        name: 'Sertralina',
290
        dosage: '50mg',
291
        periodicity: 'DAILY',
292
        scheduledAt: ['08:00', '20:00'],
293
        active: true,
294
      },
295
    })
296

297
    const medicineAction = await db.careAction.create({
298
      data: {
299
        userId: user.id,
300
        type: 'MEDICINE',
301
        moment: new Date('2026-03-05T08:05:00.000Z'),
302
        triggerId: trigger.id,
303
        moodId: mood.id,
304
        medicineLog: {
305
          create: {
306
            regimenId: regimen.id,
307
            takenAt: new Date('2026-03-05T08:05:00.000Z'),
308
          },
309
        },
310
      },
311
      include: { medicineLog: true },
312
    })
313
    const appointmentAction = await db.careAction.create({
314
      data: {
315
        userId: user.id,
316
        type: 'APPOINTMENT',
317
        moment: new Date('2026-03-06T15:00:00.000Z'),
318
        appointment: {
319
          create: { type: 'ANALYST', duration: 50, note: 'Primeira sessão' },
320
        },
321
      },
322
      include: { appointment: true },
323
    })
324
    const activityAction = await db.careAction.create({
325
      data: {
326
        userId: user.id,
327
        type: 'ACTIVITY',
328
        moment: new Date('2026-03-07T07:30:00.000Z'),
329
        activity: { create: { type: 'WALK', duration: 30 } },
330
      },
331
      include: { activity: true },
332
    })
333

334
    await exportProcessor(buildJob({ userId: user.id }))
335

336
    const { attachment } = await attachmentFromLastEmail()
337
    const zip = await JSZip.loadAsync(attachment.content)
338

339
    for (const file of [
340
      'care_actions.csv',
341
      'medicine_logs.csv',
342
      'appointments.csv',
343
      'activities.csv',
344
      'medicine_regimens.csv',
345
    ]) {
346
      expect(zip.file(file)).not.toBeNull()
347
    }
348

349
    const careCsv = await zip.file('care_actions.csv')!.async('string')
350
    const careLines = csvLines(careCsv)
351
    expect(careLines[0]).toContain('Tipo')
352
    expect(careLines[0]).toContain('trigger_id')
353
    expect(careLines[0]).toContain('mood_id')
354
    expect(careLines[0]).toContain('Criado em')
355
    expect(careLines[0]).toContain('Atualizado em')
356
    expect(careCsv).toContain('"Medicação"')
357
    expect(careCsv).toContain('"Consulta"')
358
    expect(careCsv).toContain('"Atividade"')
359

360
    const careHeader = careLines[0].split(',').map((field) => field.replace(/"/g, ''))
361
    const idIndex = careHeader.indexOf('id')
362
    const triggerIndex = careHeader.indexOf('trigger_id')
363
    const moodIndex = careHeader.indexOf('mood_id')
364
    const careIds = new Set(
365
      careLines.slice(1).map((row) => Number(row.split(',')[idIndex])),
366
    )
367
    expect(careIds.size).toBe(3)
368

369
    const medicineRow = careLines.slice(1).find((row) => row.includes('"Medicação"'))!
370
    const medicineFields = medicineRow.split(',')
371
    expect(Number(medicineFields[triggerIndex])).toBe(trigger.id)
372
    expect(Number(medicineFields[moodIndex])).toBe(mood.id)
373

374
    const logsCsv = await zip.file('medicine_logs.csv')!.async('string')
375
    const logLines = csvLines(logsCsv)
376
    expect(logLines[0]).toContain('care_action_id')
377
    expect(logLines[0]).toContain('regimen_id')
378
    expect(logLines[0]).toContain('Tomado em')
379
    const logHeader = logLines[0].split(',').map((field) => field.replace(/"/g, ''))
380
    const logFields = logLines[1].split(',')
381
    expect(Number(logFields[logHeader.indexOf('care_action_id')])).toBe(medicineAction.id)
382
    expect(Number(logFields[logHeader.indexOf('regimen_id')])).toBe(regimen.id)
383

384
    const appointmentCsv = await zip.file('appointments.csv')!.async('string')
385
    const appointmentLines = csvLines(appointmentCsv)
386
    expect(appointmentLines[0]).toContain('Tipo')
387
    expect(appointmentLines[0]).toContain('Duração')
388
    expect(appointmentLines[0]).toContain('Observação')
389
    expect(appointmentCsv).toContain('"Analista"')
390
    expect(appointmentCsv).toContain('50')
391
    expect(appointmentCsv).toContain('"Primeira sessão"')
392
    const appointmentHeader = appointmentLines[0]
393
      .split(',')
394
      .map((field) => field.replace(/"/g, ''))
395
    expect(
396
      Number(appointmentLines[1].split(',')[appointmentHeader.indexOf('care_action_id')]),
397
    ).toBe(appointmentAction.id)
398

399
    const activityCsv = await zip.file('activities.csv')!.async('string')
400
    const activityLines = csvLines(activityCsv)
401
    expect(activityLines[0]).toContain('Duração')
402
    expect(activityCsv).toContain('"Caminhada"')
403
    expect(activityCsv).toContain('30')
404
    const activityHeader = activityLines[0].split(',').map((field) => field.replace(/"/g, ''))
405
    expect(
406
      Number(activityLines[1].split(',')[activityHeader.indexOf('care_action_id')]),
407
    ).toBe(activityAction.id)
408

409
    const regimenCsv = await zip.file('medicine_regimens.csv')!.async('string')
410
    const regimenLines = csvLines(regimenCsv)
411
    expect(regimenLines[0]).toContain('Nome')
412
    expect(regimenLines[0]).toContain('Dosagem')
413
    expect(regimenLines[0]).toContain('Periodicidade')
414
    expect(regimenLines[0]).toContain('Horários')
415
    expect(regimenLines[0]).toContain('Ativo')
416
    expect(regimenLines[1]).toContain('"Sertralina"')
417
    expect(regimenLines[1]).toContain('"50mg"')
418
    expect(regimenLines[1]).toContain('"Diário"')
419
    expect(regimenLines[1]).toContain('"08:00 · 20:00"')
420
    expect(regimenLines[1]).toContain('"Sim"')
421

422
    const manifest = await zip.file('leia-me.md')!.async('string')
423
    expect(manifest).toContain('`care_actions.csv` — 3 registros')
424
    expect(manifest).toContain('`medicine_logs.csv` — 1 registro')
425
    expect(manifest).toContain('`appointments.csv` — 1 registro')
426
    expect(manifest).toContain('`activities.csv` — 1 registro')
427
    expect(manifest).toContain('`medicine_regimens.csv` — 1 registro')
428
  })
429

430
  it('preserves commas, quotes and newlines inside a field', async () => {
431
    const user = await createTestUser(db, {
432
      email: 'quoted@example.com',
433
      lastName: 'Silva, "S"\nJunior',
434
    })
435

436
    await exportProcessor(buildJob({ userId: user.id }))
437

438
    const { attachment } = await attachmentFromLastEmail()
439
    const zip = await JSZip.loadAsync(attachment.content)
440
    const csv = await zip.file('profile.csv')!.async('string')
441

442
    expect(csv).toContain('"Silva, ""S""\nJunior"')
443
  })
444

445
  it('sends the failure email only on the final attempt', async () => {
446
    const user = await createTestUser(db, { email: 'fail@example.com' })
447
    mockedSendEmail
448
      .mockRejectedValueOnce(new Error('boom'))
449
      .mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
450

451
    await expect(
452
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
453
    ).rejects.toThrow('boom')
454

455
    expect(mockedSendEmail).toHaveBeenCalledTimes(2)
456
    expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
457
      'Não conseguimos exportar seus dados'
458
    )
459
  })
460

461
  it('does not send the failure email on a non-final attempt', async () => {
462
    const user = await createTestUser(db, { email: 'retry@example.com' })
463
    mockedSendEmail.mockRejectedValueOnce(new Error('boom'))
464

465
    await expect(
466
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
467
    ).rejects.toThrow('boom')
468

469
    expect(mockedSendEmail).toHaveBeenCalledOnce()
470
  })
471

472
  it('throws for unknown job names', async () => {
473
    await expect(
474
      exportProcessor({ name: 'export:unknown', data: {} } as any)
475
    ).rejects.toThrow('Unknown export job')
476
  })
477
})