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 = `