// End-to-end coverage for the journal → planner → journal save flow, // added together with #2 Phase A (#442) + Phase B (#443). // // What this test guarantees: // 1. Clicking Save-to-Journal in the planner UI successfully POSTs // back to the journal via the server-side proxy (#442) — i.e. the // callback JWT never appears in the browser. // 2. The journal's callback enforces single-use jti (#443) — a // second save with the same session/token returns an error. // // The test bypasses the journal UI's "Edit in Planner" button (which // would need a logged-in user with a route + GPX). Instead it uses the // `/api/e2e/seed` endpoint to mint a routeId + JWT, POSTs to the // planner's `/api/sessions` with callbackUrl + callbackToken — exactly // what `api.routes.$id.edit-in-planner` does server-to-server — and // then opens the session URL with waypoint params so the planner // computes a route and the Save button has GPX to ship. import { test, expect } from "./fixtures/test"; import { mockBRouter } from "./fixtures/brouter-mock"; const JOURNAL = "http://localhost:3000"; const PLANNER = "http://localhost:3001"; // Mock BRouter so the in-browser route compute is deterministic and // fast — same approach as `planner-coloring.test.ts`. Without this the // test would block on a real BRouter cold-start. test.beforeEach(async ({ page }) => { await mockBRouter(page); }); const WAYPOINTS = encodeURIComponent(JSON.stringify([ { lat: 52.520, lon: 13.405 }, { lat: 52.515, lon: 13.351 }, ])); async function seedRouteAndPlannerSession(request: import("@playwright/test").APIRequestContext) { const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); expect(seedResp.ok()).toBeTruthy(); const { routeId, token } = (await seedResp.json()) as { routeId: string; token: string }; // Create a planner session with the journal callback wired up — no // GPX seeded into the session itself. We pass waypoints via URL // below so the in-browser Yjs doc gets them and requestRoute fires. const sessionResp = await request.post(`${PLANNER}/api/sessions`, { data: { callbackUrl: `${JOURNAL}/api/routes/${routeId}/callback`, callbackToken: token, }, }); expect(sessionResp.ok()).toBeTruthy(); const session = (await sessionResp.json()) as { sessionId: string; url: string }; const sessionPageUrl = `${PLANNER}${session.url}?waypoints=${WAYPOINTS}`; return { routeId, token, sessionPageUrl }; } test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { test("planner save POSTs back to journal — token stays server-side", async ({ page, request }) => { const { routeId, token, sessionPageUrl } = await seedRouteAndPlannerSession(request); // Capture every browser-originated request so we can assert the // specific token string never appears in any of them. const observed: string[] = []; page.on("request", (req) => { const body = req.postData(); if (body) observed.push(body); const auth = req.headers()["authorization"]; if (auth) observed.push(`AUTH:${auth}`); }); await page.goto(sessionPageUrl); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Wait for the route to actually compute — the elevation chart // canvas mounts once `routeData` has points, which is the same // condition that gates a non-empty GPX in the Save handler. await expect(page.locator("canvas")).toBeVisible({ timeout: 20000 }); const saveBtn = page.getByRole("button", { name: /Save to Journal/i }); await expect(saveBtn).toBeVisible({ timeout: 10000 }); await saveBtn.click(); await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); // The browser must never have seen the JWT. expect(observed.some((s) => s.includes(token))).toBe(false); // Sanity: the journal's route now has geometry. const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); const { hasGeom } = (await geomResp.json()) as { hasGeom: boolean }; expect(hasGeom).toBe(true); }); test("token is single-use — second save through the planner UI fails", async ({ page, request }) => { const { sessionPageUrl } = await seedRouteAndPlannerSession(request); await page.goto(sessionPageUrl); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); await expect(page.locator("canvas")).toBeVisible({ timeout: 20000 }); const saveBtn = page.getByRole("button", { name: /Save to Journal/i }); await expect(saveBtn).toBeVisible({ timeout: 10000 }); // First click consumes the token. await saveBtn.click(); await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); // Second click reuses the same session's stored JWT. The journal's // verifier rejects on jti conflict (#443); the planner proxy // forwards the error to the UI. await saveBtn.click(); await expect(page.getByText(/consumed|already|fail/i)).toBeVisible({ timeout: 15000 }); }); });