Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67552197ed | |||
| a7838b3785 |
-234
@@ -1,234 +0,0 @@
|
||||
# UAT Playbook
|
||||
|
||||
## 1. Overview
|
||||
|
||||
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
|
||||
|
||||
## 2. Environments
|
||||
|
||||
| Environment | URL | Notes |
|
||||
|-------------|-----|-------|
|
||||
| Dev | `https://dev.groombook.dev` | Development environment for active development |
|
||||
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
|
||||
| Production | `https://demo.groombook.dev` | Production/demo environment |
|
||||
|
||||
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
|
||||
|
||||
## 3. Pre-conditions
|
||||
|
||||
- UAT environment is accessible at `https://uat.groombook.dev`
|
||||
- Test accounts are seeded with the following personas:
|
||||
- **Manager:** Full administrative access
|
||||
- **Staff:** Limited access to assigned appointments and clients
|
||||
- **Client:** Portal access to view and manage their own appointments
|
||||
- OIDC is configured with Authentik at `https://auth.farh.net`
|
||||
- Seed data is populated:
|
||||
- Sample clients and pets
|
||||
- Grooming services with pricing and duration
|
||||
- Existing appointments
|
||||
- Stripe test keys are configured for payment flow testing
|
||||
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
|
||||
|
||||
## 4. Test Cases
|
||||
|
||||
### 4.1 Authentication
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
|
||||
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
|
||||
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
|
||||
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
|
||||
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
|
||||
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
|
||||
|
||||
### 4.2 Setup Wizard / OOBE
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.2.1 | First-run setup | 1. Access fresh UAT environment with no configuration<br>2. Complete setup wizard: business name, hours, services | Configuration is saved, dashboard loads with setup complete |
|
||||
| TC-APP-4.2.2 | Setup validation | 1. Start setup wizard<br>2. Leave required fields blank<br>3. Attempt to proceed | Validation errors displayed, cannot proceed without required fields |
|
||||
| TC-APP-4.2.3 | Skip setup (if already configured) | 1. Access configured environment<br>2. Attempt to access setup wizard | Redirected to dashboard or setup is marked as complete |
|
||||
|
||||
### 4.3 Client Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.3.1 | Create new client | 1. Navigate to Clients page<br>2. Click "Add Client"<br>3. Fill in client details (name, email, phone, address)<br>4. Save | Client is created and appears in client list |
|
||||
| TC-APP-4.3.2 | Edit client | 1. Select existing client<br>2. Click "Edit"<br>3. Modify client details<br>4. Save | Changes are saved and reflected in client profile |
|
||||
| TC-APP-4.3.3 | Search clients | 1. Navigate to Clients page<br>2. Enter client name or email in search<br>3. Press Enter/submit | Search results display matching clients |
|
||||
| TC-APP-4.3.4 | Archive client | 1. Select active client<br>2. Click "Archive"<br>3. Confirm action | Client is marked as archived, no longer appears in active client list |
|
||||
|
||||
### 4.4 Pet Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.4.1 | Add pet to client | 1. Select client<br>2. Click "Add Pet"<br>3. Fill in pet details (name, breed, weight, notes)<br>4. Save | Pet is added to client's pet list |
|
||||
| TC-APP-4.4.2 | Edit pet information | 1. Select pet from client profile<br>2. Click "Edit"<br>3. Modify pet details<br>4. Save | Changes are saved and reflected |
|
||||
| TC-APP-4.4.3 | View grooming history | 1. Select pet with past appointments<br>2. Navigate to "History" tab | All past grooming appointments and notes are displayed |
|
||||
| TC-APP-4.4.4 | Add breed notes | 1. Edit pet<br>2. Add breed-specific notes (temperament, special handling)<br>3. Save | Notes are saved and visible to staff when scheduling |
|
||||
|
||||
### 4.5 Appointment Scheduling
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.5.1 | Create new appointment | 1. Navigate to Calendar or Appointments page<br>2. Click "New Appointment"<br>3. Select client, pet, service, staff, date/time<br>4. Save | Appointment is created and appears in calendar |
|
||||
| TC-APP-4.5.2 | Modify appointment | 1. Select existing appointment<br>2. Click "Edit"<br>3. Change date/time, staff, or service<br>4. Save | Changes are saved and calendar updates |
|
||||
| TC-APP-4.5.3 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Confirm and optionally select reason | Appointment is marked as cancelled, slot becomes available |
|
||||
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
|
||||
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
|
||||
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
|
||||
|
||||
### 4.6 Services
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.6.1 | List all services | 1. Navigate to Services page | All configured grooming services are listed |
|
||||
| TC-APP-4.6.2 | Create new service | 1. Click "Add Service"<br>2. Enter service name, description, duration, price<br>3. Save | Service is created and appears in list |
|
||||
| TC-APP-4.6.3 | Edit service | 1. Select existing service<br>2. Modify pricing or duration<br>3. Save | Changes are saved and reflected |
|
||||
| TC-APP-4.6.4 | Deactivate service | 1. Select service<br>2. Click "Deactivate"<br>3. Confirm | Service is marked as inactive, not available for new appointments |
|
||||
|
||||
### 4.7 Staff Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.7.1 | List all staff | 1. Navigate to Staff page | All staff members are listed with roles and status |
|
||||
| TC-APP-4.7.2 | Add new staff member | 1. Click "Add Staff"<br>2. Enter staff details and assign role<br>3. Save | Staff member is created and can be assigned to appointments |
|
||||
| TC-APP-4.7.3 | Assign RBAC role | 1. Select staff member<br>2. Change role (e.g., from Staff to Manager)<br>3. Save | Role change takes effect immediately |
|
||||
| TC-APP-4.7.4 | Impersonate client | 1. As Manager, select client<br>2. Click "Impersonate"<br>3. Verify audit log | Manager views client's perspective, action is logged |
|
||||
| TC-APP-4.7.5 | End impersonation | 1. While impersonating, click "End Impersonation" | Session returns to Manager's view |
|
||||
|
||||
### 4.8 Invoicing & Payments
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.8.1 | Generate invoice | 1. Select completed appointment<br>2. Click "Generate Invoice"<br>3. Review invoice details | Invoice is created with correct services, pricing, and taxes |
|
||||
| TC-APP-4.8.2 | Process Stripe payment | 1. Open invoice<br>2. Click "Pay Now"<br>3. Enter Stripe test card details<br>4. Submit | Payment is processed, invoice marked as paid |
|
||||
| TC-APP-4.8.3 | Add tip | 1. Before or after payment, add tip amount<br>2. Save | Tip is added to invoice total |
|
||||
| TC-APP-4.8.4 | Generate receipt | 1. After payment, click "Generate Receipt"<br>2. Download or view receipt | Receipt is generated with payment details |
|
||||
| TC-APP-4.8.5 | Process refund | 1. Select paid invoice<br>2. Click "Refund"<br>3. Enter refund amount and reason<br>4. Confirm | Refund is processed via Stripe, invoice status updated |
|
||||
| TC-APP-4.8.6 | Failed payment handling | 1. Attempt payment with declined card<br>2. Verify error handling | Appropriate error message displayed, invoice remains unpaid |
|
||||
|
||||
### 4.9 Customer Portal
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.9.1 | Client login | 1. Access portal URL<br>2. Log in with client credentials | Client lands on portal dashboard |
|
||||
| TC-APP-4.9.2 | View appointments | 1. Navigate to "My Appointments"<br>2. Review upcoming and past appointments | All client's appointments are listed |
|
||||
| TC-APP-4.9.3 | Confirm appointment | 1. Select upcoming appointment<br>2. Click "Confirm" | Appointment is marked as confirmed by client |
|
||||
| TC-APP-4.9.4 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Provide reason | Appointment is cancelled, notification sent to business |
|
||||
| TC-APP-4.9.5 | View appointment history | 1. Navigate to "History" tab | All past appointments with details are shown |
|
||||
|
||||
### 4.10 Waitlist
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.10.1 | Add client to waitlist | 1. Navigate to Waitlist page<br>2. Click "Add to Waitlist"<br>3. Select client, pet, preferred dates<br>4. Save | Client is added to waitlist |
|
||||
| TC-APP-4.10.2 | View waitlist | 1. Navigate to Waitlist page | All waitlisted requests are displayed with priority |
|
||||
| TC-APP-4.10.3 | Promote to appointment | 1. Select waitlist entry<br>2. Click "Promote to Appointment"<br>3. Select available slot | Appointment is created from waitlist, entry removed |
|
||||
| TC-APP-4.10.4 | Remove from waitlist | 1. Select waitlist entry<br>2. Click "Remove"<br>3. Confirm | Entry is removed from waitlist |
|
||||
|
||||
### 4.11 Search
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.11.1 | Global search for clients | 1. Use global search bar<br>2. Enter client name or email<br>3. Select "Clients" | Search returns matching clients |
|
||||
| TC-APP-4.11.2 | Global search for pets | 1. Use global search bar<br>2. Enter pet name or breed<br>3. Select "Pets" | Search returns matching pets with owner info |
|
||||
| TC-APP-4.11.3 | Search filters | 1. Perform search<br>2. Apply filters (date range, status, etc.) | Results are filtered according to criteria |
|
||||
| TC-APP-4.11.4 | No results handling | 1. Search for non-existent term<br>2. Verify UI | "No results found" message displayed |
|
||||
|
||||
### 4.12 Reports
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.12.1 | Revenue dashboard | 1. Navigate to Reports > Revenue<br>2. Select date range | Revenue metrics displayed (total, by service, by staff) |
|
||||
| TC-APP-4.12.2 | Staff utilization | 1. Navigate to Reports > Utilization<br>2. Select date range | Staff hours booked vs. available shown |
|
||||
| TC-APP-4.12.3 | Trend analytics | 1. Navigate to Reports > Trends<br>2. Select metric and time period | Trend chart displays with data points |
|
||||
| TC-APP-4.12.4 | Export report | 1. View any report<br>2. Click "Export"<br>3. Select format (CSV, PDF) | Report file is downloaded |
|
||||
|
||||
### 4.13 Calendar
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.13.1 | Generate iCal feed | 1. Navigate to Calendar<br>2. Click "iCal Feed"<br>3. Copy URL | iCal feed URL is generated for external calendar apps |
|
||||
| TC-APP-4.13.2 | Calendar sync (external) | 1. Import iCal feed into external calendar (Google, Outlook)<br>2. Verify sync | Appointments appear in external calendar |
|
||||
| TC-APP-4.13.3 | Calendar availability display | 1. View calendar in any view mode | Available and booked slots are visually distinct |
|
||||
|
||||
### 4.14 Email Reminders
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.14.1 | Configure email reminders | 1. Navigate to Settings > Notifications<br>2. Set reminder timing (24h, 1h before)<br>3. Save | Configuration is saved |
|
||||
| TC-APP-4.14.2 | Verify reminder delivery | 1. Create appointment for tomorrow<br>2. Wait for reminder trigger<br>3. Check test email account | Reminder email is received with correct details |
|
||||
| TC-APP-4.14.3 | SMS notification | 1. Configure SMS provider (Telnyx)<br>2. Enable SMS reminders<br>3. Create appointment | SMS is sent to client's phone number |
|
||||
| TC-APP-4.14.4 | Notification preferences | 1. As client, access portal settings<br>2. Toggle email/SMS preferences | Preferences are respected for future notifications |
|
||||
|
||||
### 4.15 Grooming Logs
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.15.1 | Log grooming entry | 1. Select pet<br>2. Click "Add Grooming Log"<br>3. Enter details (date, services, notes, photos)<br>4. Save | Log entry is created and linked to pet |
|
||||
| TC-APP-4.15.2 | View grooming history | 1. Select pet<br>2. Navigate to "Grooming History" | All log entries are displayed chronologically |
|
||||
| TC-APP-4.15.3 | Add photos to log | 1. Create or edit grooming log<br>2. Upload before/after photos<br>3. Save | Photos are attached to log entry |
|
||||
| TC-APP-4.15.4 | Edit grooming log | 1. Select existing log entry<br>2. Modify notes or services<br>3. Save | Changes are saved |
|
||||
|
||||
### 4.16 Settings
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.16.1 | Business settings | 1. Navigate to Settings > Business<br>2. Update business name, hours, contact info<br>3. Save | Settings are saved and reflected app-wide |
|
||||
| TC-APP-4.16.2 | App configuration | 1. Navigate to Settings > App<br>2. Configure theme, time zone, date format<br>3. Save | Configuration takes effect immediately |
|
||||
| TC-APP-4.16.3 | Payment settings | 1. Navigate to Settings > Payments<br>2. Configure Stripe keys, tax rates<br>3. Save | Payment settings are updated |
|
||||
| TC-APP-4.16.4 | Notification settings | 1. Navigate to Settings > Notifications<br>2. Configure email/SMS providers and defaults<br>3. Save | Notification configuration is saved |
|
||||
|
||||
### 4.17 Mobile / PWA
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.17.1 | Install prompt | 1. Access app on mobile device (or DevTools mobile view)<br>2. Verify install prompt appears | "Add to Home Screen" prompt is shown |
|
||||
| TC-APP-4.17.2 | Responsive design (mobile) | 1. Resize viewport to 390x844 (iPhone dimensions)<br>2. Navigate through app | All pages are usable and properly formatted |
|
||||
| TC-APP-4.17.3 | Offline basics | 1. Load app<br>2. Enable offline mode in DevTools<br>3. Navigate to previously loaded pages | Cached content is displayed, offline indicator shown |
|
||||
| TC-APP-4.17.4 | Touch interactions | 1. On mobile viewport, tap buttons, forms, and navigation<br>2. Verify responsiveness | All touch targets are accessible and responsive |
|
||||
|
||||
### 4.18 Navigation
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.18.1 | All major sections accessible | 1. Click each main navigation item<br>2. Verify page loads | All sections (Dashboard, Calendar, Clients, Pets, Appointments, Reports, Settings) load successfully |
|
||||
| TC-APP-4.18.2 | No broken links | 1. Navigate through app<br>2. Click various links and buttons | No 404 errors or dead ends encountered |
|
||||
| TC-APP-4.18.3 | No blank pages | 1. Navigate to each section and sub-section<br>2. Verify content is displayed | All pages render with appropriate content |
|
||||
| TC-APP-4.18.4 | Back/forward navigation | 1. Navigate through multiple pages<br>2. Use browser back and forward buttons | Navigation history works correctly |
|
||||
|
||||
### 4.19 Error States
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.19.1 | Form with bad data | 1. On any form, enter invalid email, phone, or dates<br>2. Submit | Validation errors display specific issues |
|
||||
| TC-APP-4.19.2 | Missing required fields | 1. On any form, leave required fields blank<br>2. Submit | Clear error messages indicate which fields are required |
|
||||
| TC-APP-4.19.3 | Empty states | 1. Navigate to pages with no data (empty calendar, no clients)<br>2. Verify UI | Helpful empty state message with call-to-action displayed |
|
||||
| TC-APP-4.19.4 | Network error handling | 1. Disable network in DevTools<br>2. Attempt actions that require API calls<br>3. Re-enable network | Appropriate error message shown, app recovers when network restored |
|
||||
|
||||
## 5. Pass/Fail Criteria
|
||||
|
||||
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
|
||||
|
||||
**Fail:** Any unexpected result is encountered. For failures, document:
|
||||
- Severity (Critical, High, Medium, Low)
|
||||
- Steps to reproduce
|
||||
- Actual vs. expected behavior
|
||||
- Screenshot(s) if applicable
|
||||
- Browser and device information
|
||||
|
||||
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
|
||||
|
||||
## 6. Update Policy
|
||||
|
||||
**Any PR that changes user-facing behaviour MUST update this file.**
|
||||
|
||||
When modifying features that affect:
|
||||
- User workflows (authentication, scheduling, payments, etc.)
|
||||
- UI/UX (navigation, forms, responsive design)
|
||||
- Configuration (settings, integrations)
|
||||
- Data visibility (reports, search, filtering)
|
||||
|
||||
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
|
||||
@@ -0,0 +1,131 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockRows: Record<string, unknown[]> = {};
|
||||
|
||||
export function resetMock() {
|
||||
Object.keys(mockRows).forEach((key) => {
|
||||
mockRows[key] = [];
|
||||
});
|
||||
}
|
||||
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (
|
||||
prop === "where" ||
|
||||
prop === "orderBy" ||
|
||||
prop === "limit" ||
|
||||
prop === "leftJoin" ||
|
||||
prop === "rightJoin" ||
|
||||
prop === "innerJoin"
|
||||
) {
|
||||
return () => chain;
|
||||
}
|
||||
return target[prop as keyof typeof target];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
function createTableProxy(tableName: string): unknown {
|
||||
return new Proxy(
|
||||
{ _name: tableName },
|
||||
{
|
||||
get: (target, prop) =>
|
||||
prop === "_name" ? tableName : { table: tableName, column: prop },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const tables = [
|
||||
"user",
|
||||
"session",
|
||||
"account",
|
||||
"verification",
|
||||
"clients",
|
||||
"pets",
|
||||
"services",
|
||||
"staff",
|
||||
"recurringSeries",
|
||||
"appointmentGroups",
|
||||
"appointments",
|
||||
"invoices",
|
||||
"invoiceLineItems",
|
||||
"invoiceTipSplits",
|
||||
"refunds",
|
||||
"reminderLogs",
|
||||
"impersonationSessions",
|
||||
"impersonationAuditLogs",
|
||||
"conversations",
|
||||
"messages",
|
||||
"messageAttachments",
|
||||
"messageConsentEvents",
|
||||
"businessSettings",
|
||||
"groomingVisitLogs",
|
||||
"waitlistEntries",
|
||||
"authProviderConfig",
|
||||
] as const;
|
||||
|
||||
type TableName = (typeof tables)[number];
|
||||
|
||||
const tableProxies: Record<TableName, unknown> = {} as Record<TableName, unknown>;
|
||||
|
||||
tables.forEach((table) => {
|
||||
tableProxies[table] = createTableProxy(table);
|
||||
});
|
||||
|
||||
vi.mock("@groombook/db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
const tableName = table._name as TableName;
|
||||
const rows = mockRows[tableName] || [];
|
||||
return makeChainable(rows);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => [{ ...vals, id: "mock-id" }],
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => [{ ...vals, id: "mock-id" }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => ({
|
||||
returning: () => [{ id: "mock-id" }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
...tableProxies,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
ne: vi.fn(),
|
||||
gt: vi.fn(),
|
||||
gte: vi.fn(),
|
||||
lt: vi.fn(),
|
||||
lte: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
ilike: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
desc: vi.fn(),
|
||||
asc: vi.fn(),
|
||||
encryptSecret: vi.fn(),
|
||||
decryptSecret: vi.fn(),
|
||||
appointmentStatusEnum: ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"],
|
||||
staffRoleEnum: ["groomer", "receptionist", "manager"],
|
||||
invoiceStatusEnum: ["draft", "pending", "paid", "void"],
|
||||
paymentMethodEnum: ["cash", "card", "check", "other"],
|
||||
clientStatusEnum: ["active", "disabled"],
|
||||
messagingChannelEnum: ["sms", "mms"],
|
||||
messageDirectionEnum: ["inbound", "outbound"],
|
||||
messageStatusEnum: ["queued", "sent", "delivered", "failed"],
|
||||
}));
|
||||
@@ -41,12 +41,14 @@ let selectRows: Record<string, unknown>[] = [];
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let insertedAuditLogs: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
selectSessionRow = null;
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
insertedAuditLogs = [];
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
@@ -94,6 +96,11 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationAuditLogs = new Proxy(
|
||||
{ _name: "impersonationAuditLogs" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
@@ -109,9 +116,18 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
// Only count waitlist entry inserts, not audit log inserts from portalAudit middleware
|
||||
if (vals.petId || vals.serviceId || vals.status !== undefined) {
|
||||
insertedValues.push(vals);
|
||||
}
|
||||
return {
|
||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
||||
returning: () => {
|
||||
if (vals.sessionId && !vals.petId) {
|
||||
insertedAuditLogs.push(vals);
|
||||
return [{ ...vals, id: "audit-log-uuid", createdAt: new Date() }];
|
||||
}
|
||||
return [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -139,6 +155,7 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
waitlistEntries,
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { detectKeyword } from "../consent.js";
|
||||
|
||||
vi.mock("@groombook/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
select: vi.fn(),
|
||||
},
|
||||
clients: {},
|
||||
messageConsentEvents: {},
|
||||
businessSettings: {},
|
||||
eq: vi.fn(),
|
||||
}));
|
||||
|
||||
const { handleConsentKeyword } = await import("../consent.js");
|
||||
const { db } = await import("@groombook/db");
|
||||
|
||||
describe("detectKeyword", () => {
|
||||
it.each([
|
||||
["STOP", "opt_out"],
|
||||
["STOPALL", "opt_out"],
|
||||
["UNSUBSCRIBE", "opt_out"],
|
||||
["CANCEL", "opt_out"],
|
||||
["END", "opt_out"],
|
||||
["QUIT", "opt_out"],
|
||||
])("opt-out keyword %s → opt_out", (keyword, expected) => {
|
||||
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["START", "opt_in"],
|
||||
["UNSTOP", "opt_in"],
|
||||
["YES", "opt_in"],
|
||||
["SUBSCRIBE", "opt_in"],
|
||||
])("opt-in keyword %s → opt_in", (keyword, expected) => {
|
||||
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["HELP", "help"],
|
||||
["INFO", "help"],
|
||||
])("help keyword %s → help", (keyword, expected) => {
|
||||
expect(detectKeyword(keyword)).toEqual({ kind: expected });
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
|
||||
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
|
||||
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
|
||||
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
|
||||
});
|
||||
|
||||
it("returns null for non-keyword messages", () => {
|
||||
expect(detectKeyword("hello")).toBeNull();
|
||||
expect(detectKeyword("STOP IT")).toBeNull();
|
||||
expect(detectKeyword("help me")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleConsentKeyword", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
|
||||
} as any);
|
||||
db.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
} as any);
|
||||
});
|
||||
|
||||
const baseOpts = {
|
||||
clientId: "client-1",
|
||||
businessId: "biz-1",
|
||||
db: db as unknown as typeof import("@groombook/db").db,
|
||||
};
|
||||
|
||||
describe("opt_out", () => {
|
||||
it("inserts consent event with sms_keyword source", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||
|
||||
expect(db.insert).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent — second opt-out logs event but skips client update", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unsubscribe reply text", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
|
||||
expect(result.replyText).toBe(
|
||||
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opt_in", () => {
|
||||
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears smsOptOutDate on opt-in after opt-out", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent — second opt-in skips client update", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns resubscribe reply text", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
|
||||
expect(result.replyText).toBe(
|
||||
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("help", () => {
|
||||
it("does not call update — opt-in state unchanged", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: null }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
|
||||
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
expect(result.replyText).toBe(
|
||||
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
|
||||
);
|
||||
});
|
||||
|
||||
it("uses business messagingHelpReply when configured", async () => {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ messagingHelpReply: "Custom help text." }]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
|
||||
expect(result.replyText).toBe("Custom help text.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { db, clients, messageConsentEvents, businessSettings, eq } from "@groombook/db";
|
||||
|
||||
export type KeywordKind = "opt_in" | "opt_out" | "help";
|
||||
|
||||
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
|
||||
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
|
||||
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
|
||||
|
||||
export function detectKeyword(body: string): { kind: KeywordKind } | null {
|
||||
const normalized = body.trim().toUpperCase();
|
||||
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
|
||||
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
|
||||
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleConsentKeyword(opts: {
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
kind: KeywordKind;
|
||||
db: typeof import("@groombook/db").db;
|
||||
}): Promise<{ replyText: string }> {
|
||||
const { clientId, businessId, kind, db: database } = opts;
|
||||
|
||||
await database.insert(messageConsentEvents).values({
|
||||
clientId,
|
||||
businessId,
|
||||
kind,
|
||||
source: "sms_keyword",
|
||||
});
|
||||
|
||||
if (kind === "opt_out") {
|
||||
const [existing] = await database
|
||||
.select({ smsOptIn: clients.smsOptIn })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existing?.smsOptIn !== false) {
|
||||
await database
|
||||
.update(clients)
|
||||
.set({ smsOptIn: false, smsOptOutDate: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
|
||||
return {
|
||||
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "opt_in") {
|
||||
const [existing] = await database
|
||||
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existing?.smsOptIn !== true) {
|
||||
await database
|
||||
.update(clients)
|
||||
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
|
||||
return {
|
||||
replyText:
|
||||
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
|
||||
};
|
||||
}
|
||||
|
||||
// kind === "help"
|
||||
const [settings] = await database
|
||||
.select({ messagingHelpReply: businessSettings.messagingHelpReply })
|
||||
.from(businessSettings)
|
||||
.where(eq(businessSettings.id, businessId))
|
||||
.limit(1);
|
||||
|
||||
const replyText =
|
||||
settings?.messagingHelpReply ??
|
||||
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
|
||||
|
||||
return { replyText };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { detectKeyword, handleConsentKeyword } from "./consent.js";
|
||||
import { sendMessage } from "./outbound.js";
|
||||
|
||||
export interface TelnyxMessageReceivedPayload {
|
||||
data: {
|
||||
@@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
||||
"received"
|
||||
);
|
||||
|
||||
const keyword = detectKeyword(message.body ?? "");
|
||||
if (keyword) {
|
||||
const { replyText } = await handleConsentKeyword({
|
||||
clientId,
|
||||
businessId,
|
||||
kind: keyword.kind,
|
||||
db: getDb(),
|
||||
});
|
||||
await sendMessage({
|
||||
businessId,
|
||||
clientId,
|
||||
body: replyText,
|
||||
staffId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { conversationId, messageId };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user