Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1674a7df4a | |||
| 09187ca277 | |||
| 2c928ca4d7 | |||
| af75fecb66 | |||
| 2d4df6fe1e | |||
| db10320c8f | |||
| 40a4023c65 | |||
| d598511b75 | |||
| e714200b71 | |||
| 1e70e01046 | |||
| 83d7fecdd3 | |||
| 2448887924 | |||
| f4995d987d | |||
| c9b699527c | |||
| 54a6b047fb |
@@ -202,20 +202,20 @@ jobs:
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "chore/update-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/apps/api/package.json apps/api/
|
||||
COPY --from=builder /app/apps/api/dist apps/api/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# UAT Playbook — GroomBook API
|
||||
|
||||
## Overview
|
||||
|
||||
GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet grooming management platform. Handles authentication, client/pet management, appointment scheduling, invoicing, payments, staff management, and the customer portal.
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | URL |
|
||||
|------------|-----|
|
||||
| Dev | `dev.groombook.dev` |
|
||||
| UAT | `uat.groombook.dev` |
|
||||
| Prod | `demo.groombook.app` |
|
||||
|
||||
## Pre-conditions
|
||||
|
||||
- UAT environment accessible and healthy
|
||||
- Test accounts seeded (manager, staff, client personas)
|
||||
- OIDC authentication provider configured
|
||||
- Seed data present (clients, pets, services, staff)
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 4.1 Authentication
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
|
||||
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
|
||||
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
||||
| TC-API-1.4 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
|
||||
|
||||
### 4.2 Client Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-2.1 | List clients | GET /api/clients | 200 OK, list of active clients returned |
|
||||
| TC-API-2.2 | Get client details | GET /api/clients/{id} | 200 OK, client details returned |
|
||||
| TC-API-2.3 | Create client | POST /api/clients with valid data | 201 Created, client record created |
|
||||
| TC-API-2.4 | Update client | PATCH /api/clients/{id} with updated fields | 200 OK, client updated |
|
||||
| TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled |
|
||||
| TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) |
|
||||
|
||||
### 4.3 Pet Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-3.1 | List pets | GET /api/pets | 200 OK, list of pets returned |
|
||||
| TC-API-3.2 | Get pet details | GET /api/pets/{id} | 200 OK, pet details including history returned |
|
||||
| TC-API-3.3 | Add pet | POST /api/pets with valid pet data | 201 Created, pet record created |
|
||||
| TC-API-3.4 | Update pet | PATCH /api/pets/{id} with updated fields | 200 OK, pet updated |
|
||||
| TC-API-3.5 | Delete pet | DELETE /api/pets/{id} | 200 OK, pet deleted |
|
||||
| TC-API-3.6 | Upload pet photo | POST /api/pets/{id}/photo/upload-url, then confirm | 200 OK, photo uploaded and key stored |
|
||||
| TC-API-3.7 | View pet photo | GET /api/pets/{id}/photo | 200 OK, presigned URL returned |
|
||||
|
||||
### 4.4 Appointment Scheduling
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-4.1 | List appointments | GET /api/appointments | 200 OK, list of appointments returned |
|
||||
| TC-API-4.2 | Get appointment details | GET /api/appointments/{id} | 200 OK, appointment details returned |
|
||||
| TC-API-4.3 | Create single appointment | POST /api/appointments with valid data | 201 Created, appointment created |
|
||||
| TC-API-4.4 | Create recurring appointment | POST /api/appointments with recurrence object | 201 Created, series of appointments created |
|
||||
| TC-API-4.5 | Update appointment | PATCH /api/appointments/{id} with updated fields | 200 OK, appointment updated |
|
||||
| TC-API-4.6 | Reschedule with cascade | PATCH /api/appointments/{id} with cascadeMode: "this_and_future" | 200 OK, future appointments updated |
|
||||
| TC-API-4.7 | Cancel appointment | DELETE /api/appointments/{id} | 200 OK, appointment marked as cancelled |
|
||||
| TC-API-4.8 | Confirm appointment | POST /api/appointments/{id}/confirm | 200 OK, confirmation status set to confirmed |
|
||||
| TC-API-4.9 | Cancel confirmation | POST /api/appointments/{id}/cancel | 200 OK, confirmation cancelled |
|
||||
| TC-API-4.10 | Conflict detection | POST /api/appointments with conflicting time | 409 Conflict, error message returned |
|
||||
|
||||
### 4.5 Services
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-5.1 | List services | GET /api/services | 200 OK, list of active services returned |
|
||||
| TC-API-5.2 | Get service details | GET /api/services/{id} | 200 OK, service details returned |
|
||||
| TC-API-5.3 | Create service | POST /api/services with valid data | 201 Created, service created |
|
||||
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
|
||||
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
|
||||
|
||||
### 4.6 Staff Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-6.1 | List staff | GET /api/staff | 200 OK, list of active staff returned |
|
||||
| TC-API-6.2 | Get staff details | GET /api/staff/{id} | 200 OK, staff details returned |
|
||||
| TC-API-6.3 | Create staff | POST /api/staff with valid data | 201 Created, staff created |
|
||||
| TC-API-6.4 | Update staff | PATCH /api/staff/{id} with updated fields | 200 OK, staff updated |
|
||||
| TC-API-6.5 | Delete staff | DELETE /api/staff/{id} | 200 OK, staff deleted (if no appointments) |
|
||||
| TC-API-6.6 | RBAC check | Access manager-only endpoint as groomer | 403 Forbidden, error message returned |
|
||||
|
||||
### 4.7 Invoicing & Payments
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-7.1 | List invoices | GET /api/invoices | 200 OK, list of invoices returned |
|
||||
| TC-API-7.2 | Get invoice details | GET /api/invoices/{id} | 200 OK, invoice with line items returned |
|
||||
| TC-API-7.3 | Create invoice | POST /api/invoices with line items | 201 Created, invoice created |
|
||||
| TC-API-7.4 | Create from appointment | POST /api/invoices/from-appointment/{appointmentId} | 201 Created, invoice created from appointment |
|
||||
| TC-API-7.5 | Update invoice | PATCH /api/invoices/{id} with status and payment method | 200 OK, invoice updated |
|
||||
| TC-API-7.6 | Process payment via Stripe | POST /api/invoices/{id}/pay with Stripe data | 200 OK, payment intent created |
|
||||
| TC-API-7.7 | Save tip splits | POST /api/invoices/{id}/tip-splits with splits array | 201 Created, tip splits saved |
|
||||
| TC-API-7.8 | Process refund | POST /api/invoices/{id}/refund with amount | 200 OK, refund processed |
|
||||
|
||||
### 4.8 Customer Portal
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-8.1 | Access portal | GET /api/portal/me with valid session token | 200 OK, client profile returned |
|
||||
| TC-API-8.2 | View portal appointments | GET /api/portal/appointments | 200 OK, list of client's appointments returned |
|
||||
| TC-API-8.3 | Confirm appointment via portal | POST /api/portal/appointments/{id}/confirm | 200 OK, appointment confirmed |
|
||||
| TC-API-8.4 | Cancel appointment via portal | POST /api/portal/appointments/{id}/cancel | 200 OK, appointment cancelled |
|
||||
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
|
||||
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
|
||||
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-9.1 | List waitlist | GET /api/waitlist | 200 OK, list of waitlist entries returned |
|
||||
| TC-API-9.2 | Add to waitlist | POST /api/waitlist with client, pet, service | 201 Created, entry added |
|
||||
| TC-API-9.3 | Promote from waitlist | Create appointment from waitlist entry | 201 Created, appointment created, waitlist updated |
|
||||
|
||||
### 4.10 Search
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-10.1 | Global search clients | GET /api/search?q={client_name} | 200 OK, matching clients returned |
|
||||
| TC-API-10.2 | Global search pets | GET /api/search?q={pet_name} | 200 OK, matching pets with owners returned |
|
||||
| TC-API-10.3 | Search by email | GET /api/search?q={email} | 200 OK, matching client returned |
|
||||
| TC-API-10.4 | Search by phone | GET /api/search?q={phone} | 200 OK, matching client returned |
|
||||
|
||||
### 4.11 Reports
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-11.1 | Revenue summary | GET /api/reports/summary?from={date}&to={date} | 200 OK, revenue KPIs returned |
|
||||
| TC-API-11.2 | Revenue by period | GET /api/reports/revenue?groupBy=day | 200 OK, daily revenue breakdown returned |
|
||||
| TC-API-11.3 | Appointment analytics | GET /api/reports/appointments | 200 OK, appointment stats returned |
|
||||
| TC-API-11.4 | Service popularity | GET /api/reports/services | 200 OK, service usage stats returned |
|
||||
| TC-API-11.5 | Client retention | GET /api/reports/clients | 200 OK, new/returning/churn client data returned |
|
||||
| TC-API-11.6 | Tip splits report | GET /api/reports/tip-splits | 200 OK, tip earnings per staff returned |
|
||||
| TC-API-11.7 | Export revenue CSV | GET /api/reports/export.csv?type=revenue | 200 OK, CSV file downloaded |
|
||||
|
||||
### 4.12 Impersonation
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-12.1 | Start impersonation session | POST /api/impersonation/sessions with clientId | 201 Created, session token returned |
|
||||
| TC-API-12.2 | Get session details | GET /api/impersonation/sessions/{id} | 200 OK, session details returned |
|
||||
| TC-API-12.3 | Extend session | POST /api/impersonation/sessions/{id}/extend | 200 OK, session expiry extended |
|
||||
| TC-API-12.4 | End session | POST /api/impersonation/sessions/{id}/end | 200 OK, session marked as ended |
|
||||
| TC-API-12.5 | Log audit entry | POST /api/impersonation/sessions/{id}/log | 201 Created, audit log entry created |
|
||||
| TC-API-12.6 | View audit log | GET /api/impersonation/sessions/{id}/audit-log | 200 OK, audit trail returned |
|
||||
|
||||
### 4.13 Settings & Setup
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
|
||||
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
|
||||
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
|
||||
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
|
||||
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
|
||||
| TC-API-13.6 | Check setup status | GET /api/setup/status | 200 OK, setup needs returned |
|
||||
| TC-API-13.7 | Complete setup | POST /api/setup with business name | 201 Created, super user created |
|
||||
| TC-API-13.8 | Configure auth provider | POST /api/setup/auth-provider with OIDC config | 201 Created, auth provider configured |
|
||||
| TC-API-13.9 | Test auth provider | POST /api/setup/auth-provider/test with issuer URL | 200 OK, OIDC discovery successful |
|
||||
|
||||
### 4.14 Appointment Groups
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-14.1 | List appointment groups | GET /api/appointment-groups | 200 OK, list of groups returned |
|
||||
| TC-API-14.2 | Get group details | GET /api/appointment-groups/{id} | 200 OK, group with appointments returned |
|
||||
| TC-API-14.3 | Create group booking | POST /api/appointment-groups with client and pets | 201 Created, group and appointments created |
|
||||
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
|
||||
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
|
||||
|
||||
## Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
- All test cases execute without errors
|
||||
- Expected results match actual results
|
||||
- No regressions in previously working features
|
||||
- API responses have correct status codes and data structures
|
||||
- Authentication and authorization enforced correctly
|
||||
- Business rules (conflicts, validations) work as expected
|
||||
|
||||
**Fail:**
|
||||
- Any unexpected result or error
|
||||
- API returns incorrect status codes
|
||||
- Data integrity issues
|
||||
- Authentication/authorization bypass
|
||||
- Business rules not enforced
|
||||
- Severity documented with steps to reproduce and screenshot
|
||||
|
||||
## Update Policy
|
||||
|
||||
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
||||
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
|
||||
@@ -45,40 +45,72 @@ const GROOMER: StaffRow = {
|
||||
|
||||
let staffLookupResult: StaffRow | null = null;
|
||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
||||
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
|
||||
let insertedStaff: StaffRow | null = null;
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
const makeTableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return name;
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: name, column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const staff = makeTableProxy("staff");
|
||||
const user = makeTableProxy("user");
|
||||
|
||||
const buildQuery = (result: unknown, fallback: unknown) => ({
|
||||
limit: () => ({
|
||||
[Symbol.iterator]: function* () {
|
||||
if (result) yield result;
|
||||
},
|
||||
}
|
||||
);
|
||||
0: result,
|
||||
length: result ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
// dev mode fallback to first manager
|
||||
return managerFallbackResult ? [managerFallbackResult] : [];
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
if (staffLookupResult) yield staffLookupResult;
|
||||
},
|
||||
0: staffLookupResult,
|
||||
length: staffLookupResult ? 1 : 0,
|
||||
}),
|
||||
from: (table: unknown) => ({
|
||||
where: () => buildQuery(
|
||||
table === staff ? staffLookupResult : userLookupResult,
|
||||
table === staff ? managerFallbackResult : null
|
||||
),
|
||||
}),
|
||||
}),
|
||||
insert: (table: unknown) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => {
|
||||
const newStaff: StaffRow = {
|
||||
id: "new-staff-id",
|
||||
oidcSub: null,
|
||||
userId: vals.userId as string,
|
||||
role: vals.role as StaffRow["role"],
|
||||
isSuperUser: false,
|
||||
name: vals.name as string,
|
||||
email: vals.email as string,
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
insertedStaff = newStaff;
|
||||
return [newStaff];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff,
|
||||
user,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
sql: vi.fn((..._args: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -87,6 +119,8 @@ vi.mock("../db", () => {
|
||||
function resetMocks() {
|
||||
staffLookupResult = null;
|
||||
managerFallbackResult = MANAGER;
|
||||
userLookupResult = null;
|
||||
insertedStaff = null;
|
||||
}
|
||||
|
||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||
@@ -202,6 +236,50 @@ describe("resolveStaffMiddleware", () => {
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff records found/i);
|
||||
});
|
||||
|
||||
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff).not.toBeNull();
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
expect(capturedStaff!.userId).toBe("ba-user-new");
|
||||
expect(capturedStaff!.name).toBe("New User");
|
||||
expect(capturedStaff!.email).toBe("newuser@example.com");
|
||||
expect(capturedStaff!.isSuperUser).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-provision: falls back to email prefix when user has no name", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.name).toBe("firstlogin");
|
||||
});
|
||||
|
||||
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = null;
|
||||
const app = buildApp(resolveStaffMiddleware);
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff record found for authenticated user/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* readable values (e.g. "staff-1", "client-2") without needing crypto.
|
||||
*
|
||||
* Usage:
|
||||
* import { buildStaff, buildClient, buildPet } from "./db/factories.js";
|
||||
* import { buildStaff, buildClient, buildPet } from "./db/factories";
|
||||
*
|
||||
* const manager = buildStaff({ role: "manager" });
|
||||
* const client = buildClient({ name: "Alice Smith" });
|
||||
|
||||
+26
-1
@@ -94,7 +94,6 @@ function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(rand() * arr.length)]!;
|
||||
}
|
||||
|
||||
/** Return n distinct random elements from an array. */
|
||||
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand() * (max - min + 1)) + min;
|
||||
@@ -455,6 +454,32 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ──
|
||||
const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB;
|
||||
if (uatTesterOidcSub) {
|
||||
const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007";
|
||||
const [existingUatTester] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "uat-tester@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingUatTester) {
|
||||
console.log(`✓ Staff 'UAT Tester' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: UAT_TESTER_STAFF_ID,
|
||||
name: "UAT Tester",
|
||||
email: "uat-tester@groombook.dev",
|
||||
oidcSub: uatTesterOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
|
||||
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff } from "../db/index.js";
|
||||
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -110,6 +110,30 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Auto-provision: no staff record exists for this user at all, but a valid
|
||||
// Better-Auth user session exists (jwt.sub = user.id from user table).
|
||||
// Create a minimal groomer staff record on first login.
|
||||
const [userRow] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, jwt.sub))
|
||||
.limit(1);
|
||||
if (userRow) {
|
||||
const [newStaff] = await db
|
||||
.insert(staff)
|
||||
.values({
|
||||
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
|
||||
email: userRow.email ?? jwt.email ?? "",
|
||||
userId: jwt.sub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.returning();
|
||||
c.set("staff", newStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
|
||||
Generated
+13
@@ -938,66 +938,79 @@ packages:
|
||||
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
|
||||
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.2':
|
||||
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.2':
|
||||
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.2':
|
||||
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.2':
|
||||
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.2':
|
||||
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.2':
|
||||
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.2':
|
||||
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
||||
|
||||
Reference in New Issue
Block a user