eletrotupi / tcc/ api/tests/jobs/export-data.test.ts30f98b88ba3e0926a1a93ed3c7a9e2618d9ff920
19.1 KB | 539 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
    expect(lines[0]).toContain('Notificações por push')
89

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

97
    // Secrets must never travel in the export. The push token is caught by the
98
    // `token` check below; "push" alone is now a legitimate column word.
99
    const lower = csv.toLowerCase()
100
    expect(lower).not.toContain('senha')
101
    expect(lower).not.toContain('token')
102
    expect(lower).not.toContain('avatar')
103

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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