test(e2e/journal-planner-save): pass waypoints in URL + mock BRouter
The first revision opened the planner session URL without waypoints, so the in-browser Yjs doc was empty, no route was computed, and the Save button shipped an empty GPX → journal callback returned 400 → \"Saved!\" never appeared and the test timed out. The real journal→planner handoff (in \`apps/journal/app/routes/api.routes.\$id.edit-in-planner.ts\`) encodes the planner's session-creation response (initialWaypoints / noGoAreas / notes) as URL query params on the redirect. The test now mirrors that, passing a 2-waypoint encoded blob via the \`?waypoints=\` param. Also mocks BRouter via the existing \`mockBRouter(page)\` fixture so the route compute is deterministic — \`planner-coloring.test.ts\` uses the same pattern. Otherwise the test would race against a real BRouter cold-start on CI. Asserts canvas is visible before clicking Save (proxy for \"routeData is populated\" — the same condition that gates a non-empty GPX in \`SaveToJournalButton\`).
This commit is contained in:
parent
be64e2df5c
commit
206f58b1b9
1 changed files with 41 additions and 39 deletions
|
|
@ -6,59 +6,60 @@
|
|||
// 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 401.
|
||||
// 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, then directly POSTs
|
||||
// to the planner's `/api/sessions` with callbackUrl + callbackToken —
|
||||
// exactly the call that `api.routes.$id.edit-in-planner` makes
|
||||
// server-to-server. From that point on it's the same flow as a real
|
||||
// user.
|
||||
// `/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";
|
||||
|
||||
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>`;
|
||||
// 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,
|
||||
gpx: VALID_GPX,
|
||||
},
|
||||
});
|
||||
expect(sessionResp.ok()).toBeTruthy();
|
||||
const session = (await sessionResp.json()) as {
|
||||
sessionId: string;
|
||||
url: string;
|
||||
initialWaypoints?: unknown[];
|
||||
};
|
||||
return { routeId, token, session };
|
||||
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, session } = await seedRouteAndPlannerSession(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 — neither in
|
||||
// an Authorization header nor in a request body. The planner's
|
||||
// server-side proxy is the only thing that should see it.
|
||||
// specific token string never appears in any of them.
|
||||
const observed: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const body = req.postData();
|
||||
|
|
@ -67,14 +68,20 @@ test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => {
|
|||
if (auth) observed.push(`AUTH:${auth}`);
|
||||
});
|
||||
|
||||
await page.goto(`${PLANNER}${session.url}`);
|
||||
await page.goto(sessionPageUrl);
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const saveBtn = page.getByRole("button", { name: /Save/i });
|
||||
// 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.
|
||||
|
|
@ -84,27 +91,22 @@ test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => {
|
|||
});
|
||||
|
||||
test("token is single-use — second save through the planner UI fails", async ({ page, request }) => {
|
||||
const { session } = await seedRouteAndPlannerSession(request);
|
||||
const { sessionPageUrl } = await seedRouteAndPlannerSession(request);
|
||||
|
||||
await page.goto(`${PLANNER}${session.url}`);
|
||||
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/i });
|
||||
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
|
||||
// verifier should reject it on jti conflict (#443). The planner
|
||||
// proxy forwards the journal's error to the UI.
|
||||
//
|
||||
// The UI keeps the "Saved" indicator visible after the first
|
||||
// success; clicking the button a second time should swap it for an
|
||||
// error message. We assert either a visible error or the absence
|
||||
// of a second "Saved" (the component only flips the saved state on
|
||||
// a success response).
|
||||
// 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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue