assessment-model/cypress/e2e/projects/create-project.cy.js
Daniel Roth 1019e3ea3f feat(dates): enter and display dates as dd/mm/yyyy, site-wide
The create-project form's date fields were `<input type="date">`, whose
display format follows the *browser's* locale rather than the document's. On a
US-configured browser they rendered mm/dd/yyyy inside an app that renders
en-GB everywhere else (~40 call sites). That is not cosmetic: 03/09/2026 is a
valid date under both readings, so a user cannot tell which one the box took
and no validation can catch it. The failure mode is silently wrong data, not
an error message. No attribute, `lang` value or CSS overrides the native
control, so the control had to go.

`DateInput` replaces it: a text field that masks to dd/mm/yyyy as you type,
whose value and onChange remain YYYY-MM-DD. The display format therefore stops
at the input's edge — the schema, the request body and the Drizzle `date`
column are untouched, and dates keep sorting lexicographically with no
timezone in play. The cost is the native calendar picker, which ADR-0019
argues is the right way round: a picker is a convenience, an ambiguous entry
format is a correctness problem. If one is wanted later it belongs behind
`DateInput`, not in a revert to the native control.

The parsing lives in `src/utils/dates.ts`, pure and Date-free — `new
Date("2026-03-01")` is midnight UTC, which is the previous day in a
negative-offset zone, so this compares and builds strings instead. Impossible
days are rejected explicitly because `Date` would roll 31/02 forward into
March rather than refuse it.

Two behaviours worth knowing, both covered by tests. A *finished* but
impossible date is handed to the schema as typed rather than reported as
empty: a field visibly holding 31/02/2026 while an optional-date schema reads
it as unfilled would tell the user nothing. Unfinished input does report
empty, so no error fires mid-keystroke. And the mask only re-adds a separator
while characters are being added — doing it during a backspace would make the
slash undeletable.

`DateInput` adjusts state during render rather than in a `useEffect` (the
documented alternative) to resync when the form sets the field from outside.

Per CLAUDE.md's utils split, dates.ts is generic and lives in the new
`src/utils/`; `src/app/utils.ts` stays for company-specific helpers (EPC
bands, SAP points) and `src/lib/utils.ts` is vendored Tremor/shadcn.

CLAUDE.md records the rule and the "never `<input type=\"date\">`"
prohibition so the next agent finds it before writing the wrong thing.

Five other date inputs remain wrong under this ADR — PibiSection,
PibiDatesEditor and PropertyFilters — left alone as unrelated to this branch
and recorded in ADR-0019 as convert-when-next-touched.

TESTS: tsc --noEmit and ESLint clean; vitest 576 passing (53 files), 18 of
them new for the date helpers. Cypress was NOT run — this environment's
DATABASE_URL still points at production. The spec's four date entries were
converted to day-first, an assertion added that 01/03/2026 crosses the wire as
2026-03-01, and a new case covers the mask and the impossible-date refusal;
all three are unverified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:50:09 +00:00

213 lines
8.5 KiB
JavaScript

/**
* Ara Projects — projects list, empty state and create-project modal (#409)
*
* Two groups, because one of them writes:
*
* 1. The UI group stubs POST /api/projects with cy.intercept. It exercises
* the empty state, the modal, client-side validation and the navigation
* to workstream selection without touching the database, so it is safe to
* run anywhere.
*
* 2. The end-to-end group performs a real create and is the acceptance
* criterion's "create a project end-to-end". It INSERTs, so it is gated
* behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at
* a non-production database. It also needs the #407 reference-data
* migration (0274) applied, or the project-type select will be empty and
* the page will not offer the CTA at all.
*
* Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to
* mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
*
* NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
* production, so Cypress was deliberately not executed. Treat the selectors and
* assertions below as unverified until someone runs them against a scratch DB.
*/
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
const INTERNAL_USER = {
email: "dev@domna.homes",
name: "Internal User",
onboarded: true,
sub: "cypress-internal",
};
describe("Ara Projects — create project (stubbed)", function () {
beforeEach(function () {
if (!JWT_SECRET) {
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
this.skip();
}
cy.login(INTERNAL_USER);
});
it("offers the Create project CTA from the list", function () {
cy.visit("/projects");
// The CTA sits inside the welcome card when there are no projects, and in
// the page header once there are — either way there is exactly one.
cy.get("[data-testid=create-project-trigger]").should("be.visible").click();
cy.get("[data-testid=create-project-form]").should("be.visible");
cy.contains("Project details").should("be.visible");
});
it("shows the welcome empty state when the user has no visible projects", function () {
// Only meaningful on an instance with no projects visible to this user;
// where there are some, the list renders instead and this is skipped.
cy.visit("/projects");
cy.get("body").then(($body) => {
if ($body.find("[data-testid=projects-list-empty]").length === 0) {
cy.log("user has visible projects — empty state not applicable");
return;
}
cy.contains("Welcome to Ara Projects").should("be.visible");
cy.contains("How setup works").should("be.visible");
cy.get("[data-testid=create-project-trigger]").should("be.visible");
});
});
it("refuses to submit without a name and a project type", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
cy.get("[data-testid=create-project-submit]").click();
cy.contains("Enter a project name").should("be.visible");
cy.get("@createCall").should("not.have.been.called");
});
it("rejects an end date before the start date", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Backwards dates");
cy.get("[data-testid=create-project-start-date]").type("30/09/2026");
cy.get("[data-testid=create-project-end-date]").type("01/03/2026");
cy.get("[data-testid=create-project-submit]").click();
cy.contains("The end date cannot be before the start date").should(
"be.visible",
);
});
it("formats the date as it is typed and rejects a day that does not exist", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// The separators are supplied by the field, so eight digits are enough.
cy.get("[data-testid=create-project-start-date]")
.type("31022026")
.should("have.value", "31/02/2026");
cy.get("[data-testid=create-project-name]").type("Impossible date");
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
cy.get("[data-testid=create-project-submit]").click();
// A finished-but-impossible date must not read as an empty optional field.
cy.contains("Enter a valid date").should("be.visible");
cy.get("@createCall").should("not.have.been.called");
});
it("navigates to workstream selection on success", function () {
cy.intercept("POST", "/api/projects", {
statusCode: 201,
body: { id: "4242" },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026");
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-domna-admin-access]").click();
cy.get("[data-testid=create-project-submit]").click();
cy.wait("@createProject").then(({ request }) => {
expect(request.body.name).to.eq("Riverside retrofit 2026");
expect(request.body.domnaAdminAccess).to.eq(true);
// Typed day-first, sent ISO: the display format stops at the input, and
// 01/03 is 1 March rather than 3 January.
expect(request.body.startDate).to.eq("2026-03-01");
});
cy.location("pathname").should("eq", "/projects/4242/setup/workstreams");
});
it("surfaces the server's error message on a refusal", function () {
cy.intercept("POST", "/api/projects", {
statusCode: 403,
body: { error: "You cannot create a project for that organisation." },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Not mine");
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-submit]").click();
cy.wait("@createProject");
cy.get("[data-testid=create-project-error]")
.should("be.visible")
.and("contain", "cannot create a project for that organisation");
cy.location("pathname").should("eq", "/projects");
});
});
describe("Ara Projects — create project (end to end, writes)", function () {
beforeEach(function () {
if (!JWT_SECRET || !E2E_WRITES) {
cy.log(
"ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path",
);
this.skip();
}
cy.login(INTERNAL_USER);
});
it("creates a project and lands on workstream selection", function () {
// Unique per run so repeated runs do not collide on a name a human is
// eyeballing later. `project.name` has no unique constraint; this is for
// legibility, not correctness.
const name = `Cypress project ${Date.now()}`;
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type(name);
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-description]").type(
"Created by the Cypress happy path.",
);
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-end-date]").type("30/09/2026");
// Required for an internal user creating on behalf of an organisation they
// are not a member of — without it the guard refuses, by design.
cy.get("[data-testid=create-project-domna-admin-access]").click();
cy.get("[data-testid=create-project-submit]").click();
cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/);
// The new project is now visible in the list, which is the visibility rule
// agreeing with the write that just happened.
cy.visit("/projects");
cy.get("[data-testid=projects-list]").should("exist");
cy.contains("[data-testid=projects-list-item]", name).should("be.visible");
});
});
/**
* Pick the first item in a shadcn/Radix `Select`. The options live in a portal
* outside the trigger, so this opens the trigger and then reaches for the
* listbox at the document root.
*/
function selectFirstOption(testId) {
cy.get(`[data-testid=${testId}]`).click();
cy.get("[role=option]").first().click();
}