Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2c70efe1 | |||
| ff024ab375 | |||
| c19e19c709 | |||
| f9a3ebc0f3 | |||
| d3122ad701 | |||
| 9ccbc7a171 | |||
| 9ba5da5e75 | |||
| 575789f7f5 | |||
| a0a75d7e25 | |||
| 22457ac361 | |||
| f12ec4f8d3 | |||
| 566d5f4b55 | |||
| 2c928ca4d7 | |||
| af75fecb66 | |||
| 2d4df6fe1e | |||
| db10320c8f | |||
| 40a4023c65 | |||
| d598511b75 | |||
| 434c7b94e2 | |||
| 70af9da338 |
@@ -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}"
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,12 @@ 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.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 | 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.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
|
||||
|
||||
@@ -177,6 +183,23 @@ 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.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:**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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,6 +204,20 @@
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
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,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── Shared types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: MedicalAlertSeverity;
|
||||
}
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||
@@ -146,6 +156,12 @@ export const pets = pgTable(
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
|
||||
+85
-1
@@ -18,7 +18,7 @@
|
||||
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
@@ -511,6 +511,90 @@ 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 ─────────────────────
|
||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,6 +24,15 @@ const createPetSchema = z.object({
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).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 });
|
||||
|
||||
@@ -42,10 +42,23 @@ export interface Pet {
|
||||
customFields: Record<string, string>;
|
||||
photoKey?: string;
|
||||
photoUploadedAt?: string;
|
||||
coatType?: string | null;
|
||||
temperamentScore?: number | null;
|
||||
temperamentFlags?: string[];
|
||||
medicalAlerts?: MedicalAlert[];
|
||||
preferredCuts?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: MedicalAlertSeverity;
|
||||
}
|
||||
|
||||
export interface GroomingVisitLog {
|
||||
id: string;
|
||||
petId: string;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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