Files
textbee/web/e2e/bulk-send.spec.ts
isra el 6da80ac4e7 fix: correct five CSV handling defects in bulk send
The bulk send rebuild shipped several parsing bugs that silently
produced wrong results rather than failing loudly.

Columns were read from the first parsed row instead of the header.
papaparse only assigns keys for values actually present in a row, so a
short first row hid every column after it: the phone column could not
be selected and the file was unusable. Read results.meta.fields.

Row-level parse errors were discarded. Only fatal stream failures were
reported, so a mis-delimited file parsed into one mangled column and
looked fine. Surface them as a warning.

The plan row cap was checked once at drop time and skipped entirely
while the subscription was still loading, so an over-cap file passed on
a limited plan and nothing revalidated it. The cap is now derived, so
it applies whenever the limit resolves.

Rejected files returned silently, leaving a dropped .xlsx with no
feedback. Report the rejection.

The preview index was left pointing into the old recipient list when
the phone column changed, hiding the preview and disabling its own Next
button. It is now clamped and reset.

Also count segments from the rendered message rather than falling back
to the raw template, which counted the placeholder literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:37:50 +03:00

204 lines
7.1 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
// The defect this guards: the previous implementation mapped every parsed row
// into the payload, so blank cells produced messages addressed to "" and the
// same number appearing twice was texted twice.
const CSV_WITH_PROBLEM_ROWS = [
'name,phone,order_id,total',
'Alice Johnson,+14155550101,ORD-1042,49.99',
'No Number,,ORD-1043,10.00',
'Bob Martinez,+16475550187,ORD-1044,128.50',
'Junk Row,call me later,ORD-1045,5.00',
'Alice Again,+1 (415) 555-0101,ORD-1046,20.00',
].join('\n')
async function uploadCsv(page: import('@playwright/test').Page, csv: string) {
await page.locator('input[type="file"]').setInputFiles({
name: 'recipients.csv',
mimeType: 'text/csv',
buffer: Buffer.from(csv),
})
}
// Keyboard selection rather than clicking the option: the Radix popper can
// render outside the viewport on a short page, which made the click flaky.
async function selectDevice(page: import('@playwright/test').Page) {
// Fixtures: Google Pixel 8 is the enabled device.
await page.getByLabel('Send from').press('Enter')
await page.getByRole('option', { name: /Google Pixel 8/ }).press('Enter')
await expect(page.getByLabel('Send from')).toContainText('Google Pixel 8')
}
test.describe('bulk send (mocked API, no real backend)', () => {
test('sends only valid, deduplicated recipients', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
// Capture what the app actually posts to the gateway.
let payload: any = null
await page.route('**/api/v1/gateway/devices/*/send-bulk-sms', (route) => {
payload = route.request().postDataJSON()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { success: true } }),
})
})
await page.goto('/dashboard/messaging/bulk')
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
// Parse summary and auto-detected phone column.
await expect(page.getByText('5 rows, 4 columns')).toBeVisible()
await expect(page.getByText('2 will receive a message')).toBeVisible()
await expect(page.getByText('1 with no number')).toBeVisible()
await expect(page.getByText('1 invalid')).toBeVisible()
await expect(page.getByText('1 duplicate')).toBeVisible()
await selectDevice(page)
await page
.getByLabel('Message template')
.fill('Hi {{ name }}, order {{ order_id }} confirmed.')
await page.getByRole('button', { name: /send 2 messages/i }).click()
await expect(page.getByText('2 messages queued')).toBeVisible()
// The payload is the real assertion.
expect(payload).not.toBeNull()
expect(payload.messages).toHaveLength(2)
const recipients = payload.messages.flatMap((m: any) => m.recipients)
expect(recipients).toEqual(['+14155550101', '+16475550187'])
// No empty recipient may ever be sent.
expect(recipients.some((r: string) => !r || !r.trim())).toBe(false)
// Template interpolation actually happened per row.
expect(payload.messages[0].message).toBe(
'Hi Alice Johnson, order ORD-1042 confirmed.'
)
})
test('offers a downloadable sample CSV', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
const link = page.getByRole('link', { name: /download a sample csv/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('download', '')
// The file must actually be served, not just linked.
const response = await page.request.get('/samples/bulk-sms-sample.csv')
expect(response.status()).toBe(200)
const body = await response.text()
expect(body).toContain('name,phone,order_id,total')
expect(body).toContain('+14155550101')
})
test('states the file size limit in human terms', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
// The old copy printed the raw byte count ("max 1048576 bytes").
await expect(page.getByText('1048576')).toHaveCount(0)
await expect(page.getByText(/Up to 1 MB/)).toBeVisible()
})
test('the parsed-CSV table does not widen the page at 375px', async ({
page,
context,
}) => {
await page.setViewportSize({ width: 375, height: 800 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
// A wide table is the obvious way to reintroduce the sideways-scroll bug,
// and it only exists once a file has been parsed.
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
await expect(page.getByText('5 rows, 4 columns')).toBeVisible()
const { scrollWidth, innerWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}))
expect(scrollWidth).toBeLessThanOrEqual(innerWidth)
})
test('warns when the template references a missing column', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
await selectDevice(page)
await page.getByLabel('Message template').fill('Hi {{ nickname }}')
await expect(page.getByText(/Unknown column reference/)).toBeVisible()
await expect(page.getByText(/no column named "nickname"/)).toBeVisible()
})
// The columns used to be read off the first parsed row rather than the
// header. papaparse only assigns keys for values actually present, so a
// short first row made every column after it vanish: the phone column could
// not be selected and the whole file was unusable.
test('keeps every column when the first data row is short', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await uploadCsv(
page,
[
'name,phone,order_id',
'Missing Everything Else',
'Bob Martinez,+16475550187,ORD-1044',
].join('\n')
)
await expect(page.getByText('2 rows, 3 columns')).toBeVisible()
// The real proof: the phone column was still detected and mapped, which is
// impossible if the column list came from that first row.
await expect(page.getByLabel('Phone number column')).toContainText('phone')
await expect(page.getByText('1 will receive a message')).toBeVisible()
})
test('explains why a non-CSV file was rejected', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
// Rejected drops used to return silently, so nothing happened and nothing
// said why.
await page.locator('input[type="file"]').setInputFiles({
name: 'contacts.xlsx',
mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
buffer: Buffer.from('not a csv'),
})
await expect(page.getByText(/is not a CSV/)).toBeVisible()
})
})