Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1674a7df4a | |||
| 09187ca277 |
+1
-23
@@ -28,12 +28,7 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| 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.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.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.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
||||||
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
|
| 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 |
|
||||||
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
|
|
||||||
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
|
|
||||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -183,23 +178,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
|
| 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 |
|
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
|
||||||
|
|
||||||
### 4.15 Public Booking Flow (Scheduling Engine Buffer Integration)
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| TC-API-15.1 | List active services | GET /api/book/services | 200 OK, list of active services with name, price, duration |
|
|
||||||
| TC-API-15.2 | Get availability — missing params | GET /api/book/availability | 400 Bad Request, error indicating required params |
|
|
||||||
| TC-API-15.3 | Get availability — invalid date | GET /api/book/availability?serviceId=uuid&date=invalid | 400 Bad Request, date must be YYYY-MM-DD |
|
|
||||||
| TC-API-15.4 | Get availability — service not found | GET /api/book/availability?serviceId=nonexistent&date=2026-06-01 | 404 Not Found |
|
|
||||||
| TC-API-15.5 | Get availability — valid date/service | GET /api/book/availability?serviceId={serviceId}&date=2026-06-01 | 200 OK, array of ISO startTime strings for available slots |
|
|
||||||
| TC-API-15.6 | Availability excludes booked slots | GET /api/book/availability for date with existing appointments | 200 OK, only slots not overlapping booked appointments |
|
|
||||||
| TC-API-15.7 | Availability respects groomer availability | GET /api/book/availability for date with no groomers | 200 OK, empty array |
|
|
||||||
| TC-API-15.8 | Create booking — missing required fields | POST /api/book/appointments with partial data | 400 Bad Request, validation errors |
|
|
||||||
| TC-API-15.9 | Create booking — invalid pet/client/service | POST /api/book/appointments with nonexistent IDs | 400/404 Bad Request |
|
|
||||||
| TC-API-15.10 | Create booking — valid | POST /api/book/appointments with all required fields | 201 Created, appointment object returned |
|
|
||||||
| TC-API-15.11 | Create booking — saves petSizeCategory | POST /api/book/appointments with petSizeCategory | 201 Created, pet's petSizeCategory updated |
|
|
||||||
| TC-API-15.12 | Create booking — saves petCoatType | POST /api/book/appointments with petCoatType | 201 Created, pet's coatType updated |
|
|
||||||
|
|
||||||
## Pass/Fail Criteria
|
## Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/schema.ts",
|
||||||
out: "./migrations",
|
out: "./migrations",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
-- Migration: 0030_extended_pet_profile
|
|
||||||
-- Adds extended profile fields to the pets table
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE pets ADD COLUMN coat_type text;
|
|
||||||
ALTER TABLE pets ADD COLUMN temperament_score integer;
|
|
||||||
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
|
|
||||||
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
|
|
||||||
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "0030_extended_pet_profile",
|
|
||||||
"prevId": "0028_sms_reminders",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"public.pets": {
|
|
||||||
"name": "pets",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
|
|
||||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
|
||||||
"species": { "name": "species", "type": "text", "isNullable": false },
|
|
||||||
"breed": { "name": "breed", "type": "text", "isNullable": true },
|
|
||||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
|
|
||||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
|
|
||||||
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
|
|
||||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
|
|
||||||
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
|
|
||||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
|
|
||||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
|
|
||||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
|
|
||||||
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
|
|
||||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
|
|
||||||
"image": { "name": "image", "type": "text", "isNullable": true },
|
|
||||||
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
|
|
||||||
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
|
|
||||||
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
|
||||||
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
|
||||||
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
|
||||||
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
|
|
||||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } },
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
|
||||||
}
|
|
||||||
@@ -204,20 +204,6 @@
|
|||||||
"when": 1775741667192,
|
"when": 1775741667192,
|
||||||
"tag": "0028_sms_reminders",
|
"tag": "0028_sms_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 29,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775828067192,
|
|
||||||
"tag": "0029_db_indexes_constraints",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 30,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775914467192,
|
|
||||||
"tag": "0030_extended_pet_profile",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|
||||||
import { petsRouter } from "../routes/pets.js";
|
|
||||||
import { and, eq, exists, or } from "../db/index.js";
|
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MANAGER: StaffRow = {
|
|
||||||
id: "staff-manager-id",
|
|
||||||
oidcSub: "oidc-manager-sub",
|
|
||||||
userId: null,
|
|
||||||
role: "manager",
|
|
||||||
isSuperUser: true,
|
|
||||||
name: "Manager McManager",
|
|
||||||
email: "manager@example.com",
|
|
||||||
active: true,
|
|
||||||
icalToken: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CLIENT_ID = "11111111-1111-1111-1111-111111111111";
|
|
||||||
const PET_ID = "22222222-2222-2222-2222-222222222222";
|
|
||||||
|
|
||||||
let petRows: Record<string, unknown>[] = [];
|
|
||||||
let appointmentRows: Record<string, unknown>[] = [];
|
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
|
||||||
let deletedId: string | null = null;
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
petRows = [{
|
|
||||||
id: PET_ID,
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
name: "Biscuit",
|
|
||||||
species: "dog",
|
|
||||||
breed: "Golden Retriever",
|
|
||||||
weightKg: "30.00",
|
|
||||||
dateOfBirth: null,
|
|
||||||
healthAlerts: null,
|
|
||||||
groomingNotes: null,
|
|
||||||
cutStyle: null,
|
|
||||||
shampooPreference: null,
|
|
||||||
specialCareNotes: null,
|
|
||||||
customFields: {},
|
|
||||||
photoKey: null,
|
|
||||||
photoUploadedAt: null,
|
|
||||||
image: null,
|
|
||||||
coatType: null,
|
|
||||||
temperamentScore: null,
|
|
||||||
temperamentFlags: [],
|
|
||||||
medicalAlerts: [],
|
|
||||||
preferredCuts: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}];
|
|
||||||
appointmentRows = [];
|
|
||||||
insertedValues = [];
|
|
||||||
updatedValues = [];
|
|
||||||
deletedId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSelectChainable(rows: unknown[]): unknown {
|
|
||||||
const chain = new Proxy([...rows], {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
|
||||||
return () => chain;
|
|
||||||
}
|
|
||||||
// @ts-expect-error proxy
|
|
||||||
return target[prop];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeInsertChainable(): unknown {
|
|
||||||
let vals: Record<string, unknown> = {};
|
|
||||||
const chain = new Proxy({}, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "values") {
|
|
||||||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
|
||||||
}
|
|
||||||
if (prop === "returning") {
|
|
||||||
return () => {
|
|
||||||
insertedValues.push(vals);
|
|
||||||
return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return chain;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeUpdateChainable(): unknown {
|
|
||||||
let vals: Record<string, unknown> = {};
|
|
||||||
let whereId: string | null = null;
|
|
||||||
const chain = new Proxy({}, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "set") {
|
|
||||||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
|
||||||
}
|
|
||||||
if (prop === "where") {
|
|
||||||
return (cond: unknown) => {
|
|
||||||
// Extract id from condition if it's an eq call
|
|
||||||
if (whereId) vals = { ...vals };
|
|
||||||
return chain;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (prop === "returning") {
|
|
||||||
return () => {
|
|
||||||
const merged = { ...petRows[0], ...vals };
|
|
||||||
updatedValues.push(vals);
|
|
||||||
return [merged];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return chain;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDeleteChainable(): unknown {
|
|
||||||
let whereId: string | null = null;
|
|
||||||
const chain = new Proxy({}, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where") {
|
|
||||||
return (cond: unknown) => {
|
|
||||||
whereId = PET_ID;
|
|
||||||
return chain;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (prop === "returning") {
|
|
||||||
return () => {
|
|
||||||
const row = petRows[0]!;
|
|
||||||
deletedId = row.id as string;
|
|
||||||
return [row];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return chain;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("../db", async (importOriginal) => {
|
|
||||||
const db = await importOriginal<typeof import("../db/index.js")>();
|
|
||||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
|
||||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: () => ({
|
|
||||||
from: (table: unknown) => {
|
|
||||||
const name = (table as { _name?: string })._name;
|
|
||||||
if (name === "appointments") return makeSelectChainable(appointmentRows);
|
|
||||||
return makeSelectChainable(petRows);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
insert: () => makeInsertChainable(),
|
|
||||||
update: () => makeUpdateChainable(),
|
|
||||||
delete: () => makeDeleteChainable(),
|
|
||||||
}),
|
|
||||||
pets,
|
|
||||||
appointments,
|
|
||||||
and: db.and,
|
|
||||||
eq: db.eq,
|
|
||||||
exists: db.exists,
|
|
||||||
or: db.or,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeApp(staff: StaffRow = MANAGER) {
|
|
||||||
const app = new Hono<AppEnv>();
|
|
||||||
app.use("*", async (c, next) => {
|
|
||||||
c.set("staff", staff);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
return app.route("/pets", petsRouter);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp() {
|
|
||||||
const app = makeApp(MANAGER);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("Extended pet profile fields — validation", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("rejects temperamentScore of 0 (below min)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects temperamentScore of 6 (above max)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-integer temperamentScore", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid medicalAlert severity", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
name: "Test",
|
|
||||||
species: "dog",
|
|
||||||
medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid temperamentScore 1–5", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
for (const score of [1, 2, 3, 4, 5]) {
|
|
||||||
resetMock();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts all valid medicalAlert severity values", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
for (const severity of ["low", "medium", "high"] as const) {
|
|
||||||
resetMock();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
name: "Test",
|
|
||||||
species: "dog",
|
|
||||||
medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Extended pet profile fields — create", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("accepts all extended fields on create", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
name: "Biscuit",
|
|
||||||
species: "dog",
|
|
||||||
breed: "Golden Retriever",
|
|
||||||
coatType: "double",
|
|
||||||
temperamentScore: 4,
|
|
||||||
temperamentFlags: ["anxious_with_dryers", "gentle"],
|
|
||||||
medicalAlerts: [
|
|
||||||
{ type: "seizure", description: "Occasional episodes", severity: "medium" },
|
|
||||||
],
|
|
||||||
preferredCuts: ["puppy cut", "teddy bear"],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.coatType).toBe("double");
|
|
||||||
expect(body.temperamentScore).toBe(4);
|
|
||||||
expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]);
|
|
||||||
expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]);
|
|
||||||
expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("create without extended fields works (all optional)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Extended pet profile fields — update", () => {
|
|
||||||
beforeEach(resetMock);
|
|
||||||
|
|
||||||
it("updates coatType", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request(`/pets/${PET_ID}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ coatType: "smooth" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.coatType).toBe("smooth");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates temperamentScore", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request(`/pets/${PET_ID}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ temperamentScore: 2 }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.temperamentScore).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects temperamentScore 0 on update", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request(`/pets/${PET_ID}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ temperamentScore: 0 }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid severity on update", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request(`/pets/${PET_ID}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects too many temperamentFlags (>20)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`);
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects too many preferredCuts (>20)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`);
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects too many medicalAlerts (>50)", async () => {
|
|
||||||
const app = createApp();
|
|
||||||
const alerts = Array.from({ length: 51 }, (_, i) => ({
|
|
||||||
type: `type_${i}`,
|
|
||||||
description: `desc_${i}`,
|
|
||||||
severity: "low" as const,
|
|
||||||
}));
|
|
||||||
const res = await app.request("/pets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns extended fields in GET response", async () => {
|
|
||||||
petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }];
|
|
||||||
const app = createApp();
|
|
||||||
const res = await app.request(`/pets/${PET_ID}`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.coatType).toBe("wire");
|
|
||||||
expect(body.temperamentScore).toBe(3);
|
|
||||||
expect(body.temperamentFlags).toEqual(["gentle"]);
|
|
||||||
expect(body.preferredCuts).toEqual(["scissor cut"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -45,40 +45,72 @@ const GROOMER: StaffRow = {
|
|||||||
|
|
||||||
let staffLookupResult: StaffRow | null = null;
|
let staffLookupResult: StaffRow | null = null;
|
||||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
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", () => {
|
vi.mock("../db", () => {
|
||||||
const staff = new Proxy(
|
const makeTableProxy = (name: string) =>
|
||||||
{ _name: "staff" },
|
new Proxy(
|
||||||
{
|
{ _name: name },
|
||||||
get(target, prop) {
|
{
|
||||||
if (prop === "_name") return "staff";
|
get(target, prop) {
|
||||||
if (prop === "$inferSelect") return {};
|
if (prop === "_name") return name;
|
||||||
return { table: "staff", column: prop };
|
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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
from: () => ({
|
from: (table: unknown) => ({
|
||||||
where: () => ({
|
where: () => buildQuery(
|
||||||
limit: () => {
|
table === staff ? staffLookupResult : userLookupResult,
|
||||||
// dev mode fallback to first manager
|
table === staff ? managerFallbackResult : null
|
||||||
return managerFallbackResult ? [managerFallbackResult] : [];
|
),
|
||||||
},
|
}),
|
||||||
[Symbol.iterator]: function* () {
|
}),
|
||||||
if (staffLookupResult) yield staffLookupResult;
|
insert: (table: unknown) => ({
|
||||||
},
|
values: (vals: Record<string, unknown>) => ({
|
||||||
0: staffLookupResult,
|
returning: () => {
|
||||||
length: staffLookupResult ? 1 : 0,
|
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,
|
staff,
|
||||||
|
user,
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||||
|
sql: vi.fn((..._args: unknown[]) => ({})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +119,8 @@ vi.mock("../db", () => {
|
|||||||
function resetMocks() {
|
function resetMocks() {
|
||||||
staffLookupResult = null;
|
staffLookupResult = null;
|
||||||
managerFallbackResult = MANAGER;
|
managerFallbackResult = MANAGER;
|
||||||
|
userLookupResult = null;
|
||||||
|
insertedStaff = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||||
@@ -202,6 +236,50 @@ describe("resolveStaffMiddleware", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/no staff records found/i);
|
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 ────────────────────────────────────────────────────────
|
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,431 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
|
|
||||||
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
|
|
||||||
|
|
||||||
const UAT_ACCOUNTS = [
|
|
||||||
{
|
|
||||||
email: "uat-super@groombook.dev",
|
|
||||||
name: "UAT Super User",
|
|
||||||
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
|
|
||||||
staffEmail: "uat-super@groombook.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-groomer@groombook.dev",
|
|
||||||
name: "UAT Staff Groomer",
|
|
||||||
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
|
|
||||||
staffEmail: "uat-groomer@groombook.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-customer@groombook.dev",
|
|
||||||
name: "UAT Customer",
|
|
||||||
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
|
|
||||||
staffEmail: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: "uat-tester@groombook.dev",
|
|
||||||
name: "UAT Tester",
|
|
||||||
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
|
|
||||||
staffEmail: "uat-tester@groombook.dev",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const TEST_PASSWORD = "test-password-123";
|
|
||||||
|
|
||||||
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
|
|
||||||
|
|
||||||
async function hashPassword(password: string): Promise<string> {
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
return hashPassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock DB state ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserRow {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
emailVerified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountRow {
|
|
||||||
id: string;
|
|
||||||
accountId: string;
|
|
||||||
providerId: string;
|
|
||||||
userId: string;
|
|
||||||
password: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaffRow {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
userId: string | null;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dbUsers: UserRow[] = [];
|
|
||||||
let dbAccounts: AccountRow[] = [];
|
|
||||||
let dbStaff: StaffRow[] = [];
|
|
||||||
let insertedUsers: UserRow[] = [];
|
|
||||||
let insertedAccounts: AccountRow[] = [];
|
|
||||||
let updatedStaff: Array<{ id: string; userId: string }> = [];
|
|
||||||
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
dbUsers = [];
|
|
||||||
dbAccounts = [];
|
|
||||||
dbStaff = [];
|
|
||||||
insertedUsers = [];
|
|
||||||
insertedAccounts = [];
|
|
||||||
updatedStaff = [];
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock schema ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeSchemaMock() {
|
|
||||||
const user = new Proxy({ _name: "user" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "user";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "user", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const account = new Proxy({ _name: "account" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "account";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "account", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const staff = new Proxy({ _name: "staff" }, {
|
|
||||||
get(_t, p) {
|
|
||||||
if (p === "_name") return "staff";
|
|
||||||
if (p === "$inferSelect") return {};
|
|
||||||
return { table: "staff", column: p };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user, account, staff };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
|
|
||||||
|
|
||||||
function eq(col: unknown, val: unknown) {
|
|
||||||
return { __type: "eq" as const, col, val };
|
|
||||||
}
|
|
||||||
|
|
||||||
function and(...conds: unknown[]) {
|
|
||||||
return { __type: "and" as const, conds };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Seed logic helper ─────────────────────────────────────────────────────────
|
|
||||||
// Inline the credential provisioning logic under test so we can call it directly.
|
|
||||||
// This is the same logic as seed.ts lines 514-598.
|
|
||||||
|
|
||||||
interface SeedAccount {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
passwordEnv: string;
|
|
||||||
staffEmail: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let uuidCounter = 0;
|
|
||||||
function mockUuid(): string {
|
|
||||||
return `mock-uuid-${++uuidCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedUatCredentials(
|
|
||||||
accounts: SeedAccount[],
|
|
||||||
opts: {
|
|
||||||
users?: UserRow[];
|
|
||||||
accounts?: AccountRow[];
|
|
||||||
staff?: StaffRow[];
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
|
|
||||||
|
|
||||||
for (const acct of accounts) {
|
|
||||||
const password = process.env[acct.passwordEnv];
|
|
||||||
if (!password) {
|
|
||||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Find or create the Better-Auth user
|
|
||||||
const existingUser = users.find((u) => u.email === acct.email);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (existingUser) {
|
|
||||||
userId = existingUser.id;
|
|
||||||
} else {
|
|
||||||
userId = mockUuid();
|
|
||||||
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
|
|
||||||
insertedUsers.push(newUser);
|
|
||||||
dbUsers.push(newUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if credential account already exists
|
|
||||||
const existingAccount = accts.find(
|
|
||||||
(a) => a.userId === userId && a.providerId === "credential"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingAccount) {
|
|
||||||
// skip — already has credential account
|
|
||||||
} else {
|
|
||||||
// Use Better-Auth's hashPassword so test helper matches production seed.ts
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
const newAccount: AccountRow = {
|
|
||||||
id: mockUuid(),
|
|
||||||
accountId: userId,
|
|
||||||
providerId: "credential",
|
|
||||||
userId,
|
|
||||||
password: passwordHash,
|
|
||||||
};
|
|
||||||
insertedAccounts.push(newAccount);
|
|
||||||
dbAccounts.push(newAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link staff record to Better-Auth user
|
|
||||||
if (acct.staffEmail) {
|
|
||||||
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
|
|
||||||
if (existingStaff && !existingStaff.userId) {
|
|
||||||
existingStaff.userId = userId;
|
|
||||||
updatedStaff.push({ id: existingStaff.id, userId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("seedUatCredentials — credential provisioning logic", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetMock();
|
|
||||||
uuidCounter = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-1: creates user + account when neither exists ──────────────────────
|
|
||||||
|
|
||||||
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
// 4 users created (customer + tester have no staff, super + groomer do)
|
|
||||||
expect(insertedUsers).toHaveLength(4);
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
|
|
||||||
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
|
|
||||||
|
|
||||||
// 4 accounts created
|
|
||||||
expect(insertedAccounts).toHaveLength(4);
|
|
||||||
for (const acct of insertedAccounts) {
|
|
||||||
expect(acct.providerId).toBe("credential");
|
|
||||||
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
|
|
||||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
|
|
||||||
const parts = acct.password!.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
const salt = Buffer.from(saltHex, "hex");
|
|
||||||
const storedHash = Buffer.from(keyHex, "hex");
|
|
||||||
expect(salt).toHaveLength(16);
|
|
||||||
expect(storedHash).toHaveLength(64);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-2: created users have emailVerified = true", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(
|
|
||||||
[UAT_ACCOUNTS[2]!], // customer only
|
|
||||||
{ users: [], accounts: [], staff: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(insertedUsers[0]!.emailVerified).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-3: providerId = credential, password is hashed ──────────────────────
|
|
||||||
|
|
||||||
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
await seedUatCredentials(
|
|
||||||
[UAT_ACCOUNTS[2]!],
|
|
||||||
{ users: [], accounts: [], staff: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const acct = insertedAccounts[0]!;
|
|
||||||
expect(acct.providerId).toBe("credential");
|
|
||||||
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
|
|
||||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
const parts = acct.password!.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
|
|
||||||
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
|
|
||||||
const salt = Buffer.from(saltHex, "hex");
|
|
||||||
const storedHash = Buffer.from(keyHex, "hex");
|
|
||||||
expect(salt).toHaveLength(16);
|
|
||||||
expect(storedHash).toHaveLength(64);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
const staffRows: StaffRow[] = [
|
|
||||||
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
|
|
||||||
];
|
|
||||||
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
|
|
||||||
|
|
||||||
expect(updatedStaff).toHaveLength(1);
|
|
||||||
expect(updatedStaff[0]!.id).toBe("staff-super-1");
|
|
||||||
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
|
|
||||||
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AC-4b: does not update staff.userId if already set", async () => {
|
|
||||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
const staffRows: StaffRow[] = [
|
|
||||||
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
|
|
||||||
|
|
||||||
expect(updatedStaff).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-5: idempotent — skips when user already exists ───────────────────────
|
|
||||||
|
|
||||||
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
|
|
||||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
|
||||||
|
|
||||||
const preExistingUsers: UserRow[] = [
|
|
||||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
|
||||||
];
|
|
||||||
const preExistingAccounts: AccountRow[] = [
|
|
||||||
{
|
|
||||||
id: "pre-existing-acct",
|
|
||||||
accountId: "pre-existing-user",
|
|
||||||
providerId: "credential",
|
|
||||||
userId: "pre-existing-user",
|
|
||||||
password: await hashPassword(TEST_PASSWORD),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// First call — nothing inserted (user + account pre-exist)
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
|
||||||
users: preExistingUsers,
|
|
||||||
accounts: preExistingAccounts,
|
|
||||||
staff: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
|
|
||||||
// Second call — still nothing inserted
|
|
||||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
|
||||||
users: preExistingUsers,
|
|
||||||
accounts: preExistingAccounts,
|
|
||||||
staff: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-6: missing env var skips with warning ────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
|
|
||||||
// No env vars set at all
|
|
||||||
delete process.env.SEED_UAT_SUPER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_GROOMER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
|
|
||||||
delete process.env.SEED_UAT_TESTER_PASSWORD;
|
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
// Nothing created
|
|
||||||
expect(insertedUsers).toHaveLength(0);
|
|
||||||
expect(insertedAccounts).toHaveLength(0);
|
|
||||||
// Warning logged for each of the 4 accounts
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(4);
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
|
|
||||||
);
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AC-7: partial env var coverage ─────────────────────────────────────────
|
|
||||||
|
|
||||||
it("AC-7: only accounts with password env var set are provisioned", async () => {
|
|
||||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
|
||||||
// Only super has password set
|
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
|
||||||
|
|
||||||
expect(insertedUsers).toHaveLength(1);
|
|
||||||
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
|
|
||||||
expect(insertedAccounts).toHaveLength(1);
|
|
||||||
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
|
|
||||||
|
|
||||||
// 3 warnings for missing accounts
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Password hash format verification ───────────────────────────────────────
|
|
||||||
|
|
||||||
describe("password hash format — scrypt parameters", () => {
|
|
||||||
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
|
|
||||||
const hash = await hashPassword("test-password");
|
|
||||||
const parts = hash.split(":");
|
|
||||||
const saltHex = parts[0]!;
|
|
||||||
const keyHex = parts[1]!;
|
|
||||||
|
|
||||||
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
|
|
||||||
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("same password produces different hashes (due to random salt)", async () => {
|
|
||||||
const hash1 = await hashPassword("same-password");
|
|
||||||
const hash2 = await hashPassword("same-password");
|
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
// Both are valid Better-Auth hex format
|
|
||||||
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("different passwords produce different hashes", async () => {
|
|
||||||
const hash1 = await hashPassword("password1");
|
|
||||||
const hash2 = await hashPassword("password2");
|
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -103,11 +103,6 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
|||||||
photoKey: null,
|
photoKey: null,
|
||||||
photoUploadedAt: null,
|
photoUploadedAt: null,
|
||||||
image: null,
|
image: null,
|
||||||
coatType: null,
|
|
||||||
temperamentScore: null,
|
|
||||||
temperamentFlags: [],
|
|
||||||
medicalAlerts: [],
|
|
||||||
preferredCuts: [],
|
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,16 +12,6 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// ─── Shared types ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
|
||||||
|
|
||||||
export interface MedicalAlert {
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
severity: MedicalAlertSeverity;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||||
@@ -156,12 +146,6 @@ export const pets = pgTable(
|
|||||||
photoKey: text("photo_key"),
|
photoKey: text("photo_key"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
// Extended profile fields
|
|
||||||
coatType: text("coat_type"),
|
|
||||||
temperamentScore: integer("temperament_score"),
|
|
||||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
|
||||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
|
||||||
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-85
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
@@ -511,90 +511,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
|
|
||||||
// Provisions Better-Auth user + account records so UAT testers can log in
|
|
||||||
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
|
|
||||||
const uatPasswordAccounts = [
|
|
||||||
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
|
|
||||||
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
|
|
||||||
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
|
|
||||||
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const acct of uatPasswordAccounts) {
|
|
||||||
const password = process.env[acct.passwordEnv];
|
|
||||||
if (!password) {
|
|
||||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Find or create the Better-Auth user
|
|
||||||
const [existingUser] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.user)
|
|
||||||
.where(eq(schema.user.email, acct.email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (existingUser) {
|
|
||||||
userId = existingUser.id;
|
|
||||||
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
|
|
||||||
} else {
|
|
||||||
userId = uuid();
|
|
||||||
await db.insert(schema.user).values({
|
|
||||||
id: userId,
|
|
||||||
name: acct.name,
|
|
||||||
email: acct.email,
|
|
||||||
emailVerified: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if credential account already exists
|
|
||||||
const [existingAccount] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.account)
|
|
||||||
.where(and(
|
|
||||||
eq(schema.account.userId, userId),
|
|
||||||
eq(schema.account.providerId, "credential")
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingAccount) {
|
|
||||||
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
|
|
||||||
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
|
|
||||||
// hex string, key hex-encoded, format saltHex:keyHex.
|
|
||||||
const { hashPassword } = await import("better-auth/crypto");
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await db.insert(schema.account).values({
|
|
||||||
id: uuid(),
|
|
||||||
accountId: userId,
|
|
||||||
providerId: "credential",
|
|
||||||
userId,
|
|
||||||
password: passwordHash,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created credential account for '${acct.email}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
|
|
||||||
if (acct.staffEmail) {
|
|
||||||
const [existingStaff] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, acct.staffEmail))
|
|
||||||
.limit(1);
|
|
||||||
if (existingStaff && !existingStaff.userId) {
|
|
||||||
await db.update(schema.staff)
|
|
||||||
.set({ userId })
|
|
||||||
.where(eq(schema.staff.id, existingStaff.id));
|
|
||||||
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
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 StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -110,6 +110,30 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
return;
|
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(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -24,15 +24,6 @@ const createPetSchema = z.object({
|
|||||||
shampooPreference: z.string().max(500).optional(),
|
shampooPreference: z.string().max(500).optional(),
|
||||||
specialCareNotes: z.string().max(2000).optional(),
|
specialCareNotes: z.string().max(2000).optional(),
|
||||||
customFields: z.record(z.string(), z.string()).optional(),
|
customFields: z.record(z.string(), z.string()).optional(),
|
||||||
coatType: z.string().max(100).optional(),
|
|
||||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
|
||||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
|
||||||
medicalAlerts: z.array(z.object({
|
|
||||||
type: z.string().max(100),
|
|
||||||
description: z.string().max(1000),
|
|
||||||
severity: z.enum(["low", "medium", "high"]),
|
|
||||||
})).max(50).optional(),
|
|
||||||
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
|
|||||||
@@ -42,23 +42,10 @@ export interface Pet {
|
|||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
photoKey?: string;
|
photoKey?: string;
|
||||||
photoUploadedAt?: string;
|
photoUploadedAt?: string;
|
||||||
coatType?: string | null;
|
|
||||||
temperamentScore?: number | null;
|
|
||||||
temperamentFlags?: string[];
|
|
||||||
medicalAlerts?: MedicalAlert[];
|
|
||||||
preferredCuts?: string[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
|
||||||
|
|
||||||
export interface MedicalAlert {
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
severity: MedicalAlertSeverity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroomingVisitLog {
|
export interface GroomingVisitLog {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
|
|
||||||
"labels": ["dependencies"],
|
|
||||||
"prConcurrentLimit": 5,
|
|
||||||
"packageRules": [
|
|
||||||
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
|
|
||||||
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user