mirror of
https://github.com/vernu/textbee.git
synced 2026-08-02 10:28:21 -04:00
Brings the send screen up to the standard of the rebuilt bulk send flow. Fixes real defects: - The single-device auto-select never worked. It was computed in defaultValues from devices?.data?.length === 1, but react-hook-form reads defaultValues once on mount, while the devices query is still pending, so the condition was always false. It now preselects once devices resolve. - Submitting before devices loaded showed "Required" on the device field, which then auto-filled a moment later, leaving a stale error beside a populated field. Submit is disabled until devices load. - Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared cache key with a different unwrapped shape that caused the devices?.filter crash previously. - Every field used a placeholder as its label. Placeholders vanish on focus and are not reliably announced, so all three now have real labels. Recipients are chips instead of a growing stack of inputs. They commit on Enter, comma or blur, so a number typed and left uncommitted is not silently dropped at send time. Pasting a list adds several at once. Splitting a pasted list needed care, and an e2e caught it: splitting on whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous separators split first; whitespace splitting is a fallback for tokens that cannot be a single number. Also adds a segment counter, inline alerts for success and failure instead of bare text, and a form reset after sending that keeps the device selected. Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send page and bulk send share one tested implementation. One test correction: the desktop footer assertion required links to sit on exactly one row, but the row layout wraps by design, so it failed whenever font metrics pushed a link to a second line. It now asserts the property the design actually guarantees, that links are not one-per-row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
import { authenticate } from './session'
|
|
import { mockApi } from './mock-api'
|
|
|
|
async function captureSend(page: import('@playwright/test').Page) {
|
|
const payloads: any[] = []
|
|
await page.route('**/api/v1/gateway/devices/*/send-sms', (route) => {
|
|
payloads.push(route.request().postDataJSON())
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: { success: true } }),
|
|
})
|
|
})
|
|
return payloads
|
|
}
|
|
|
|
test.describe('send sms (mocked API, no real backend)', () => {
|
|
test('commits recipients as chips and posts exactly those numbers', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
const payloads = await captureSend(page)
|
|
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
const to = page.getByLabel('To', { exact: true })
|
|
await to.fill('+1 (415) 555-0101')
|
|
await to.press('Enter')
|
|
await to.fill('+16475550187')
|
|
await to.press('Enter')
|
|
|
|
// Both are chips now, each removable.
|
|
await expect(
|
|
page.getByRole('button', { name: 'Remove +14155550101' })
|
|
).toBeVisible()
|
|
await expect(
|
|
page.getByRole('button', { name: 'Remove +16475550187' })
|
|
).toBeVisible()
|
|
|
|
await page.getByLabel('Message').fill('Hello from the send page')
|
|
await page.getByRole('button', { name: /Send to 2 recipients/ }).click()
|
|
|
|
await expect(page.getByText('Message sent')).toBeVisible()
|
|
expect(payloads).toHaveLength(1)
|
|
// Formatting is stripped, because the API schema accepts digits with an
|
|
// optional leading plus.
|
|
expect(payloads[0].recipients).toEqual(['+14155550101', '+16475550187'])
|
|
expect(payloads[0].message).toBe('Hello from the send page')
|
|
})
|
|
|
|
test('splits a pasted list into separate chips', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
await page
|
|
.getByLabel('To', { exact: true })
|
|
.fill('+14155550101, +16475550187 +12065550142')
|
|
await page.getByLabel('To', { exact: true }).press('Enter')
|
|
|
|
for (const number of ['+14155550101', '+16475550187', '+12065550142']) {
|
|
await expect(
|
|
page.getByRole('button', { name: `Remove ${number}` })
|
|
).toBeVisible()
|
|
}
|
|
})
|
|
|
|
test('keeps a spaced number whole instead of shredding it', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
// Spaces are formatting inside one number here, not separators.
|
|
const to = page.getByLabel('To', { exact: true })
|
|
await to.fill('+1 415 555 0101')
|
|
await to.press('Enter')
|
|
|
|
await expect(
|
|
page.getByRole('button', { name: 'Remove +14155550101' })
|
|
).toBeVisible()
|
|
// Exactly one chip, not four fragments.
|
|
await expect(page.getByRole('button', { name: /^Remove/ })).toHaveCount(1)
|
|
})
|
|
|
|
test('rejects a number that cannot be dialled', async ({ page, context }) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
const to = page.getByLabel('To', { exact: true })
|
|
await to.fill('not a number')
|
|
await to.press('Enter')
|
|
|
|
await expect(
|
|
page.getByText(/does not look like a phone number/)
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('does not silently drop a number typed but not committed', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
const payloads = await captureSend(page)
|
|
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
// Type a number then go straight to the message without pressing Enter.
|
|
await page.getByLabel('To', { exact: true }).fill('+14155550101')
|
|
await page.getByLabel('Message').fill('Committed on blur')
|
|
await page.getByRole('button', { name: /Send message/ }).click()
|
|
|
|
await expect(page.getByText('Message sent')).toBeVisible()
|
|
expect(payloads[0].recipients).toEqual(['+14155550101'])
|
|
})
|
|
|
|
test('preselects the only enabled device', async ({ page, context }) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
// Fixtures have one enabled device (Pixel 8) and one disabled. The old
|
|
// implementation computed this in defaultValues before devices loaded, so
|
|
// it never preselected anything.
|
|
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
|
|
})
|
|
|
|
test('counts SMS segments', async ({ page, context }) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
await page.getByLabel('Message').fill('a'.repeat(161))
|
|
await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible()
|
|
})
|
|
|
|
test('clears the form after a successful send', async ({ page, context }) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await captureSend(page)
|
|
|
|
await page.goto('/dashboard/messaging')
|
|
const to = page.getByLabel('To', { exact: true })
|
|
await to.fill('+14155550101')
|
|
await to.press('Enter')
|
|
await page.getByLabel('Message').fill('One off')
|
|
await page.getByRole('button', { name: /Send message/ }).click()
|
|
|
|
await expect(page.getByText('Message sent')).toBeVisible()
|
|
await expect(page.getByLabel('Message')).toHaveValue('')
|
|
await expect(
|
|
page.getByRole('button', { name: 'Remove +14155550101' })
|
|
).toHaveCount(0)
|
|
// The device stays selected, since the next message usually goes out the
|
|
// same way.
|
|
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
|
|
})
|
|
})
|