Files
when/apps/web/e2e/decline-note.e2e.ts

242 lines
11 KiB
TypeScript

import { expect, test } from './support/test.ts';
import { appendActionLogSql, openDb, parseActionLog } from '@when/db';
import { signInAsAdmin } from './support/auth.ts';
import { APP_DB_PATH, readAppointment, readQueuedWorkflows } from './support/database.ts';
import { seedAppointment } from './support/seed.ts';
async function seedWithPrivateNote() {
const appointment = await seedAppointment({ status: 'pending' });
const db = openDb(APP_DB_PATH);
try {
await db
.updateTable('appointments')
.set({
note: `Private appointment note ${appointment.id}`,
action_log: appendActionLogSql({
action: 'edit',
actor: 'host',
at: new Date().toISOString(),
payload: { metadata: { changes: ['note_updated'] } }
})
})
.where('id', '=', appointment.id)
.execute();
} finally {
await db.destroy();
}
const seeded = await readAppointment(appointment.id);
return seeded;
}
test('a decline note is stored separately and rendered as plain text to host and guest', async ({
page,
browser
}) => {
const appointment = await seedWithPrivateNote();
const note = 'Sorry, <b>not this time</b>.\n<script>window.declineNoteInjected = true</script>';
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
const origin = new URL(page.url()).origin;
const guest = await browser.newContext();
try {
const guestPage = await guest.newPage();
const guestUrl = `${origin}/appointment/${appointment.id}?token=${appointment.cancel_token}`;
await guestPage.goto(guestUrl);
await expect(guestPage.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
await expect(guestPage.getByRole('button', { name: 'Decline', exact: true })).toHaveCount(0);
const forbidden = await guest.request.post(
`${origin}/admin/appointment/${appointment.id}?/decline&token=${appointment.cancel_token}&role=host`,
{
headers: { origin, accept: 'application/json', 'x-sveltekit-action': 'true' },
form: {
decline_note: note,
token: appointment.cancel_token,
role: 'host',
isAdmin: 'true'
},
maxRedirects: 0
}
);
expect(forbidden.status()).toBe(403);
const afterForbidden = await readAppointment(appointment.id);
expect(afterForbidden).toEqual(appointment);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
await dialog.getByLabel('Note (optional)', { exact: true }).fill(note);
await expect(dialog.getByText(`${note.length}/500`, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const hostNote = page.locator('.state-note-line');
await expect(hostNote).toBeVisible();
await expect(hostNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
const hostText = await hostNote.textContent();
expect(hostText).toContain(note);
await expect(hostNote.locator('b, script')).toHaveCount(0);
await expect(page.getByText(appointment.note!, { exact: true })).toBeVisible();
const row = await readAppointment(appointment.id);
expect(row.status).toBe('declined');
expect(row.note).toBe(appointment.note);
const log = parseActionLog(row.action_log);
expect(log.slice(0, -1)).toEqual(parseActionLog(appointment.action_log));
expect(log.filter((entry) => entry.action === 'decline')).toHaveLength(1);
expect(log.at(-1)).toMatchObject({ action: 'decline', actor: 'host', payload: { note } });
expect(log.at(-1)?.payload).toEqual({ note });
expect(readQueuedWorkflows(`${appointment.id}:declined:${row.ics_sequence}`)).toEqual([
{ workflow_name: 'reconcile-appointment' }
]);
const guestResponse = await guestPage.goto(guestUrl);
expect(guestResponse?.status()).toBe(200);
const guestHtml = await guestResponse!.text();
expect(guestHtml).not.toContain(appointment.note!);
expect(guestHtml).not.toContain('note_updated');
await expect(guestPage.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const guestNote = guestPage.locator('.state-note-line');
await expect(guestNote).toBeVisible();
await expect(guestNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
const guestText = await guestNote.textContent();
expect(guestText).toContain(note);
await expect(guestNote.locator('b, script')).toHaveCount(0);
await expect(guestPage.getByText(appointment.note!, { exact: true })).toHaveCount(0);
await expect(guestPage.getByText('Activity log', { exact: true })).toHaveCount(0);
} finally {
await guest.close();
}
});
test('server rejection of 501 characters keeps the decline dialog and draft for a 500-character retry', async ({
page
}) => {
const appointment = await seedAppointment({ status: 'pending' });
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
const field = dialog.getByLabel('Note (optional)', { exact: true });
await expect(field).not.toHaveAttribute('required');
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
const draft = 'Please try another day.\nThank you for understanding.';
await field.fill(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toHaveAttribute('maxlength', '500');
// Bypass only native maxlength, leaving the real enhanced action and visible draft intact.
await dialog.locator('form').evaluate((element) => {
if (!(element instanceof HTMLFormElement)) throw new Error('Expected decline form');
element.addEventListener(
'formdata',
(event) => {
event.formData.set('decline_note', 'x'.repeat(501));
},
{ once: true }
);
});
const actionPath = `/admin/appointment/${appointment.id}`;
const rejectionPromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === actionPath &&
new URL(response.url()).searchParams.has('/decline')
);
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
const rejection = await rejectionPromise;
const result = await rejection.json();
expect(result).toMatchObject({ type: 'failure', status: 400 });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('alert')).toHaveText(
'Decline note must be 500 characters or fewer.'
);
await expect(field).toHaveValue(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toBeEnabled();
await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(appointment);
expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
false
);
expect(
readQueuedWorkflows(`${appointment.id}:declined:${appointment.ics_sequence + 1}`)
).toHaveLength(0);
const corrected = 'x'.repeat(500);
await field.fill(corrected);
await expect(dialog.getByText('500/500', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
const saved = await readAppointment(appointment.id);
expect(saved.status).toBe('declined');
expect(saved.note).toBe(appointment.note);
expect(parseActionLog(saved.action_log).at(-1)).toMatchObject({
action: 'decline',
actor: 'host',
payload: { note: corrected }
});
expect(readQueuedWorkflows(`${appointment.id}:declined:${saved.ics_sequence}`)).toHaveLength(1);
});
test('accepting in another host tab makes the stale decline fail without writing its note', async ({
page
}) => {
const appointment = await seedWithPrivateNote();
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
const draft = 'Do not save this stale decline.\nThe request has already been accepted.';
const field = dialog.getByLabel('Note (optional)', { exact: true });
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
await field.fill(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
const otherHost = await page.context().newPage();
try {
await otherHost.goto(page.url());
await otherHost.getByRole('button', { name: 'Accept', exact: true }).click();
await expect(otherHost.getByRole('heading', { name: 'Confirmed', exact: true })).toBeVisible();
} finally {
await otherHost.close();
}
const confirmed = await readAppointment(appointment.id);
expect(confirmed.status).toBe('confirmed');
expect(confirmed.note).toBe(appointment.note);
const actionPath = `/admin/appointment/${appointment.id}`;
const conflictPromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === actionPath &&
new URL(response.url()).searchParams.has('/decline')
);
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
const conflict = await conflictPromise;
const result = await conflict.json();
expect(result).toMatchObject({ type: 'failure', status: 409 });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('alert')).toHaveText('This appointment can no longer be declined.');
await expect(field).toHaveValue(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toBeEnabled();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(confirmed);
expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
false
);
expect(readQueuedWorkflows(`${appointment.id}:confirmed:${confirmed.ics_sequence}`)).toHaveLength(
1
);
expect(readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence}`)).toHaveLength(
0
);
expect(
readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence + 1}`)
).toHaveLength(0);
});