eletrotupi / tcc/ api/tests/jobs/export-data.test.ts8480619e654d675a3b0327e5df6b8b94dab2c32b
7.4 KB | 223 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
    ])
71

72
    const csv = await zip.file('profile.csv')!.async('string')
73
    expect(csv.startsWith('\ufeff')).toBe(true)
74

75
    const lines = csvLines(csv)
76
    expect(lines).toHaveLength(2)
77
    expect(lines[0]).toContain('Nome')
78
    expect(lines[0]).toContain('Email')
79

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

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

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

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

132
    await exportProcessor(buildJob({ userId: user.id }))
133

134
    const { attachment } = await attachmentFromLastEmail()
135
    const zip = await JSZip.loadAsync(attachment.content)
136

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

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

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

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

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

176
  it('preserves commas, quotes and newlines inside a field', async () => {
177
    const user = await createTestUser(db, {
178
      email: 'quoted@example.com',
179
      lastName: 'Silva, "S"\nJunior',
180
    })
181

182
    await exportProcessor(buildJob({ userId: user.id }))
183

184
    const { attachment } = await attachmentFromLastEmail()
185
    const zip = await JSZip.loadAsync(attachment.content)
186
    const csv = await zip.file('profile.csv')!.async('string')
187

188
    expect(csv).toContain('"Silva, ""S""\nJunior"')
189
  })
190

191
  it('sends the failure email only on the final attempt', async () => {
192
    const user = await createTestUser(db, { email: 'fail@example.com' })
193
    mockedSendEmail
194
      .mockRejectedValueOnce(new Error('boom'))
195
      .mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
196

197
    await expect(
198
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
199
    ).rejects.toThrow('boom')
200

201
    expect(mockedSendEmail).toHaveBeenCalledTimes(2)
202
    expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
203
      'Não conseguimos exportar seus dados'
204
    )
205
  })
206

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

211
    await expect(
212
      exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
213
    ).rejects.toThrow('boom')
214

215
    expect(mockedSendEmail).toHaveBeenCalledOnce()
216
  })
217

218
  it('throws for unknown job names', async () => {
219
    await expect(
220
      exportProcessor({ name: 'export:unknown', data: {} } as any)
221
    ).rejects.toThrow('Unknown export job')
222
  })
223
})