eletrotupi / tcc/ api/tests/jobs/export-data.test.tsa135a130af592f91579f04c070b0652d5a6c52c2
19.0 KB | 538 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
      'insights.csv',
70
      'leia-me.md',
71
      'medicine_logs.csv',
72
      'medicine_regimens.csv',
73
      'mood_components.csv',
74
      'moods.csv',
75
      'profile.csv',
76
      'sleep_records.csv',
77
      'trigger_mood_links.csv',
78
      'triggers.csv',
79
    ])
80

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

431
  it('exports insights with pt-BR labels and JSON metadata', async () => {
432
    const user = await createTestUser(db, { email: 'insights@example.com' })
433
    await db.insight.create({
434
      data: {
435
        userId: user.id,
436
        type: 'WEEKLY_SUMMARY',
437
        period: 'WEEKLY',
438
        title: 'Sua semana',
439
        body: 'Você registrou sete dias.',
440
        metadata: { note: 'bom, "ótimo"' },
441
        generatedAt: new Date('2026-03-08T09:00:00.000Z'),
442
        periodStart: new Date('2026-03-01T00:00:00.000Z'),
443
        periodEnd: new Date('2026-03-07T23:59:59.000Z'),
444
      },
445
    })
446
    await db.insight.create({
447
      data: {
448
        userId: user.id,
449
        type: 'STREAK',
450
        period: 'DAILY',
451
        title: 'Sequência de 3 dias',
452
        body: 'Continue assim!',
453
        generatedAt: new Date('2026-03-09T09:00:00.000Z'),
454
        periodStart: new Date('2026-03-09T00:00:00.000Z'),
455
        periodEnd: new Date('2026-03-09T23:59:59.000Z'),
456
      },
457
    })
458

459
    await exportProcessor(buildJob({ userId: user.id }))
460

461
    const { attachment } = await attachmentFromLastEmail()
462
    const zip = await JSZip.loadAsync(attachment.content)
463

464
    const insightsCsv = await zip.file('insights.csv')!.async('string')
465
    const lines = csvLines(insightsCsv)
466
    expect(lines[0]).toContain('Tipo')
467
    expect(lines[0]).toContain('Período')
468
    expect(lines[0]).toContain('Título')
469
    expect(lines[0]).toContain('Corpo')
470
    expect(lines[0]).toContain('Metadados')
471
    expect(lines[0]).toContain('Início do período')
472
    expect(lines[0]).toContain('Fim do período')
473
    expect(lines[0]).toContain('Gerado em')
474
    expect(lines).toHaveLength(3)
475

476
    expect(insightsCsv).toContain('"Resumo Semanal"')
477
    expect(insightsCsv).toContain('"Semanal"')
478
    expect(insightsCsv).toContain('"Sequência"')
479
    expect(insightsCsv).toContain('"Diário"')
480

481
    // The JSON travels as one quoted cell; un-escaping it yields the original.
482
    const metadataCell = '"{""note"":""bom, \\""ótimo\\""""}"'
483
    expect(insightsCsv).toContain(metadataCell)
484
    const unescaped = metadataCell.slice(1, -1).replace(/""/g, '"')
485
    expect(JSON.parse(unescaped)).toEqual({ note: 'bom, "ótimo"' })
486

487
    const manifest = await zip.file('leia-me.md')!.async('string')
488
    expect(manifest).toContain('`insights.csv` — 2 registros')
489
  })
490

491
  it('preserves commas, quotes and newlines inside a field', async () => {
492
    const user = await createTestUser(db, {
493
      email: 'quoted@example.com',
494
      lastName: 'Silva, "S"\nJunior',
495
    })
496

497
    await exportProcessor(buildJob({ userId: user.id }))
498

499
    const { attachment } = await attachmentFromLastEmail()
500
    const zip = await JSZip.loadAsync(attachment.content)
501
    const csv = await zip.file('profile.csv')!.async('string')
502

503
    expect(csv).toContain('"Silva, ""S""\nJunior"')
504
  })
505

506
  it('sends the failure email only on the final attempt', async () => {
507
    const user = await createTestUser(db, { email: 'fail@example.com' })
508
    mockedSendEmail
509
      .mockRejectedValueOnce(new Error('boom'))
510
      .mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
511

512
    await expect(
513
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
514
    ).rejects.toThrow('boom')
515

516
    expect(mockedSendEmail).toHaveBeenCalledTimes(2)
517
    expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
518
      'Não conseguimos exportar seus dados'
519
    )
520
  })
521

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

526
    await expect(
527
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
528
    ).rejects.toThrow('boom')
529

530
    expect(mockedSendEmail).toHaveBeenCalledOnce()
531
  })
532

533
  it('throws for unknown job names', async () => {
534
    await expect(
535
      exportProcessor({ name: 'export:unknown', data: {} } as any)
536
    ).rejects.toThrow('Unknown export job')
537
  })
538
})