diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa9034e..326b3e0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -231,6 +231,7 @@ jobs:
run: pnpm test:e2e
env:
BROUTER_URL: http://localhost:17777
+ E2E: "true"
- name: Playwright job summary
if: ${{ !cancelled() }}
diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts
index 4c51727..ea456b1 100644
--- a/apps/journal/app/routes.ts
+++ b/apps/journal/app/routes.ts
@@ -16,6 +16,8 @@ export default [
route("routes/new", "routes/routes.new.tsx"),
route("routes/:id", "routes/routes.$id.tsx"),
route("routes/:id/edit", "routes/routes.$id.edit.tsx"),
+ route("api/e2e/seed", "routes/api.e2e.seed.ts"),
+ route("api/e2e/route/:id", "routes/api.e2e.route.$id.ts"),
route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"),
route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"),
route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"),
diff --git a/apps/journal/app/routes/api.e2e.route.$id.ts b/apps/journal/app/routes/api.e2e.route.$id.ts
new file mode 100644
index 0000000..4a2e862
--- /dev/null
+++ b/apps/journal/app/routes/api.e2e.route.$id.ts
@@ -0,0 +1,27 @@
+import { data } from "react-router";
+import { sql } from "drizzle-orm";
+import { getDb } from "~/lib/db";
+
+// Only available when the server is started with E2E=true.
+function assertE2EEnabled() {
+ if (process.env.E2E !== "true") {
+ throw new Response("Not found", { status: 404 });
+ }
+}
+
+/**
+ * GET /api/e2e/route/:id
+ *
+ * Returns minimal route metadata for e2e assertions — specifically whether
+ * the PostGIS geom column is populated. Gated behind E2E=true.
+ */
+export async function loader({ params }: { params: { id: string } }) {
+ assertE2EEnabled();
+ const db = getDb();
+ const result = await db.execute(
+ sql`SELECT id, geom IS NOT NULL AS has_geom FROM journal.routes WHERE id = ${params.id}`,
+ );
+ const row = (result as unknown as Array<{ id: string; has_geom: boolean }>)[0];
+ if (!row) return data({ error: "Not found" }, { status: 404 });
+ return data({ id: row.id, hasGeom: row.has_geom });
+}
diff --git a/apps/journal/app/routes/api.e2e.seed.ts b/apps/journal/app/routes/api.e2e.seed.ts
new file mode 100644
index 0000000..6d0b67b
--- /dev/null
+++ b/apps/journal/app/routes/api.e2e.seed.ts
@@ -0,0 +1,75 @@
+import { randomUUID } from "node:crypto";
+import { data } from "react-router";
+import { eq } from "drizzle-orm";
+import { getDb } from "~/lib/db";
+import { users, routes } from "@trails-cool/db/schema/journal";
+import { createRouteToken } from "~/lib/jwt.server";
+import { TERMS_VERSION } from "~/lib/legal";
+
+// Only available when the server is started with E2E=true.
+// Never enabled in production.
+function assertE2EEnabled() {
+ if (process.env.E2E !== "true") {
+ throw new Response("Not found", { status: 404 });
+ }
+}
+
+const E2E_USER_USERNAME = "e2e-test-user";
+const E2E_USER_EMAIL = "e2e@localhost";
+
+/**
+ * POST /api/e2e/seed
+ *
+ * Idempotently creates the e2e test user and a bare route (no GPX, no geom),
+ * then returns a callback-scoped JWT for that route. Used by e2e tests that
+ * need to hit the Planner callback endpoint without going through the browser
+ * auth flow.
+ *
+ * Body: none required. Optionally pass { routeName } to label the route.
+ */
+export async function action({ request }: { request: Request }) {
+ assertE2EEnabled();
+ if (request.method !== "POST") {
+ return data({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ const db = getDb();
+
+ // Upsert the e2e test user
+ await db
+ .insert(users)
+ .values({
+ id: randomUUID(),
+ username: E2E_USER_USERNAME,
+ email: E2E_USER_EMAIL,
+ displayName: "E2E Test User",
+ domain: "localhost",
+ termsAcceptedAt: new Date(),
+ termsVersion: TERMS_VERSION,
+ })
+ .onConflictDoNothing({ target: users.username });
+
+ const [user] = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.username, E2E_USER_USERNAME));
+
+ if (!user) throw new Error("e2e seed: failed to upsert test user");
+
+ const body = request.headers.get("content-type")?.includes("application/json")
+ ? await request.json().catch(() => ({}))
+ : {};
+ const routeName = (body as { routeName?: string }).routeName ?? "E2E callback test route";
+
+ const routeId = randomUUID();
+ await db.insert(routes).values({
+ id: routeId,
+ ownerId: user.id,
+ name: routeName,
+ description: "",
+ });
+
+ const token = await createRouteToken(routeId);
+
+ return data({ routeId, token, ownerId: user.id });
+}
diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts
index 4834e9d..c4b5c3e 100644
--- a/e2e/integration.test.ts
+++ b/e2e/integration.test.ts
@@ -6,11 +6,73 @@ import { test, expect } from "./fixtures/test";
* - BRouter (for route computation)
*
* In CI, these services are started by the workflow.
- * Locally, run `pnpm dev:full` first.
+ * Locally, run `pnpm dev:full` first (with E2E=true for the callback tests).
*/
+const JOURNAL = "http://localhost:3000";
const PLANNER = "http://localhost:3001";
+const VALID_GPX = `
+
+
+ 34
+ 40
+ 35
+
+`;
+
+const ONE_POINT_GPX = `
+
+
+ 34
+
+`;
+
+test.describe("Integration: Planner callback → geometry stored", () => {
+ test("valid GPX stores geometry atomically", async ({ request }) => {
+ const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`);
+ expect(seedResp.ok()).toBeTruthy();
+ const { routeId, token } = await seedResp.json() as { routeId: string; token: string };
+
+ const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { gpx: VALID_GPX },
+ });
+ expect(callbackResp.status()).toBe(200);
+
+ const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`);
+ const { hasGeom } = await geomResp.json() as { hasGeom: boolean };
+ expect(hasGeom).toBe(true);
+ });
+
+ test("invalid GPX returns 400 and does not store geometry", async ({ request }) => {
+ const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`);
+ const { routeId, token } = await seedResp.json() as { routeId: string; token: string };
+
+ const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { gpx: ONE_POINT_GPX },
+ });
+ expect(callbackResp.status()).toBe(400);
+ const body = await callbackResp.json() as { error: string };
+ expect(body.error).toMatch(/at least 2 track points/);
+
+ const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`);
+ const { hasGeom } = await geomResp.json() as { hasGeom: boolean };
+ expect(hasGeom).toBe(false);
+ });
+
+ test("missing token returns 401", async ({ request }) => {
+ const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`);
+ const { routeId } = await seedResp.json() as { routeId: string };
+
+ const resp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, {
+ data: { gpx: VALID_GPX },
+ });
+ expect(resp.status()).toBe(401);
+ });
+});
+
test.describe("Integration: Journal ↔ Planner handoff", () => {
test("GPX import → view route → export GPX", async ({ request }) => {
const gpx = `