Add e2e tests for Planner callback → geometry stored atomically
Introduces two E2E=true-gated test endpoints:
- POST /api/e2e/seed — creates a test user + bare route, returns routeId + JWT
- GET /api/e2e/route/:id — returns { hasGeom } for post-callback assertions
Three new integration tests:
- valid GPX via callback stores geometry (hasGeom = true)
- invalid GPX (< 2 track points) returns 400, geometry not stored
- missing token returns 401
CI: E2E=true added to the "Run E2E tests" step so the seed endpoints
are enabled when react-router-serve runs during the Playwright job.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4189187dd4
commit
e7a0c132b9
5 changed files with 169 additions and 1 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -231,6 +231,7 @@ jobs:
|
||||||
run: pnpm test:e2e
|
run: pnpm test:e2e
|
||||||
env:
|
env:
|
||||||
BROUTER_URL: http://localhost:17777
|
BROUTER_URL: http://localhost:17777
|
||||||
|
E2E: "true"
|
||||||
|
|
||||||
- name: Playwright job summary
|
- name: Playwright job summary
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export default [
|
||||||
route("routes/new", "routes/routes.new.tsx"),
|
route("routes/new", "routes/routes.new.tsx"),
|
||||||
route("routes/:id", "routes/routes.$id.tsx"),
|
route("routes/:id", "routes/routes.$id.tsx"),
|
||||||
route("routes/:id/edit", "routes/routes.$id.edit.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/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/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"),
|
||||||
route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"),
|
route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"),
|
||||||
|
|
|
||||||
28
apps/journal/app/routes/api.e2e.route.$id.ts
Normal file
28
apps/journal/app/routes/api.e2e.route.$id.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { data } from "react-router";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
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 });
|
||||||
|
}
|
||||||
75
apps/journal/app/routes/api.e2e.seed.ts
Normal file
75
apps/journal/app/routes/api.e2e.seed.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,73 @@ import { test, expect } from "./fixtures/test";
|
||||||
* - BRouter (for route computation)
|
* - BRouter (for route computation)
|
||||||
*
|
*
|
||||||
* In CI, these services are started by the workflow.
|
* 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 PLANNER = "http://localhost:3001";
|
||||||
|
|
||||||
|
const VALID_GPX = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<trk><trkseg>
|
||||||
|
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||||
|
<trkpt lat="52.51" lon="13.38"><ele>40</ele></trkpt>
|
||||||
|
<trkpt lat="52.50" lon="13.35"><ele>35</ele></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>`;
|
||||||
|
|
||||||
|
const ONE_POINT_GPX = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<trk><trkseg>
|
||||||
|
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>`;
|
||||||
|
|
||||||
|
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.describe("Integration: Journal ↔ Planner handoff", () => {
|
||||||
test("GPX import → view route → export GPX", async ({ request }) => {
|
test("GPX import → view route → export GPX", async ({ request }) => {
|
||||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue