Compare commits

..

2 Commits

Author SHA1 Message Date
Flea Flicker a91361296a fix(GRO-2672): correct reset.ts content (raw TS, not base64)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 1m10s
2026-08-06 09:12:15 +00:00
Flea Flicker 48de6ec7c4 fix(GRO-2672): use drizzle-kit migrate in reset.ts to bypass HWM bug
CI / Lint & Typecheck (pull_request) Failing after 19s
CI / Test (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Has been skipped
drizzle-orm's migrate() has a high-water-mark (HWM) bug: on a fresh DB,
migration 0000 sets the watermark to 2026-03-17. Migrations 0001, 0003,
0010, 0011 have stale 2025-era `when` timestamps and are silently
skipped. Migration 0003 (recurring_series) is critical -- its skip
leaves `recurring_series`, `appointments.series_id`, and
`appointments.series_index` missing. A downstream migration inside
migrate()'s single Postgres transaction then fails, rolling back
everything including 0000's `staff` and `services` tables.

Replace the drizzle-orm migrate() call with `pnpm exec drizzle-kit
migrate` (hash-based). drizzle-kit applies every unhashed migration
regardless of `when` ordering, matching the K8s migrate Job exactly.
2026-08-06 09:06:30 +00:00
4 changed files with 0 additions and 214 deletions
-3
View File
@@ -65,11 +65,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
| TC-API-0.2 | DB-touching readiness check — healthy (GRO-2678) | GET /api/readyz | 200 OK, `{"status":"ready"}` |
| TC-API-0.3 | DB-touching readiness check — response body safe | GET /api/readyz and inspect body | Body contains only `status` and (on error) `check` fields — no raw SQL, driver messages, or stack traces |
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
> **Note (GRO-2678):** `/api/readyz` is a separate DB-touching endpoint for monitoring. It is intentionally NOT used for K8s liveness/readiness probes — those remain DB-less to avoid pod cycling on transient DB blips.
### 4.1 Authentication
-102
View File
@@ -1,102 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock db module ───────────────────────────────────────────────────────────
let selectImpl: () => Promise<unknown>;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(_target, prop) {
if (prop === "_name") return "staff";
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: (_fields: unknown) => ({
from: (_table: unknown) => ({
limit: (_n: number) => selectImpl(),
}),
}),
}),
staff,
};
});
// ─── Build test app ───────────────────────────────────────────────────────────
async function makeApp() {
const { getDb, staff } = await import("@groombook/db");
const app = new Hono();
app.get("/health/ready", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
const pgCode = (err as Record<string, unknown>).code ?? "unknown";
console.error("[health/ready] DB check failed:", pgCode);
return c.json({ status: "degraded" }, 503);
}
});
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /health/ready", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
const app = await makeApp();
const res = await app.request("/health/ready", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(body.status).toBe("ready");
});
it("returns 503 {status:'degraded'} when DB query throws (schema dropped)", async () => {
const schemaErr = Object.assign(new Error("relation \"staff\" does not exist"), { code: "42P01" });
selectImpl = () => Promise.reject(schemaErr);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const app = await makeApp();
const res = await app.request("/health/ready", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(503);
expect(body.status).toBe("degraded");
// Must not leak SQL error details in the response body
expect(JSON.stringify(body)).not.toContain("42P01");
expect(JSON.stringify(body)).not.toContain("relation");
consoleSpy.mockRestore();
});
it("returns 503 {status:'degraded'} on any DB connection error", async () => {
selectImpl = () => Promise.reject(new Error("ECONNREFUSED"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const app = await makeApp();
const res = await app.request("/health/ready", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(503);
expect(body.status).toBe("degraded");
consoleSpy.mockRestore();
});
});
-87
View File
@@ -1,87 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock db module ───────────────────────────────────────────────────────────
let selectImpl: () => Promise<unknown>;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(_target, prop) {
if (prop === "_name") return "staff";
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: (_fields: unknown) => ({
from: (_table: unknown) => ({
limit: (_n: number) => selectImpl(),
}),
}),
}),
staff,
};
});
// ─── Build test app ───────────────────────────────────────────────────────────
async function makeApp() {
// Import after mocks are in place
const { getDb, staff } = await import("@groombook/db");
const app = new Hono();
app.get("/api/readyz", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
console.error("[readyz] DB check failed:", err);
return c.json({ status: "degraded", check: "db" }, 503);
}
});
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /api/readyz", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
const app = await makeApp();
const res = await app.request("/api/readyz", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(body.status).toBe("ready");
});
it("returns 503 {status:'degraded',check:'db'} when DB query throws", async () => {
selectImpl = () => Promise.reject(new Error("42P01: relation staff does not exist"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const app = await makeApp();
const res = await app.request("/api/readyz", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(503);
expect(body.status).toBe("degraded");
expect(body.check).toBe("db");
// Raw SQL / driver error must NOT appear in the response body
expect(JSON.stringify(body)).not.toContain("42P01");
expect(JSON.stringify(body)).not.toContain("relation");
consoleSpy.mockRestore();
});
});
-22
View File
@@ -65,28 +65,6 @@ app.use(
app.get("/health", (c) => c.json({ status: "ok" }));
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
app.get("/api/health", (c) => c.json({ status: "ok" }));
// /health/ready: DB-touching readiness probe — K8s removes pod from endpoints when schema is dropped (GRO-2689)
app.get("/health/ready", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
const pgCode = (err as Record<string, unknown>).code ?? "unknown";
console.error("[health/ready] DB check failed:", pgCode);
return c.json({ status: "degraded" }, 503);
}
});
// /api/readyz: DB-touching deep health check consumed by monitoring (not K8s probes)
// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678)
app.get("/api/readyz", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
console.error("[readyz] DB check failed:", err);
return c.json({ status: "degraded", check: "db" }, 503);
}
});
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);