Files
eleith a1a3d31277
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
feat(web): allow duration-aware rescheduling
2026-11-01 16:05:28 -08:00

133 lines
5.4 KiB
TypeScript

import { expect, test } from './support/test.ts';
import { parseActionLog } from '@when/db';
import { readAppointment } from './support/database.ts';
import { signInAsAdmin } from './support/auth.ts';
import { seedAppointment } from './support/seed.ts';
import { pickLastBookableDay, pickSlotFromEndOfDay } from './support/wizard.ts';
test('a stale link shows the move but cannot authorize access to the new appointment', async ({
page
}) => {
const original = await seedAppointment({ status: 'confirmed' });
const staleLink = `/appointment/${original.id}?token=${original.cancel_token}`;
await page.goto(`/appointment/${original.id}/reschedule?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await pickLastBookableDay(page);
await pickSlotFromEndOfDay(page);
await page.getByRole('button', { name: 'Confirm' }).click();
await page.locator('textarea[name="reschedule_reason"]').fill('Something came up');
await page
.getByRole('button', { name: /Reschedule|Request|Confirm/ })
.last()
.click();
await page.waitForURL(/\/appointment\/[^/?]+\?token=.+/);
const successorId = new URL(page.url()).pathname.split('/').at(-1)!;
expect(successorId).not.toBe(original.id);
await page.goto(staleLink);
await expect(page.getByRole('heading', { name: 'Rescheduled', exact: true })).toBeVisible();
const latest = page.getByRole('link', { name: 'View latest appointment' });
await expect(latest).toBeVisible();
expect(await latest.getAttribute('href')).toBe(
`/appointment/${successorId}?token=${encodeURIComponent(original.cancel_token)}`
);
const followed = await page.goto(`/appointment/${successorId}?token=${original.cancel_token}`);
expect(followed?.status()).toBe(404);
});
test('the live token is a master key over the appointment history', async ({ page }) => {
const original = await seedAppointment({ status: 'confirmed' });
await page.goto(`/appointment/${original.id}/reschedule?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await pickLastBookableDay(page);
await pickSlotFromEndOfDay(page);
await page.getByRole('button', { name: 'Confirm' }).click();
await page.locator('textarea[name="reschedule_reason"]').fill('Moving again');
await page
.getByRole('button', { name: /Reschedule|Request|Confirm/ })
.last()
.click();
await page.waitForURL(/\/appointment\/[^/?]+\?token=.+/);
const successorId = new URL(page.url()).pathname.split('/').at(-1)!;
const successor = await readAppointment(successorId);
const response = await page.goto(`/appointment/${original.id}?token=${successor.cancel_token}`);
expect(response?.status()).toBe(200);
await expect(page.getByRole('heading', { name: 'Rescheduled', exact: true })).toBeVisible();
});
test('the cancel form refuses a token from a different appointment', async ({ page, request }) => {
const mine = await seedAppointment({ status: 'confirmed' });
const theirs = await seedAppointment({ status: 'confirmed' });
await page.goto(`/appointment/${mine.id}?token=${mine.cancel_token}`);
const response = await request.post(`/appointment/${mine.id}?/cancel`, {
headers: { origin: new URL(page.url()).origin },
form: { token: theirs.cancel_token, reason: 'let me in' }
});
expect(await response.json()).toMatchObject({ type: 'failure', status: 403 });
expect((await readAppointment(mine.id)).status).toBe('confirmed');
});
test('rotating the guest link kills the old one and issues a working replacement', async ({
page,
request
}) => {
const appointment = await seedAppointment({ status: 'confirmed' });
const oldLink = `/appointment/${appointment.id}?token=${appointment.cancel_token}`;
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Appointment actions' }).click();
await page.getByRole('menuitem', { name: 'Guest Link' }).click();
const link = page.locator('input.share-input');
await expect(link).toHaveValue(new RegExp(appointment.cancel_token));
await page.getByRole('button', { name: 'Rotate link' }).click();
// The dialog stays open and swaps in the new link, ready to copy.
await expect(link).not.toHaveValue(new RegExp(appointment.cancel_token));
const rotated = await readAppointment(appointment.id);
await expect(link).toHaveValue(new RegExp(rotated.cancel_token));
// Selected, so the token at the tail is scrolled into view and ready to copy.
await expect(link).toBeFocused();
expect(await link.evaluate((el: HTMLInputElement) => el.selectionEnd === el.value.length)).toBe(
true
);
expect(rotated.cancel_token).not.toBe(appointment.cancel_token);
expect(parseActionLog(rotated.action_log).at(-1)).toMatchObject({
action: 'rotate',
actor: 'host'
});
const stale = await request.get(oldLink);
expect(stale.status()).toBe(404);
const fresh = await request.get(`/appointment/${appointment.id}?token=${rotated.cancel_token}`);
expect(fresh.status()).toBe(200);
});
test('a signed-out visitor cannot rotate a guest link', async ({ request, baseURL }) => {
const appointment = await seedAppointment({ status: 'confirmed' });
const response = await request.post(`/admin/appointment/${appointment.id}?/rotate`, {
headers: { origin: baseURL! },
form: {}
});
expect(response.status()).toBe(403);
expect((await readAppointment(appointment.id)).cancel_token).toBe(appointment.cancel_token);
});