From 6b9bed1e84fc5ce69123b03debd943af2817e38e Mon Sep 17 00:00:00 2001 From: cultron Date: Sat, 1 Aug 2026 11:27:53 -0500 Subject: [PATCH 1/4] fix(cli): honor per-entity table override in Python and Go generators (#107) Parity with #98/#106 (TypeScript). The Python and Go generators derived the table name as snakeCase(entity.name) and never read the per-entity `table` override, so an entity named `Order` with "table": "order_record" generated the SQL reserved word `order`. - Both generators now emit `tableName = entity.table || snakeCase(name)`. - Python template uses it for `__tablename__`, the self-referencing ManyToMany ForeignKey, join-table names, and index names, so every reference to this entity's own table follows the override consistently. - Go template uses it in `TableName()`. Without an override tableName === snakeCase(name), so output is unchanged for the common case. Added override + fallback tests to both suites. Full suite: 318 passed, 0 failed. fixes #107 --- src/lib/generators/go.ts | 3 ++ src/lib/generators/python.ts | 3 ++ src/lib/templates/go/models/model.eta | 4 +-- src/lib/templates/python/models/model.eta | 12 +++---- test/lib/generators/go.test.ts | 38 +++++++++++++++++++++ test/lib/generators/python.test.ts | 40 +++++++++++++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/lib/generators/go.ts b/src/lib/generators/go.ts index 0b59ece..20d32f4 100644 --- a/src/lib/generators/go.ts +++ b/src/lib/generators/go.ts @@ -206,6 +206,9 @@ export class GoGenerator extends BaseGenerator { createPrimaryKey, primaryKeyType, snakeCasedName: snakeCase(name), + // Honor an explicit per-entity `table` override; otherwise derive from + // the entity name. See apsoai/cli#107 (Python/Go parity with #98). + tableName: entity.table || snakeCase(name), createdAt, updatedAt, pluralizedName: camelCase(pluralize(name)), diff --git a/src/lib/generators/python.ts b/src/lib/generators/python.ts index 5297cb2..2631977 100644 --- a/src/lib/generators/python.ts +++ b/src/lib/generators/python.ts @@ -142,6 +142,9 @@ export class PythonGenerator extends BaseGenerator { createPrimaryKey, primaryKeyType, snakeCasedName: snakeCase(name), + // Honor an explicit per-entity `table` override; otherwise derive from + // the entity name. See apsoai/cli#107 (Python/Go parity with #98). + tableName: entity.table || snakeCase(name), createdAt, updatedAt, pluralizedName: camelCase(pluralize(name)), diff --git a/src/lib/templates/go/models/model.eta b/src/lib/templates/go/models/model.eta index 1cb619d..9e677e7 100644 --- a/src/lib/templates/go/models/model.eta +++ b/src/lib/templates/go/models/model.eta @@ -9,7 +9,7 @@ import ( <% } %> ) -// <%= it.name %> represents the <%= it.snakeCasedName %> table +// <%= it.name %> represents the <%= it.tableName %> table type <%= it.name %> struct { <% if (it.createPrimaryKey) { %> <% if (it.primaryKeyType === 'uuid') { %> @@ -48,5 +48,5 @@ type <%= it.name %> struct { // TableName overrides the table name func (<%= it.name %>) TableName() string { - return "<%= it.snakeCasedName %>" + return "<%= it.tableName %>" } diff --git a/src/lib/templates/python/models/model.eta b/src/lib/templates/python/models/model.eta index 24a8753..7f3e761 100644 --- a/src/lib/templates/python/models/model.eta +++ b/src/lib/templates/python/models/model.eta @@ -25,10 +25,10 @@ if TYPE_CHECKING: <% /* Generate ManyToMany junction tables */ %> <% it.associations.filter(a => a.type === 'ManyToMany' && a.joinTable).forEach((assoc) => { %> -<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %> = Table( - "<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>", +<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %> = Table( + "<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>", Base.metadata, - Column("<%= assoc.joinColumnName || `${it.snakeCasedName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.snakeCasedName %>.id"), primary_key=True), + Column("<%= assoc.joinColumnName || `${it.tableName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableName %>.id"), primary_key=True), Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= assoc.name.toLowerCase() %>.id"), primary_key=True), ) <% }) %> @@ -37,13 +37,13 @@ class <%= it.name %>(Base): """ SQLAlchemy model for <%= it.name %> """ - __tablename__ = "<%= it.snakeCasedName %>" + __tablename__ = "<%= it.tableName %>" <% /* Generate table arguments for indexes and unique constraints */ %> <% if ((it.indexes && it.indexes.length > 0) || (it.uniques && it.uniques.length > 0)) { %> __table_args__ = ( <% it.indexes.forEach((index) => { %> - Index("<%= index.name || `idx_${it.snakeCasedName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>), + Index("<%= index.name || `idx_${it.tableName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>), <% }) %> <% it.uniques.forEach((unique) => { %> UniqueConstraint(<%~ unique.fields.map(f => `"${f}"`).join(', ') %><% if (unique.name) { %>, name="<%= unique.name %>"<% } %>), @@ -105,7 +105,7 @@ columnsToProcess.forEach((field) => { -%> <% } else if (assoc.type === 'ManyToMany') { %> <%= assoc.pluralizedRelationshipName %>: Mapped[List["<%= assoc.name %>"]] = relationship( "<%= assoc.name %>", - secondary=<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>, + secondary=<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>, <% if (assoc.biDirectional) { %>back_populates="<%= assoc.inverseSidePropertyName %>",<% } %> lazy="selectin" ) diff --git a/test/lib/generators/go.test.ts b/test/lib/generators/go.test.ts index 5d97d99..52a22e7 100644 --- a/test/lib/generators/go.test.ts +++ b/test/lib/generators/go.test.ts @@ -76,6 +76,44 @@ describe("GoGenerator", () => { expect(content).toContain('return "blog_post"'); }); + test("honors an explicit per-entity table override (cli#107)", async () => { + const entity: Entity = { + name: "Order", + table: "order_record", + fields: [{ name: "total", type: "integer" }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const content = findFileContent(files, "order.go"); + expect(content).toBeDefined(); + expect(content).toContain('return "order_record"'); + // must NOT fall back to the reserved-word snake_case name + expect(content).not.toContain('return "order"'); + }); + + test("falls back to snake_case of the name when no override is set (cli#107)", async () => { + const entity: Entity = { + name: "Order", + fields: [{ name: "total", type: "integer" }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const content = findFileContent(files, "order.go"); + expect(content).toContain('return "order"'); + }); + test("output path uses snake_case filename", async () => { const entity: Entity = { name: "UserProfile", diff --git a/test/lib/generators/python.test.ts b/test/lib/generators/python.test.ts index 2bf4809..80d6cf5 100644 --- a/test/lib/generators/python.test.ts +++ b/test/lib/generators/python.test.ts @@ -73,6 +73,46 @@ describe("PythonGenerator", () => { }); }); + describe("table name override (cli#107)", () => { + test("honors an explicit per-entity table override", async () => { + const entity: Entity = { + name: "Order", + table: "order_record", + fields: [{ name: "total", type: "integer", nullable: false }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "order.py"); + expect(modelContent).toBeDefined(); + expect(modelContent).toContain('__tablename__ = "order_record"'); + // must NOT fall back to the reserved-word snake_case name + expect(modelContent).not.toContain('__tablename__ = "order"'); + }); + + test("falls back to snake_case of the name when no override is set", async () => { + const entity: Entity = { + name: "Order", + fields: [{ name: "total", type: "integer", nullable: false }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "order.py"); + expect(modelContent).toContain('__tablename__ = "order"'); + }); + }); + describe("default value handling", () => { test("string defaults render without HTML encoding", async () => { const entity: Entity = { From cfc35b1f2bb48cccac598e711b05d05cc297b0a9 Mon Sep 17 00:00:00 2001 From: cultron Date: Sun, 4 Oct 2026 19:05:40 -0500 Subject: [PATCH 2/4] fix(python): point ManyToOne and join-table FKs at the parent's real table PR #119 moves __tablename__ to the per-entity table override, but child models still wrote ForeignKey(".id"). With an override on the parent, or any multi-word parent (UserProfile -> user_profile, FK said userprofile), the FK named a table that does not exist. Pass every entity's table name into the model template and use it. --- src/lib/generators/python.ts | 4 ++++ src/lib/templates/python/models/model.eta | 4 ++-- test/lib/generators/python.test.ts | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib/generators/python.ts b/src/lib/generators/python.ts index 2631977..2f92a9e 100644 --- a/src/lib/generators/python.ts +++ b/src/lib/generators/python.ts @@ -145,6 +145,10 @@ export class PythonGenerator extends BaseGenerator { // Honor an explicit per-entity `table` override; otherwise derive from // the entity name. See apsoai/cli#107 (Python/Go parity with #98). tableName: entity.table || snakeCase(name), + // Foreign keys name the referenced entity's real table, override included. + tableNames: Object.fromEntries( + allEntities.map((e) => [e.name, e.table || snakeCase(e.name)]) + ), createdAt, updatedAt, pluralizedName: camelCase(pluralize(name)), diff --git a/src/lib/templates/python/models/model.eta b/src/lib/templates/python/models/model.eta index 7f3e761..9191325 100644 --- a/src/lib/templates/python/models/model.eta +++ b/src/lib/templates/python/models/model.eta @@ -29,7 +29,7 @@ if TYPE_CHECKING: "<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>", Base.metadata, Column("<%= assoc.joinColumnName || `${it.tableName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableName %>.id"), primary_key=True), - Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= assoc.name.toLowerCase() %>.id"), primary_key=True), + Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"), primary_key=True), ) <% }) %> @@ -93,7 +93,7 @@ columnsToProcess.forEach((field) => { -%> # Foreign key for <%= assoc.name %> <%= assoc.camelCasedId %>: Mapped[<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>Optional[UUID]<% } else { %>Optional[int]<% } %>] = mapped_column( <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, - ForeignKey("<%= assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>), + ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>), nullable=<%= assoc.nullable ? 'True' : 'False' %><% if (assoc.index) { %>, index=True<% } %> ) diff --git a/test/lib/generators/python.test.ts b/test/lib/generators/python.test.ts index 80d6cf5..4f37da3 100644 --- a/test/lib/generators/python.test.ts +++ b/test/lib/generators/python.test.ts @@ -317,6 +317,27 @@ describe("PythonGenerator", () => { expect(modelContent).toContain("ForeignKey("); expect(modelContent).toContain("relationship("); }); + + test("ManyToOne FK names the parent's real table (override, multi-word)", async () => { + const order: Entity = { name: "Order", table: "order_record", fields: [] }; + const profile: Entity = { name: "UserProfile", fields: [] }; + const item: Entity = { name: "LineItem", fields: [] }; + const relationships: Relationship[] = [ + { type: "ManyToOne", name: "Order", referenceName: "Order" }, + { type: "ManyToOne", name: "UserProfile", referenceName: "UserProfile" }, + ]; + + const files = await generator.generateEntity({ + entity: item, + relationships, + allEntities: [order, profile, item], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "lineitem.py"); + expect(modelContent).toContain('ForeignKey("order_record.id")'); + expect(modelContent).toContain('ForeignKey("user_profile.id")'); + }); }); describe("validation", () => { From f76394b20aa0503d4da70ca2b70857b1627bd7cb Mon Sep 17 00:00:00 2001 From: cultron Date: Sun, 4 Oct 2026 19:17:27 -0500 Subject: [PATCH 3/4] feat(test smoke): create, read and delete one row per entity apso test smoke only called GET /health and each list route. It now builds a payload per entity from the .apsorc (required fields only: not nullable, no default), creates entities parents first so required ManyToOne foreign keys (Id) can be set from the parent's id, reads each row back by id, and deletes children before parents. Entities it cannot fill (unsupported required types, cycles of required relationships, children of skipped entities) get a skip step with the reason instead of failing. The planner lives in src/lib/verify/crud-plan.ts with unit tests for the payload values and the ordering. --- src/commands/test/smoke.ts | 20 +- src/lib/verify/crud-plan.ts | 124 ++++++++++++ src/lib/verify/index.ts | 242 ++++++++++++++++++------ test/lib/verify/crud-plan.test.ts | 91 +++++++++ test/lib/verify/supports-pglite.test.ts | 2 +- 5 files changed, 410 insertions(+), 69 deletions(-) create mode 100644 src/lib/verify/crud-plan.ts create mode 100644 test/lib/verify/crud-plan.test.ts diff --git a/src/commands/test/smoke.ts b/src/commands/test/smoke.ts index f191a1a..f0e045e 100644 --- a/src/commands/test/smoke.ts +++ b/src/commands/test/smoke.ts @@ -5,7 +5,7 @@ import { report, smokeTest } from "../../lib/verify"; export default class TestSmoke extends BaseCommand { static description = - "Start the service on a local PGlite database and call /health and every entity's list endpoint (TypeScript services)"; + "Start the service on a local PGlite database and call /health, every entity's list endpoint, and a create/read/delete of one row per entity (TypeScript services)"; static examples = [`$ apso test smoke`]; @@ -22,17 +22,23 @@ export default class TestSmoke extends BaseCommand { async run(): Promise { const { flags } = await this.parse(TestSmoke); - const { entities, auth } = parseApsorc(); + const { entities, relationshipMap, auth } = parseApsorc(); // With auth configured every entity route needs a session, so only /health is checked. - const names = auth ? [] : entities.map((e) => e.name); - const result = await smokeTest(process.cwd(), names, { - skipBuild: flags["skip-build"], - }); + const result = await smokeTest( + process.cwd(), + auth ? [] : entities, + relationshipMap, + { skipBuild: flags["skip-build"] } + ); + const summary = + result.steps[0]?.name === "skipped" + ? "Smoke test skipped." + : "Smoke test passed."; report( (s) => this.log(s), result, flags.json, - result.ok ? "Smoke test passed." : undefined + result.ok ? summary : undefined ); if (!result.ok) this.exit(1); } diff --git a/src/lib/verify/crud-plan.ts b/src/lib/verify/crud-plan.ts new file mode 100644 index 0000000..d1fbf74 --- /dev/null +++ b/src/lib/verify/crud-plan.ts @@ -0,0 +1,124 @@ +/** + * What the smoke test creates, reads and deletes: one row per entity, built + * from the .apsorc, in an order where every required parent exists first. + */ + +import { randomUUID } from "crypto"; +import { Entity } from "../types/entity"; +import { Field } from "../types/field"; +import { RelationshipMap } from "../types/relationship"; +import { getRelationshipIdField } from "../utils/relationships"; + +const DATE = "2024-01-01T00:00:00.000Z"; + +/** A value for a required field, or undefined when its type can't be filled. */ +export function sampleValue(field: Field): unknown { + if (field.is_email) return "smoke@example.com"; + switch (field.type as string) { + case "text": + case "string": + case "varchar": + return "smoke".slice(0, Math.min(5, field.length ?? 5)); + case "integer": + case "int": + case "bigint": + return 1; + case "decimal": + case "numeric": + case "float": + return 1.5; + case "boolean": + return true; + case "date": + case "timestamp": + case "timestamptz": + case "datetime": + return DATE; + case "enum": + return field.values?.[0]; + case "uuid": + return randomUUID(); + case "json": + case "jsonb": + case "json-plain": + return {}; + default: + return undefined; + } +} + +/** Required = not nullable, no default, and not a user-declared primary key. */ +const required = (field: Field) => + !field.nullable && + (field.default === undefined || field.default === null) && + !field.primary; + +export interface PlannedEntity { + name: string; + payload: Record; + /** Foreign keys to fill from parents created earlier: property -> parent entity. */ + parents: { property: string; entity: string }[]; +} + +export interface CrudPlan { + /** Parents before children. */ + order: PlannedEntity[]; + skipped: { name: string; reason: string }[]; +} + +export function planCrud( + entities: Entity[], + relationships: RelationshipMap +): CrudPlan { + const pending: PlannedEntity[] = []; + const skipped: CrudPlan["skipped"] = []; + for (const entity of entities) { + const payload: Record = {}; + const bad = (entity.fields || []) + .filter((field) => required(field)) + .find((field) => { + payload[field.name] = sampleValue(field); + return payload[field.name] === undefined; + }); + if (bad) { + skipped.push({ + name: entity.name, + reason: `required field "${bad.name}" has type "${bad.type}", which the smoke test can't fill`, + }); + continue; + } + const parents = (relationships[entity.name] || []) + .filter((r) => r.type === "ManyToOne" && !r.nullable) + .map((r) => ({ property: getRelationshipIdField(r), entity: r.name })); + pending.push({ name: entity.name, payload, parents }); + } + + // Topological order: take entities whose required parents are all planned. + const order: PlannedEntity[] = []; + const planned = new Set(); + let progress = true; + while (progress) { + progress = false; + for (const p of pending) { + if (planned.has(p.name)) continue; + if (p.parents.every((parent) => planned.has(parent.entity))) { + order.push(p); + planned.add(p.name); + progress = true; + } + } + } + for (const p of pending) { + if (planned.has(p.name)) continue; + const missing = p.parents + .map((parent) => parent.entity) + .filter((name) => !planned.has(name)); + skipped.push({ + name: p.name, + reason: `needs a ${missing.join( + ", " + )} row first, which can't be created (a cycle of required relationships or a skipped entity)`, + }); + } + return { order, skipped }; +} diff --git a/src/lib/verify/index.ts b/src/lib/verify/index.ts index 3b6fcd2..d4186a6 100644 --- a/src/lib/verify/index.ts +++ b/src/lib/verify/index.ts @@ -5,13 +5,18 @@ * the CLI in a WebContainer) share these steps. */ -import { spawn } from "child_process"; +import { ChildProcess, spawn } from "child_process"; import * as fs from "fs"; import * as http from "http"; import * as net from "net"; import * as os from "os"; import * as path from "path"; import pluralize from "pluralize"; +import { Entity } from "../types/entity"; +import { TargetLanguage } from "../types/generator"; +import { RelationshipMap } from "../types/relationship"; +import { kebabCase } from "../utils/casing"; +import { planCrud } from "./crud-plan"; export interface Step { name: string; @@ -280,32 +285,191 @@ const freePort = () => s.on("error", reject); }); -const get = (url: string) => - new Promise<{ status: number; body: string }>((resolve) => { +type Res = { status: number; body: string }; + +const request = (method: string, url: string, body?: unknown) => + new Promise((resolve) => { + const data = body === undefined ? undefined : JSON.stringify(body); + const headers: http.OutgoingHttpHeaders = data + ? { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + } + : {}; http - .get(url, { timeout: 15_000 }, (res) => { - let body = ""; + .request(url, { method, headers, timeout: 15_000 }, (res) => { + let text = ""; res.on("data", (d) => { - body += d; + text += d; }); - res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, body: text }) + ); }) .on("timeout", function (this: http.ClientRequest) { this.destroy(new Error("No response within 15s")); }) - .on("error", (e) => resolve({ status: 0, body: e.message })); + .on("error", (e) => resolve({ status: 0, body: e.message })) + .end(data); + }); + +const get = (url: string) => request("GET", url); + +const expectStatus = (res: Res, ...ok: number[]) => ({ + ok: ok.includes(res.status), + output: ok.includes(res.status) + ? undefined + : `${res.status} ${tail(res.body, 500)}`, +}); + +const idOf = (body: string): unknown => { + try { + return JSON.parse(body).id ?? undefined; + } catch { + return undefined; + } +}; + +/** The route each language's generated controller serves an entity on. */ +export const entityRoute = (language: TargetLanguage, name: string): string => { + const plural = pluralize(name); + if (language === "python") return `/api/${plural.toLowerCase()}`; + if (language === "go") return `/api/${kebabCase(plural)}`; + return `/${plural}`; +}; + +/* eslint-disable no-await-in-loop */ +/** + * List every entity, then create, read and delete one row of each: parents + * before children on the way in, children before parents on the way out. + */ +async function checkEndpoints( + base: string, + language: TargetLanguage, + entities: Entity[], + relationships: RelationshipMap, + done: (s: Step) => boolean +): Promise { + const route = (name: string) => entityRoute(language, name); + for (const { name } of entities) { + done({ + name: `GET ${route(name)}`, + ...expectStatus(await get(base + route(name)), 200), + }); + } + + const plan = planCrud(entities, relationships); + for (const s of plan.skipped) + done({ name: `skip ${s.name}`, ok: true, output: s.reason }); + const ids = new Map(); + for (const p of plan.order) { + const r = route(p.name); + const failed = p.parents.find((parent) => !ids.has(parent.entity)); + if (failed) { + done({ + name: `skip ${p.name}`, + ok: true, + output: `needs a ${failed.entity} row, which failed to create`, + }); + continue; + } + const body = { ...p.payload }; + for (const parent of p.parents) + body[parent.property] = ids.get(parent.entity); + const created = await request("POST", base + r, body); + const id = [200, 201].includes(created.status) + ? idOf(created.body) + : undefined; + if ( + !done({ + name: `POST ${r}`, + ok: id !== undefined, + output: + id === undefined + ? `${created.status} ${tail(created.body, 500)}` + : undefined, + }) + ) + continue; + ids.set(p.name, id); + done({ + name: `GET ${r}/${id}`, + ...expectStatus(await get(`${base}${r}/${id}`), 200), + }); + } + for (const p of [...plan.order].reverse()) { + if (!ids.has(p.name)) continue; + const url = `${route(p.name)}/${ids.get(p.name)}`; + done({ + name: `DELETE ${url}`, + ...expectStatus(await request("DELETE", base + url), 200, 204), + }); + } +} + +/** Wait for the server's /health, run check against it, and stop the server. */ +async function serve( + server: ChildProcess, + port: number, + timeoutMs: number, + done: (s: Step) => boolean, + check: (base: string) => Promise +): Promise { + let log = ""; + server.stdout?.on("data", (d) => { + log += d; + }); + server.stderr?.on("data", (d) => { + log += d; }); + // A command that can't start (e.g. no python3) has no pid; keep its error. + server.on("error", (e) => { + log += e.message; + }); + try { + const base = `http://127.0.0.1:${port}`; + const deadline = Date.now() + timeoutMs; + let health = await get(`${base}/health`); + while ( + health.status !== 200 && + Date.now() < deadline && + server.exitCode === null && + server.pid !== undefined + ) { + await new Promise((r) => { + setTimeout(r, 500); + }); + health = await get(`${base}/health`); + } + if ( + done({ + name: "start", + ok: health.status === 200, + output: health.status === 200 ? undefined : tail(log), + }) + ) + await check(base); + } finally { + server.kill(); + } +} +/* eslint-enable no-await-in-loop */ + +export interface SmokeOptions { + skipBuild?: boolean; + timeoutMs?: number; +} /** * Start the built service on a PGlite database synced to its entities and call - * GET /health and the list endpoint of every entity. - * ponytail: list only, no create/read/delete; add when payloads can be built - * from required fields and relationships. + * GET /health, every entity's list endpoint, and a create/read/delete of one + * row per entity. */ export async function smokeTest( projectDir: string, - entityNames: string[], - { skipBuild = false, timeoutMs = 60_000 } = {} + entities: Entity[], + relationships: RelationshipMap = {}, + { skipBuild = false, timeoutMs = 60_000 }: SmokeOptions = {} ): Promise { if (!supportsPglite(projectDir)) return NO_PGLITE; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-")); @@ -314,7 +478,6 @@ export async function smokeTest( steps.push(s); return s.ok; }; - let server: ReturnType | undefined; try { if (!skipBuild && !done(step("build", await build(projectDir)))) return { ok: false, steps }; @@ -329,58 +492,15 @@ export async function smokeTest( return { ok: false, steps }; const port = await freePort(); - let log = ""; - server = spawn(process.execPath, ["dist/main"], { + const server = spawn(process.execPath, ["dist/main"], { cwd: projectDir, env: { ...process.env, ...pgliteEnv(dataDir), APP_PORT: String(port) }, }); - server.stdout?.on("data", (d) => { - log += d; - }); - server.stderr?.on("data", (d) => { - log += d; - }); - const base = `http://127.0.0.1:${port}`; - - const deadline = Date.now() + timeoutMs; - let health = await get(`${base}/health`); - while ( - health.status !== 200 && - Date.now() < deadline && - server.exitCode === null - ) { - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => { - setTimeout(r, 500); - }); - // eslint-disable-next-line no-await-in-loop - health = await get(`${base}/health`); - } - if ( - !done({ - name: "start", - ok: health.status === 200, - output: health.status === 200 ? undefined : tail(log), - }) - ) - return { ok: false, steps }; - - for (const name of entityNames) { - const route = `/${pluralize(name)}`; - // eslint-disable-next-line no-await-in-loop - const res = await get(base + route); - done({ - name: `GET ${route}`, - ok: res.status === 200, - output: - res.status === 200 - ? undefined - : `${res.status} ${tail(res.body, 500)}`, - }); - } + await serve(server, port, timeoutMs, done, (base) => + checkEndpoints(base, "typescript", entities, relationships, done) + ); return { ok: steps.every((s) => s.ok), steps }; } finally { - server?.kill(); fs.rmSync(dataDir, { recursive: true, force: true }); } } diff --git a/test/lib/verify/crud-plan.test.ts b/test/lib/verify/crud-plan.test.ts new file mode 100644 index 0000000..ccefaec --- /dev/null +++ b/test/lib/verify/crud-plan.test.ts @@ -0,0 +1,91 @@ +import { expect, describe, test } from "@jest/globals"; +import { planCrud, sampleValue } from "../../../src/lib/verify/crud-plan"; +import { entityRoute } from "../../../src/lib/verify"; +import { parseRelationships } from "../../../src/lib/utils/relationships"; +import { Entity } from "../../../src/lib/types"; + +describe("sampleValue", () => { + test("fills each supported type", () => { + expect(sampleValue({ name: "a", type: "text" })).toBe("smoke"); + expect(sampleValue({ name: "a", type: "text", length: 3 })).toBe("smo"); + expect(sampleValue({ name: "a", type: "text", is_email: true })).toBe("smoke@example.com"); + expect(sampleValue({ name: "a", type: "integer" })).toBe(1); + expect(sampleValue({ name: "a", type: "decimal" })).toBe(1.5); + expect(sampleValue({ name: "a", type: "boolean" })).toBe(true); + expect(sampleValue({ name: "a", type: "timestamptz" })).toBe("2024-01-01T00:00:00.000Z"); + expect(sampleValue({ name: "a", type: "enum", values: ["gold", "silver"] })).toBe("gold"); + expect(sampleValue({ name: "a", type: "json" })).toEqual({}); + expect(sampleValue({ name: "a", type: "uuid" as never })).toMatch(/^[\da-f-]{36}$/); + }); + + test("returns undefined for a type it can't fill", () => { + expect(sampleValue({ name: "a", type: "array" })).toBeUndefined(); + }); +}); + +describe("planCrud", () => { + const customer: Entity = { + name: "Customer", + fields: [ + { name: "name", type: "text" }, + { name: "nickname", type: "text", nullable: true }, + { name: "score", type: "integer", default: "0" }, + ], + }; + const order: Entity = { name: "Order", fields: [{ name: "total", type: "decimal" }] }; + const lineItem: Entity = { name: "LineItem", fields: [{ name: "qty", type: "integer" }] }; + + test("sends only required fields", () => { + const { order: planned } = planCrud([customer], {}); + expect(planned[0].payload).toEqual({ name: "smoke" }); + }); + + test("orders parents before children and names the foreign keys", () => { + const relationships = parseRelationships([ + { from: "LineItem", to: "Order", type: "ManyToOne" }, + { from: "Customer", to: "Order", type: "OneToMany", to_name: "purchases" }, + ]); + const plan = planCrud([lineItem, order, customer], relationships); + expect(plan.order.map((p) => p.name)).toEqual(["Customer", "Order", "LineItem"]); + expect(plan.order[1].parents).toEqual([{ property: "customerId", entity: "Customer" }]); + expect(plan.order[2].parents).toEqual([{ property: "orderId", entity: "Order" }]); + expect(plan.skipped).toEqual([]); + }); + + test("uses to_name for the foreign key and ignores nullable parents", () => { + const relationships = parseRelationships([ + { from: "Order", to: "Customer", type: "ManyToOne", to_name: "buyer" }, + { from: "LineItem", to: "Order", type: "ManyToOne", nullable: true }, + ]); + const plan = planCrud([customer, order, lineItem], relationships); + expect(plan.order.find((p) => p.name === "Order")?.parents).toEqual([ + { property: "buyerId", entity: "Customer" }, + ]); + expect(plan.order.find((p) => p.name === "LineItem")?.parents).toEqual([]); + }); + + test("skips cycles, unsupported types, and their children", () => { + const tags: Entity = { name: "Tagged", fields: [{ name: "tags", type: "array" }] }; + const note: Entity = { name: "Note", fields: [] }; + const a: Entity = { name: "A", fields: [] }; + const b: Entity = { name: "B", fields: [] }; + const relationships = parseRelationships([ + { from: "Note", to: "Tagged", type: "ManyToOne" }, + { from: "A", to: "B", type: "ManyToOne", bi_directional: false }, + { from: "B", to: "A", type: "ManyToOne", bi_directional: false }, + ]); + const plan = planCrud([tags, note, a, b, customer], relationships); + expect(plan.order.map((p) => p.name)).toEqual(["Customer"]); + expect(plan.skipped.map((s) => s.name).sort()).toEqual(["A", "B", "Note", "Tagged"]); + expect(plan.skipped.find((s) => s.name === "Tagged")?.reason).toContain('"array"'); + expect(plan.skipped.find((s) => s.name === "Note")?.reason).toContain("Tagged"); + }); +}); + +describe("entityRoute", () => { + test("matches each language's generated controller", () => { + expect(entityRoute("typescript", "LineItem")).toBe("/LineItems"); + expect(entityRoute("python", "LineItem")).toBe("/api/lineitems"); + expect(entityRoute("go", "LineItem")).toBe("/api/line-items"); + }); +}); diff --git a/test/lib/verify/supports-pglite.test.ts b/test/lib/verify/supports-pglite.test.ts index cd5214a..08e6368 100644 --- a/test/lib/verify/supports-pglite.test.ts +++ b/test/lib/verify/supports-pglite.test.ts @@ -24,7 +24,7 @@ describe("supportsPglite", () => { }); test("checks pass as skipped instead of failing a v1 project", async () => { - const result = await smokeTest(project("type: 'postgres'"), ["Widget"], { skipBuild: true }); + const result = await smokeTest(project("type: 'postgres'"), [{ name: "Widget" }], {}, { skipBuild: true }); expect(result.ok).toBe(true); expect(result.steps.map((s) => s.name)).toEqual(["skipped"]); }); From 7509a90e9b3b44eb0da6651098f7bca18dd7eaad Mon Sep 17 00:00:00 2001 From: cultron Date: Sun, 4 Oct 2026 19:18:12 -0500 Subject: [PATCH 4/4] feat(test smoke, migrate): Python and Go support where it is honest apso test smoke for Python and Go projects starts the generated server (uvicorn from .venv, or a go build ./cmd binary so stopping it cannot orphan the server) and runs the same HTTP checks against DATABASE_URL from the environment or .env. It never creates a database or tables; with no DATABASE_URL, or nothing listening at its host and port, the check is skipped with a message and reports "Smoke test skipped.". Routes follow each generator: /api/ (Python) and /api/ (Go). apso migrate generate and verify fail fast for Python and Go and name the native tool (Alembic, goose). The baseline, run and drift flow needs a scratch database; TypeScript has PGlite, the others do not. scaffold-smoke.sh: the go leg runs apso test smoke when SMOKE_DATABASE_URL points at an empty Postgres (opt in, off in CI). --- CLAUDE.md | 2 +- scripts/scaffold-smoke.sh | 8 ++ src/commands/migrate/generate.ts | 10 +- src/commands/migrate/verify.ts | 10 +- src/commands/test/smoke.ts | 9 +- src/lib/verify/index.ts | 159 ++++++++++++++++++++++++++++-- test/lib/verify/crud-plan.test.ts | 14 ++- 7 files changed, 197 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32cb45f..dd9c1d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ apso dev # Start local dev server via Docker Compose apso migrate # Test schema migrations locally (PGlite sandbox) apso migrate generate # Migration from a deployed .apsorc to this project's entities (TypeScript, PGlite) apso migrate verify # Run a migration on the deployed schema, then check for drift -apso test smoke # Start the service on PGlite; call /health and every list endpoint +apso test smoke # Start the service; call /health, list, and create/read/delete each entity (TS on PGlite, Python/Go on DATABASE_URL) apso schema validate # Validate local .apsorc (includes lint) apso schema lint # Lint .apsorc with @apso/schema-tools (--fix, --json) apso schema diff # Diff local vs remote schema diff --git a/scripts/scaffold-smoke.sh b/scripts/scaffold-smoke.sh index d7be914..bac0b09 100755 --- a/scripts/scaffold-smoke.sh +++ b/scripts/scaffold-smoke.sh @@ -14,6 +14,7 @@ # Exit non-zero on the first failure so CI surfaces a broken scaffold. # Run locally: bash scripts/scaffold-smoke.sh [typescript|python|go] # Optional: APSORC_FIXTURE=test/fixtures/.apsorc to generate from a fixture. +# Optional (go): SMOKE_DATABASE_URL= to also run `apso test smoke`. set -euo pipefail LANGUAGE="${1:-typescript}" @@ -103,6 +104,13 @@ case "$LANGUAGE" in "$(go env GOPATH)/bin/swag" init -g cmd/main.go -o docs echo "==> Compiling the scaffolded project" go build ./... + # Go services need a real Postgres. Opt in with an empty database, e.g. + # SMOKE_DATABASE_URL=postgres://me@localhost:5432/apso_smoke?sslmode=disable + # (DATABASE_SYNC lets the service create its tables there). + if [ -n "${SMOKE_DATABASE_URL:-}" ]; then + echo "==> Smoke testing the endpoints on SMOKE_DATABASE_URL" + DATABASE_URL="$SMOKE_DATABASE_URL" DATABASE_SYNC=true "$CLI_ROOT/bin/run" test smoke + fi ;; python) # The template requires Python >= 3.11; override with PYTHON=python3.12 diff --git a/src/commands/migrate/generate.ts b/src/commands/migrate/generate.ts index ab17555..dcb0426 100644 --- a/src/commands/migrate/generate.ts +++ b/src/commands/migrate/generate.ts @@ -1,7 +1,13 @@ import * as path from "path"; import { Flags } from "@oclif/core"; import BaseCommand from "../../lib/base-command"; -import { cliBinPath, generateMigration, report } from "../../lib/verify"; +import { parseApsorc } from "../../lib/apsorc-parser"; +import { + cliBinPath, + migrateUnsupported, + generateMigration, + report, +} from "../../lib/verify"; export default class MigrateGenerate extends BaseCommand { static description = @@ -26,6 +32,8 @@ export default class MigrateGenerate extends BaseCommand { async run(): Promise { const { flags } = await this.parse(MigrateGenerate); + const unsupported = migrateUnsupported(parseApsorc().language); + if (unsupported) this.error(unsupported); const result = await generateMigration( process.cwd(), path.resolve(flags.baseline), diff --git a/src/commands/migrate/verify.ts b/src/commands/migrate/verify.ts index 6bdd2d2..39a62b4 100644 --- a/src/commands/migrate/verify.ts +++ b/src/commands/migrate/verify.ts @@ -1,7 +1,13 @@ import * as path from "path"; import { Flags } from "@oclif/core"; import BaseCommand from "../../lib/base-command"; -import { cliBinPath, report, verifyMigration } from "../../lib/verify"; +import { parseApsorc } from "../../lib/apsorc-parser"; +import { + cliBinPath, + migrateUnsupported, + report, + verifyMigration, +} from "../../lib/verify"; export default class MigrateVerify extends BaseCommand { static description = @@ -32,6 +38,8 @@ export default class MigrateVerify extends BaseCommand { async run(): Promise { const { flags } = await this.parse(MigrateVerify); + const unsupported = migrateUnsupported(parseApsorc().language); + if (unsupported) this.error(unsupported); const result = await verifyMigration( process.cwd(), path.resolve(flags.baseline), diff --git a/src/commands/test/smoke.ts b/src/commands/test/smoke.ts index f0e045e..abd010e 100644 --- a/src/commands/test/smoke.ts +++ b/src/commands/test/smoke.ts @@ -5,13 +5,14 @@ import { report, smokeTest } from "../../lib/verify"; export default class TestSmoke extends BaseCommand { static description = - "Start the service on a local PGlite database and call /health, every entity's list endpoint, and a create/read/delete of one row per entity (TypeScript services)"; + "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers"; static examples = [`$ apso test smoke`]; static flags = { "skip-build": Flags.boolean({ - description: "Use the existing dist/ build instead of building first", + description: + "Use the existing dist/ build instead of building first (TypeScript)", default: false, }), json: Flags.boolean({ @@ -22,13 +23,13 @@ export default class TestSmoke extends BaseCommand { async run(): Promise { const { flags } = await this.parse(TestSmoke); - const { entities, relationshipMap, auth } = parseApsorc(); + const { entities, relationshipMap, auth, language } = parseApsorc(); // With auth configured every entity route needs a session, so only /health is checked. const result = await smokeTest( process.cwd(), auth ? [] : entities, relationshipMap, - { skipBuild: flags["skip-build"] } + { skipBuild: flags["skip-build"], language } ); const summary = result.steps[0]?.name === "skipped" diff --git a/src/lib/verify/index.ts b/src/lib/verify/index.ts index d4186a6..7da48cd 100644 --- a/src/lib/verify/index.ts +++ b/src/lib/verify/index.ts @@ -1,6 +1,6 @@ /** - * Pre-deploy checks for a generated TypeScript service, run against PGlite: - * generate the migration from the deployed schema, run it and check for drift, + * Pre-deploy checks for a generated service: generate the migration from the + * deployed schema, run it and check for drift (TypeScript only, on PGlite), * and smoke test the endpoints. The CLI and the in-browser editor (which runs * the CLI in a WebContainer) share these steps. */ @@ -124,6 +124,21 @@ export async function buildBaseline( } } +/** + * Why `apso migrate` can't run for this project's language, or undefined. + * ponytail: Python (Alembic) and Go (goose) have no local scratch database to + * build the baseline on; add when they get one like PGlite for TypeScript. + */ +export const migrateUnsupported = ( + language: TargetLanguage = "typescript" +): string | undefined => { + if (language === "python") + return "apso migrate supports TypeScript services only for now. This is a Python service: use Alembic directly (alembic revision --autogenerate, alembic upgrade head, alembic check)."; + if (language === "go") + return "apso migrate supports TypeScript services only for now. This is a Go service: write goose migrations in migrations/ and run them with `go run cmd/migrate/main.go up`."; + return undefined; +}; + export interface GenerateResult { ok: boolean; needed: boolean; @@ -458,19 +473,33 @@ async function serve( export interface SmokeOptions { skipBuild?: boolean; timeoutMs?: number; + language?: TargetLanguage; } /** - * Start the built service on a PGlite database synced to its entities and call - * GET /health, every entity's list endpoint, and a create/read/delete of one - * row per entity. + * Start the service and call GET /health, every entity's list endpoint, and a + * create/read/delete of one row per entity. TypeScript services run on a + * PGlite database synced to their entities; Python and Go services run on the + * database in DATABASE_URL. */ export async function smokeTest( projectDir: string, entities: Entity[], relationships: RelationshipMap = {}, - { skipBuild = false, timeoutMs = 60_000 }: SmokeOptions = {} + { + skipBuild = false, + timeoutMs = 60_000, + language = "typescript", + }: SmokeOptions = {} ): Promise { + if (language !== "typescript") + return smokeTestOnDatabaseUrl( + projectDir, + entities, + relationships, + language, + timeoutMs + ); if (!supportsPglite(projectDir)) return NO_PGLITE; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-")); const steps: Step[] = []; @@ -497,7 +526,7 @@ export async function smokeTest( env: { ...process.env, ...pgliteEnv(dataDir), APP_PORT: String(port) }, }); await serve(server, port, timeoutMs, done, (base) => - checkEndpoints(base, "typescript", entities, relationships, done) + checkEndpoints(base, language, entities, relationships, done) ); return { ok: steps.every((s) => s.ok), steps }; } finally { @@ -505,6 +534,122 @@ export async function smokeTest( } } +/** KEY=value lines of the project's .env; no interpolation. */ +export function readDotEnv(projectDir: string): Record { + let text = ""; + try { + text = fs.readFileSync(path.join(projectDir, ".env"), "utf8"); + } catch { + return {}; + } + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*?)\s*$/); + if (m) env[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2"); + } + return env; +} + +const canConnect = (host: string, port: number) => + new Promise((resolve) => { + const s = net.connect({ host, port, timeout: 3000 }); + const end = (ok: boolean) => { + s.destroy(); + resolve(ok); + }; + s.on("connect", () => end(true)); + s.on("timeout", () => end(false)); + s.on("error", () => end(false)); + }); + +const skipped = (output: string): CheckResult => ({ + ok: true, + steps: [{ name: "skipped", ok: true, output }], +}); + +/** + * Python and Go services have no embedded database, so they run against the + * one in DATABASE_URL (environment first, then .env), as configured: the + * smoke test never creates tables, and it skips when no database answers. + */ +async function smokeTestOnDatabaseUrl( + projectDir: string, + entities: Entity[], + relationships: RelationshipMap, + language: TargetLanguage, + timeoutMs: number +): Promise { + const env = { ...readDotEnv(projectDir), ...process.env }; + if (!env.DATABASE_URL) + return skipped( + "No DATABASE_URL in the environment or .env, so there is no database to run the service against. Set it and rerun." + ); + let db: URL; + try { + db = new URL(env.DATABASE_URL); + } catch { + return { + ok: false, + steps: [ + { name: "database", ok: false, output: "DATABASE_URL is not a URL." }, + ], + }; + } + const dbPort = Number(db.port) || 5432; + if (!(await canConnect(db.hostname, dbPort))) + return skipped( + `No database is listening at ${db.hostname}:${dbPort} (DATABASE_URL), so the service can't start. Start the database and rerun.` + ); + + const steps: Step[] = []; + const done = (s: Step) => { + steps.push(s); + return s.ok; + }; + const port = await freePort(); + const serverEnv = { ...env, PORT: String(port) }; + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-smoke-")); + try { + let server: ChildProcess; + if (language === "go") { + // A built binary, not `go run`: killing `go run` can leave the server running. + const bin = path.join(binDir, "server"); + if ( + !done( + step( + "build", + await run("go", ["build", "-o", bin, "./cmd"], projectDir) + ) + ) + ) + return { ok: false, steps }; + server = spawn(bin, [], { cwd: projectDir, env: serverEnv }); + } else { + const venv = path.join(projectDir, ".venv", "bin", "python"); + const python = fs.existsSync(venv) ? venv : "python3"; + server = spawn( + python, + [ + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + String(port), + ], + { cwd: projectDir, env: serverEnv } + ); + } + await serve(server, port, timeoutMs, done, (base) => + checkEndpoints(base, language, entities, relationships, done) + ); + return { ok: steps.every((s) => s.ok), steps }; + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } +} + /** The CLI's own entry point, for running `generate` in the baseline copy. */ export const cliBinPath = (root: string): string => path.join(root, "bin", "run"); diff --git a/test/lib/verify/crud-plan.test.ts b/test/lib/verify/crud-plan.test.ts index ccefaec..b0931b6 100644 --- a/test/lib/verify/crud-plan.test.ts +++ b/test/lib/verify/crud-plan.test.ts @@ -1,8 +1,11 @@ import { expect, describe, test } from "@jest/globals"; import { planCrud, sampleValue } from "../../../src/lib/verify/crud-plan"; -import { entityRoute } from "../../../src/lib/verify"; +import { entityRoute, readDotEnv } from "../../../src/lib/verify"; import { parseRelationships } from "../../../src/lib/utils/relationships"; import { Entity } from "../../../src/lib/types"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; describe("sampleValue", () => { test("fills each supported type", () => { @@ -89,3 +92,12 @@ describe("entityRoute", () => { expect(entityRoute("go", "LineItem")).toBe("/api/line-items"); }); }); + +describe("readDotEnv", () => { + test("reads KEY=value lines and strips quotes", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-env-")); + fs.writeFileSync(path.join(dir, ".env"), '# db\nDATABASE_URL="postgres://u:p@h:5433/d"\nPORT=3000\n'); + expect(readDotEnv(dir)).toEqual({ DATABASE_URL: "postgres://u:p@h:5433/d", PORT: "3000" }); + expect(readDotEnv(path.join(dir, "missing"))).toEqual({}); + }); +});