feat(web): offer host duration choices and preserve booked lengths #87
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@when/web': patch
|
||||
'@when/worker': patch
|
||||
'@when/cli': patch
|
||||
---
|
||||
|
||||
Offer hosts a finite set of booking lengths alongside configured choices. Preserve the current appointment length by default when rescheduling, allowing other lengths only when the requester may choose them. Keep new-booking defaults explicit and the duration picker reachable when a chosen length has no availability.
|
||||
@@ -42,6 +42,12 @@ schedules:
|
||||
from: '09:00'
|
||||
to: '17:00'
|
||||
|
||||
host-durations:
|
||||
weekly:
|
||||
- days: [mon, tue, wed, thu, fri]
|
||||
from: '09:00'
|
||||
to: '12:00'
|
||||
|
||||
meetings:
|
||||
chat:
|
||||
title: '30-minute chat'
|
||||
@@ -67,6 +73,20 @@ meetings:
|
||||
padding_after_minutes: 15
|
||||
daily_booking_limit: 1
|
||||
|
||||
host-durations:
|
||||
title: 'Host duration choices fixture'
|
||||
visibility: 'unlisted'
|
||||
duration_minutes: 60
|
||||
additional_duration_minutes: [25]
|
||||
require_approval: true
|
||||
show_slots: true
|
||||
schedule: 'host-durations'
|
||||
booking_calendar: 'main'
|
||||
notice_minutes: 0
|
||||
padding_before_minutes: 0
|
||||
padding_after_minutes: 0
|
||||
daily_booking_limit: null
|
||||
|
||||
url:
|
||||
app: 'http://localhost:4183'
|
||||
worker: 'http://127.0.0.1:9'
|
||||
@@ -110,7 +110,7 @@ test('host adds beside a busy appointment despite guest buffers and daily cap',
|
||||
expect(overlapRejected).toMatchObject({ type: 'failure', status: 409 });
|
||||
const unsupported = await page.request.post(`/admin/schedule/${slug}`, {
|
||||
...postOptions,
|
||||
form: { ...form, slot: `${day}T15:00:00Z`, duration: '45' }
|
||||
form: { ...form, slot: `${day}T15:00:00Z`, duration: '75' }
|
||||
});
|
||||
const durationRejected = await unsupported.json();
|
||||
expect(durationRejected).toMatchObject({ type: 'failure', status: 400 });
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { expect, test, type Page, type Request } from '@playwright/test';
|
||||
import { signInAsAdmin } from './support/auth.ts';
|
||||
import { readAppointment } from './support/database.ts';
|
||||
import { seedAppointment } from './support/seed.ts';
|
||||
|
||||
const slug = 'host-durations';
|
||||
const hostDurations = [15, 25, 30, 45, 60, 90, 120, 180, 240, 300];
|
||||
const postOptions = {
|
||||
headers: { origin: 'http://localhost:4183', accept: 'application/json' },
|
||||
maxRedirects: 0
|
||||
};
|
||||
const forgedHost = { role: 'host', viewer: 'host', isAdmin: 'true' };
|
||||
|
||||
function futureWeekday(offset: number, retry: number): string {
|
||||
const date = new Date();
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
for (let attempt = 0; attempt <= retry; attempt++) {
|
||||
if (attempt > 0) date.setUTCDate(date.getUTCDate() + 1);
|
||||
while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function durationTrigger(page: Page, current: number) {
|
||||
return page.getByRole('button', {
|
||||
name: `Change duration, currently ${current} minutes`,
|
||||
exact: true
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseDuration(page: Page, current: number, next: number): Promise<void> {
|
||||
await durationTrigger(page, current).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'How long?' });
|
||||
await dialog.getByRole('button', { name: `${next} minutes`, exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
test('host uses the configured default and finite palette, recovering locally from no availability', async ({
|
||||
page,
|
||||
request
|
||||
}, testInfo) => {
|
||||
const day = futureWeekday(14, testInfo.retry);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto(`/schedule/${slug}?date=${day}`);
|
||||
await expect(durationTrigger(page, 60)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
|
||||
await durationTrigger(page, 60).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'How long?' });
|
||||
await expect(dialog.locator('.duration-row')).toHaveText(
|
||||
hostDurations.map((duration) => `${duration} minutes`)
|
||||
);
|
||||
|
||||
const durationRequests: string[] = [];
|
||||
const trackDurationRequest = (request: Request) => {
|
||||
if (request.isNavigationRequest() || ['fetch', 'xhr'].includes(request.resourceType())) {
|
||||
durationRequests.push(request.url());
|
||||
}
|
||||
};
|
||||
page.on('request', trackDurationRequest);
|
||||
await dialog.getByRole('button', { name: '300 minutes', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('No availability in the near future.', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(durationTrigger(page, 300)).toBeVisible();
|
||||
await chooseDuration(page, 300, 90);
|
||||
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
|
||||
await expect(page.getByText('No availability in the near future.', { exact: true })).toHaveCount(
|
||||
0
|
||||
);
|
||||
await chooseDuration(page, 90, 300);
|
||||
await expect(
|
||||
page.getByText('No availability in the near future.', { exact: true })
|
||||
).toBeVisible();
|
||||
await chooseDuration(page, 300, 15);
|
||||
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
|
||||
await expect(durationTrigger(page, 15)).toBeVisible();
|
||||
await chooseDuration(page, 15, 90);
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
const target = page.getByRole('radio', { name: /10:30\s*AM\s*–\s*12:00\s*PM/i });
|
||||
await expect(target).toBeVisible();
|
||||
page.off('request', trackDurationRequest);
|
||||
expect(durationRequests).toEqual([]);
|
||||
|
||||
await target.click();
|
||||
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await expect(page.locator('#appointment-form')).toHaveAttribute(
|
||||
'action',
|
||||
`/admin/schedule/${slug}`
|
||||
);
|
||||
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
|
||||
const slot = await page.locator('input[name="slot"]').inputValue();
|
||||
expect(Date.parse(slot)).toBe(Date.parse(`${day}T10:30:00Z`));
|
||||
const form = {
|
||||
slot,
|
||||
duration: '90',
|
||||
name: 'Host Duration Guest',
|
||||
email: 'duration@example.test'
|
||||
};
|
||||
for (const client of [request, page.request]) {
|
||||
// Neither a forged current length nor a real host cookie promotes the public action.
|
||||
const rejected = await client.post(`/schedule/${slug}`, {
|
||||
...postOptions,
|
||||
form: { ...form, ...forgedHost, currentDuration: '90' }
|
||||
});
|
||||
const result = await rejected.json();
|
||||
expect(result).toMatchObject({ type: 'failure', status: 400 });
|
||||
}
|
||||
await page.getByLabel('What is your name?').fill(form.name);
|
||||
await page.getByLabel('What is your email?').fill(form.email);
|
||||
await page.getByRole('button', { name: 'Request', exact: true }).click();
|
||||
await page.waitForURL(/\/appointment\/[^/?]+$/);
|
||||
|
||||
const id = new URL(page.url()).pathname.split('/').at(-1)!;
|
||||
const created = await readAppointment(id);
|
||||
expect(created).toMatchObject({
|
||||
event_type_id: slug,
|
||||
status: 'confirmed',
|
||||
guest_name: form.name,
|
||||
guest_email: form.email
|
||||
});
|
||||
expect(Date.parse(created.start_time)).toBe(Date.parse(slot));
|
||||
expect(Date.parse(created.end_time) - Date.parse(created.start_time)).toBe(90 * 60_000);
|
||||
});
|
||||
|
||||
test('rescheduling defaults to the trusted current 90 for both roles and omission preserves it', async ({
|
||||
page,
|
||||
request
|
||||
}, testInfo) => {
|
||||
// Between the existing host-availability cases at roughly 40 and 50 days.
|
||||
const day = futureWeekday(45, testInfo.retry);
|
||||
const original = await seedAppointment({
|
||||
eventTypeId: slug,
|
||||
status: 'confirmed',
|
||||
start: new Date(`${day}T09:00:00Z`),
|
||||
durationMinutes: 90
|
||||
});
|
||||
const publicPath = `/appointment/${original.id}/reschedule`;
|
||||
const link = `${publicPath}?token=${original.cancel_token}&date=${day}`;
|
||||
await page.goto(link);
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
await durationTrigger(page, 90).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'How long?' });
|
||||
await expect(dialog.locator('.duration-row')).toHaveText([
|
||||
'60 minutes',
|
||||
'25 minutes',
|
||||
'90 minutes'
|
||||
]);
|
||||
await expect(dialog.getByRole('button', { name: '120 minutes', exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
|
||||
const form = {
|
||||
slot: `${day}T10:30:00Z`,
|
||||
token: original.cancel_token,
|
||||
name: original.guest_name,
|
||||
email: original.guest_email!,
|
||||
timezone: 'UTC',
|
||||
reschedule_reason: 'Keep the booked length at a new time'
|
||||
};
|
||||
const forged = { ...form, ...forgedHost, currentDuration: '120', duration: '120' };
|
||||
const rejected = await request.post(publicPath, { ...postOptions, form: forged });
|
||||
const rejectedResult = await rejected.json();
|
||||
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
|
||||
const sameStart = await request.post(publicPath, {
|
||||
...postOptions,
|
||||
form: { ...form, slot: original.start_time, duration: '25' }
|
||||
});
|
||||
const sameStartResult = await sameStart.json();
|
||||
expect(sameStartResult).toMatchObject({ type: 'failure', status: 400 });
|
||||
|
||||
await signInAsAdmin(page);
|
||||
await page.goto(link);
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
await durationTrigger(page, 90).click();
|
||||
await expect(dialog.locator('.duration-row')).toHaveText(
|
||||
hostDurations.map((duration) => `${duration} minutes`)
|
||||
);
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
const hostOnPublic = await page.request.post(publicPath, { ...postOptions, form: forged });
|
||||
const hostOnPublicResult = await hostOnPublic.json();
|
||||
expect(hostOnPublicResult).toMatchObject({ type: 'failure', status: 400 });
|
||||
const hostSameStart = await page.request.post(`/admin/appointment/${original.id}/reschedule`, {
|
||||
...postOptions,
|
||||
form: { ...form, slot: original.start_time, duration: '25' }
|
||||
});
|
||||
const hostSameStartResult = await hostSameStart.json();
|
||||
expect(hostSameStartResult).toMatchObject({ type: 'failure', status: 400 });
|
||||
const unchanged = await readAppointment(original.id);
|
||||
expect(unchanged).toEqual(original);
|
||||
|
||||
// Omit duration entirely; even a forged currentDuration must not replace the row's length.
|
||||
const moved = await request.post(publicPath, {
|
||||
...postOptions,
|
||||
form: { ...form, ...forgedHost, currentDuration: '120' }
|
||||
});
|
||||
const redirect = await moved.json();
|
||||
expect(redirect).toMatchObject({ type: 'redirect', status: 303 });
|
||||
expect(redirect.location).toMatch(/^\/appointment\/[^/?]+\?token=.+/);
|
||||
const id = new URL(redirect.location, 'http://localhost:4183').pathname.split('/').at(-1)!;
|
||||
expect(id).not.toBe(original.id);
|
||||
const old = await readAppointment(original.id);
|
||||
const next = await readAppointment(id);
|
||||
expect(old.status).toBe('rescheduled');
|
||||
expect(next).toMatchObject({
|
||||
event_type_id: slug,
|
||||
status: 'pending',
|
||||
origin_id: original.id,
|
||||
guest_name: original.guest_name,
|
||||
guest_email: original.guest_email,
|
||||
guest_timezone: 'UTC'
|
||||
});
|
||||
expect(Date.parse(next.start_time)).toBe(Date.parse(form.slot));
|
||||
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(90 * 60_000);
|
||||
expect(next.cancel_token).not.toBe(original.cancel_token);
|
||||
expect(redirect.location).toBe(
|
||||
`/appointment/${id}?token=${encodeURIComponent(next.cancel_token)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('guest can change a booked host-only 90 to configured 25, but cannot invent 75', async ({
|
||||
page,
|
||||
request
|
||||
}, testInfo) => {
|
||||
const day = futureWeekday(24, testInfo.retry);
|
||||
const original = await seedAppointment({
|
||||
eventTypeId: slug,
|
||||
status: 'confirmed',
|
||||
start: new Date(`${day}T09:00:00Z`),
|
||||
durationMinutes: 90
|
||||
});
|
||||
const publicPath = `/appointment/${original.id}/reschedule`;
|
||||
const reason = 'Use the shorter configured length';
|
||||
const rejected = await request.post(publicPath, {
|
||||
...postOptions,
|
||||
form: {
|
||||
slot: `${day}T10:30:00Z`,
|
||||
duration: '75',
|
||||
currentDuration: '75',
|
||||
...forgedHost,
|
||||
token: original.cancel_token,
|
||||
name: original.guest_name,
|
||||
email: original.guest_email!,
|
||||
reschedule_reason: reason
|
||||
}
|
||||
});
|
||||
const rejectedResult = await rejected.json();
|
||||
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
|
||||
const unchanged = await readAppointment(original.id);
|
||||
expect(unchanged).toEqual(original);
|
||||
|
||||
await page.goto(`${publicPath}?token=${original.cancel_token}&date=${day}`);
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
await chooseDuration(page, 90, 25);
|
||||
await expect(durationTrigger(page, 25)).toBeVisible();
|
||||
await page.getByRole('radio', { name: /10:30\s*AM\s*–\s*10:55\s*AM/i }).click();
|
||||
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await expect(page.locator('#appointment-form')).toHaveAttribute('action', publicPath);
|
||||
await expect(page.locator('input[name="duration"]')).toHaveValue('25');
|
||||
await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name);
|
||||
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
|
||||
const slot = await page.locator('input[name="slot"]').inputValue();
|
||||
await page.getByLabel('Reason for rescheduling').fill(reason);
|
||||
await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click();
|
||||
await page.waitForURL(/\/appointment\/[^/?]+\?token=.+/);
|
||||
|
||||
const id = new URL(page.url()).pathname.split('/').at(-1)!;
|
||||
expect(id).not.toBe(original.id);
|
||||
const old = await readAppointment(original.id);
|
||||
const next = await readAppointment(id);
|
||||
expect(old.status).toBe('rescheduled');
|
||||
expect(next).toMatchObject({
|
||||
event_type_id: slug,
|
||||
status: 'pending',
|
||||
origin_id: original.id,
|
||||
guest_name: original.guest_name,
|
||||
guest_email: original.guest_email
|
||||
});
|
||||
expect(Date.parse(next.start_time)).toBe(Date.parse(slot));
|
||||
expect(Date.parse(next.start_time)).not.toBe(Date.parse(original.start_time));
|
||||
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(25 * 60_000);
|
||||
});
|
||||
|
||||
test('an empty weekend timeline keeps its duration toolbar usable without fetching', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
const saturday = new Date();
|
||||
saturday.setUTCDate(saturday.getUTCDate() + 7 + testInfo.retry * 7);
|
||||
while (saturday.getUTCDay() !== 6) {
|
||||
saturday.setUTCDate(saturday.getUTCDate() + 1);
|
||||
}
|
||||
const day = saturday.toISOString().slice(0, 10);
|
||||
await signInAsAdmin(page);
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'tz',
|
||||
value: encodeURIComponent('Pacific/Kiritimati'),
|
||||
url: 'http://localhost:4183'
|
||||
}
|
||||
]);
|
||||
// Friday's UTC window reaches Saturday here, letting the public UI open that day.
|
||||
await page.goto(`/schedule/${slug}?date=${day}`);
|
||||
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
|
||||
await expect(page.getByRole('radio').first()).toBeVisible();
|
||||
|
||||
const durationRequests: string[] = [];
|
||||
const trackDurationRequest = (request: Request) => {
|
||||
if (request.isNavigationRequest() || ['fetch', 'xhr'].includes(request.resourceType())) {
|
||||
durationRequests.push(request.url());
|
||||
}
|
||||
};
|
||||
page.on('request', trackDurationRequest);
|
||||
await page.getByRole('button', { name: /^Change timezone, currently/ }).click();
|
||||
const timezoneDialog = page.getByRole('dialog', { name: 'Choose timezone' });
|
||||
await timezoneDialog.getByRole('searchbox').fill('Abidjan');
|
||||
await timezoneDialog.getByRole('button', { name: /^Abidjan\b/ }).click();
|
||||
await expect(timezoneDialog).not.toBeVisible();
|
||||
// Abidjan is UTC year-round: Saturday now has no working-window geometry.
|
||||
await expect(page.locator('.timeline-scroll')).toHaveCount(0);
|
||||
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('group', { name: 'Duration selector' })).toBeVisible();
|
||||
await expect(durationTrigger(page, 60)).toBeVisible();
|
||||
await chooseDuration(page, 60, 90);
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
|
||||
await chooseDuration(page, 90, 25);
|
||||
await expect(durationTrigger(page, 25)).toBeVisible();
|
||||
await expect(page.locator('.timeline-scroll')).toHaveCount(0);
|
||||
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('No availability in the near future.', { exact: true })).toHaveCount(
|
||||
0
|
||||
);
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await expect(
|
||||
page.locator('.cal-day:not([data-unavailable]):not([data-disabled])').first()
|
||||
).toBeVisible();
|
||||
page.off('request', trackDurationRequest);
|
||||
expect(durationRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test('client navigation between meeting slugs replaces duration defaults, choices and maps', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
const day = futureWeekday(20, testInfo.retry);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto(`/schedule/${slug}?date=${day}`);
|
||||
await chooseDuration(page, 60, 90);
|
||||
await expect(durationTrigger(page, 90)).toBeVisible();
|
||||
const lateSlot = page.getByRole('radio', { name: /0?4:30\s*PM\s*–\s*0?5:00\s*PM/i });
|
||||
await expect(lateSlot).toHaveCount(0);
|
||||
|
||||
await page.evaluate((href) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.id = 'e2e-switch-meeting';
|
||||
anchor.href = href;
|
||||
anchor.textContent = 'Switch to chat fixture';
|
||||
anchor.setAttribute('data-sveltekit-preload-data', 'off');
|
||||
document.body.append(anchor);
|
||||
}, `/schedule/chat?date=${day}`);
|
||||
const documentRequests: string[] = [];
|
||||
const trackDocumentRequest = (request: Request) => {
|
||||
if (request.isNavigationRequest()) documentRequests.push(request.url());
|
||||
};
|
||||
page.on('request', trackDocumentRequest);
|
||||
await page.getByRole('link', { name: 'Switch to chat fixture', exact: true }).click();
|
||||
await expect(page).toHaveURL(`/schedule/chat?date=${day}`);
|
||||
await expect(durationTrigger(page, 30)).toBeVisible();
|
||||
// The body sentinel survives client routing, but would disappear on a document reload.
|
||||
await expect(page.locator('#e2e-switch-meeting')).toHaveCount(1);
|
||||
page.off('request', trackDocumentRequest);
|
||||
expect(documentRequests).toEqual([]);
|
||||
await durationTrigger(page, 30).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'How long?' });
|
||||
await expect(dialog.locator('.duration-row')).toHaveText(
|
||||
hostDurations.filter((duration) => duration !== 25).map((duration) => `${duration} minutes`)
|
||||
);
|
||||
await expect(dialog.getByRole('button', { name: '25 minutes', exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(lateSlot).toBeVisible();
|
||||
});
|
||||
|
||||
test('an enhanced host booking conflict preserves entered fields and the chosen length', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
const day = futureWeekday(34, testInfo.retry);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto(`/schedule/${slug}?date=${day}`);
|
||||
await chooseDuration(page, 60, 90);
|
||||
await page.getByRole('radio', { name: /10:30\s*AM\s*–\s*12:00\s*PM/i }).click();
|
||||
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
const name = 'Retained Host Guest';
|
||||
const email = 'retained-duration@example.test';
|
||||
await page.getByLabel('What is your name?').fill(name);
|
||||
await page.getByLabel('What is your email?').fill(email);
|
||||
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
|
||||
const slot = await page.locator('input[name="slot"]').inputValue();
|
||||
const blocker = await seedAppointment({
|
||||
eventTypeId: slug,
|
||||
status: 'confirmed',
|
||||
start: new Date(slot),
|
||||
durationMinutes: 90
|
||||
});
|
||||
const responsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === 'POST' && url.pathname === `/admin/schedule/${slug}`;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Request', exact: true }).click();
|
||||
const response = await responsePromise;
|
||||
const result = await response.json();
|
||||
expect(result).toMatchObject({ type: 'failure', status: 409 });
|
||||
// Default enhance does not apply a different route's failure to the current page's form prop.
|
||||
expect(result.data).toContain('That time is no longer available. Please pick another.');
|
||||
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
|
||||
await expect(page.getByLabel('What is your name?')).toHaveValue(name);
|
||||
await expect(page.getByLabel('What is your email?')).toHaveValue(email);
|
||||
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
|
||||
await expect(page.locator('input[name="slot"]')).toHaveValue(slot);
|
||||
|
||||
// Re-run the same identity's load through public routing, not a document reload.
|
||||
await page.evaluate((href) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.id = 'e2e-refresh-meeting';
|
||||
anchor.href = href;
|
||||
anchor.textContent = 'Refresh this meeting fixture';
|
||||
anchor.setAttribute('data-sveltekit-preload-data', 'off');
|
||||
document.body.append(anchor);
|
||||
}, `/schedule/${slug}?duration=90&date=${day}`);
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
const loadPromise = page.waitForResponse((loadResponse) => {
|
||||
const url = new URL(loadResponse.url());
|
||||
return (
|
||||
loadResponse.request().method() === 'GET' && url.pathname === `/schedule/${slug}/__data.json`
|
||||
);
|
||||
});
|
||||
await page.getByRole('link', { name: 'Refresh this meeting fixture', exact: true }).click();
|
||||
const loadResponse = await loadPromise;
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => history.length)).toBe(historyLength + 1);
|
||||
await expect(page.locator('#e2e-refresh-meeting')).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
|
||||
await expect(page.getByLabel('What is your name?')).toHaveValue(name);
|
||||
await expect(page.getByLabel('What is your email?')).toHaveValue(email);
|
||||
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
|
||||
await expect(page.locator('input[name="slot"]')).toHaveValue(slot);
|
||||
const unchangedBlocker = await readAppointment(blocker.id);
|
||||
expect(unchangedBlocker).toEqual(blocker);
|
||||
// This checks preservation only, not the deferred same-identity slot-map resync.
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page } from '$app/state';
|
||||
import DatePicker from '$lib/components/DatePicker.svelte';
|
||||
import DayTimeline from '$lib/components/DayTimeline.svelte';
|
||||
import DurationDialog from '$lib/components/DurationDialog.svelte';
|
||||
import CalendarSkeleton from '$lib/components/CalendarSkeleton.svelte';
|
||||
import TimelineSkeleton from '$lib/components/TimelineSkeleton.svelte';
|
||||
import RescheduleBanner from '$lib/components/RescheduleBanner.svelte';
|
||||
@@ -68,7 +69,10 @@
|
||||
const durations = data.eventType.durations;
|
||||
// svelte-ignore state_referenced_locally
|
||||
const slotsByDuration = data.availability.slotsByDuration;
|
||||
const initialDuration = durations.includes(durationParam) ? durationParam : durations[0];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialDuration = durations.includes(durationParam)
|
||||
? durationParam
|
||||
: data.eventType.duration_minutes;
|
||||
|
||||
const flow = createAppointmentFlow({ slotsByDuration, durations, initialDuration });
|
||||
const ptz = getPreferredTimezone();
|
||||
@@ -78,6 +82,7 @@
|
||||
let selectedSlot = $derived(flow.selectedSlot);
|
||||
let userTz = $derived(flow.userTz);
|
||||
let isGuestFormValid = $state(false);
|
||||
let durationOpen = $state(false);
|
||||
|
||||
let formAction = $derived.by(() => {
|
||||
if (isHost) {
|
||||
@@ -129,7 +134,7 @@
|
||||
const urlSync = createAppointmentUrlSync(flow, {
|
||||
deepLink,
|
||||
tz: ptz.current ?? data.user.timezone,
|
||||
defaultDuration: durations[0]
|
||||
defaultDuration: data.eventType.duration_minutes
|
||||
});
|
||||
let linkNotice = $derived(urlSync.notice);
|
||||
</script>
|
||||
@@ -161,6 +166,18 @@
|
||||
</header>
|
||||
|
||||
<main class="appointment">
|
||||
{#if !rescheduleError && durations.length > 1 && (step === 1 || flow.availableDates.size === 0)}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-trigger"
|
||||
onclick={() => (durationOpen = true)}
|
||||
aria-label="Change duration, currently {flow.duration} minutes"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Change duration · {flow.duration} min
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if rescheduleError}
|
||||
<div class="card reschedule-error-card">
|
||||
<h1 class="error-title">Can't reschedule this appointment</h1>
|
||||
@@ -274,6 +291,13 @@
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<DurationDialog
|
||||
bind:open={durationOpen}
|
||||
{durations}
|
||||
value={flow.duration}
|
||||
onSelect={flow.setDuration}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.appointment {
|
||||
max-width: 960px;
|
||||
@@ -282,6 +306,29 @@
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
.duration-trigger {
|
||||
display: block;
|
||||
min-height: calc(var(--space-8) + var(--space-4));
|
||||
margin: var(--space-4) auto;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--when-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-trigger:hover {
|
||||
background: var(--color-surface-active);
|
||||
}
|
||||
|
||||
.duration-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -280,8 +280,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if viewDate && timeline}
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-container">
|
||||
{#if viewDate}
|
||||
<div class="slots-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,6 +330,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if timeline}
|
||||
<div class="timeline-scroll">
|
||||
<div class="timeline" style:height="{(timeline.totalMs / 3600000) * 96}px">
|
||||
{#each timeline.labels as { label, top } (label)}
|
||||
@@ -392,12 +394,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="timeline-container empty-state">
|
||||
<p>Select a highlighted date to see available times.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>
|
||||
{#if viewDate}
|
||||
No available times on this date.
|
||||
{:else}
|
||||
Select a highlighted date to see available times.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet discreteSlots()}
|
||||
{#if timeline}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { Meeting } from '@when/config';
|
||||
import { resolveDuration } from './duration';
|
||||
import type { Appointment } from '@when/db';
|
||||
import { resolveBookingDurations, resolveDuration } from './duration';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
|
||||
const base: Meeting = {
|
||||
@@ -8,6 +9,7 @@ const base: Meeting = {
|
||||
title: 'Chat',
|
||||
booking_calendar: 'cal'
|
||||
};
|
||||
const hostPalette = [15, 30, 45, 60, 90, 120, 180, 240, 300];
|
||||
|
||||
function fd(entries: Record<string, string>): FormData {
|
||||
const f = new FormData();
|
||||
@@ -15,34 +17,159 @@ function fd(entries: Record<string, string>): FormData {
|
||||
return f;
|
||||
}
|
||||
|
||||
test('single-length meeting: defaults to the only length when none submitted', () => {
|
||||
expect(resolveDuration(base, fd({}))).toBe(30);
|
||||
function currentDuration(minutes: number): Pick<Appointment, 'start_time' | 'end_time'> {
|
||||
const start = Temporal.Instant.from('2030-06-03T10:00:00Z');
|
||||
return { start_time: start.toString(), end_time: start.add({ minutes }).toString() };
|
||||
}
|
||||
|
||||
describe('resolveBookingDurations', () => {
|
||||
test('hosts get the full palette union configured extras, deduplicated without mutating config', () => {
|
||||
const meeting = {
|
||||
...base,
|
||||
duration_minutes: 60,
|
||||
additional_duration_minutes: [75, 15, 17, 120]
|
||||
};
|
||||
const original = structuredClone(meeting);
|
||||
const profile = resolveBookingDurations(meeting, 'host');
|
||||
expect(profile).toEqual({
|
||||
defaultDuration: 60,
|
||||
durations: [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300]
|
||||
});
|
||||
expect(meeting).toEqual(original);
|
||||
expect(profile.durations).not.toBe(meeting.additional_duration_minutes);
|
||||
expect(resolveBookingDurations(meeting, 'host')).toEqual(profile);
|
||||
});
|
||||
|
||||
test('guest choices retain configured order, not the host palette or numeric order', () => {
|
||||
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 75, 30] };
|
||||
const original = structuredClone(meeting);
|
||||
expect(resolveBookingDurations(meeting, 'guest')).toEqual({
|
||||
defaultDuration: 60,
|
||||
durations: [60, 15, 75, 30]
|
||||
});
|
||||
expect(meeting).toEqual(original);
|
||||
});
|
||||
|
||||
test('a new single-length meeting offers one guest length and the full host palette', () => {
|
||||
expect(resolveBookingDurations(base, 'guest')).toEqual({
|
||||
defaultDuration: 30,
|
||||
durations: [30]
|
||||
});
|
||||
expect(resolveBookingDurations(base, 'host')).toEqual({
|
||||
defaultDuration: 30,
|
||||
durations: hostPalette
|
||||
});
|
||||
});
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s retains the actual current 90 or non-palette 85 as its default',
|
||||
(role) => {
|
||||
for (const minutes of [90, 85]) {
|
||||
const current = currentDuration(minutes);
|
||||
const original = structuredClone(current);
|
||||
const profile = resolveBookingDurations(base, role, current);
|
||||
expect(profile.defaultDuration).toBe(minutes);
|
||||
expect(profile.durations).toEqual(
|
||||
role === 'guest'
|
||||
? [30, minutes]
|
||||
: minutes === 90
|
||||
? hostPalette
|
||||
: [15, 30, 45, 60, 85, 90, 120, 180, 240, 300]
|
||||
);
|
||||
expect(current).toEqual(original);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('retaining a configured guest length does not duplicate or reorder it', () => {
|
||||
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 30] };
|
||||
expect(resolveBookingDurations(meeting, 'guest', currentDuration(30))).toEqual({
|
||||
defaultDuration: 30,
|
||||
durations: [60, 15, 30]
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['spring forward', '2030-03-10T01:30:00-05:00', '2030-03-10T04:00:00-04:00', 90],
|
||||
['fall back', '2030-11-03T01:30:00-04:00', '2030-11-03T01:55:00-05:00', 85],
|
||||
['seconds across DST', '2030-03-10T01:45:30-05:00', '2030-03-10T04:10:30-04:00', 85],
|
||||
['equivalent offsets', '2030-06-03T12:00:00+02:00', '2030-06-03T11:25:00+00:00', 85]
|
||||
])('uses elapsed instants without rounding for %s', (_label, start_time, end_time, minutes) => {
|
||||
const current = { start_time: String(start_time), end_time: String(end_time) };
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
const profile = resolveBookingDurations(base, role, current);
|
||||
expect(profile.defaultDuration).toBe(minutes);
|
||||
expect(profile.durations).toContain(minutes);
|
||||
expect(resolveDuration(base, role, fd({}), current)).toBe(minutes);
|
||||
expect(resolveDuration(base, role, fd({ duration: String(minutes) }), current)).toBe(minutes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('array meeting: defaults to the first length when none submitted', () => {
|
||||
expect(
|
||||
resolveDuration(
|
||||
{ ...base, duration_minutes: 45, additional_duration_minutes: [15, 60] },
|
||||
fd({})
|
||||
)
|
||||
).toBe(45);
|
||||
});
|
||||
describe('resolveDuration', () => {
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s defaults missing and empty submissions to configured default, not the first host choice',
|
||||
(role) => {
|
||||
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 30] };
|
||||
expect(resolveDuration(base, role, fd({}))).toBe(30);
|
||||
expect(resolveDuration(meeting, role, fd({}))).toBe(60);
|
||||
expect(resolveDuration(meeting, role, fd({ duration: '' }))).toBe(60);
|
||||
}
|
||||
);
|
||||
|
||||
test('array meeting: accepts a submitted length that is offered', () => {
|
||||
expect(
|
||||
resolveDuration(
|
||||
{ ...base, duration_minutes: 15, additional_duration_minutes: [30, 60] },
|
||||
fd({ duration: '60' })
|
||||
)
|
||||
).toBe(60);
|
||||
});
|
||||
test('guests can choose configured lengths but not new host-only lengths', () => {
|
||||
const meeting = { ...base, duration_minutes: 15, additional_duration_minutes: [30, 60] };
|
||||
expect(resolveDuration(meeting, 'guest', fd({ duration: '60' }))).toBe(60);
|
||||
expect(resolveDuration(meeting, 'guest', fd({ duration: '45' }))).toBeNull();
|
||||
expect(resolveDuration(meeting, 'guest', fd({ duration: '90' }))).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when a submitted length is not offered', () => {
|
||||
expect(
|
||||
resolveDuration(
|
||||
{ ...base, duration_minutes: 15, additional_duration_minutes: [30] },
|
||||
fd({ duration: '45' })
|
||||
)
|
||||
).toBeNull();
|
||||
expect(resolveDuration(base, fd({ duration: '999' }))).toBeNull();
|
||||
test('hosts can submit every palette length and configured extras, but not arbitrary lengths', () => {
|
||||
const meeting = { ...base, additional_duration_minutes: [75] };
|
||||
for (const minutes of [...hostPalette, 75]) {
|
||||
expect(resolveDuration(meeting, 'host', fd({ duration: String(minutes) }))).toBe(minutes);
|
||||
}
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
for (const duration of ['17', '999', '0', '-30', 'not-a-number']) {
|
||||
expect(resolveDuration(meeting, role, fd({ duration }))).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s can keep 90 and 85 with missing, empty, or explicit duration',
|
||||
(role) => {
|
||||
for (const minutes of [90, 85]) {
|
||||
const current = currentDuration(minutes);
|
||||
expect(resolveDuration(base, role, fd({}), current)).toBe(minutes);
|
||||
expect(resolveDuration(base, role, fd({ duration: '' }), current)).toBe(minutes);
|
||||
expect(resolveDuration(base, role, fd({ duration: String(minutes) }), current)).toBe(
|
||||
minutes
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('a guest retaining 90 can change to configured 30, not host-only 120; hosts may choose 120', () => {
|
||||
const current = currentDuration(90);
|
||||
expect(resolveDuration(base, 'guest', fd({ duration: '120' }), current)).toBeNull();
|
||||
expect(resolveDuration(base, 'guest', fd({ duration: '30' }), current)).toBe(30);
|
||||
expect(resolveDuration(base, 'host', fd({ duration: '120' }), current)).toBe(120);
|
||||
});
|
||||
|
||||
test('forged role and current-duration fields cannot replace the trusted role or server row', () => {
|
||||
const current = currentDuration(85);
|
||||
const forged = {
|
||||
role: 'host',
|
||||
currentDuration: '120',
|
||||
start_time: '2030-06-03T10:00:00Z',
|
||||
end_time: '2030-06-03T12:00:00Z'
|
||||
};
|
||||
expect(resolveDuration(base, 'guest', fd(forged), current)).toBe(85);
|
||||
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '' }), current)).toBe(85);
|
||||
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '120' }), current)).toBeNull();
|
||||
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '85' }), current)).toBe(85);
|
||||
expect(resolveDuration(base, 'guest', fd(forged))).toBe(30);
|
||||
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '120' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,44 @@
|
||||
import { durationsOf, type Meeting } from '@when/config';
|
||||
import type { Appointment } from '@when/db';
|
||||
import type { AppointmentRole } from './actions';
|
||||
|
||||
// Missing → the default (first) length; a submitted value the meeting doesn't offer → null.
|
||||
export function resolveDuration(eventType: Meeting, form: FormData): number | null {
|
||||
const durations = durationsOf(eventType);
|
||||
const HOST_DURATIONS = [15, 30, 45, 60, 90, 120, 180, 240, 300];
|
||||
|
||||
export interface BookingDurations {
|
||||
defaultDuration: number;
|
||||
durations: number[];
|
||||
}
|
||||
|
||||
export function resolveBookingDurations(
|
||||
eventType: Meeting,
|
||||
role: AppointmentRole,
|
||||
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
|
||||
): BookingDurations {
|
||||
const defaultDuration = currentAppointment
|
||||
? (Date.parse(currentAppointment.end_time) - Date.parse(currentAppointment.start_time)) / 60_000
|
||||
: eventType.duration_minutes;
|
||||
const choices = durationsOf(eventType);
|
||||
if (role === 'host') choices.push(...HOST_DURATIONS);
|
||||
// Keeping a booked length is not permission to choose another host-only length.
|
||||
choices.push(defaultDuration);
|
||||
const durations = [...new Set(choices)];
|
||||
if (role === 'host') durations.sort((a, b) => a - b);
|
||||
return { defaultDuration, durations };
|
||||
}
|
||||
|
||||
export function resolveDuration(
|
||||
eventType: Meeting,
|
||||
role: AppointmentRole,
|
||||
form: FormData,
|
||||
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
|
||||
): number | null {
|
||||
const { defaultDuration, durations } = resolveBookingDurations(
|
||||
eventType,
|
||||
role,
|
||||
currentAppointment
|
||||
);
|
||||
const raw = form.get('duration');
|
||||
if (raw === null || raw === '') return durations[0];
|
||||
if (raw === null || raw === '') return defaultDuration;
|
||||
const value = Number(raw);
|
||||
return durations.includes(value) ? value : null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { toPublicEventType, toPublicAppointment } from './sanitize';
|
||||
import { resolveBookingDurations } from './duration';
|
||||
import type { Meeting } from '@when/config';
|
||||
import type { Appointment } from '@when/db';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
@@ -40,39 +41,40 @@ const baseRow: Appointment = {
|
||||
|
||||
describe('toPublicEventType', () => {
|
||||
test('hides location for guests', () => {
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest');
|
||||
const profile = resolveBookingDurations(baseEventType, 'guest');
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
|
||||
expect(res.location).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shows location for hosts', () => {
|
||||
const res = toPublicEventType('chat', baseEventType, 'host');
|
||||
const profile = resolveBookingDurations(baseEventType, 'host');
|
||||
const res = toPublicEventType('chat', baseEventType, 'host', profile);
|
||||
expect(res.location).toBe('https://meet.example.com/jane');
|
||||
});
|
||||
|
||||
test('applies default padding and notice values', () => {
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest');
|
||||
const profile = resolveBookingDurations(baseEventType, 'guest');
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
|
||||
expect(res.padding_before_minutes).toBe(0);
|
||||
expect(res.padding_after_minutes).toBe(0);
|
||||
expect(res.notice_minutes).toBe(120);
|
||||
});
|
||||
|
||||
test('passes through explicit padding and notice values', () => {
|
||||
const res = toPublicEventType(
|
||||
'chat',
|
||||
{
|
||||
...baseEventType,
|
||||
padding_before_minutes: 15,
|
||||
padding_after_minutes: 10,
|
||||
notice_minutes: 60
|
||||
},
|
||||
'guest'
|
||||
);
|
||||
const meeting = {
|
||||
...baseEventType,
|
||||
padding_before_minutes: 15,
|
||||
padding_after_minutes: 10,
|
||||
notice_minutes: 60
|
||||
};
|
||||
const profile = resolveBookingDurations(meeting, 'guest');
|
||||
const res = toPublicEventType('chat', meeting, 'guest', profile);
|
||||
expect(res.padding_before_minutes).toBe(15);
|
||||
expect(res.padding_after_minutes).toBe(10);
|
||||
expect(res.notice_minutes).toBe(60);
|
||||
});
|
||||
|
||||
test('projects effective host padding without changing durations, notice, privacy, or config', () => {
|
||||
test('projects effective host padding and role-specific choices without changing notice, privacy, or config', () => {
|
||||
const cfg = structuredClone(validConfig);
|
||||
const meeting = cfg.meetings['30-min-chat'];
|
||||
Object.assign(meeting, {
|
||||
@@ -84,9 +86,10 @@ describe('toPublicEventType', () => {
|
||||
additional_duration_minutes: [15, 60]
|
||||
});
|
||||
const original = structuredClone(cfg);
|
||||
|
||||
const guest = toPublicEventType('chat', meeting, 'guest');
|
||||
const host = toPublicEventType('chat', meeting, 'host');
|
||||
const guestProfile = resolveBookingDurations(meeting, 'guest');
|
||||
const hostProfile = resolveBookingDurations(meeting, 'host');
|
||||
const guest = toPublicEventType('chat', meeting, 'guest', guestProfile);
|
||||
const host = toPublicEventType('chat', meeting, 'host', hostProfile);
|
||||
|
||||
expect(guest.padding_before_minutes).toBe(15);
|
||||
expect(guest.padding_after_minutes).toBe(10);
|
||||
@@ -96,21 +99,64 @@ describe('toPublicEventType', () => {
|
||||
expect(guest.notice_minutes).toBe(60);
|
||||
expect(host).toEqual({
|
||||
...guest,
|
||||
durations: [15, 30, 45, 60, 90, 120, 180, 240, 300],
|
||||
location: meeting.location,
|
||||
padding_before_minutes: 0,
|
||||
padding_after_minutes: 0
|
||||
});
|
||||
expect(toPublicEventType('chat', meeting, 'guest')).toEqual(guest);
|
||||
expect(host.durations).toBe(hostProfile.durations);
|
||||
expect(guest.durations).toBe(guestProfile.durations);
|
||||
expect(toPublicEventType('chat', meeting, 'guest', guestProfile)).toEqual(guest);
|
||||
expect(cfg).toEqual(original);
|
||||
});
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s consumes the supplied profile default and exact choice array instead of recomputing them',
|
||||
(role) => {
|
||||
const meeting = { ...baseEventType, duration_minutes: 60, additional_duration_minutes: [15] };
|
||||
const profile = { defaultDuration: 85, durations: [60, 15, 85] };
|
||||
const originalMeeting = structuredClone(meeting);
|
||||
const originalProfile = structuredClone(profile);
|
||||
const res = toPublicEventType('chat', meeting, role, profile);
|
||||
expect(res.duration_minutes).toBe(85);
|
||||
expect(res.durations).toBe(profile.durations);
|
||||
expect(res.durations).toEqual([60, 15, 85]);
|
||||
expect(meeting).toEqual(originalMeeting);
|
||||
expect(profile).toEqual(originalProfile);
|
||||
}
|
||||
);
|
||||
|
||||
test('creation projects configured 60 as default even though the host choices start at 15', () => {
|
||||
const meeting = { ...baseEventType, duration_minutes: 60 };
|
||||
const profile = resolveBookingDurations(meeting, 'host');
|
||||
const res = toPublicEventType('chat', meeting, 'host', profile);
|
||||
expect(res.duration_minutes).toBe(60);
|
||||
expect(res.durations[0]).toBe(15);
|
||||
});
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s reschedule projects actual current 90 instead of configured 30',
|
||||
(role) => {
|
||||
const current = { ...baseRow, end_time: '2026-05-01T16:30:00Z' };
|
||||
const profile = resolveBookingDurations(baseEventType, role, current);
|
||||
const res = toPublicEventType('chat', baseEventType, role, profile);
|
||||
expect(res.duration_minutes).toBe(90);
|
||||
expect(res.durations).toBe(profile.durations);
|
||||
expect(res.durations).toContain(30);
|
||||
expect(res.durations).toContain(90);
|
||||
}
|
||||
);
|
||||
|
||||
test('maps show_slots', () => {
|
||||
const res = toPublicEventType('chat', { ...baseEventType, show_slots: true }, 'guest');
|
||||
const meeting = { ...baseEventType, show_slots: true };
|
||||
const profile = resolveBookingDurations(meeting, 'guest');
|
||||
const res = toPublicEventType('chat', meeting, 'guest', profile);
|
||||
expect(res.show_slots).toBe(true);
|
||||
});
|
||||
|
||||
test('passes through require_approval', () => {
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest');
|
||||
const profile = resolveBookingDurations(baseEventType, 'guest');
|
||||
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
|
||||
expect(res.require_approval).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { durationsOf, parseGuestAnswers, type Meeting } from '@when/config';
|
||||
import { parseGuestAnswers, type Meeting } from '@when/config';
|
||||
import { parseActionLog, type ActionLogEntry, type Appointment } from '@when/db';
|
||||
import { resolveAvailabilityLimits } from '../availability/settings';
|
||||
import type { AppointmentRole } from './actions';
|
||||
import type { BookingDurations } from './duration';
|
||||
|
||||
export interface PublicEventType extends Pick<
|
||||
Meeting,
|
||||
@@ -16,8 +17,8 @@ export interface PublicEventType extends Pick<
|
||||
| 'notice_minutes'
|
||||
> {
|
||||
slug: string; // the meeting's config key, which is its booking page URL
|
||||
duration_minutes: number; // the default length
|
||||
durations: number[]; // every offered length, default first
|
||||
duration_minutes: number; // the effective default: configured for booking, current for rescheduling
|
||||
durations: number[]; // every offered length, independent of the default's position
|
||||
}
|
||||
|
||||
export interface PublicAppointment {
|
||||
@@ -37,14 +38,15 @@ export interface PublicAppointment {
|
||||
export function toPublicEventType(
|
||||
slug: string,
|
||||
eventType: Meeting,
|
||||
role: AppointmentRole
|
||||
role: AppointmentRole,
|
||||
bookingDurations: BookingDurations
|
||||
): PublicEventType {
|
||||
const limits = resolveAvailabilityLimits(eventType, role);
|
||||
return {
|
||||
slug,
|
||||
title: eventType.title,
|
||||
duration_minutes: eventType.duration_minutes,
|
||||
durations: durationsOf(eventType),
|
||||
duration_minutes: bookingDurations.defaultDuration,
|
||||
durations: bookingDurations.durations,
|
||||
description: eventType.description,
|
||||
visibility: eventType.visibility,
|
||||
require_approval: eventType.require_approval,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { WhenConfiguration } from '@when/config';
|
||||
import type { Appointment } from '@when/db';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
import { resolveBookingDurations, resolveDuration } from '../appointment/duration';
|
||||
|
||||
const h = vi.hoisted(() => ({ nowMs: 0 }));
|
||||
vi.mock('$lib/server/clock', () => ({ systemClock: { nowMs: () => h.nowMs } }));
|
||||
@@ -23,6 +25,20 @@ const CHAT = '30-min-chat';
|
||||
const EDGE_START = `${DAY}T16:30:00Z`;
|
||||
let db: ReturnType<typeof openDb>;
|
||||
|
||||
function currentAppointment(
|
||||
start: string,
|
||||
minutes = 30
|
||||
): Pick<Appointment, 'start_time' | 'end_time'> {
|
||||
const instant = Temporal.Instant.from(start);
|
||||
return { start_time: start, end_time: instant.add({ minutes }).toString() };
|
||||
}
|
||||
|
||||
function durationForm(duration?: number): FormData {
|
||||
const form = new FormData();
|
||||
if (duration !== undefined) form.set('duration', String(duration));
|
||||
return form;
|
||||
}
|
||||
|
||||
function makeConfig(durations: number[]): WhenConfiguration {
|
||||
const cfg = structuredClone(validConfig);
|
||||
cfg.user.timezone = 'UTC';
|
||||
@@ -70,7 +86,8 @@ describe('per-duration availability', () => {
|
||||
test('precomputes one slot map per offered length', async () => {
|
||||
const cfg = makeConfig([30, 60]);
|
||||
const availability = await loadAvailability(db, cfg, CHAT, cfg.meetings[CHAT], 'guest');
|
||||
expect(availability.durations).toEqual([30, 60]);
|
||||
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations: [30, 60] });
|
||||
expect(availability).not.toHaveProperty('durations');
|
||||
expect(Object.keys(availability.slotsByDuration)).toEqual(['30', '60']);
|
||||
expect(availability.slotsByDuration[30][DAY].length).toBeGreaterThan(0);
|
||||
expect(availability.slotsByDuration[60][DAY].length).toBeGreaterThan(0);
|
||||
@@ -96,7 +113,7 @@ describe('per-duration availability', () => {
|
||||
'guest',
|
||||
EDGE_START,
|
||||
30,
|
||||
`${DAY}T10:00:00Z`
|
||||
currentAppointment(`${DAY}T10:00:00Z`)
|
||||
);
|
||||
const movedLong = await isRescheduleSlotBookable(
|
||||
db,
|
||||
@@ -106,7 +123,7 @@ describe('per-duration availability', () => {
|
||||
'guest',
|
||||
EDGE_START,
|
||||
60,
|
||||
`${DAY}T10:00:00Z`
|
||||
currentAppointment(`${DAY}T10:00:00Z`)
|
||||
);
|
||||
expect([newShort, newLong, movedShort, movedLong]).toEqual([true, false, true, false]);
|
||||
});
|
||||
@@ -131,7 +148,7 @@ describe('per-duration availability', () => {
|
||||
'guest',
|
||||
`${DAY}T10:00:00Z`,
|
||||
45,
|
||||
`${DAY}T11:00:00Z`
|
||||
currentAppointment(`${DAY}T11:00:00Z`)
|
||||
);
|
||||
expect(created).toBe(false);
|
||||
expect(moved).toBe(false);
|
||||
@@ -149,7 +166,7 @@ describe('per-duration availability', () => {
|
||||
'guest',
|
||||
slot,
|
||||
30,
|
||||
`${DAY}T11:00:00Z`
|
||||
currentAppointment(`${DAY}T11:00:00Z`)
|
||||
);
|
||||
expect(created).toBe(false);
|
||||
expect(moved).toBe(false);
|
||||
@@ -166,7 +183,7 @@ describe('per-duration availability', () => {
|
||||
test('a single-length meeting keeps one map', async () => {
|
||||
const cfg = makeConfig([30]);
|
||||
const availability = await loadAvailability(db, cfg, CHAT, cfg.meetings[CHAT], 'guest');
|
||||
expect(availability.durations).toEqual([30]);
|
||||
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations: [30] });
|
||||
expect(Object.keys(availability.slotsByDuration)).toEqual(['30']);
|
||||
});
|
||||
|
||||
@@ -192,7 +209,7 @@ describe('busy-data read windows', () => {
|
||||
'guest',
|
||||
EDGE_START,
|
||||
30,
|
||||
`${DAY}T10:00:00Z`
|
||||
currentAppointment(`${DAY}T10:00:00Z`)
|
||||
);
|
||||
const windows = vi.mocked(getBusyIntervals).mock.calls.map(([, , window]) => window);
|
||||
expect(windows).toEqual([
|
||||
@@ -230,7 +247,7 @@ describe('busy-data read windows', () => {
|
||||
'guest',
|
||||
slot,
|
||||
30,
|
||||
`${DAY}T21:00:00Z`
|
||||
currentAppointment(`${DAY}T21:00:00Z`)
|
||||
);
|
||||
// Create's historical day read omits the next-day event; reschedule's horizon includes it.
|
||||
expect(created).toBe(true);
|
||||
@@ -298,7 +315,7 @@ describe('active appointments and self-exclusion', () => {
|
||||
'guest',
|
||||
own,
|
||||
30,
|
||||
own
|
||||
currentAppointment(own)
|
||||
);
|
||||
const otherSlot = await isRescheduleSlotBookable(
|
||||
db,
|
||||
@@ -308,7 +325,7 @@ describe('active appointments and self-exclusion', () => {
|
||||
'guest',
|
||||
`${DAY}T11:00:00Z`,
|
||||
30,
|
||||
own
|
||||
currentAppointment(own)
|
||||
);
|
||||
expect(ownSlot).toBe(true);
|
||||
expect(otherSlot).toBe(false);
|
||||
@@ -327,7 +344,7 @@ describe('active appointments and self-exclusion', () => {
|
||||
'guest',
|
||||
`${DAY}T13:00:00Z`,
|
||||
30,
|
||||
own
|
||||
currentAppointment(own)
|
||||
);
|
||||
expect(moved).toBe(false);
|
||||
});
|
||||
@@ -356,7 +373,7 @@ describe('role-specific availability limits', () => {
|
||||
role,
|
||||
slot,
|
||||
30,
|
||||
`${DAY}T14:00:00Z`
|
||||
currentAppointment(`${DAY}T14:00:00Z`)
|
||||
);
|
||||
const expected = role === 'host';
|
||||
expect(availability.slotsByDuration[30][DAY].includes(slot), role).toBe(expected);
|
||||
@@ -385,7 +402,16 @@ describe('role-specific availability limits', () => {
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
|
||||
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, 30);
|
||||
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, slot, 30, slot);
|
||||
const moved = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
slot,
|
||||
30,
|
||||
currentAppointment(slot)
|
||||
);
|
||||
expect(availability.slotsByDuration[30][DAY], role).not.toContain(slot);
|
||||
expect([created, moved], role).toEqual([false, false]);
|
||||
}
|
||||
@@ -407,13 +433,23 @@ describe('role-specific availability limits', () => {
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.execute();
|
||||
|
||||
const current = currentAppointment(own);
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
|
||||
const rescheduleAvailability = await loadAvailability(db, cfg, CHAT, meeting, role, own);
|
||||
const rescheduleAvailability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
|
||||
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, free, 30);
|
||||
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, free, 30, own);
|
||||
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, free, 30, current);
|
||||
const occupied = await isNewSlotBookable(db, cfg, CHAT, meeting, role, own, 30);
|
||||
const retained = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, own, 30, own);
|
||||
const retained = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
own,
|
||||
30,
|
||||
current
|
||||
);
|
||||
const expected = role === 'host';
|
||||
if (role === 'guest') {
|
||||
expect(availability.slotsByDuration[30][DAY]).toBeUndefined();
|
||||
@@ -449,8 +485,9 @@ describe('role-specific availability limits', () => {
|
||||
meeting.daily_booking_limit = 1;
|
||||
meeting.notice_minutes = 600;
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
|
||||
expect(availability.durations).toEqual([30, 60]);
|
||||
expect(Object.keys(availability.slotsByDuration)).toEqual(['30', '60']);
|
||||
const durations = role === 'host' ? [15, 30, 45, 60, 90, 120, 180, 240, 300] : [30, 60];
|
||||
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations });
|
||||
expect(Object.keys(availability.slotsByDuration)).toEqual(durations.map(String));
|
||||
expect(availability.slotsByDuration[30][DAY]).toContain(`${DAY}T10:00:00Z`);
|
||||
expect(availability.slotsByDuration[30][DAY]).toContain(EDGE_START);
|
||||
|
||||
@@ -459,7 +496,7 @@ describe('role-specific availability limits', () => {
|
||||
{ rule: 'notice', slot: `${DAY}T09:30:00Z`, duration: 30 },
|
||||
{ rule: 'slot grid', slot: `${DAY}T10:15:00Z`, duration: 30 },
|
||||
{ rule: 'lookahead', slot: '2030-06-11T10:00:00Z', duration: 30 },
|
||||
{ rule: 'unconfigured duration', slot: `${DAY}T11:00:00Z`, duration: 45 }
|
||||
{ rule: 'unoffered duration', slot: `${DAY}T11:00:00Z`, duration: 75 }
|
||||
];
|
||||
for (const { rule, slot, duration } of rejected) {
|
||||
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, duration);
|
||||
@@ -471,7 +508,7 @@ describe('role-specific availability limits', () => {
|
||||
role,
|
||||
slot,
|
||||
duration,
|
||||
`${DAY}T14:00:00Z`
|
||||
currentAppointment(`${DAY}T14:00:00Z`)
|
||||
);
|
||||
expect(
|
||||
availability.slotsByDuration[duration]?.[slot.slice(0, 10)] ?? [],
|
||||
@@ -482,3 +519,251 @@ describe('role-specific availability limits', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('booking duration profiles', () => {
|
||||
test('new hosts get a map for every palette and configured extra while guests keep configured order', async () => {
|
||||
const cfg = makeConfig([60, 75, 17, 15]);
|
||||
const meeting = cfg.meetings[CHAT];
|
||||
const original = structuredClone(cfg);
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
|
||||
const durations =
|
||||
role === 'host' ? [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300] : [60, 75, 17, 15];
|
||||
expect(availability.bookingDurations).toEqual({ defaultDuration: 60, durations });
|
||||
expect(availability.bookingDurations).toEqual(resolveBookingDurations(meeting, role));
|
||||
expect(Object.keys(availability.slotsByDuration).map(Number)).toEqual(
|
||||
[...durations].sort((a, b) => a - b)
|
||||
);
|
||||
expect(resolveDuration(meeting, role, durationForm())).toBe(60);
|
||||
for (const duration of durations) {
|
||||
const slot = `${DAY}T09:00:00Z`;
|
||||
const bookable = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, duration);
|
||||
expect(availability.slotsByDuration[duration][DAY], `${role} ${duration}`).toContain(slot);
|
||||
expect(resolveDuration(meeting, role, durationForm(duration))).toBe(duration);
|
||||
expect(bookable, `${role} ${duration}`).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(cfg).toEqual(original);
|
||||
});
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s current 90 profile, picker, and submissions agree without granting other guest lengths',
|
||||
async (role) => {
|
||||
const cfg = makeConfig([30]);
|
||||
const meeting = cfg.meetings[CHAT];
|
||||
const own = `${DAY}T10:00:00Z`;
|
||||
await book(CHAT, own, `${DAY}T11:30:00Z`);
|
||||
const current = await db
|
||||
.selectFrom('appointments')
|
||||
.selectAll()
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
|
||||
const newAvailability = await loadAvailability(db, cfg, CHAT, meeting, role);
|
||||
const durations = role === 'host' ? [15, 30, 45, 60, 90, 120, 180, 240, 300] : [30, 90];
|
||||
expect(availability.bookingDurations).toEqual({ defaultDuration: 90, durations });
|
||||
expect(availability.bookingDurations).toEqual(
|
||||
resolveBookingDurations(meeting, role, current)
|
||||
);
|
||||
expect(Object.keys(availability.slotsByDuration)).toEqual(durations.map(String));
|
||||
expect(newAvailability.bookingDurations.defaultDuration).toBe(30);
|
||||
expect(newAvailability.bookingDurations.durations).toEqual(
|
||||
role === 'host' ? durations : [30]
|
||||
);
|
||||
expect(resolveDuration(meeting, role, durationForm(), current)).toBe(90);
|
||||
expect(availability.slotsByDuration[90][DAY]).toContain(own);
|
||||
const retained = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
own,
|
||||
90,
|
||||
current
|
||||
);
|
||||
expect(retained).toBe(true);
|
||||
|
||||
const free = `${DAY}T11:30:00Z`;
|
||||
for (const duration of [30, 90, 120]) {
|
||||
const expectedNew = role === 'host' || duration === 30;
|
||||
const expectedMoved = role === 'host' || duration !== 120;
|
||||
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, free, duration);
|
||||
const moved = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
free,
|
||||
duration,
|
||||
current
|
||||
);
|
||||
expect(newAvailability.slotsByDuration[duration]?.[DAY] ?? []).toEqual(expect.any(Array));
|
||||
expect((newAvailability.slotsByDuration[duration]?.[DAY] ?? []).includes(free)).toBe(
|
||||
expectedNew
|
||||
);
|
||||
expect((availability.slotsByDuration[duration]?.[DAY] ?? []).includes(free)).toBe(
|
||||
expectedMoved
|
||||
);
|
||||
expect(resolveDuration(meeting, role, durationForm(duration), current)).toBe(
|
||||
expectedMoved ? duration : null
|
||||
);
|
||||
expect([created, moved]).toEqual([expectedNew, expectedMoved]);
|
||||
}
|
||||
|
||||
const short = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
EDGE_START,
|
||||
30,
|
||||
current
|
||||
);
|
||||
const long = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
EDGE_START,
|
||||
90,
|
||||
current
|
||||
);
|
||||
expect(availability.slotsByDuration[30][DAY]).toContain(EDGE_START);
|
||||
expect(availability.slotsByDuration[90][DAY]).not.toContain(EDGE_START);
|
||||
expect([short, long]).toEqual([true, false]);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['guest', 'host'] as const)(
|
||||
'%s gets a usable map and submission for current non-palette 85 only when rescheduling',
|
||||
async (role) => {
|
||||
const cfg = makeConfig([30]);
|
||||
const meeting = cfg.meetings[CHAT];
|
||||
await book(CHAT, `${DAY}T10:00:00Z`, `${DAY}T11:25:00Z`);
|
||||
const current = await db
|
||||
.selectFrom('appointments')
|
||||
.selectAll()
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
|
||||
const slot = `${DAY}T13:00:00Z`;
|
||||
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, slot, 85, current);
|
||||
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, 85);
|
||||
expect(availability.bookingDurations.defaultDuration).toBe(85);
|
||||
expect(availability.bookingDurations.durations.filter((duration) => duration === 85)).toEqual(
|
||||
[85]
|
||||
);
|
||||
expect(availability.slotsByDuration[85][DAY]).toContain(slot);
|
||||
expect(resolveDuration(meeting, role, durationForm(), current)).toBe(85);
|
||||
expect([moved, created]).toEqual([true, false]);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['appointment', 'remote'] as const)(
|
||||
'chosen long durations still respect actual %s busy time beyond the short duration',
|
||||
async (source) => {
|
||||
const cfg = makeConfig([30]);
|
||||
const meeting = cfg.meetings[CHAT];
|
||||
meeting.padding_before_minutes = 15;
|
||||
meeting.padding_after_minutes = 15;
|
||||
await book(CHAT, `${DAY}T14:00:00Z`, `${DAY}T15:30:00Z`);
|
||||
const current = await db
|
||||
.selectFrom('appointments')
|
||||
.selectAll()
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
if (source === 'appointment') {
|
||||
cfg.meetings.lunch = { ...meeting };
|
||||
await book('lunch', `${DAY}T11:00:00Z`, `${DAY}T11:30:00Z`);
|
||||
} else {
|
||||
await replaceCalendarBusy(db, meeting.booking_calendar, [
|
||||
{ start: `${DAY}T11:00:00Z`, end: `${DAY}T11:30:00Z` }
|
||||
]);
|
||||
}
|
||||
const slot = `${DAY}T10:00:00Z`;
|
||||
for (const role of ['guest', 'host'] as const) {
|
||||
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
|
||||
const short = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
slot,
|
||||
30,
|
||||
current
|
||||
);
|
||||
const long = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
role,
|
||||
slot,
|
||||
90,
|
||||
current
|
||||
);
|
||||
expect(availability.slotsByDuration[30][DAY]).toContain(slot);
|
||||
expect(availability.slotsByDuration[90][DAY]).not.toContain(slot);
|
||||
expect([short, long]).toEqual([true, false]);
|
||||
}
|
||||
const newHostShort = await isNewSlotBookable(db, cfg, CHAT, meeting, 'host', slot, 30);
|
||||
const newHostLong = await isNewSlotBookable(db, cfg, CHAT, meeting, 'host', slot, 90);
|
||||
expect([newHostShort, newHostLong]).toEqual([true, false]);
|
||||
}
|
||||
);
|
||||
|
||||
test('a long host-created row still occupies time and counts against the guest daily cap', async () => {
|
||||
const cfg = makeConfig([30]);
|
||||
const meeting = cfg.meetings[CHAT];
|
||||
const own = `${DAY}T10:00:00Z`;
|
||||
await book(CHAT, own, `${DAY}T11:30:00Z`);
|
||||
await db
|
||||
.updateTable('appointments')
|
||||
.set({
|
||||
action_log: JSON.stringify([{ action: 'create', actor: 'host', at: `${DAY}T00:00:00Z` }])
|
||||
})
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.execute();
|
||||
const current = await db
|
||||
.selectFrom('appointments')
|
||||
.selectAll()
|
||||
.where('id', '=', `appt-${CHAT}`)
|
||||
.executeTakeFirstOrThrow();
|
||||
meeting.daily_booking_limit = 1;
|
||||
const guest = await loadAvailability(db, cfg, CHAT, meeting, 'guest', current);
|
||||
const host = await loadAvailability(db, cfg, CHAT, meeting, 'host', current);
|
||||
const free = `${DAY}T13:00:00Z`;
|
||||
const guestMoved = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
'guest',
|
||||
free,
|
||||
90,
|
||||
current
|
||||
);
|
||||
const hostMoved = await isRescheduleSlotBookable(
|
||||
db,
|
||||
cfg,
|
||||
CHAT,
|
||||
meeting,
|
||||
'host',
|
||||
free,
|
||||
90,
|
||||
current
|
||||
);
|
||||
expect(guest.slotsByDuration[90][DAY]).toBeUndefined();
|
||||
expect(host.slotsByDuration[90][DAY]).toContain(free);
|
||||
expect([guestMoved, hostMoved]).toEqual([false, true]);
|
||||
meeting.daily_booking_limit = null;
|
||||
const uncappedGuest = await loadAvailability(db, cfg, CHAT, meeting, 'guest');
|
||||
expect(uncappedGuest.slotsByDuration[30][DAY]).not.toContain(`${DAY}T11:00:00Z`);
|
||||
expect(uncappedGuest.slotsByDuration[30][DAY]).toContain(free);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Kysely } from 'kysely';
|
||||
import { getBusyIntervals, type BusyInterval, type Database } from '@when/db';
|
||||
import { getBusyIntervals, type Appointment, type BusyInterval, type Database } from '@when/db';
|
||||
import { busyCalendarsFor } from '@when/calendar';
|
||||
import { durationsOf, type Meeting, type WhenConfiguration } from '@when/config';
|
||||
import type { Meeting, WhenConfiguration } from '@when/config';
|
||||
import { systemClock } from '$lib/server/clock';
|
||||
import { computeSlots, type ComputeOptions } from './calc';
|
||||
import { mergeBlocks } from './intervals';
|
||||
@@ -10,10 +10,11 @@ import { resolveAvailabilitySettings } from './settings';
|
||||
import { buildBaseWindows, candidateDates } from './windows';
|
||||
import type { AvailabilitySettings } from './types';
|
||||
import type { AppointmentRole } from '../appointment/actions';
|
||||
import { resolveBookingDurations, type BookingDurations } from '../appointment/duration';
|
||||
|
||||
export interface Availability {
|
||||
settings: AvailabilitySettings;
|
||||
durations: number[];
|
||||
bookingDurations: BookingDurations;
|
||||
slotsByDuration: Record<number, Record<string, string[]>>;
|
||||
workingWindows: { start: string; end: string }[];
|
||||
busyBlocks: { start: string; end: string }[];
|
||||
@@ -25,19 +26,19 @@ export async function loadAvailability(
|
||||
slug: string,
|
||||
eventType: Meeting,
|
||||
role: AppointmentRole,
|
||||
excludeStart: string | null = null
|
||||
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
|
||||
): Promise<Availability> {
|
||||
const { options, allowedDurations } = await readAvailabilityInputs(
|
||||
const { options, bookingDurations } = await readAvailabilityInputs(
|
||||
db,
|
||||
cfg,
|
||||
slug,
|
||||
eventType,
|
||||
role,
|
||||
excludeStart
|
||||
currentAppointment
|
||||
);
|
||||
const { settings, rangeStart, rangeEnd, userTz } = options;
|
||||
const slotsByDuration: Record<number, Record<string, string[]>> = {};
|
||||
for (const duration of allowedDurations) {
|
||||
for (const duration of bookingDurations.durations) {
|
||||
const slots = computeSlots({ ...options, settings: { ...settings, duration } });
|
||||
const byDate: Record<string, string[]> = {};
|
||||
for (const slot of slots) {
|
||||
@@ -56,7 +57,7 @@ export async function loadAvailability(
|
||||
const busyBlocks = mergeBlocks([...options.existingAppointments, ...options.remoteBusy]).map(
|
||||
(block) => ({ start: block.start.toString(), end: block.end.toString() })
|
||||
);
|
||||
return { settings, durations: allowedDurations, slotsByDuration, workingWindows, busyBlocks };
|
||||
return { settings, bookingDurations, slotsByDuration, workingWindows, busyBlocks };
|
||||
}
|
||||
|
||||
// Keep creation's day-bounded busy read distinct from rescheduling's full horizon.
|
||||
@@ -70,7 +71,7 @@ export async function isNewSlotBookable(
|
||||
duration: number
|
||||
): Promise<boolean> {
|
||||
const busyWindow = slotDayWindow(slot, cfg.user.timezone);
|
||||
const { options, allowedDurations } = await readAvailabilityInputs(
|
||||
const { options, bookingDurations } = await readAvailabilityInputs(
|
||||
db,
|
||||
cfg,
|
||||
slug,
|
||||
@@ -79,7 +80,7 @@ export async function isNewSlotBookable(
|
||||
null,
|
||||
busyWindow
|
||||
);
|
||||
return isOfferedSlot(options, allowedDurations, slot, duration);
|
||||
return isOfferedSlot(options, bookingDurations.durations, slot, duration);
|
||||
}
|
||||
|
||||
export async function isRescheduleSlotBookable(
|
||||
@@ -90,17 +91,17 @@ export async function isRescheduleSlotBookable(
|
||||
role: AppointmentRole,
|
||||
slot: string,
|
||||
duration: number,
|
||||
excludeStart: string
|
||||
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'>
|
||||
): Promise<boolean> {
|
||||
const { options, allowedDurations } = await readAvailabilityInputs(
|
||||
const { options, bookingDurations } = await readAvailabilityInputs(
|
||||
db,
|
||||
cfg,
|
||||
slug,
|
||||
eventType,
|
||||
role,
|
||||
excludeStart
|
||||
currentAppointment
|
||||
);
|
||||
return isOfferedSlot(options, allowedDurations, slot, duration);
|
||||
return isOfferedSlot(options, bookingDurations.durations, slot, duration);
|
||||
}
|
||||
|
||||
async function readAvailabilityInputs(
|
||||
@@ -109,10 +110,11 @@ async function readAvailabilityInputs(
|
||||
slug: string,
|
||||
eventType: Meeting,
|
||||
role: AppointmentRole,
|
||||
excludeStart: string | null,
|
||||
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null,
|
||||
remoteBusyWindow?: BusyInterval
|
||||
): Promise<{ options: ComputeOptions; allowedDurations: number[] }> {
|
||||
): Promise<{ options: ComputeOptions; bookingDurations: BookingDurations }> {
|
||||
const settings = resolveAvailabilitySettings(cfg, eventType, role);
|
||||
const bookingDurations = resolveBookingDurations(eventType, role, currentAppointment);
|
||||
const now = Temporal.Instant.fromEpochMilliseconds(systemClock.nowMs());
|
||||
const rangeEnd = now.add({ hours: 24 * settings.maximum_lookahead });
|
||||
const blocks = await loadAppointmentBlocks(
|
||||
@@ -121,7 +123,7 @@ async function readAvailabilityInputs(
|
||||
now,
|
||||
rangeEnd,
|
||||
cfg.user.timezone,
|
||||
excludeStart
|
||||
currentAppointment?.start_time ?? null
|
||||
);
|
||||
const busyWindow = remoteBusyWindow ?? { start: now.toString(), end: rangeEnd.toString() };
|
||||
const remote = await getBusyIntervals(db, busyCalendarsFor(eventType), busyWindow);
|
||||
@@ -140,7 +142,7 @@ async function readAvailabilityInputs(
|
||||
remoteBusy,
|
||||
perDayCount: blocks.perDayCount
|
||||
},
|
||||
allowedDurations: durationsOf(eventType)
|
||||
bookingDurations
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type Appointment
|
||||
} from '@when/db';
|
||||
import { toPublicAppointment, toPublicEventType } from '$lib/server/appointment/sanitize';
|
||||
import { resolveBookingDurations } from '$lib/server/appointment/duration';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { cancelAppointment } from '$lib/server/appointment/cancel';
|
||||
import { validateReason } from '$lib/server/appointment/form.server';
|
||||
@@ -133,10 +134,12 @@ export const load: PageServerLoad = async ({ params, url, locals, cookies }) =>
|
||||
row.video_chat_request !== 'suppress' &&
|
||||
(row.video_chat_request === 'generate' || automaticVideoChat);
|
||||
|
||||
const bookingDurations = resolveBookingDurations(resolvedEventType, role, row);
|
||||
|
||||
return {
|
||||
appointment,
|
||||
videoChatRequested,
|
||||
eventType: toPublicEventType(row.event_type_id, resolvedEventType, role),
|
||||
eventType: toPublicEventType(row.event_type_id, resolvedEventType, role, bookingDurations),
|
||||
calendarLinks,
|
||||
actions,
|
||||
flash: flash ?? null,
|
||||
|
||||
@@ -42,17 +42,17 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
});
|
||||
const rescheduleError = ctx.kind === 'error' ? ctx.code : null;
|
||||
|
||||
const { slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
|
||||
const { bookingDurations, slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
|
||||
getDb(),
|
||||
cfg,
|
||||
row.event_type_id,
|
||||
eventType,
|
||||
role,
|
||||
row.start_time
|
||||
row
|
||||
);
|
||||
|
||||
return {
|
||||
eventType: toPublicEventType(row.event_type_id, eventType, role),
|
||||
eventType: toPublicEventType(row.event_type_id, eventType, role, bookingDurations),
|
||||
formFields: resolveFormFields(eventType),
|
||||
availability: { slotsByDuration, workingWindows, busyBlocks },
|
||||
reschedule: {
|
||||
@@ -94,7 +94,7 @@ export const actions: Actions = {
|
||||
if (!reasonResult.ok) return fail(400, { error: reasonResult.error });
|
||||
const reason = reasonResult.reason;
|
||||
|
||||
const duration = resolveDuration(eventType, form);
|
||||
const duration = resolveDuration(eventType, 'guest', form, found);
|
||||
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
|
||||
|
||||
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
|
||||
@@ -106,7 +106,7 @@ export const actions: Actions = {
|
||||
'guest',
|
||||
slotStr,
|
||||
duration,
|
||||
found.start_time
|
||||
found
|
||||
);
|
||||
if (!bookable) {
|
||||
return fail(409, { error: 'That time is no longer available. Please pick another.' });
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import AppointmentWizard from '$lib/components/AppointmentWizard.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<AppointmentWizard {data} {form} />
|
||||
{#key `${page.params.id}:${data.role}`}
|
||||
<AppointmentWizard {data} {form} />
|
||||
{/key}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
import type { Appointment } from '@when/db';
|
||||
import type { Meeting } from '@when/config';
|
||||
import type { AppointmentRole } from '$lib/server/appointment/actions';
|
||||
import { resolveBookingDurations } from '$lib/server/appointment/duration';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
db: {},
|
||||
@@ -46,11 +49,18 @@ vi.mock('$lib/server/appointment/form.server', async (io) => {
|
||||
validateReason: h.validateReason
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
|
||||
vi.mock('$lib/server/appointment/sanitize', () => ({
|
||||
toPublicAppointment: h.toPublicAppointment,
|
||||
toPublicEventType: h.toPublicEventType
|
||||
}));
|
||||
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
|
||||
return { ...original, resolveDuration: h.resolveDuration };
|
||||
});
|
||||
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
|
||||
return {
|
||||
...original,
|
||||
toPublicAppointment: h.toPublicAppointment,
|
||||
toPublicEventType: h.toPublicEventType.mockImplementation(original.toPublicEventType)
|
||||
};
|
||||
});
|
||||
vi.mock('@when/config', async (io) => {
|
||||
const original = await io<typeof import('@when/config')>();
|
||||
return {
|
||||
@@ -73,6 +83,7 @@ function appt(): Appointment {
|
||||
id: 'appt-1',
|
||||
event_type_id: '30-min-chat',
|
||||
start_time: CURRENT,
|
||||
end_time: '2099-05-01T16:30:00Z',
|
||||
cancel_token: 'tok-1',
|
||||
status: 'confirmed'
|
||||
} as unknown as Appointment;
|
||||
@@ -122,13 +133,31 @@ beforeEach(() => {
|
||||
h.row.current = appt();
|
||||
h.viewAllowed.mockResolvedValue(true);
|
||||
h.classify.mockReturnValue({ kind: 'ok' });
|
||||
h.loadAvailability.mockResolvedValue({ slotsByDuration: {}, workingWindows: [], busyBlocks: [] });
|
||||
h.loadAvailability.mockImplementation(
|
||||
async (
|
||||
_db,
|
||||
_cfg,
|
||||
_slug,
|
||||
meeting: Meeting,
|
||||
role: AppointmentRole,
|
||||
currentAppointment: Appointment
|
||||
) => {
|
||||
const bookingDurations = resolveBookingDurations(meeting, role, currentAppointment);
|
||||
return {
|
||||
bookingDurations,
|
||||
slotsByDuration: Object.fromEntries(
|
||||
bookingDurations.durations.map((duration) => [duration, {}])
|
||||
),
|
||||
workingWindows: [],
|
||||
busyBlocks: []
|
||||
};
|
||||
}
|
||||
);
|
||||
h.isRescheduleSlotBookable.mockResolvedValue(true);
|
||||
h.resolveTimezone.mockReturnValue('UTC');
|
||||
h.validateReason.mockReturnValue({ ok: true, reason: null });
|
||||
h.resolveDuration.mockReturnValue(30);
|
||||
h.toPublicAppointment.mockReturnValue({ id: 'appt-1' });
|
||||
h.toPublicEventType.mockReturnValue({ slug: '30-min-chat' });
|
||||
});
|
||||
|
||||
describe('/appointment/[id]/reschedule load', () => {
|
||||
@@ -151,13 +180,21 @@ describe('/appointment/[id]/reschedule load', () => {
|
||||
expect(result.reschedule.appt).toEqual({ id: 'appt-1' });
|
||||
expect(result.reschedule.error).toBeNull();
|
||||
expect(result.reschedule.token).toBe('tok-1');
|
||||
expect(result.eventType).toEqual({ slug: '30-min-chat' });
|
||||
const availability = await h.loadAvailability.mock.results[0].value;
|
||||
expect(result.eventType).toMatchObject({ slug: '30-min-chat', duration_minutes: 90 });
|
||||
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
|
||||
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
|
||||
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
|
||||
[...result.eventType.durations].sort((a, b) => a - b)
|
||||
);
|
||||
expect(h.toPublicAppointment).toHaveBeenCalledExactlyOnceWith(h.row.current, role);
|
||||
expect(h.toPublicEventType).toHaveBeenCalledExactlyOnceWith(
|
||||
'30-min-chat',
|
||||
validConfig.meetings['30-min-chat'],
|
||||
role
|
||||
role,
|
||||
availability.bookingDurations
|
||||
);
|
||||
expect(h.toPublicEventType.mock.calls[0][3]).toBe(availability.bookingDurations);
|
||||
expect(result.role).toBe(role);
|
||||
expect(result).not.toHaveProperty('viewer');
|
||||
expect(result).not.toHaveProperty('isAdmin');
|
||||
@@ -168,7 +205,7 @@ describe('/appointment/[id]/reschedule load', () => {
|
||||
'30-min-chat',
|
||||
validConfig.meetings['30-min-chat'],
|
||||
role,
|
||||
CURRENT
|
||||
h.row.current
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -247,7 +284,7 @@ describe('/appointment/[id]/reschedule book action', () => {
|
||||
'guest',
|
||||
NEW_SLOT,
|
||||
30,
|
||||
CURRENT
|
||||
h.row.current
|
||||
);
|
||||
expect(h.reschedule).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -255,8 +292,22 @@ describe('/appointment/[id]/reschedule book action', () => {
|
||||
test('ignores forged host flags and checks guest availability excluding self', async () => {
|
||||
h.parseForm.mockReturnValue({ ok: true, data: {} });
|
||||
h.isRescheduleSlotBookable.mockResolvedValue(false);
|
||||
const result = await book(form({ viewer: 'host', role: 'host', isAdmin: 'true' }));
|
||||
const fd = form({
|
||||
viewer: 'host',
|
||||
role: 'host',
|
||||
isAdmin: 'true',
|
||||
currentDuration: '240',
|
||||
end_time: '2099-05-01T19:00:00Z'
|
||||
});
|
||||
const result = await book(fd);
|
||||
expect(result.status).toBe(409);
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
validConfig.meetings['30-min-chat'],
|
||||
'guest',
|
||||
fd,
|
||||
h.row.current
|
||||
);
|
||||
expect(h.resolveDuration.mock.calls[0][3]).toBe(h.row.current);
|
||||
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
validConfig,
|
||||
@@ -265,8 +316,9 @@ describe('/appointment/[id]/reschedule book action', () => {
|
||||
'guest',
|
||||
NEW_SLOT,
|
||||
30,
|
||||
CURRENT
|
||||
h.row.current
|
||||
);
|
||||
expect(h.isRescheduleSlotBookable.mock.calls[0][7]).toBe(h.row.current);
|
||||
expect(h.reschedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -280,13 +332,25 @@ describe('/appointment/[id]/reschedule book action', () => {
|
||||
appointment: { id: 'appt-2', cancel_token: 'tok-2' }
|
||||
});
|
||||
const session = forged ? { user: { name: 'Host' } } : null;
|
||||
const event = bookEvent(
|
||||
form(forged ? { viewer: 'host', role: 'host', isAdmin: 'true' } : {}),
|
||||
session
|
||||
const fd = form(
|
||||
forged ? { viewer: 'host', role: 'host', isAdmin: 'true', currentDuration: '240' } : {}
|
||||
);
|
||||
const event = bookEvent(fd, session);
|
||||
const r = await caught(() => actions.default(event));
|
||||
expect(r.status).toBe(303);
|
||||
expect(r.location).toBe('/appointment/appt-2?token=tok-2');
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
validConfig.meetings['30-min-chat'],
|
||||
'guest',
|
||||
fd,
|
||||
h.row.current
|
||||
);
|
||||
expect(event.cookies.set).toHaveBeenCalledWith('submitted', 'reschedule', {
|
||||
path: '/',
|
||||
maxAge: 10,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
validConfig,
|
||||
@@ -295,7 +359,7 @@ describe('/appointment/[id]/reschedule book action', () => {
|
||||
'guest',
|
||||
NEW_SLOT,
|
||||
60,
|
||||
CURRENT
|
||||
h.row.current
|
||||
);
|
||||
expect(h.reschedule).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { actions, load } from './+page.server';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
import type { Appointment } from '@when/db';
|
||||
import type { AppointmentRole } from '$lib/server/appointment/actions';
|
||||
import { resolveBookingDurations } from '$lib/server/appointment/duration';
|
||||
import { toPublicEventType } from '$lib/server/appointment/sanitize';
|
||||
|
||||
const h = vi.hoisted(() => ({ cancelAppointment: vi.fn() }));
|
||||
|
||||
vi.mock('$lib/server/appointment/cancel', () => ({ cancelAppointment: h.cancelAppointment }));
|
||||
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
|
||||
return { ...original, resolveBookingDurations: vi.fn(original.resolveBookingDurations) };
|
||||
});
|
||||
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
|
||||
return { ...original, toPublicEventType: vi.fn(original.toPublicEventType) };
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const mockDb = {
|
||||
selectFrom: vi.fn(),
|
||||
@@ -41,6 +53,11 @@ const mockAppt: Appointment = {
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
const bookedAppointment: Appointment = {
|
||||
...mockAppt,
|
||||
id: 'appt-long',
|
||||
end_time: '2099-05-01T16:30:00Z'
|
||||
};
|
||||
// transitions.ts appends the move to the old row's log and copies that whole log onto the
|
||||
// new row, so a later row carries every earlier move as well as its own.
|
||||
function moves(...pairs: [previousId: string, nextId: string][]) {
|
||||
@@ -117,6 +134,7 @@ vi.mock('@when/db', async (importOriginal) => {
|
||||
...actual,
|
||||
findAppointment: async (_db: unknown, id: string) => {
|
||||
if (id === 'appt-1') return mockAppt;
|
||||
if (id === bookedAppointment.id) return bookedAppointment;
|
||||
if (id === 'appt-pending')
|
||||
return {
|
||||
...mockAppt,
|
||||
@@ -179,6 +197,37 @@ function loadAppointment(id: string, token: string | null, role: AppointmentRole
|
||||
}
|
||||
|
||||
describe('/appointment/[id] server load', () => {
|
||||
test.for([
|
||||
{
|
||||
row: bookedAppointment,
|
||||
meeting: validConfig.meetings['30-min-chat'],
|
||||
role: 'guest' as const
|
||||
},
|
||||
{ row: bookedAppointment, meeting: validConfig.meetings['30-min-chat'], role: 'host' as const }
|
||||
])(
|
||||
'projects $row.id with an explicit $role profile based on its booked length',
|
||||
async ({ row, meeting, role }) => {
|
||||
const result = await loadAppointment(
|
||||
row.id,
|
||||
role === 'guest' ? row.cancel_token : null,
|
||||
role
|
||||
);
|
||||
expect(resolveBookingDurations).toHaveBeenCalledExactlyOnceWith(meeting, role, row);
|
||||
expect(vi.mocked(resolveBookingDurations).mock.calls[0][2]).toBe(row);
|
||||
const bookingDurations = vi.mocked(resolveBookingDurations).mock.results[0].value;
|
||||
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
|
||||
row.event_type_id,
|
||||
meeting,
|
||||
role,
|
||||
bookingDurations
|
||||
);
|
||||
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(bookingDurations);
|
||||
expect(result.eventType.duration_minutes).toBe(90);
|
||||
expect(result.eventType.durations).toBe(bookingDurations.durations);
|
||||
expect(result.eventType.durations).toContain(90);
|
||||
}
|
||||
);
|
||||
|
||||
test.for(['guest', 'host'] as const)(
|
||||
'reports %s and applies the same role to privacy and actions',
|
||||
async (role) => {
|
||||
@@ -430,15 +479,19 @@ describe('/appointment/[id] cancel action — characterization', () => {
|
||||
});
|
||||
|
||||
test('another row token in the chain cannot cancel this row', async () => {
|
||||
expect(await cancelWith('chain-3', 'tok-1')).toMatchObject({ status: 403 });
|
||||
expect(await cancelWith('chain-3', 'tok-2')).toMatchObject({ status: 403 });
|
||||
const first = await cancelWith('chain-3', 'tok-1');
|
||||
const second = await cancelWith('chain-3', 'tok-2');
|
||||
expect(first).toMatchObject({ status: 403 });
|
||||
expect(second).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
test('an empty token cannot cancel', async () => {
|
||||
expect(await cancelWith('chain-3', '')).toMatchObject({ status: 403 });
|
||||
const result = await cancelWith('chain-3', '');
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
test('an unknown appointment is a 404, not a token check', async () => {
|
||||
expect(await cancelWith('nope', 'tok-3')).toMatchObject({ status: 404 });
|
||||
const result = await cancelWith('nope', 'tok-3');
|
||||
expect(result).toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { isNewSlotBookable, loadAvailability } from '$lib/server/availability/booking';
|
||||
import { logger } from '$lib/server/logger';
|
||||
import { getConfig, getDb } from '$lib/server/state';
|
||||
import { durationsOf, resolveFormFields } from '@when/config';
|
||||
import { resolveFormFields } from '@when/config';
|
||||
import { createAppointment } from '$lib/server/appointment/create';
|
||||
import type { AppointmentRole } from '$lib/server/appointment/actions';
|
||||
import { resolveDuration } from '$lib/server/appointment/duration';
|
||||
import { resolveBookingDurations, resolveDuration } from '$lib/server/appointment/duration';
|
||||
import {
|
||||
parseAndValidateAppointmentForm,
|
||||
parseSlot,
|
||||
@@ -26,12 +26,12 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
|
||||
// Strip malformed/unknown deep-link params up front by redirecting to the canonical URL.
|
||||
const clean = normalizeDeepLinkParams(url.searchParams);
|
||||
const durations = durationsOf(eventType);
|
||||
const canonicalDurations = resolveBookingDurations(eventType, role);
|
||||
const expected: [string, string][] = [];
|
||||
if (
|
||||
clean.duration !== undefined &&
|
||||
durations.includes(clean.duration) &&
|
||||
clean.duration !== durations[0]
|
||||
canonicalDurations.durations.includes(clean.duration) &&
|
||||
clean.duration !== canonicalDurations.defaultDuration
|
||||
) {
|
||||
expected.push(['duration', String(clean.duration)]);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
redirect(307, query ? `${url.pathname}?${query}` : url.pathname);
|
||||
}
|
||||
|
||||
const { slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
|
||||
const { bookingDurations, slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
|
||||
getDb(),
|
||||
cfg,
|
||||
params.slug,
|
||||
@@ -58,7 +58,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
);
|
||||
|
||||
return {
|
||||
eventType: toPublicEventType(params.slug, eventType, role),
|
||||
eventType: toPublicEventType(params.slug, eventType, role, bookingDurations),
|
||||
formFields: resolveFormFields(eventType),
|
||||
availability: { slotsByDuration, workingWindows, busyBlocks },
|
||||
reschedule: null,
|
||||
@@ -88,7 +88,7 @@ export const actions: Actions = {
|
||||
}
|
||||
const { name, email, answers, location: resolvedLocation } = parsed.data;
|
||||
|
||||
const duration = resolveDuration(eventType, form);
|
||||
const duration = resolveDuration(eventType, 'guest', form);
|
||||
if (duration === null) {
|
||||
bookingAttemptsTotal.inc({ event_type_id: params.slug, status: 'invalid_input' });
|
||||
return fail(400, { error: 'Please pick a valid meeting length.' });
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<AppointmentWizard {data} {form} deepLink />
|
||||
{#key `${data.eventType.slug}:${data.role}`}
|
||||
<AppointmentWizard {data} {form} deepLink />
|
||||
{/key}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { validConfig } from '$lib/server/__fixtures__/valid-config';
|
||||
import type { WhenConfiguration } from '@when/config';
|
||||
import type { Meeting, WhenConfiguration } from '@when/config';
|
||||
import type { AppointmentRole } from '$lib/server/appointment/actions';
|
||||
import { resolveBookingDurations } from '$lib/server/appointment/duration';
|
||||
import { toPublicEventType } from '$lib/server/appointment/sanitize';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
cfg: { current: null as unknown as WhenConfiguration },
|
||||
loadAvailability: vi.fn(),
|
||||
db: {},
|
||||
getDb: vi.fn(),
|
||||
isNewSlotBookable: vi.fn(),
|
||||
createAppointment: vi.fn(),
|
||||
parseForm: vi.fn(),
|
||||
@@ -13,7 +17,7 @@ const h = vi.hoisted(() => ({
|
||||
resolveDuration: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current, getDb: () => h.db }));
|
||||
vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current, getDb: h.getDb }));
|
||||
vi.mock('$lib/server/availability/booking', () => ({
|
||||
loadAvailability: h.loadAvailability,
|
||||
isNewSlotBookable: h.isNewSlotBookable
|
||||
@@ -27,7 +31,14 @@ vi.mock('$lib/server/appointment/form.server', async (io) => {
|
||||
resolveTimezone: h.resolveTimezone
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
|
||||
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
|
||||
return { ...original, resolveDuration: h.resolveDuration };
|
||||
});
|
||||
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
|
||||
return { ...original, toPublicEventType: vi.fn(original.toPublicEventType) };
|
||||
});
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
@@ -67,7 +78,20 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.cfg.current = validConfig;
|
||||
h.resolveTimezone.mockReturnValue('UTC');
|
||||
h.loadAvailability.mockResolvedValue({ slotsByDuration: {}, workingWindows: [], busyBlocks: [] });
|
||||
h.getDb.mockReturnValue(h.db);
|
||||
h.loadAvailability.mockImplementation(
|
||||
async (_db, _cfg, _slug, meeting: Meeting, role: AppointmentRole) => {
|
||||
const bookingDurations = resolveBookingDurations(meeting, role);
|
||||
return {
|
||||
bookingDurations,
|
||||
slotsByDuration: Object.fromEntries(
|
||||
bookingDurations.durations.map((duration) => [duration, {}])
|
||||
),
|
||||
workingWindows: [],
|
||||
busyBlocks: []
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('/schedule/[slug] load — deep-link canonicalization', () => {
|
||||
@@ -82,9 +106,11 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
|
||||
const r = await caught(() => load(loadEvent('?foo=bar')));
|
||||
expect(r.status).toBe(307);
|
||||
expect(r.location).toBe('/schedule/30-min-chat');
|
||||
expect(h.getDb).not.toHaveBeenCalled();
|
||||
expect(h.loadAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('strips the default duration (durations[0]) from the query', async () => {
|
||||
test('strips the configured default duration from the query', async () => {
|
||||
const r = await caught(() => load(loadEvent('?duration=30')));
|
||||
expect(r.status).toBe(307);
|
||||
expect(r.location).toBe('/schedule/30-min-chat');
|
||||
@@ -112,6 +138,30 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
|
||||
expect(r.location).toBe('/schedule/30-min-chat?duration=60');
|
||||
});
|
||||
|
||||
test('canonicalizes the configured default of 60 before reading availability, not the host palette minimum', async () => {
|
||||
h.cfg.current = structuredClone(validConfig);
|
||||
h.cfg.current.meetings['30-min-chat'].duration_minutes = 60;
|
||||
const session = { user: { name: 'Host' } };
|
||||
const redirect = await caught(() => load(loadEvent('?duration=60', session)));
|
||||
expect(redirect).toMatchObject({ status: 307, location: '/schedule/30-min-chat' });
|
||||
expect(h.getDb).not.toHaveBeenCalled();
|
||||
expect(h.loadAvailability).not.toHaveBeenCalled();
|
||||
const loaded = await load(loadEvent('?duration=15', session));
|
||||
const result = loaded as LoadResult;
|
||||
expect(result.eventType.duration_minutes).toBe(60);
|
||||
expect(result.eventType.durations[0]).toBe(15);
|
||||
});
|
||||
|
||||
test('keeps a host-only duration=90 for hosts but strips it for guests before availability', async () => {
|
||||
const redirect = await caught(() => load(loadEvent('?duration=90')));
|
||||
expect(redirect).toMatchObject({ status: 307, location: '/schedule/30-min-chat' });
|
||||
expect(h.getDb).not.toHaveBeenCalled();
|
||||
expect(h.loadAvailability).not.toHaveBeenCalled();
|
||||
const loaded = await load(loadEvent('?duration=90', { user: { name: 'Host' } }));
|
||||
const result = loaded as LoadResult;
|
||||
expect(result.eventType.durations).toContain(90);
|
||||
});
|
||||
|
||||
test.for([
|
||||
{ role: 'guest', session: null },
|
||||
{ role: 'host', session: { user: { name: 'Host' } } }
|
||||
@@ -124,6 +174,20 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
|
||||
});
|
||||
const loaded = await load(loadEvent(`?slot=${SLOT}`, session));
|
||||
const result = loaded as LoadResult;
|
||||
const availability = await h.loadAvailability.mock.results[0].value;
|
||||
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
|
||||
'30-min-chat',
|
||||
h.cfg.current.meetings['30-min-chat'],
|
||||
role,
|
||||
availability.bookingDurations
|
||||
);
|
||||
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(availability.bookingDurations);
|
||||
expect(result.eventType.duration_minutes).toBe(availability.bookingDurations.defaultDuration);
|
||||
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
|
||||
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
|
||||
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
|
||||
[...result.eventType.durations].sort((a, b) => a - b)
|
||||
);
|
||||
expect(result.eventType.slug).toBe('30-min-chat');
|
||||
expect(result.eventType.location).toBe(role === 'host' ? 'Private host location' : undefined);
|
||||
expect(result.eventType.padding_before_minutes).toBe(role === 'host' ? 0 : 15);
|
||||
@@ -221,7 +285,13 @@ describe('/schedule/[slug] book action', () => {
|
||||
fd.set('viewer', 'host');
|
||||
fd.set('role', 'host');
|
||||
fd.set('isAdmin', 'true');
|
||||
fd.set('currentDuration', '90');
|
||||
const response = await actions.default(bookEvent(fd));
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
h.cfg.current.meetings['30-min-chat'],
|
||||
'guest',
|
||||
fd
|
||||
);
|
||||
expect((response as Failure)?.status).toBe(409);
|
||||
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
@@ -248,12 +318,24 @@ describe('/schedule/[slug] book action', () => {
|
||||
fd.set('viewer', 'host');
|
||||
fd.set('role', 'host');
|
||||
fd.set('isAdmin', 'true');
|
||||
fd.set('currentDuration', '90');
|
||||
}
|
||||
const session = forged ? { user: { name: 'Host' } } : null;
|
||||
const event = bookEvent(fd, session);
|
||||
const r = await caught(() => actions.default(event));
|
||||
expect(r.status).toBe(303);
|
||||
expect(r.location).toBe('/appointment/new-1?token=tok%20new');
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
h.cfg.current.meetings['30-min-chat'],
|
||||
'guest',
|
||||
fd
|
||||
);
|
||||
expect(event.cookies.set).toHaveBeenCalledWith('submitted', 'request', {
|
||||
path: '/',
|
||||
maxAge: 10,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
h.cfg.current,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const actions: Actions = {
|
||||
if (!reasonResult.ok) return fail(400, { error: reasonResult.error });
|
||||
const reason = reasonResult.reason;
|
||||
|
||||
const duration = resolveDuration(eventType, form);
|
||||
const duration = resolveDuration(eventType, 'host', form, found);
|
||||
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
|
||||
|
||||
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
|
||||
@@ -43,7 +43,7 @@ export const actions: Actions = {
|
||||
'host',
|
||||
slotStr,
|
||||
duration,
|
||||
found.start_time
|
||||
found
|
||||
);
|
||||
if (!bookable) {
|
||||
return fail(409, { error: 'That time is no longer available. Please pick another.' });
|
||||
|
||||
@@ -19,7 +19,10 @@ vi.mock('$lib/server/availability/booking', () => ({
|
||||
isRescheduleSlotBookable: h.isRescheduleSlotBookable
|
||||
}));
|
||||
vi.mock('$lib/server/appointment/reschedule', () => ({ rescheduleAppointment: h.reschedule }));
|
||||
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
|
||||
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
|
||||
return { ...original, resolveDuration: h.resolveDuration };
|
||||
});
|
||||
vi.mock('$lib/server/appointment/form.server', async (io) => {
|
||||
const original = await io<typeof import('$lib/server/appointment/form.server')>();
|
||||
return { ...original, validateReason: h.validateReason };
|
||||
@@ -35,6 +38,7 @@ const row = {
|
||||
id: 'appt-1',
|
||||
event_type_id: '30-min-chat',
|
||||
start_time: CURRENT,
|
||||
end_time: '2099-05-01T16:30:00Z',
|
||||
cancel_token: 'own-token',
|
||||
status: 'confirmed'
|
||||
};
|
||||
@@ -74,9 +78,21 @@ beforeEach(() => {
|
||||
|
||||
describe('/admin/appointment/[id]/reschedule book action', () => {
|
||||
test('needs no guest token, delegates the chosen length excluding self, and reschedules as host', async () => {
|
||||
const result = await caught(() => actions.default(bookEvent()));
|
||||
const event = bookEvent();
|
||||
const form = await event.request.formData();
|
||||
form.set('role', 'guest');
|
||||
form.set('currentDuration', '240');
|
||||
form.set('end_time', '2099-05-01T19:00:00Z');
|
||||
const result = await caught(() => actions.default(event));
|
||||
expect(result).toMatchObject({ status: 303, location: '/appointment/appt-2' });
|
||||
expect(h.findAppointment).toHaveBeenCalledExactlyOnceWith(h.db, row.id);
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
validConfig.meetings[row.event_type_id],
|
||||
'host',
|
||||
form,
|
||||
row
|
||||
);
|
||||
expect(h.resolveDuration.mock.calls[0][3]).toBe(row);
|
||||
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
validConfig,
|
||||
@@ -85,8 +101,9 @@ describe('/admin/appointment/[id]/reschedule book action', () => {
|
||||
'host',
|
||||
NEW_SLOT,
|
||||
60,
|
||||
CURRENT
|
||||
row
|
||||
);
|
||||
expect(h.isRescheduleSlotBookable.mock.calls[0][7]).toBe(row);
|
||||
expect(h.validateReason).toHaveBeenCalledWith(expect.any(FormData), 'rescheduling');
|
||||
expect(h.reschedule).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ db: h.db, cfg: validConfig }),
|
||||
|
||||
@@ -31,7 +31,7 @@ export const actions: Actions = {
|
||||
}
|
||||
const { name, email, answers, location: resolvedLocation } = parsed.data;
|
||||
|
||||
const duration = resolveDuration(eventType, form);
|
||||
const duration = resolveDuration(eventType, 'host', form);
|
||||
if (duration === null) {
|
||||
return fail(400, { error: 'Please pick a valid meeting length.' });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ const h = vi.hoisted(() => ({
|
||||
vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig, getDb: () => h.db }));
|
||||
vi.mock('$lib/server/availability/booking', () => ({ isNewSlotBookable: h.isNewSlotBookable }));
|
||||
vi.mock('$lib/server/appointment/create', () => ({ createAppointment: h.createAppointment }));
|
||||
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
|
||||
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
|
||||
return { ...original, resolveDuration: h.resolveDuration };
|
||||
});
|
||||
vi.mock('$lib/server/appointment/form.server', async (io) => {
|
||||
const original = await io<typeof import('$lib/server/appointment/form.server')>();
|
||||
return { ...original, parseAndValidateAppointmentForm: h.parseForm };
|
||||
@@ -55,8 +58,17 @@ beforeEach(() => {
|
||||
|
||||
describe('/admin/schedule/[slug] book action', () => {
|
||||
test('delegates the chosen length and books as host with the configured timezone fallback', async () => {
|
||||
const result = await caught(() => actions.default(bookEvent()));
|
||||
const event = bookEvent();
|
||||
const form = await event.request.formData();
|
||||
form.set('role', 'guest');
|
||||
form.set('currentDuration', '300');
|
||||
const result = await caught(() => actions.default(event));
|
||||
expect(result).toMatchObject({ status: 303, location: '/appointment/new-1' });
|
||||
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
|
||||
validConfig.meetings['30-min-chat'],
|
||||
'host',
|
||||
form
|
||||
);
|
||||
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
|
||||
h.db,
|
||||
validConfig,
|
||||
|
||||
Reference in new issue
Block a user