eletrotupi / tcc/ api/tests/jobs/export-data.test.tsc901c35af9c84b6a29e5185545ced1129f33b8f4
21.8 KB | 617 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
      'notifications.csv',
76
      'profile.csv',
77
      'sleep_records.csv',
78
      'trigger_mood_links.csv',
79
      'triggers.csv',
80
    ])
81

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

85
    const lines = csvLines(csv)
86
    expect(lines).toHaveLength(2)
87
    expect(lines[0]).toContain('Nome')
88
    expect(lines[0]).toContain('Email')
89
    expect(lines[0]).toContain('Notificações por push')
90

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

493
  it('exports notifications with pt-BR labels and the relation column', async () => {
494
    const user = await createTestUser(db, { email: 'notifications@example.com' })
495
    const regimen = await db.medicineRegimen.create({
496
      data: {
497
        userId: user.id,
498
        name: 'Sertralina',
499
        dosage: '50mg',
500
        periodicity: 'DAILY',
501
        scheduledAt: ['08:00'],
502
        active: true,
503
      },
504
    })
505
    await db.notification.create({
506
      data: {
507
        userId: user.id,
508
        kind: 'MEDICINE_REMINDER',
509
        title: 'Hora de tomar Sertralina',
510
        body: '50mg',
511
        scheduledFor: new Date('2026-03-05T08:00:00.000Z'),
512
        expiresAt: new Date('2026-03-06T02:59:59.999Z'),
513
        pushedAt: new Date('2026-03-05T08:00:01.000Z'),
514
        regimenId: regimen.id,
515
      },
516
    })
517
    await db.notification.create({
518
      data: {
519
        userId: user.id,
520
        kind: 'DAILY_REMINDER',
521
        title: 'Como você está?',
522
        body: 'Registre seu humor para acompanhar sua saúde.',
523
        scheduledFor: new Date('2026-03-04T22:00:00.000Z'),
524
        expiresAt: new Date('2026-03-05T02:59:59.999Z'),
525
      },
526
    })
527
    await db.notification.create({
528
      data: {
529
        userId: user.id,
530
        kind: 'INSIGHT_ALERT',
531
        title: 'Trabalho aparece muito',
532
        body: 'Trabalho tem aparecido bastante nos seus gatilhos.',
533
        scheduledFor: new Date('2026-03-06T12:00:00.000Z'),
534
      },
535
    })
536

537
    await exportProcessor(buildJob({ userId: user.id }))
538

539
    const { attachment } = await attachmentFromLastEmail()
540
    const zip = await JSZip.loadAsync(attachment.content)
541

542
    const csv = await zip.file('notifications.csv')!.async('string')
543
    const lines = csvLines(csv)
544
    expect(lines[0]).toContain('Tipo')
545
    expect(lines[0]).toContain('Título')
546
    expect(lines[0]).toContain('Corpo')
547
    expect(lines[0]).toContain('Agendada para')
548
    expect(lines[0]).toContain('Expira em')
549
    expect(lines[0]).toContain('Enviada em')
550
    expect(lines[0]).toContain('regimen_id')
551

552
    // Every row ships, the expired reminder included
553
    expect(lines).toHaveLength(4)
554
    expect(csv).toContain('"Lembrete de medicação"')
555
    expect(csv).toContain('"Lembrete diário"')
556
    expect(csv).toContain('"Alerta de indicador"')
557
    expect(csv).toContain('"Hora de tomar Sertralina"')
558

559
    const header = lines[0].split(',').map((field) => field.replace(/"/g, ''))
560
    const regimenIndex = header.indexOf('regimen_id')
561
    const medicineRow = lines
562
      .slice(1)
563
      .find((row) => row.includes('"Lembrete de medicação"'))!
564
    expect(Number(medicineRow.split(',')[regimenIndex])).toBe(regimen.id)
565

566
    const manifest = await zip.file('leia-me.md')!.async('string')
567
    expect(manifest).toContain('`notifications.csv` — 3 registros')
568
  })
569

570
  it('preserves commas, quotes and newlines inside a field', async () => {
571
    const user = await createTestUser(db, {
572
      email: 'quoted@example.com',
573
      lastName: 'Silva, "S"\nJunior',
574
    })
575

576
    await exportProcessor(buildJob({ userId: user.id }))
577

578
    const { attachment } = await attachmentFromLastEmail()
579
    const zip = await JSZip.loadAsync(attachment.content)
580
    const csv = await zip.file('profile.csv')!.async('string')
581

582
    expect(csv).toContain('"Silva, ""S""\nJunior"')
583
  })
584

585
  it('sends the failure email only on the final attempt', async () => {
586
    const user = await createTestUser(db, { email: 'fail@example.com' })
587
    mockedSendEmail
588
      .mockRejectedValueOnce(new Error('boom'))
589
      .mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
590

591
    await expect(
592
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
593
    ).rejects.toThrow('boom')
594

595
    expect(mockedSendEmail).toHaveBeenCalledTimes(2)
596
    expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
597
      'Não conseguimos exportar seus dados'
598
    )
599
  })
600

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

605
    await expect(
606
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
607
    ).rejects.toThrow('boom')
608

609
    expect(mockedSendEmail).toHaveBeenCalledOnce()
610
  })
611

612
  it('throws for unknown job names', async () => {
613
    await expect(
614
      exportProcessor({ name: 'export:unknown', data: {} } as any)
615
    ).rejects.toThrow('Unknown export job')
616
  })
617
})