eletrotupi / tcc/ api/tests/jobs/export-data.test.ts82219bffa59da16277fbdf343d327a77d7714dfc
10.5 KB | 309 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
      'leia-me.md',
67
      'mood_components.csv',
68
      'moods.csv',
69
      'profile.csv',
70
      'sleep_records.csv',
71
      'trigger_mood_links.csv',
72
      'triggers.csv',
73
    ])
74

75
    const csv = await zip.file('profile.csv')!.async('string')
76
    expect(csv.startsWith('\ufeff')).toBe(true)
77

78
    const lines = csvLines(csv)
79
    expect(lines).toHaveLength(2)
80
    expect(lines[0]).toContain('Nome')
81
    expect(lines[0]).toContain('Email')
82

83
    const [row] = lines.slice(1)
84
    expect(row).toContain('"Ana"')
85
    expect(row).toContain('"Silva"')
86
    expect(row).toContain('"export@example.com"')
87
    expect(row).toContain('"Sim"') // notifications default to enabled
88
    expect(row).toContain('"Não"') // freshly created users are inactive
89

90
    // Secrets must never travel in the export
91
    const lower = csv.toLowerCase()
92
    expect(lower).not.toContain('senha')
93
    expect(lower).not.toContain('token')
94
    expect(lower).not.toContain('push')
95
    expect(lower).not.toContain('avatar')
96

97
    const manifest = await zip.file('leia-me.md')!.async('string')
98
    expect(manifest).toContain('`profile.csv`')
99
    expect(manifest).toContain('1 registro')
100
    expect(manifest).toContain('_id')
101
  })
102

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

135
    await exportProcessor(buildJob({ userId: user.id }))
136

137
    const { attachment } = await attachmentFromLastEmail()
138
    const zip = await JSZip.loadAsync(attachment.content)
139

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

148
    const componentsCsv = await zip.file('mood_components.csv')!.async('string')
149
    const componentLines = csvLines(componentsCsv)
150
    expect(componentLines[0]).toContain('Sentimento')
151
    expect(componentLines[0]).toContain('Intensidade')
152

153
    const moodIdFor = new Map(
154
      [...firstMood.moodComponents, ...secondMood.moodComponents].map((component) => [
155
        component.id,
156
        component.moodId,
157
      ]),
158
    )
159
    const header = componentLines[0].split(',').map((field) => field.replace(/"/g, ''))
160
    const idIndex = header.indexOf('id')
161
    const moodIdIndex = header.indexOf('mood_id')
162

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

174
    const manifest = await zip.file('leia-me.md')!.async('string')
175
    expect(manifest).toContain('`moods.csv` — 2 registros')
176
    expect(manifest).toContain('`mood_components.csv` — 2 registros')
177
  })
178

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

224
    await exportProcessor(buildJob({ userId: user.id }))
225

226
    const { attachment } = await attachmentFromLastEmail()
227
    const zip = await JSZip.loadAsync(attachment.content)
228

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

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

246
    const linksCsv = await zip.file('trigger_mood_links.csv')!.async('string')
247
    const linkLines = csvLines(linksCsv)
248
    expect(linkLines[0]).toContain('Impacto percebido')
249
    expect(linkLines[0]).toContain('Vinculado em')
250

251
    const header = linkLines[0].split(',').map((field) => field.replace(/"/g, ''))
252
    const fields = linkLines[1].split(',')
253
    expect(Number(fields[header.indexOf('trigger_id')])).toBe(trigger.id)
254
    expect(Number(fields[header.indexOf('mood_id')])).toBe(mood.id)
255

256
    const manifest = await zip.file('leia-me.md')!.async('string')
257
    expect(manifest).toContain('`sleep_records.csv` — 1 registro')
258
    expect(manifest).toContain('`triggers.csv` — 2 registros')
259
    expect(manifest).toContain('`trigger_mood_links.csv` — 1 registro')
260
  })
261

262
  it('preserves commas, quotes and newlines inside a field', async () => {
263
    const user = await createTestUser(db, {
264
      email: 'quoted@example.com',
265
      lastName: 'Silva, "S"\nJunior',
266
    })
267

268
    await exportProcessor(buildJob({ userId: user.id }))
269

270
    const { attachment } = await attachmentFromLastEmail()
271
    const zip = await JSZip.loadAsync(attachment.content)
272
    const csv = await zip.file('profile.csv')!.async('string')
273

274
    expect(csv).toContain('"Silva, ""S""\nJunior"')
275
  })
276

277
  it('sends the failure email only on the final attempt', async () => {
278
    const user = await createTestUser(db, { email: 'fail@example.com' })
279
    mockedSendEmail
280
      .mockRejectedValueOnce(new Error('boom'))
281
      .mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
282

283
    await expect(
284
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
285
    ).rejects.toThrow('boom')
286

287
    expect(mockedSendEmail).toHaveBeenCalledTimes(2)
288
    expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
289
      'Não conseguimos exportar seus dados'
290
    )
291
  })
292

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

297
    await expect(
298
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
299
    ).rejects.toThrow('boom')
300

301
    expect(mockedSendEmail).toHaveBeenCalledOnce()
302
  })
303

304
  it('throws for unknown job names', async () => {
305
    await expect(
306
      exportProcessor({ name: 'export:unknown', data: {} } as any)
307
    ).rejects.toThrow('Unknown export job')
308
  })
309
})