uat→main (PROD): GRO-2157 nav export + GRO-2225/2235 (frozen @4868f18) (#192)
feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235)
Squash-merges PR #192: uat→main PROD promotion.
Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS).
Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard).
CTO-reviewed and approved (review #4542).
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
This commit was merged in pull request #192.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildGoogleMapsUrl,
|
||||
buildAppleMapsUrl,
|
||||
buildNavigationUrl,
|
||||
intermediateWaypointCount,
|
||||
GOOGLE_MAPS_MAX_WAYPOINTS,
|
||||
APPLE_MAPS_MAX_WAYPOINTS,
|
||||
type NavigationStop,
|
||||
} from "../services/navigationExport.js";
|
||||
|
||||
function stops(n: number): NavigationStop[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
latitude: 47 + i / 100,
|
||||
longitude: -122 - i / 100,
|
||||
label: `Stop ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("intermediateWaypointCount", () => {
|
||||
it("excludes origin and destination", () => {
|
||||
expect(intermediateWaypointCount(0)).toBe(0);
|
||||
expect(intermediateWaypointCount(1)).toBe(0);
|
||||
expect(intermediateWaypointCount(2)).toBe(0);
|
||||
expect(intermediateWaypointCount(5)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGoogleMapsUrl", () => {
|
||||
it("rejects an empty route", () => {
|
||||
const r = buildGoogleMapsUrl([]);
|
||||
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
|
||||
});
|
||||
|
||||
it("builds a single-stop link (destination only, no waypoints)", () => {
|
||||
const r = buildGoogleMapsUrl(stops(1));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("google-maps");
|
||||
expect(r.stopCount).toBe(1);
|
||||
expect(r.waypointCount).toBe(0);
|
||||
expect(r.url).toContain("https://www.google.com/maps/dir/?");
|
||||
expect(r.url).toContain("api=1");
|
||||
expect(r.url).toContain("travelmode=driving");
|
||||
expect(r.url).toContain("origin=47%2C-122");
|
||||
expect(r.url).toContain("destination=47%2C-122");
|
||||
expect(r.url).not.toContain("waypoints=");
|
||||
});
|
||||
|
||||
it("builds origin/destination only for two stops", () => {
|
||||
const r = buildGoogleMapsUrl(stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(0);
|
||||
expect(r.url).not.toContain("waypoints=");
|
||||
expect(r.url).toContain("origin=47%2C-122");
|
||||
expect(r.url).toContain("destination=47.01%2C-122.01");
|
||||
});
|
||||
|
||||
it("includes intermediate waypoints in order, pipe-separated", () => {
|
||||
const r = buildGoogleMapsUrl(stops(4));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.stopCount).toBe(4);
|
||||
expect(r.waypointCount).toBe(2);
|
||||
// waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C)
|
||||
const url = new URL(r.url);
|
||||
expect(url.searchParams.get("origin")).toBe("47,-122");
|
||||
expect(url.searchParams.get("destination")).toBe("47.03,-122.03");
|
||||
expect(url.searchParams.get("waypoints")).toBe(
|
||||
"47.01,-122.01|47.02,-122.02"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a route at exactly the waypoint limit", () => {
|
||||
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS);
|
||||
});
|
||||
|
||||
it("rejects a route over the waypoint limit", () => {
|
||||
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3));
|
||||
expect("error" in r).toBe(true);
|
||||
if ("error" in r) {
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppleMapsUrl", () => {
|
||||
it("rejects an empty route", () => {
|
||||
const r = buildAppleMapsUrl([]);
|
||||
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
|
||||
});
|
||||
|
||||
it("builds a destination-only link for one stop", () => {
|
||||
const r = buildAppleMapsUrl(stops(1));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("apple-maps");
|
||||
expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d");
|
||||
expect(r.url).not.toContain("saddr=");
|
||||
});
|
||||
|
||||
it("chains destinations with +to: for multiple stops", () => {
|
||||
const r = buildAppleMapsUrl(stops(3));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.stopCount).toBe(3);
|
||||
expect(r.waypointCount).toBe(1);
|
||||
expect(r.url).toBe(
|
||||
"maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a route at exactly the waypoint limit", () => {
|
||||
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS);
|
||||
});
|
||||
|
||||
it("rejects a route over the waypoint limit", () => {
|
||||
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3));
|
||||
expect("error" in r).toBe(true);
|
||||
if ("error" in r) {
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNavigationUrl", () => {
|
||||
it("dispatches to the google-maps builder", () => {
|
||||
const r = buildNavigationUrl("google-maps", stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("google-maps");
|
||||
});
|
||||
|
||||
it("dispatches to the apple-maps builder", () => {
|
||||
const r = buildNavigationUrl("apple-maps", stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("apple-maps");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// GRO-2235: a duplicate active waitlist entry violates the partial unique index
|
||||
// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the
|
||||
// handler must return a friendly 409, not a generic 500. The first insert still
|
||||
// returns 201, and unrelated errors still surface as 500.
|
||||
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
||||
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
|
||||
const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005";
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
reason: "manual",
|
||||
startedAt: new Date(),
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws
|
||||
// a postgres-js-shaped unique-violation, "other" throws an unrelated error.
|
||||
let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok";
|
||||
|
||||
function resetMock() {
|
||||
waitlistInsertMode = "ok";
|
||||
}
|
||||
|
||||
function tableProxy(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const impersonationSessions = tableProxy("impersonationSessions");
|
||||
const waitlistEntries = tableProxy("waitlistEntries");
|
||||
const impersonationAuditLogs = tableProxy("impersonationAuditLogs");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable([ACTIVE_SESSION]);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => {
|
||||
if (table._name === "waitlistEntries") {
|
||||
if (waitlistInsertMode === "duplicate") {
|
||||
throw Object.assign(new Error("duplicate key value"), { code: "23505" });
|
||||
}
|
||||
if (waitlistInsertMode === "other") {
|
||||
throw Object.assign(new Error("not null violation"), { code: "23502" });
|
||||
}
|
||||
return [{ id: "entry-1", ...vals }];
|
||||
}
|
||||
// impersonationAuditLogs and anything else: succeed silently.
|
||||
return [{ id: "audit-1", ...vals }];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({ where: () => Promise.resolve() }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
waitlistEntries,
|
||||
impersonationAuditLogs,
|
||||
appointments: tableProxy("appointments"),
|
||||
clients: tableProxy("clients"),
|
||||
pets: tableProxy("pets"),
|
||||
services: tableProxy("services"),
|
||||
staff: tableProxy("staff"),
|
||||
invoices: tableProxy("invoices"),
|
||||
invoiceLineItems: tableProxy("invoiceLineItems"),
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function postWaitlist(body: unknown) {
|
||||
return app.request("/portal/waitlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Impersonation-Session-Id": SESSION_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
petId: PET_ID,
|
||||
serviceId: SERVICE_ID,
|
||||
preferredDate: "2026-07-01",
|
||||
preferredTime: "09:00",
|
||||
};
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => {
|
||||
it("returns 201 for the first insert", async () => {
|
||||
waitlistInsertMode = "ok";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("returns 409 with a friendly message for a duplicate (23505)", async () => {
|
||||
waitlistInsertMode = "duplicate";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toBe(
|
||||
"You already have a booking for this pet at that date and time."
|
||||
);
|
||||
});
|
||||
|
||||
it("still surfaces unrelated DB errors as 500", async () => {
|
||||
waitlistInsertMode = "other";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user