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
40 changes: 36 additions & 4 deletions packages/core/src/loop/acceptance/acceptance-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,50 @@ export function testIdsFor(key: string): ITestIds {
};
}

function isDateType(type: string): boolean {
return type === "date" || type === "datetime" || type === "timestamp";
}

// Whether a field should be treated as an email. Explicit TYPE wins over the name heuristic: a
// `date`/`number`-typed field named like an email (e.g. `emailVerifiedAt`) is NOT an email — else
// both the positive sample and the invalid-email negative feed a non-string value into a
// date/number column and 500 the insert (a false park). Shared by validValue AND negativesFor so
// the two can never disagree about whether a field is an email.
function isEmailField(field: { name: string; type: string }): boolean {
if (field.type === "email") {
return true;
}

if (field.type === "number" || isDateType(field.type)) {
return false;
}

return /email/i.test(field.name);
}

// Deterministic valid sample per field, seeded off (entityIndex, fieldName) — no Date/random.
function validValue(
field: { name: string; type: string },
seed: number
): string {
const isEmail = field.type === "email" || /email/i.test(field.name);

if (isEmail) {
// Explicit TYPE wins over name heuristics (see isEmailField).
if (isEmailField(field)) {
return `user${seed}@example.com`;
}

if (field.type === "number") {
return String(seed + 1);
}

if (isDateType(field.type)) {
// A real calendar date. Date-typed fields land in a DB date/timestamp column, so the generic
// `${name}-${seed}` sample would pass string-level validation (Zod z.string / TypeBox t.String)
// yet throw at the insert ("invalid input syntax for type timestamp") → the create 500s and the
// form never closes → false e2e park. Date-only ("YYYY-MM-DD") so it stays a substring of
// whatever timestamp representation the row cell renders back for the shows assertion.
return "2024-06-15";
}

if (/url|website/i.test(field.name)) {
return `https://example${seed}.com`;
}
Expand Down Expand Up @@ -77,7 +106,10 @@ function negativesFor(
out.push({ field: f.name, value: "", why: `${f.name} is required` });
}

if (!isForeignKey && (f.type === "email" || /email/i.test(f.name))) {
// Use the SAME email determination as validValue (type wins over name), so a date/number
// field named like an email (emailVerifiedAt) never gets a "not-an-email" negative — that
// value would reach a timestamp/number column and 500 (not the expected 400/422) → false park.
if (!isForeignKey && isEmailField(f)) {
out.push({
field: f.name,
value: "not-an-email",
Expand Down
107 changes: 107 additions & 0 deletions packages/core/tests/acceptance-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,110 @@ test("fieldIsMentioned matches on WORD BOUNDARIES, not raw substring", () => {
// And it isn't tripped by an unrelated word either.
expect(fieldIsMentioned(acceptField("name"), "the total amount")).toBe(false);
});

test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (not a `${name}-${seed}` string a timestamp column rejects)", () => {
// Regression: a `date`/`datetime`/`timestamp` field used to fall through to the generic
// "${name}-${seed}" sample (e.g. "dueDate-2"). That passes z.string()/t.String() but the app
// inserts it into a timestamptz column, where Postgres throws "invalid input syntax for type
// timestamp" → the create 500s and the form never closes → false e2e park.
// A strict calendar-date check: rejects impossible dates that `new Date()` would silently
// normalize (e.g. "2024-02-31" rolls to 2024-03-02, which a naive !isNaN check would accept).
const isRealCalendarDate = (s: string): boolean => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);

if (!m) {
return false;
}

const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
const dt = new Date(Date.UTC(year, month - 1, day));

return (
dt.getUTCFullYear() === year &&
dt.getUTCMonth() === month - 1 &&
dt.getUTCDate() === day
);
};

// Sanity-check the checker itself: it must reject a normalized-impossible date.
expect(isRealCalendarDate("2024-02-31")).toBe(false);
expect(isRealCalendarDate("2024-06-15")).toBe(true);

const datePlan: IProductPlan = {
product: "Tracker",
slices: [
{
entity: {
id: "Task",
desc: "t",
fields: [
{ name: "title", type: "string" },
{ name: "dueDate", type: "date" },
{ name: "startsAt", type: "datetime" },
{ name: "loggedAt", type: "timestamp" },
// Name matches the email heuristic but the TYPE is a date — type must win, in BOTH
// the positive sample (validValue) and the negatives (no "not-an-email").
{ name: "emailVerifiedAt", type: "timestamp", optional: true },
// A genuine email field — positive control: it SHOULD get the not-an-email negative.
{ name: "contactEmail", type: "email", optional: true },
// Number type whose name matches the email heuristic — type must win here too: a
// numeric sample + the negative-number negative, never an email value/negative.
{ name: "emailCount", type: "number", optional: true },
],
relationships: [],
rules: ["title is required"],
},
ui: {
screens: ["list", "form"],
action: "add",
shows: ["title", "dueDate"],
nav: "Tasks",
},
verification: {
mustRemainTrue: ["x"],
mustNotHappen: ["a task can be saved without a title"],
acceptanceCheck: "create a task",
},
},
],
};

const task = planToAcceptanceSpec(datePlan).entities[0];

// Cover every date-ish type the production branch handles, incl. a date field whose name
// matches the email heuristic (emailVerifiedAt) — TYPE must take precedence over name.
for (const name of ["dueDate", "startsAt", "loggedAt", "emailVerifiedAt"]) {
const field = task?.fields.find((f) => f.name === name);

expect(field, `expected field ${name}`).toBeDefined();
// NOT the generic garbage sample, and NOT an email (the name-precedence trap).
expect(field?.valid).not.toContain(`${name}-`);
expect(field?.valid).not.toContain("@");
// A real, valid calendar date (what a timestamp column accepts) — not a normalized impossible one.
expect(isRealCalendarDate(field?.valid ?? "")).toBe(true);
}

// Negatives must apply the SAME type-precedence: a date-typed field named like an email gets
// NO "not-an-email" negative (that value would 500 a timestamp insert, not 400/422 → false park),
// while a genuinely email-typed field DOES.
const emailNegs = (fieldName: string): boolean =>
(task?.negatives ?? []).some(
(n) => n.field === fieldName && n.value === "not-an-email"
);

expect(emailNegs("emailVerifiedAt")).toBe(false);
expect(emailNegs("contactEmail")).toBe(true);

// A NUMBER field named like an email must also resolve by type, not name: numeric valid sample
// (no "@"), and the negative-number negative — never a not-an-email negative.
const emailCount = task?.fields.find((f) => f.name === "emailCount");

expect(emailCount?.valid).not.toContain("@");
expect(emailCount?.valid).toMatch(/^\d+$/);
expect(emailNegs("emailCount")).toBe(false);
expect(
(task?.negatives ?? []).some(
(n) => n.field === "emailCount" && n.value === "-1"
)
).toBe(true);
});