Files
PacketStation/tests/e2e/tests/close.spec.ts
T

423 lines
18 KiB
TypeScript

import { type Page } from "@playwright/test";
import { expect, test, PIN } from "./helpers";
import {
actor,
api,
closeAll,
packet,
packetTab,
setPin,
sign,
signSheet,
signWithPassword,
signWithPin,
statusOf,
stepApi,
userId,
} from "./shophelpers";
import type { Crew, QcPacket } from "./qchelpers";
import {
L,
atCompleteApi,
cardOf,
clickAndCatch,
clickAndPrint,
column,
expectPdf,
fetchOk,
itpOf,
makeCloseProject,
openClericalHome,
openPacket,
panel,
printCount,
prints,
signedOffItpApi,
type CloseProject,
} from "./closehelpers";
import type { Json } from "./whhelpers";
// Phase 7 close and file scenarios on the CI stack, modelled on the old tests/ps tclose and tpr. clerk.clara makes
// a project with one unit; the crew (wh.wade, sup.wes, weld.andy) and qc.quinn take two packets, R1 and R2, to
// status complete routed to the data book through the API. Then the Clerical home lists both as ready to close,
// clara confirms R1 closed in VISUAL, R2 cannot be filed yet, the data book, the CSV and the prints answer as
// files, R1 is filed with her PIN and the seal holds, admin.ann reopens it with a reason and her password, and
// clara files it again on a second seal. Last, the unit's ITP is signed off through the API and its PDF is filed in
// the unit folder. Every UI label used here is in L (closehelpers.ts).
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
const PROJECT = `C7${RUN}`;
const T = {
r1: `Close R1 spool ${RUN}`,
r2: `Close R2 spool ${RUN}`,
};
let proj: CloseProject;
const P: Partial<Record<keyof typeof T, QcPacket>> = {};
function pk(k: keyof typeof T): QcPacket {
const p = P[k];
if (!p) throw new Error(`packet ${k} was not made`);
return p;
}
async function homeShows(page: Page, heading: string, id: number): Promise<void> {
await openClericalHome(page, PROJECT);
await expect(cardOf(column(page, heading), id)).toBeVisible();
}
test.describe.serial("close", () => {
test.setTimeout(300_000);
test("setup: a project with one unit, two packets complete routed to the data book, clara's PIN", async ({
browser,
}) => {
const clara = await actor(browser, "clerk.clara");
const wade = await actor(browser, "wh.wade");
const wes = await actor(browser, "sup.wes");
const andy = await actor(browser, "weld.andy");
const quinn = await actor(browser, "qc.quinn");
const andyId = await userId(andy, "weld.andy");
const crew: Crew = { clara, wade, wes, andy, andyId };
proj = await makeCloseProject(clara, PROJECT);
P.r1 = await atCompleteApi(crew, quinn, proj.unit, T.r1, "Stall 2");
P.r2 = await atCompleteApi(crew, quinn, proj.unit, T.r2, "Stall 3");
for (const id of [P.r1.id, P.r2.id]) {
const p = await packet(clara, id);
expect(p.vclosed).toBeFalsy();
expect(p.seal).toBeFalsy();
}
await setPin(clara, PIN);
await closeAll(clara, wade, wes, andy, quinn);
});
test("clerical home: both packets are ready to close", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openClericalHome(page, PROJECT);
const toClose = column(page, L.colToClose);
await expect(cardOf(toClose, pk("r1").id)).toBeVisible();
await expect(cardOf(toClose, pk("r2").id)).toBeVisible();
await expect(page.locator(`[aria-label="${L.kpiToClose(2)}"]`)).toBeVisible();
await closeAll(clara);
});
test("R1: confirmed closed in VISUAL, it moves to ready for the data book", async ({ browser }) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box).toBeVisible();
await expect(box.getByText(L.readyToClose)).toBeVisible();
// a hand packet has no feed state, so no VISUAL line
expect((await packet(clara, id)).vclose_check ?? null).toBeNull();
await expect(box.getByTestId(L.visualLine)).toHaveCount(0);
const close = box.getByRole("button", { name: L.jobClosed, exact: true });
await expect(close).toBeEnabled();
await close.click();
await expect(box.getByText(L.readyForBook)).toBeVisible();
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(true);
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
await expect(box.getByRole("button", { name: L.moveBack, exact: true })).toBeVisible();
await homeShows(page, L.colToBook, id);
await expect(cardOf(column(page, L.colToClose), id)).toHaveCount(0);
await expect(page.locator(`[aria-label="${L.kpiToClose(1)}"]`)).toBeVisible();
await closeAll(clara);
});
test("R2: filing is blocked until the job is closed in VISUAL; move back undoes the close", async ({
browser,
}) => {
const id = pk("r2").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyToClose)).toBeVisible();
const file = box.getByRole("button", { name: L.fileIt, exact: true });
await expect(file).toBeVisible();
await expect(file).toBeDisabled();
await expect(box.getByText(L.blocked)).toBeVisible();
// the API refuses it the same way (403 precondition)
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
expect(await statusOf(clara, id)).toBe("complete");
// close, then move back: it is ready to close again (the old vReopen)
await box.getByRole("button", { name: L.jobClosed, exact: true }).click();
await expect(box.getByText(L.readyForBook)).toBeVisible();
await box.getByRole("button", { name: L.moveBack, exact: true }).click();
await expect(box.getByText(L.readyToClose)).toBeVisible();
await expect.poll(async () => Boolean((await packet(clara, id)).vclosed)).toBe(false);
await homeShows(page, L.colToClose, id);
await closeAll(clara);
});
test("R1: the data book and the traceability CSV answer as files", async ({ browser }) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyForBook)).toBeVisible();
await expect(box.getByRole("button", { name: L.download, exact: true }).or(
box.getByRole("link", { name: L.download, exact: true }),
).first()).toBeVisible();
// Data book (PDF): the browser asks for the preview data book; the same URL answers a PDF
const bookBtn = box
.getByRole("button", { name: L.dataBookPdf, exact: true })
.or(box.getByRole("link", { name: L.dataBookPdf, exact: true }))
.first();
const bookReq = await clickAndCatch(clara.ctx, page, bookBtn, `/api/packets/${id}/pdf/databook`);
await expectPdf(clara, bookReq.url());
await expect(page).toHaveURL(new RegExp(`/packets/${id}`));
// Traceability (CSV): a link to the CSV; text/csv with the record's first line
const csvLink = box
.getByRole("link", { name: L.csv, exact: true })
.or(box.getByRole("button", { name: L.csv, exact: true }))
.first();
const csvReq = await clickAndCatch(clara.ctx, page, csvLink, `/api/packets/${id}/export/traceability.csv`);
const r = await fetchOk(clara, csvReq.url());
expect(r.headers()["content-type"]).toContain("text/csv");
const text = await r.text();
expect(text.split("\r\n")[0]).toBe("PESCO weld & heat traceability record");
expect(text).toContain("BOM item");
await closeAll(clara);
});
test("R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab", async ({
browser,
}) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyForBook)).toBeVisible();
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
let sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
await expect(sheet).toBeVisible();
for (const row of L.printRows) await expect(sheet.getByRole("button", { name: row, exact: true })).toBeVisible();
// a hand packet with no change orders has no void pages
await expect(sheet.getByRole("button", { name: L.voidRow, exact: true })).toHaveCount(0);
await expect(sheet.getByText(L.printFooter)).toBeVisible();
await sheet.getByRole("button", { name: L.cancel, exact: true }).click();
await expect(sheet).toBeHidden();
// a row closes the sheet and prints on the same page (C4): the stamped PDF is fetched and the print dialog
// opens, no new tab; the server logs it
const before = await printCount(clara, id, "traveler");
await box.getByRole("button", { name: L.printOpen, exact: true }).click();
sheet = page.getByRole("dialog", { name: L.printSheet, exact: true });
await expect(sheet).toBeVisible();
await clickAndPrint(
page,
sheet.getByRole("button", { name: L.printRows[0], exact: true }),
`/api/packets/${id}/print/traveler`,
);
await expect(sheet).toBeHidden();
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
expect(clara.ctx.pages().length).toBe(1);
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 1);
// the same print through the API: a PDF, and one more log row
await expectPdf(clara, `/api/packets/${id}/print/traveler`);
await expect.poll(() => printCount(clara, id, "traveler")).toBe(before + 2);
const rows = await prints(clara, id);
expect(rows[0].kind).toBe("traveler");
expect(rows[0].what).toBe("Job traveler");
expect(Number(rows[0].pages)).toBeGreaterThan(0);
// the Work orders tab has the traveler print and the traveler PDF
await packetTab(page, L.tabWorkOrders).click();
await expect(page.getByRole("button", { name: L.printTraveler, exact: true })).toBeVisible();
const trav = page.getByRole("link", { name: L.travelerPdf, exact: true });
await expect(trav).toBeVisible();
await expect(trav).toHaveAttribute("href", new RegExp(`/api/packets/${id}/pdf/traveler$`));
// the History tab lists the printed copies
await packetTab(page, L.tabHistory).click();
const hist = page.getByRole("region", { name: L.printsSection });
await expect(hist).toBeVisible();
await expect(hist.getByText(/Job traveler/).first()).toBeVisible();
await expect(hist.getByText(L.nothingPrinted)).toHaveCount(0);
await closeAll(clara);
});
test("C3: an open ITP on the unit blocks filing until Q.C. signs it off", async ({ browser }) => {
const id = pk("r1").id;
const quinn = await actor(browser, "qc.quinn");
const unitId = Number(proj.unit.id);
const started = await api(quinn, "post", `/api/units/${unitId}/itp`, {});
expect(started.status).not.toBe("complete");
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.readyForBook)).toBeVisible();
const file = box.getByRole("button", { name: L.fileIt, exact: true });
await expect(file).toBeDisabled();
await expect(box.getByText(L.itpOpen)).toBeVisible();
expect((await packet(clara, id)).blocked.file).toContain(L.itpOpen);
await stepApi(clara, id, "file", { sign: sign("clerk.clara") }, 403);
expect(await statusOf(clara, id)).toBe("complete");
// signed off: File is enabled again
await signedOffItpApi(quinn, unitId);
await page.reload();
await expect(box.getByRole("button", { name: L.fileIt, exact: true })).toBeEnabled();
await expect(box.getByText(L.itpOpen)).toHaveCount(0);
await closeAll(quinn, clara);
});
test("R1: filed in the data book with the PIN; the seal holds and the filed data book downloads", async ({
browser,
}) => {
const id = pk("r1").id;
const clara = await actor(browser, "clerk.clara");
const page = clara.page;
await openPacket(page, id);
const box = panel(page);
const file = box.getByRole("button", { name: L.fileIt, exact: true });
await expect(file).toBeEnabled();
await file.click();
await signWithPin(page, PIN);
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(clara, id)).toBe("filed");
await expect(box.getByText(L.filed).first()).toBeVisible();
await expect(box.getByText(L.sealIntact).first()).toBeVisible();
await expect(box.getByText(L.sealBroken)).toHaveCount(0);
const p = await packet(clara, id);
expect(p.seals).toBe(1);
expect(p.databook_at).toBeTruthy();
expect(Number(p.seal.databook.pages)).toBeGreaterThan(0);
expect(p.seal.broken_at ?? null).toBeNull();
const seal = await api(clara, "get", `/api/packets/${id}/seal`);
expect(seal.valid).toBe(true);
const dl = await fetchOk(clara, String(p.seal.databook.download_url));
expect(dl.headers()["content-type"]).toContain("application/pdf");
expect(dl.headers()["content-disposition"] ?? "").toMatch(/^attachment/);
expect((await dl.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
// the export endpoint hands back the stored copy once filed
await expectPdf(clara, `/api/packets/${id}/pdf/databook`);
// the home lists it as filed
await openClericalHome(page, PROJECT);
const card = cardOf(column(page, L.colFiled), id);
await expect(card).toBeVisible();
await expect(card).toContainText(L.filedPill);
await closeAll(clara);
});
test("R1: admin.ann reopens with a reason and her password; clara files it again on a second seal", async ({
browser,
}) => {
const id = pk("r1").id;
const ann = await actor(browser, "admin.ann");
const page = ann.page;
await openPacket(page, id);
const box = panel(page);
await expect(box.getByText(L.filed).first()).toBeVisible();
await box.getByRole("button", { name: L.reopenIt, exact: true }).click();
const reason = page.getByRole("dialog", { name: L.reopenSheet, exact: true });
await expect(reason).toBeVisible();
const field = reason.getByRole("textbox").first();
await field.fill("Wrong heat number filed on item 2");
await field.press("Enter");
await signWithPassword(page, "admin.ann");
await expect(signSheet(page)).toBeHidden();
await expect.poll(() => statusOf(ann, id)).toBe("complete");
await expect(box.getByText(L.reopenedLine).first()).toBeVisible();
await expect(box.getByText(L.reopenedLine).first()).toContainText("Wrong heat number filed on item 2");
let seal = await api(ann, "get", `/api/packets/${id}/seal`);
expect((seal.seals as Json[]).length).toBe(1);
expect((seal.seals as Json[])[0].broken_reason).toBe("Wrong heat number filed on item 2");
const reopened = await packet(ann, id);
expect(reopened.seal.broken_at).toBeTruthy();
expect(reopened.seal.databook).toBeTruthy();
// clara files it again through the API (the UI path is covered above)
const clara = await actor(browser, "clerk.clara");
if (!(await packet(clara, id)).vclosed) await api(clara, "post", `/api/packets/${id}/vclosed`, { closed: true });
const out = await stepApi(clara, id, "file", { sign: sign("clerk.clara") });
expect(out.status).toBe("filed");
expect(out.seals).toBe(2);
seal = await api(clara, "get", `/api/packets/${id}/seal`);
expect((seal.seals as Json[]).length).toBe(2);
expect(seal.valid).toBe(true);
await openPacket(clara.page, id);
await expect(panel(clara.page).getByText(L.sealIntact).first()).toBeVisible();
await closeAll(ann, clara);
});
test("ITP: signed off, its PDF is filed in the unit folder and linked on the ITP page", async ({
browser,
}) => {
const quinn = await actor(browser, "qc.quinn");
const unitId = Number(proj.unit.id);
// signed off in the C3 scenario above
const itp = (await itpOf(quinn, unitId)) as Json;
expect(itp.status).toBe("complete");
const no = String(itp.no);
expect(itp.signoff.asset_id).toBeTruthy();
// the ITP PDF, built now
await expectPdf(quinn, `/api/itps/${itp.id}/pdf`);
// the signed copy filed at sign-off
await expectPdf(quinn, `/_blob/${itp.signoff.asset_id}`);
// the unit folder lists the automatic ITP document
const folder = await api(quinn, "get", `/api/qc/folders/units/${unitId}`);
const titles = ((folder.docs ?? []) as Json[]).map((d) => String(d.title));
expect(titles).toContain(L.signedOffDoc(no));
await quinn.page.goto(`/qc/units/${unitId}`);
await expect(quinn.page.getByText(L.signedOffDoc(no)).first()).toBeVisible();
// the ITP page has the PDF button and the signed PDF link
await quinn.page.goto(`/qc/units/${unitId}/itp`);
await expect(quinn.page.getByRole("heading", { level: 1 })).toHaveText(no);
const pdfBtn = quinn.page
.getByRole("button", { name: L.itpPdf, exact: true })
.or(quinn.page.getByRole("link", { name: L.itpPdf, exact: true }))
.first();
const req = await clickAndCatch(quinn.ctx, quinn.page, pdfBtn, `/api/itps/${itp.id}/pdf`);
await expectPdf(quinn, req.url());
// Print goes to the print dialog on the same page (C4)
await clickAndPrint(
quinn.page,
quinn.page.getByRole("button", { name: "Print", exact: true }),
`/api/itps/${itp.id}/print`,
);
await expect(quinn.page).toHaveURL(new RegExp(`/qc/units/${unitId}/itp$`));
const signed = quinn.page.getByRole("link", { name: L.signedPdf, exact: true });
await expect(signed).toBeVisible();
await expect(signed).toHaveAttribute("href", new RegExp(`/_blob/${itp.signoff.asset_id}`));
await closeAll(quinn);
});
test("api: print guards and a signed-out read", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const id = pk("r2").id;
// R2 has no void pages
const v = await clara.page.request.get(`/api/packets/${id}/print/void`);
expect(v.status()).toBe(404);
expect(((await v.json()) as Json).code).toBe("nothing_to_print");
// a document that is not one of the packet's is refused
const d = await clara.page.request.get(`/api/packets/${id}/print/doc?asset=00000000-0000-0000-0000-000000000000`);
expect(d.status()).toBe(404);
// nobody signed in: 401
const anon = await browser.newContext();
const r = await anon.request.get(new URL(`/api/packets/${id}/pdf/traveler`, clara.page.url()).toString());
expect(r.status()).toBe(401);
await anon.close();
await closeAll(clara);
});
});