Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
expectPreview,
openMapper,
preview,
withFormats,
} from "./mapperHelpers";

/**
Expand Down Expand Up @@ -42,11 +43,13 @@ async function openWithCsv(page: import("@playwright/test").Page, sample: string
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);

await page.getByLabel("From format").selectOption("csv");
await page.getByLabel("source delimiter").selectOption(delimiter);
const box = page.getByRole("checkbox", { name: "source header row" });
if (header) await box.check();
else await box.uncheck();
await withFormats(page, async () => {
await page.getByLabel("From format").selectOption("csv");
await page.getByLabel("source delimiter").selectOption(delimiter);
const box = page.getByRole("checkbox", { name: "source header row" });
if (header) await box.check();
else await box.uncheck();
});

await page.getByRole("textbox", { name: "Sample source document" }).fill(sample);
return subscriptionId;
Expand Down Expand Up @@ -142,9 +145,11 @@ test("writing a delimited file takes its delimiter and header from the target si
}) => {
await openWithCsv(page, TRACKING, "|", false);

await page.getByLabel("To format").selectOption("csv");
await page.getByLabel("target delimiter").selectOption(";");
await page.getByRole("checkbox", { name: "target header row" }).check();
await withFormats(page, async () => {
await page.getByLabel("To format").selectOption("csv");
await page.getByLabel("target delimiter").selectOption(";");
await page.getByRole("checkbox", { name: "target header row" }).check();
});

const root = await rootListOverTheDocument(page);
await page.getByRole("button", { name: "Settings for the list at the root" }).click();
Expand All @@ -163,7 +168,7 @@ test("writing a delimited file takes its delimiter and header from the target si

test("a nested rule becomes a dotted column", async ({ page }) => {
await openWithCsv(page, MOVEMENTS, ",", true);
await page.getByLabel("To format").selectOption("csv");
await withFormats(page, () => page.getByLabel("To format").selectOption("csv"));

const root = await rootListOverTheDocument(page);
// A row is flat, so the nesting has to land somewhere. The name box splits on dots, so
Expand All @@ -176,7 +181,7 @@ test("a nested rule becomes a dotted column", async ({ page }) => {

test("a shape a row cannot hold is refused with a reason", async ({ page }) => {
await openWithCsv(page, MOVEMENTS, ",", true);
await page.getByLabel("To format").selectOption("csv");
await withFormats(page, () => page.getByLabel("To format").selectOption("csv"));

// A list inside a row. There is no cell that holds one, and inventing a way to fit it —
// joining the entries, taking the first — would lose data without a word.
Expand Down Expand Up @@ -208,9 +213,11 @@ test("the carrier's whole file can be produced, trailer count and all", async ({
// client's ends with a record carrying how many records came before it.
await openWithCsv(page, TRACKING, "|", false);

await page.getByLabel("To format").selectOption("csv");
await page.getByLabel("target delimiter").selectOption("|");
await page.getByRole("checkbox", { name: "target header row" }).uncheck();
await withFormats(page, async () => {
await page.getByLabel("To format").selectOption("csv");
await page.getByLabel("target delimiter").selectOption("|");
await page.getByRole("checkbox", { name: "target header row" }).uncheck();
});

const root = await rootListOverTheDocument(page);
await page.getByRole("button", { name: "Settings for the list at the root" }).click();
Expand Down Expand Up @@ -261,7 +268,7 @@ test("counting is offered inside a list and nowhere else", async ({ page }) => {

test("a file can be marked so Excel opens accented names correctly", async ({ page }) => {
await openWithCsv(page, MOVEMENTS, ",", true);
await page.getByLabel("To format").selectOption("csv");
await withFormats(page, () => page.getByLabel("To format").selectOption("csv"));

const root = await rootListOverTheDocument(page);
await addListField(root, "the root list", "shipment", "ShipmentNumber");
Expand All @@ -270,7 +277,9 @@ test("a file can be marked so Excel opens accented names correctly", async ({ pa
// The mark itself is invisible, so what is checked is that asking for it changes the
// document the server produced rather than that anything looks different.
const before = await preview(page).textContent();
await page.getByRole("checkbox", { name: "write a byte-order mark" }).check();
await withFormats(page, () =>
page.getByRole("checkbox", { name: "write a byte-order mark" }).check(),
);
await expect
.poll(async () => (await preview(page).textContent())?.charCodeAt(0), { timeout: 15000 })
.toBe(0xfeff);
Expand Down
27 changes: 18 additions & 9 deletions SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
openMapper,
saveAndReload,
suggestionsFor,
withFormats,
} from "./mapperHelpers";

/**
Expand Down Expand Up @@ -59,7 +60,7 @@ const SOAP_REQUEST = `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envel
async function openWithXml(page: import("@playwright/test").Page, sample = SOAP_REQUEST) {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await withFormats(page, () => page.getByLabel("From format").selectOption("xml"));
await page.getByRole("textbox", { name: "Sample source document" }).fill(sample);
return subscriptionId;
}
Expand Down Expand Up @@ -120,8 +121,10 @@ test("a mapping that writes XML takes its namespaces from the sample of the outp
}) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
await withFormats(page, async () => {
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
});
await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST);

await buildFromSample(
Expand Down Expand Up @@ -152,8 +155,10 @@ test("a shape XML cannot hold is refused with a reason, not a broken document",
}) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
await withFormats(page, async () => {
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
});
await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST);

// JSON writes as many top-level keys as it likes; XML has exactly one root element.
Expand All @@ -179,8 +184,10 @@ test("an element that carries both an attribute and a value maps as two rules",
// same convention as reading, in reverse.
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
await withFormats(page, async () => {
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
});
await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST);

// The sample needs a value between the tags, not just the attribute: an element with
Expand All @@ -200,8 +207,10 @@ test("an element that carries both an attribute and a value maps as two rules",
test("an attribute can be added to an element by hand, without a sample", async ({ page }) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
await withFormats(page, async () => {
await page.getByLabel("From format").selectOption("xml");
await page.getByLabel("To format").selectOption("xml");
});
await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST);

// Dots separate the levels, so `order.weight.@unit` puts the attribute on `weight`
Expand Down
20 changes: 20 additions & 0 deletions SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ export async function openWithSample(page: Page, sample: unknown = SAMPLE): Prom
return subscriptionId;
}

/**
* Runs `set` with the format panel open, and closes it afterwards.
*
* What the mapping reads and writes sits behind a summary chip rather than in the
* toolbar row, so a test that changes a format has to open the panel first — and the
* panel covers the rules underneath, so it has to be closed before touching them.
*/
export async function withFormats(page: Page, set: () => Promise<unknown>) {
const chip = page.getByRole("button", { name: "What this mapping reads and writes" });
const panel = page.getByLabel("From format");

// Waited for on both sides because the chip toggles: acting before the panel has
// opened, or opening again before the last one has gone, closes it instead.
await chip.click();
await expect(panel).toBeVisible({ timeout: 15000 });
await set();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden({ timeout: 15000 });
}

/** Pastes an output sample into the toolbar panel and builds the rules from it. */
export async function buildFromSample(page: Page, target: unknown) {
await page.getByRole("button", { name: "Build from a sample of the output" }).click();
Expand Down
4 changes: 3 additions & 1 deletion SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ test("the editor opens for the mapper you picked, not the one that is saved", as
// Saved as the old mapper, picking the new one: the new editor, no save in between.
await pickOption(page, "mapper adapter", "NativeMapper");
await link.click();
await expect(page.getByLabel("From format")).toBeVisible({ timeout: 15000 });
await expect(
page.getByRole("button", { name: "What this mapping reads and writes" }),
).toBeVisible({ timeout: 15000 });

// And back the other way, which is the same bug reversed.
await page.goto(`subscriptions/${subscriptionId}/mapper?mapper=NativeJSONMapper`);
Expand Down
5 changes: 3 additions & 2 deletions SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
expectPreview,
openMapper,
preview,
withFormats,
} from "./mapperHelpers";

/**
Expand Down Expand Up @@ -40,7 +41,7 @@ test.beforeEach(async ({ page }) => {
test("lays out a one-line XML sample, and the tree still reads it", async ({ page }) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page.getByLabel("From format").selectOption("xml");
await withFormats(page, () => page.getByLabel("From format").selectOption("xml"));

const sample = page.getByRole("textbox", { name: "Sample source document" });
await sample.fill(MINIFIED_XML);
Expand Down Expand Up @@ -133,7 +134,7 @@ test("the mapped document is coloured, in whichever format it is written", async

// Switching the output to XML colours it as XML, because the mapping declares the
// format rather than the pane guessing from the text.
await page.getByLabel("To format").selectOption("xml");
await withFormats(page, () => page.getByLabel("To format").selectOption("xml"));
await page.getByRole("textbox", { name: "Output field name" }).first().fill("order");
await expect(preview.locator(".hljs-name").first()).toBeVisible({ timeout: 15000 });
});
Expand Down
61 changes: 61 additions & 0 deletions SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";
import { signInAsAdmin } from "./helpers";

/**
* A server that cannot answer is not a server saying "signed out".
*
* `getSession` used to swallow every failure and return null, which the guard reads as
* "not signed in" — so a rate-limited or briefly unreachable backend threw people to
* the sign-in page mid-task, with a perfectly good token still in localStorage. That is
* also what made a whole afternoon of rate-limited test runs look like an auth problem.
*/
test.beforeEach(async ({ page }) => {
await signInAsAdmin(page);
});

for (const status of [429, 500, 503]) {
test(`a ${status} from the profile call is an outage, not a sign-out`, async ({ page }) => {
await page.route("**/api/accounts/profile", (route) =>
route.fulfill({ status, contentType: "application/json", body: "{}" }),
);

await page.goto("subscriptions");

await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({
timeout: 15000,
});
// The distinction that matters: still signed in, so nothing asks for a password
// and the token is left where it is.
await expect(page).not.toHaveURL(/\/login/);
expect(await page.evaluate(() => localStorage.getItem("access_token"))).toBeTruthy();
});
}

test("a 401 still signs you out, because that one is the server's answer", async ({ page }) => {
// Both the profile read and the silent refresh behind it, so there is nothing left
// to restore the session with — which is a real, unrecoverable sign-out.
await page.route("**/api/accounts/profile", (route) => route.fulfill({ status: 401, body: "" }));
await page.route("**/api/accounts/login", (route) => route.fulfill({ status: 401, body: "" }));

await page.goto("subscriptions");

await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
});

test("the outage screen recovers when the server comes back", async ({ page }) => {
let failing = true;
await page.route("**/api/accounts/profile", (route) =>
failing ? route.fulfill({ status: 503, contentType: "application/json", body: "{}" }) : route.fallback(),
);

await page.goto("subscriptions");
await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({
timeout: 15000,
});

failing = false;
await page.getByRole("button", { name: "Try again" }).click();

await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible({ timeout: 15000 });
});
19 changes: 16 additions & 3 deletions SW.Bitween.Web/ClientApp/src/api/http/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,29 @@ let logoutInFlight: AbortController | null = null;
const abortPendingLogout = () => logoutInFlight?.abort();

export const sessionMethods = {
/**
* The session this browser already holds, or null when there genuinely isn't one.
*
* Only an answer from the server counts as "signed out". This used to swallow every
* failure and return null, which reads as "no session" and sends the caller to the
* sign-in page — so a rate-limited or momentarily unreachable server signed people
* out mid-task while their token sat in localStorage, still perfectly good. Anything
* that is not an answer about *this* token is rethrown for the caller to show as the
* outage it is.
*/
async getSession(): Promise<Session | null> {
// No stored Jwt → anonymous; don't probe the backend (an expired token still
// gets refreshed via cookie inside request() on its 401). The token is only
// cleared on logout or an unrecoverable 401, so returning users keep it.
if (!getToken()) return null;
try {
return await loadSession();
} catch {
// Token invalid and no refresh cookie → signed out. Show login, don't fake it.
return null;
} catch (e) {
// Token refused and no refresh cookie left to replace it → signed out for real.
// `request()` has already cleared the token and told the app by this point.
if (e instanceof ApiRequestError && (e.code === "UNAUTHENTICATED" || e.code === "HTTP_403"))
return null;
throw e;
}
},

Expand Down
43 changes: 40 additions & 3 deletions SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ interface SessionContextValue {
/** Re-fetch the session after profile changes. */
refresh: () => Promise<void>;
signOut: () => Promise<void>;
/**
* Set when the server could not be asked whether this browser's token is still good
* — rate limited, down, or unreachable. Distinct from `session === null`, which
* means it answered and the answer was no.
*/
unreachable: boolean;
/** Ask again after `unreachable`. */
retry: () => void;
}

const SessionContext = createContext<SessionContextValue | null>(null);
Expand All @@ -42,15 +50,32 @@ export function SessionProvider({ children }: { children: ReactNode }) {
*/
const generation = useRef(0);

const [unreachable, setUnreachable] = useState(false);
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => {
setUnreachable(false);
setInitializing(true);
setAttempt((n) => n + 1);
}, []);

useEffect(() => {
const startedAt = generation.current;
api
.getSession()
.then((next) => {
if (generation.current === startedAt) setSession(next);
if (generation.current !== startedAt) return;
setSession(next);
setUnreachable(false);
})
.catch(() => {
// Not "signed out": `getSession` only resolves to null when the server said so.
// Reaching here means it could not be asked, and the token this browser holds
// may well still be good — so keep it and say the server is unreachable rather
// than sending someone to sign in again for a blip.
if (generation.current === startedAt) setUnreachable(true);
})
.finally(() => setInitializing(false));
}, []);
}, [attempt]);

const adoptSession = useCallback(
(next: Session) => {
Expand Down Expand Up @@ -150,8 +175,20 @@ export function SessionProvider({ children }: { children: ReactNode }) {
adoptSession,
refresh,
signOut,
unreachable,
retry,
}),
[session, initializing, signIn, signInWithMicrosoft, adoptSession, refresh, signOut],
[
session,
initializing,
signIn,
signInWithMicrosoft,
adoptSession,
refresh,
signOut,
unreachable,
retry,
],
);

return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
Expand Down
Loading
Loading