diff --git a/AGENTS.md b/AGENTS.md index cc015cf..0764a75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ The release commit writes `CHANGELOG.md`. Upgrade notes for that version follow ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. @@ -68,10 +68,6 @@ Verify a boundary against the imports a developer or agent would plausibly write `guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. - ### What enforces what | Rule | Enforced by | @@ -113,6 +109,5 @@ Verify a boundary against the imports a developer or agent would plausibly write | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | | A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index 4dbcf74..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "pack": "0.2.17", - "rules": { - "anti-slop/no-unsafe-dictionary-type": 1, - "typescript/no-unsafe-return": 2 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index eef79b1..050895f 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,5 +1,8 @@ { - "rules": {}, + "rules": { + "anti-slop/no-unsafe-dictionary-type": 1, + "typescript/no-unsafe-return": 1 + }, "tsExpectError": 5, "tsIgnore": 0, "safety": 4, diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/package.json b/package.json index 3187272..3713ba2 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", @@ -96,7 +94,8 @@ "release-it": "^19.2.4", "release-it-changelogen": "^0.1.0", "rimraf": "^6.1.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "zod": "4.5.4" }, "peerDependencies": { "@antelopejs/interface-api": ">=0.3.0 <1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f5c19c..e2d9042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + zod: + specifier: 4.5.4 + version: 4.5.4 packages: diff --git a/src/internal/deferred-joined-stream.ts b/src/internal/deferred-joined-stream.ts index b8bfc6c..7b3ede4 100644 --- a/src/internal/deferred-joined-stream.ts +++ b/src/internal/deferred-joined-stream.ts @@ -107,6 +107,7 @@ function forwardingDeferredJoinOperation( ): DeferredJoinedOperation { return function (this: DeferredJoinedStream, ...args: any[]) { // SAFETY: name comes from wireDeferredJoinedStream, which lists the own method names of Stream.prototype plus run, cursor and build, all present on any Stream, and Joined returns a Stream for the raw Stream it is given. + // oxlint-disable-next-line typescript/no-unsafe-return -- the method is looked up by a runtime name on a Stream, which TypeScript cannot relate to a signature; Reflect.apply and a typed lookup were tried and moved the finding to no-reflect-get, no-reflect-apply and no-unknown-returns return (materializeDeferredJoin(this) as any)[name](...args); }; } diff --git a/src/internal/field-types.ts b/src/internal/field-types.ts index a36e721..b73ef10 100644 --- a/src/internal/field-types.ts +++ b/src/internal/field-types.ts @@ -48,6 +48,7 @@ export interface FieldValues { * what the getter returned. Read a field through a guard. */ export interface RowFields { + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- a table row holds whatever its table class declares (relation instances, getter results), and a route reads each field through a guard, so no schema-derived value type exists for the fields of a schemaless store row [field: string]: unknown; } diff --git a/src/tests/components/access_control.test.ts b/src/tests/components/access_control.test.ts index 7bdf8db..1d3efe1 100644 --- a/src/tests/components/access_control.test.ts +++ b/src/tests/components/access_control.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import type { SchemaInstance } from "@antelopejs/interface-database"; import { Access, AccessMode, ModelReference } from "../../metadata"; @@ -50,6 +51,14 @@ class User extends Table { declare age: number; } class UserModel extends BasicDataModel(User, userTableName) {} + +const userRowSchema = z.looseObject({ + _id: z.string().optional(), + email: z.string().optional(), + password: z.string().optional(), + name: z.string().optional(), + age: z.number().optional(), +}); let database: SchemaInstance; const defaultUserDataset: Partial = { @@ -115,7 +124,7 @@ async function expectReadInReadWriteField() { const response = await getRequest(getFunctionName(), { id }); expect(response.status).to.equal(200); - const data = await readJson(response); + const data = await readJson(userRowSchema, response); expect(data.name).to.equal(defaultUserDataset.name); expect(data.age).to.equal(defaultUserDataset.age); expect(data.email).to.equal(defaultUserDataset.email); @@ -147,7 +156,7 @@ async function expectReadInWriteOnlyField() { const response = await getRequest(getFunctionName(), { id }); expect(response.status).to.equal(200); - const data = await readJson(response); + const data = await readJson(userRowSchema, response); expect(data.password).to.equal(undefined); } @@ -175,7 +184,7 @@ async function expectReadInReadOnlyField() { const response = await getRequest(getFunctionName(), { id }); expect(response.status).to.equal(200); - const data = await readJson(response); + const data = await readJson(userRowSchema, response); expect(data.email).to.equal(defaultUserDataset.email); } diff --git a/src/tests/components/computed.test.ts b/src/tests/components/computed.test.ts index 05d5f89..70423d7 100644 --- a/src/tests/components/computed.test.ts +++ b/src/tests/components/computed.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { GetMetadata } from "@antelopejs/interface-core"; import { Schema } from "@antelopejs/interface-database"; @@ -29,7 +30,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, @@ -68,19 +69,19 @@ class Device extends Table { } class DeviceModel extends BasicDataModel(Device, deviceTableName) {} -interface GroupListed { - _id: string; - name: string; - devices_count: number; - display_name: string; -} +const groupListedSchema = z.looseObject({ + _id: z.string(), + name: z.string(), + devices_count: z.number(), + display_name: z.string(), +}); -interface ListResponse { - results: GroupListed[]; - total: number; - offset: number; - limit: number; -} +const listSchema = listResponseSchema(groupListedSchema); +type GroupListedFields = Pick< + z.infer, + "name" | "devices_count" | "display_name" +>; +type ListResponse = z.infer; const groupsDataset: Partial[] = [ { name: "Alpha" }, @@ -187,7 +188,7 @@ async function _listGroups( const text = await response.text(); throw new Error(`list failed (${response.status}): ${text}`); } - return await readJson(response); + return await readJson(listSchema, response); } async function expectStoresComputedMetadata() { @@ -229,7 +230,7 @@ async function expectGetsRowWithComputedValues() { const text = await response.text(); throw new Error(`get failed (${response.status}): ${text}`); } - const group = await readJson(response); + const group = await readJson(groupListedSchema, response); expect(group.name).to.equal("Alpha"); expect(group.devices_count).to.equal(3); expect(group.display_name).to.equal("Alpha!"); @@ -309,9 +310,8 @@ async function expectIgnoresComputedFieldsOnEdit() { }); expect(response.status).to.equal(200); - const updatedGroup: Partial | undefined = await groupModel.get( - groupIds[0], - ); + const updatedGroup: Partial | undefined = + await groupModel.get(groupIds[0]); expect(updatedGroup?.name).to.equal("Alpha Prime"); expect(updatedGroup?.devices_count).to.equal(undefined); expect(updatedGroup?.display_name).to.equal(undefined); diff --git a/src/tests/components/filter_list_route.test.ts b/src/tests/components/filter_list_route.test.ts index 42e6338..f677c79 100644 --- a/src/tests/components/filter_list_route.test.ts +++ b/src/tests/components/filter_list_route.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -23,7 +24,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { getSchemaInstance, listRequest, request } from "../utils"; const currentTestName = path @@ -44,9 +45,9 @@ class Page extends Table { } class PageModel extends BasicDataModel(Page, pageTableName) {} -interface ListResponse { - results: { title: string; url: string }[]; -} +const listSchema = listResponseSchema( + z.looseObject({ title: z.string(), url: z.string() }), +); async function seedPages() { await RegisterSchema(schemaName); @@ -182,7 +183,7 @@ describe("List route - filter comparisons", () => { }); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["alpha"]); }); @@ -194,7 +195,7 @@ describe("List route - filter comparisons", () => { }); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["beta"]); }); @@ -206,7 +207,7 @@ describe("List route - filter comparisons", () => { }); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["alpha"]); }); @@ -218,7 +219,7 @@ describe("List route - filter comparisons", () => { }); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["alpha"]); }); }); diff --git a/src/tests/components/foreign_computed.test.ts b/src/tests/components/foreign_computed.test.ts index 2ce40c9..bd413d7 100644 --- a/src/tests/components/foreign_computed.test.ts +++ b/src/tests/components/foreign_computed.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -26,7 +27,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { getFunctionName, getRequest, @@ -66,17 +67,15 @@ class Member extends Table { } class MemberModel extends BasicDataModel(Member, memberTableName) {} -interface MemberListed { - _id: string; - label: string; - team: { _id: string; name: string }; - teammates: number; -} +const memberListedSchema = z.looseObject({ + _id: z.string(), + label: z.string(), + team: z.looseObject({ _id: z.string(), name: z.string() }), + teammates: z.number(), +}); -interface ListResponse { - results: MemberListed[]; - total: number; -} +const listSchema = listResponseSchema(memberListedSchema); +type ListResponse = z.infer; describe("Foreign and Computed fields on the same key", () => { it("lists the computed value read from the key and the resolved relation", async () => @@ -166,7 +165,7 @@ async function _list( ): Promise { const response = await request(testName, "list", "GET", undefined, query); expect(response.status, await response.clone().text()).to.equal(200); - return await readJson(response); + return await readJson(listSchema, response); } async function expectListsComputedFromForeignKey() { @@ -188,7 +187,7 @@ async function expectGetsComputedFromForeignKey() { const response = await getRequest(getFunctionName(), { id: memberIds[3] }); expect(response.status).to.equal(200); - const member = await readJson(response); + const member = await readJson(memberListedSchema, response); expect(member.teammates).to.equal(1); expect(member.team).to.deep.equal({ _id: teamIds[1], name: "Blue" }); } diff --git a/src/tests/components/foreign_joined.test.ts b/src/tests/components/foreign_joined.test.ts index c5bf83e..1c639c2 100644 --- a/src/tests/components/foreign_joined.test.ts +++ b/src/tests/components/foreign_joined.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { GetMetadata } from "@antelopejs/interface-core"; @@ -75,15 +76,18 @@ class Shelf extends Table { } class ShelfModel extends BasicDataModel(Shelf, shelfTableName) {} -interface ShelfListed { - _id: string; - book: { - _id: string; - title: string; - authorId: string; - authorName: string | null; - } | null; -} +const shelfListedSchema = z.looseObject({ + _id: z.string(), + book: z + .looseObject({ + _id: z.string(), + title: z.string(), + authorName: z.string().nullable(), + }) + .nullable(), +}); + +const shelfListSchema = z.looseObject({ results: z.array(shelfListedSchema) }); const orphanBookId = "orphan-book-id"; @@ -203,7 +207,7 @@ async function expectResolvesJoinedLabelInList() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson<{ results: ShelfListed[] }>(response); + const data = await readJson(shelfListSchema, response); const alpha = data.results.find((s) => s.book?.title === "Alpha Rising"); expect(alpha, "row for Alpha Rising").to.not.equal(undefined); @@ -223,7 +227,7 @@ async function expectReturnsNullForOrphanRelation() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson<{ results: ShelfListed[] }>(response); + const data = await readJson(shelfListSchema, response); const resolved = data.results.filter((s) => s.book?.title); expect(resolved).to.have.lengthOf(2); @@ -240,7 +244,7 @@ async function expectResolvesJoinedLabelInGet() { const text = await response.text(); throw new Error(`get failed (${response.status}): ${text}`); } - const shelf = await readJson(response); + const shelf = await readJson(shelfListedSchema, response); expect(shelf.book?.title).to.equal("Alpha Rising"); expect(shelf.book?.authorName).to.equal("Alice Carter"); } @@ -253,6 +257,6 @@ async function expectReturnsNullForOrphanRelationInGet() { const text = await response.text(); throw new Error(`get orphan failed (${response.status}): ${text}`); } - const shelf = await readJson(response); + const shelf = await readJson(shelfListedSchema, response); expect(shelf.book, "orphan relation value in get").to.equal(null); } diff --git a/src/tests/components/joined.test.ts b/src/tests/components/joined.test.ts index e64a0c3..0d36bba 100644 --- a/src/tests/components/joined.test.ts +++ b/src/tests/components/joined.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -27,7 +28,7 @@ import { Sortable, } from "../../metadata"; -import { type ListResponse, readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, @@ -71,20 +72,25 @@ class Book extends Table { } class BookModel extends BasicDataModel(Book, bookTableName) {} -interface BookListed { - _id: string; - authorId: string; - title: string; - price: number; - name: string | null; - email: string | null; -} +const bookListedSchema = z.looseObject({ + _id: z.string(), + authorId: z.string(), + title: z.string(), + price: z.number(), + name: z.string().nullable(), + email: z.string().nullable(), +}); -interface BookDisplayed { - title: string; - display: string | null; - name?: string; -} +const bookListSchema = z.looseObject({ + results: z.array(bookListedSchema), + total: z.number(), +}); + +const bookDisplayedSchema = z.looseObject({ + title: z.string(), + display: z.string().nullable(), + name: z.string().optional(), +}); const authorsDataset: Partial[] = [ { name: "Alice Carter", email: "alice@example.com" }, @@ -243,7 +249,7 @@ async function expectListsRowsWithJoinedFieldsFlattened() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson<{ results: BookListed[] }>(response); + const data = await readJson(bookListSchema, response); expect(data.results).to.have.lengthOf(5); const alphaRising = data.results.find((b) => b.title === "Alpha Rising"); @@ -265,7 +271,7 @@ async function expectGetsRowWithJoinedFieldsFlattened() { const text = await response.text(); throw new Error(`get failed (${response.status}): ${text}`); } - const book = await readJson(response); + const book = await readJson(bookListedSchema, response); expect(book.title).to.equal("Alpha Rising"); expect(book.name).to.equal("Alice Carter"); expect(book.email).to.equal("alice@example.com"); @@ -280,7 +286,7 @@ async function expectSortsByJoinedFieldAscending() { sortDirection: "asc", }); expect(response.status).to.equal(200); - const data = await readJson<{ results: BookListed[] }>(response); + const data = await readJson(bookListSchema, response); const namesInOrder = data.results .map((b) => b.name) .filter((n) => n !== null); @@ -296,7 +302,7 @@ async function expectSortsByJoinedFieldDescending() { sortDirection: "desc", }); expect(response.status).to.equal(200); - const data = await readJson<{ results: BookListed[] }>(response); + const data = await readJson(bookListSchema, response); const namesInOrder = data.results .map((b) => b.name) .filter((n) => n !== null); @@ -314,7 +320,7 @@ async function expectFiltersByJoinedField() { const text = await response.text(); throw new Error(`filter failed (${response.status}): ${text}`); } - const data = await readJson<{ results: BookListed[] }>(response); + const data = await readJson(bookListSchema, response); expect(data.results.length).to.equal(2); for (const book of data.results) { expect(book.name).to.equal("Alice Carter"); @@ -331,10 +337,7 @@ async function expectKeepsJoinedFieldsOnPaginatedRows() { limit: "2", }); expect(response.status).to.equal(200); - const data = await readJson<{ - results: BookListed[]; - total: number; - }>(response); + const data = await readJson(bookListSchema, response); expect(data.total).to.equal(5); expect(data.results.map((b) => b.title)).to.deep.equal([ "Alpha Rising", @@ -355,7 +358,10 @@ async function expectResolvesComputedFieldReadingNonPluckedJoinedField() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson( + listResponseSchema(bookDisplayedSchema), + response, + ); expect(data.total).to.equal(5); expect(data.results).to.have.lengthOf(5); @@ -370,7 +376,7 @@ async function expectReturnsNullForOrphanForeignKey() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson<{ results: BookListed[] }>(response); + const data = await readJson(bookListSchema, response); const orphan = data.results.find((b) => b.title === "Lost Chapter"); expect(orphan).to.not.equal(undefined); expect(orphan?.name).to.equal(null); diff --git a/src/tests/components/listable.test.ts b/src/tests/components/listable.test.ts index ecce6a4..2316f1f 100644 --- a/src/tests/components/listable.test.ts +++ b/src/tests/components/listable.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -24,7 +25,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { type ListResponse, readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { getFunctionName, getSchemaInstance, @@ -68,12 +69,26 @@ class Product extends Table { } class ProductModel extends BasicDataModel(Product, productTableName) {} -interface ProductListed { - _id: string; - name: string; - price: number; - addedAt: string; -} +const productRowSchema = z.looseObject({ + _id: z.string(), + reference: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + price: z.number().optional(), + addedAt: z.string().optional(), + internalNotes: z.string().optional(), + metadata: z.string().optional(), +}); + +const productListedSchema = z.looseObject({ + _id: z.string(), + name: z.string(), + price: z.number(), + addedAt: z.string(), +}); + +const productRowListSchema = listResponseSchema(productRowSchema); +const productListedListSchema = listResponseSchema(productListedSchema); const defaultProductDataset: Partial[] = [ { @@ -215,7 +230,7 @@ async function expectDefaultList() { const response = await listRequest(getFunctionName(), {}); expect(response.status).to.equal(200); - const data = await readJson<{ results: Product[] }>(response); + const data = await readJson(productRowListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const listed_products = data.results; const database_products = await _getDatabaseProducts( @@ -254,7 +269,7 @@ async function expectDetailedList() { {}, ); expect(response.status).to.equal(200); - const data = await readJson<{ results: Product[] }>(response); + const data = await readJson(productRowListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const listed_products = data.results; const database_products = await _getDatabaseProducts( @@ -304,7 +319,7 @@ async function expectTwoRowPage() { const response = await listRequest(getFunctionName(), { limit: "2" }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(2); expect(data.total).to.equal(defaultProductDataset.length); expect(data.limit).to.equal(2); @@ -322,7 +337,7 @@ async function expectFromPageTwo() { const response = await listRequest(getFunctionName(), { offset: "2" }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(1); expect(data.total).to.equal(defaultProductDataset.length); expect(data.offset).to.equal(2); @@ -338,7 +353,7 @@ async function expectTwoFirstPages() { const response = await listRequest(getFunctionName(), { maxPage: "2" }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); expect(data.total).to.equal(defaultProductDataset.length); expect(data.results[0]._id).to.not.equal(undefined); @@ -356,7 +371,7 @@ async function expectPageTwoOnly() { limit: "1", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(1); expect(data.total).to.equal(defaultProductDataset.length); expect(data.offset).to.equal(2); @@ -405,7 +420,7 @@ async function expectNameAscending() { sortDirection: "asc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedNames = data.results.map((product) => product.name); @@ -425,7 +440,7 @@ async function expectNameDescending() { sortDirection: "desc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedNames = data.results.map((product) => product.name); @@ -445,7 +460,7 @@ async function expectPriceAscending() { sortDirection: "asc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedPrices = data.results.map((product) => product.price); @@ -465,7 +480,7 @@ async function expectPriceDescending() { sortDirection: "desc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedPrices = data.results.map((product) => product.price); @@ -489,7 +504,7 @@ async function expectAddedAtAscending() { sortDirection: "asc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedDates = data.results.map((product) => new Date(product.addedAt).getTime(), @@ -512,7 +527,7 @@ async function expectAddedAtDescending() { sortDirection: "desc", }); expect(response.status).to.equal(200); - const data = await readJson>(response); + const data = await readJson(productListedListSchema, response); expect(data.results).to.have.length(defaultProductDataset.length); const sortedDates = data.results.map((product) => diff --git a/src/tests/components/localized_sort.test.ts b/src/tests/components/localized_sort.test.ts index 61220c7..a1604ab 100644 --- a/src/tests/components/localized_sort.test.ts +++ b/src/tests/components/localized_sort.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller, Parameter } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -48,10 +49,9 @@ class Fruit extends Table.with(LocalizationModifier) { } class FruitModel extends BasicDataModel(Fruit, fruitTableName) {} -interface FruitListed { - code: string; - name: string; -} +const fruitListSchema = z.looseObject({ + results: z.array(z.looseObject({ code: z.string(), name: z.string() })), +}); const fruits = [ { code: "apple", name: { en: "Apple", fr: "Pomme" } }, @@ -112,7 +112,7 @@ async function _sortedCodes( sortDirection, }); expect(response.status).to.equal(200); - const data = await readJson<{ results: FruitListed[] }>(response); + const data = await readJson(fruitListSchema, response); return data.results.map((fruit) => fruit.code); } diff --git a/src/tests/components/mandatory.test.ts b/src/tests/components/mandatory.test.ts index ee02587..6b43ec5 100644 --- a/src/tests/components/mandatory.test.ts +++ b/src/tests/components/mandatory.test.ts @@ -19,14 +19,13 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, getSchemaInstance, newRequest, request, - type CreatedRow, validateObject, } from "../utils"; @@ -165,7 +164,7 @@ async function expectNewWithAllMandatoryFields() { totalAmount: validOrderDataset.default.totalAmount, }); expect(response.status).to.equal(200); - const created = await readJson(response); + const created = await readJson(createdRowSchema, response); expect(created._id).to.be.a("string"); const order = await orderModel.get(created._id); expect(order).to.not.equal(undefined); diff --git a/src/tests/components/modifier_key.test.ts b/src/tests/components/modifier_key.test.ts index db5c91c..0deb050 100644 --- a/src/tests/components/modifier_key.test.ts +++ b/src/tests/components/modifier_key.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Schema } from "@antelopejs/interface-database"; import { Controller, Parameter } from "@antelopejs/interface-api"; import { @@ -68,16 +69,14 @@ class Post extends Table.with(LocalizationModifier) { } class PostModel extends BasicDataModel(Post, postTableName) {} -interface AuthorReference { - _id: string; - name: string; - email: string; -} +const postListedSchema = z.looseObject({ + _id: z.string(), + authorId: z + .looseObject({ _id: z.string(), name: z.string(), email: z.string() }) + .nullish(), +}); -interface PostListed { - _id: string; - authorId: AuthorReference | null; -} +const postListSchema = z.looseObject({ results: z.array(postListedSchema) }); const authorDataset: Partial[] = [ { name: "Alice Carter", email: "alice@example.com" }, @@ -166,7 +165,7 @@ async function expectGetsRowWithResolvedForeignReference() { locale, }); expect(response.status).to.equal(200); - const post = await readJson(response); + const post = await readJson(postListedSchema, response); expect(post.authorId).to.not.equal(null); expect(post.authorId?.name).to.equal("Alice Carter"); } @@ -179,7 +178,7 @@ async function expectGetsRowWithNullForeignReference() { locale, }); expect(response.status).to.equal(200); - const post = await readJson(response); + const post = await readJson(postListedSchema, response); expect(post.authorId).to.equal(null); } @@ -191,7 +190,7 @@ async function expectGetsRowWithDanglingForeignReference() { locale, }); expect(response.status).to.equal(200); - const post = await readJson(response); + const post = await readJson(postListedSchema, response); expect(post.authorId).to.equal(null); } @@ -200,7 +199,7 @@ async function expectListsRowsContainingNullForeignReferences() { const response = await listRequest(getFunctionName(), { locale }); expect(response.status).to.equal(200); - const data = await readJson<{ results: PostListed[] }>(response); + const data = await readJson(postListSchema, response); expect(data.results).to.have.lengthOf(3); const nullAuthorPosts = data.results.filter((post) => post.authorId == null); diff --git a/src/tests/components/parsed_value.test.ts b/src/tests/components/parsed_value.test.ts index 3a15d0f..535cd8d 100644 --- a/src/tests/components/parsed_value.test.ts +++ b/src/tests/components/parsed_value.test.ts @@ -24,13 +24,12 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, getSchemaInstance, newRequest, - type CreatedRow, } from "../utils"; import { isString } from "../../internal/guards"; @@ -132,7 +131,7 @@ async function expectWritesParsedValueOnNew() { startsAt, }); expect(response.status).to.equal(200); - const { _id: id } = await readJson(response); + const { _id: id } = await readJson(createdRowSchema, response); const event = await eventModel.get(id); expect(event?.startsAt).to.be.instanceOf(Date); @@ -166,7 +165,7 @@ async function expectWritesAsyncParsedValue() { code: " launch-26 ", }); expect(response.status).to.equal(200); - const { _id: id } = await readJson(response); + const { _id: id } = await readJson(createdRowSchema, response); const event = await eventModel.get(id); expect(event?.code).to.equal("LAUNCH-26"); diff --git a/src/tests/components/partial_edit.test.ts b/src/tests/components/partial_edit.test.ts index a00970c..9431d03 100644 --- a/src/tests/components/partial_edit.test.ts +++ b/src/tests/components/partial_edit.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller, Parameter } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -25,14 +26,13 @@ import { } from "@antelopejs/interface-database-decorators"; import { URL_BASE } from "../constants"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, getRequest, getSchemaInstance, newRequest, - type CreatedRow, } from "../utils"; const currentTestName = path @@ -68,10 +68,10 @@ class Article extends Table.with(LocalizationModifier) { } class ArticleModel extends BasicDataModel(Article, articleTableName) {} -interface LocalizedArticle { - title: string; - summary: string; -} +const localizedArticleSchema = z.looseObject({ + title: z.string(), + summary: z.string(), +}); const storedArticle: Partial
= { reference: "ART-001", @@ -178,14 +178,14 @@ async function _createLocalizedArticle(testName: string) { locale: allLocales, }); expect(response.status).to.equal(200); - const { _id: id } = await readJson(response); + const { _id: id } = await readJson(createdRowSchema, response); return id; } async function _getLocalized(testName: string, id: string, locale: string) { const response = await getRequest(testName, { id, locale }); expect(response.status).to.equal(200); - return await readJson(response); + return await readJson(localizedArticleSchema, response); } async function _rawRequest( @@ -286,7 +286,7 @@ async function expectRunsEverySetterOnNew() { reference: "ART-003", }); expect(response.status).to.equal(200); - const { _id: id } = await readJson(response); + const { _id: id } = await readJson(createdRowSchema, response); const article = await articleModel.get(id); expect(article?.slug).to.equal(defaultSlug); diff --git a/src/tests/components/per_action_access.test.ts b/src/tests/components/per_action_access.test.ts index 9684a46..36fdbe8 100644 --- a/src/tests/components/per_action_access.test.ts +++ b/src/tests/components/per_action_access.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { DataController, @@ -23,14 +24,13 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { editRequest, getFunctionName, getRequest, getSchemaInstance, newRequest, - type CreatedRow, } from "../utils"; const currentTestName = path @@ -58,6 +58,14 @@ class User extends Table { } class UserModel extends BasicDataModel(User, userTableName) {} +const userRowSchema = z.looseObject({ + _id: z.string().optional(), + email: z.string().optional(), + name: z.string().optional(), + role: z.string().optional(), + age: z.number().optional(), +}); + const defaultUserDataset: Partial = { name: "Jean Test", email: "jean.test@email.com", @@ -127,7 +135,7 @@ async function expectWritableInNewReadOnlyInEdit() { const getResponse = await getRequest(getFunctionName(), { id }); expect(getResponse.status).to.equal(200); - const data = await readJson(getResponse); + const data = await readJson(userRowSchema, getResponse); expect(data.role).to.equal("admin"); await editRequest(getFunctionName(), { role: "user" }, { id }); @@ -143,7 +151,7 @@ async function expectReadOnlyGloballyReadWriteInNew() { const newResponse = await newRequest(getFunctionName(), defaultUserDataset); expect(newResponse.status).to.equal(200); - const created = await readJson(newResponse); + const created = await readJson(createdRowSchema, newResponse); const newUser = await userModel.get(created._id); expect(newUser?.age).to.equal(30); @@ -153,6 +161,6 @@ async function expectReadOnlyGloballyReadWriteInNew() { const getResponse = await getRequest(getFunctionName(), { id }); expect(getResponse.status).to.equal(200); - const getData = await readJson(getResponse); + const getData = await readJson(userRowSchema, getResponse); expect(getData.age).to.equal(30); } diff --git a/src/tests/components/prototype_named_filter.test.ts b/src/tests/components/prototype_named_filter.test.ts index e47f65e..6f76925 100644 --- a/src/tests/components/prototype_named_filter.test.ts +++ b/src/tests/components/prototype_named_filter.test.ts @@ -1,4 +1,5 @@ import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -22,7 +23,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { listResponseSchema, readJson } from "../internal/http"; import { getSchemaInstance, listRequest } from "../utils"; const tableName = "prototype-named-filter-rows"; @@ -38,9 +39,7 @@ class Row extends Table { Field("string")(Row.prototype, "valueOf"); class RowModel extends BasicDataModel(Row, tableName) {} -interface ListResponse { - results: { title: string }[]; -} +const listSchema = listResponseSchema(z.looseObject({ title: z.string() })); async function createController(testName: string) { @RegisterDataController() @@ -80,7 +79,7 @@ describe("List route - a field named like an Object.prototype member", () => { const response = await listRequest("valueof-plain"); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.have.members([ "alpha", "beta", @@ -95,7 +94,7 @@ describe("List route - a field named like an Object.prototype member", () => { }); expect(response.status, await response.clone().text()).to.equal(200); - const data = await readJson(response); + const data = await readJson(listSchema, response); expect(data.results.map(({ title }) => title)).to.eql(["alpha"]); }); }); diff --git a/src/tests/components/validator.test.ts b/src/tests/components/validator.test.ts index 15b70a1..e99c96e 100644 --- a/src/tests/components/validator.test.ts +++ b/src/tests/components/validator.test.ts @@ -17,12 +17,11 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { editRequest, getSchemaInstance, newRequest, - type CreatedRow, validateObject, } from "../utils"; import { isNumber, isString } from "../../internal/guards"; @@ -231,7 +230,7 @@ function createCorrectValidator( if (id) { product_fetched = await productModel.get(id); } else { - const created = await readJson(response); + const created = await readJson(createdRowSchema, response); expect(created._id).to.be.a("string"); product_fetched = await productModel.get(created._id); } diff --git a/src/tests/index/inheritance.test.ts b/src/tests/index/inheritance.test.ts index 1eb7522..eb34055 100644 --- a/src/tests/index/inheritance.test.ts +++ b/src/tests/index/inheritance.test.ts @@ -1,4 +1,5 @@ import { expect } from "chai"; +import { z } from "zod"; import { Controller } from "@antelopejs/interface-api"; import { Access, AccessMode, Listable, ModelReference } from "../../metadata"; import { @@ -15,7 +16,7 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -import { readJson } from "../internal/http"; +import { createdRowSchema, readJson } from "../internal/http"; import { deleteRequest, editRequest, @@ -23,7 +24,6 @@ import { getSchemaInstance, listRequest, newRequest, - type CreatedRow, } from "../utils"; const NOTE_TABLE = "inheritance-notes"; @@ -84,9 +84,11 @@ class _ArchiveAPI extends DataController( declare archiveModel: ArchiveModel; } -interface NoteListResponse { - results: Partial[]; -} +const noteListSchema = z.looseObject({ + results: z.array( + z.looseObject({ _id: z.string().optional(), title: z.string().optional() }), + ), +}); describe("Derived data controllers", () => { let noteModel: NoteModel; @@ -106,14 +108,14 @@ describe("Derived data controllers", () => { const list = await listRequest(DERIVED_LOCATION); expect(list.status).to.equal(OK); - const { results } = await readJson(list); + const { results } = await readJson(noteListSchema, list); expect(results).to.deep.include({ _id: id, title: "Inherited" }); }); it("inherits the parent model reference for new, edit and delete", async () => { const created = await newRequest(DERIVED_LOCATION, { title: "Created" }); expect(created.status).to.equal(OK); - const { _id: id } = await readJson(created); + const { _id: id } = await readJson(createdRowSchema, created); expect(await noteModel.get(id)).to.have.property("title", "Created"); const edited = await editRequest( diff --git a/src/tests/index/routes.test.ts b/src/tests/index/routes.test.ts index 47bf924..4e7348e 100644 --- a/src/tests/index/routes.test.ts +++ b/src/tests/index/routes.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { expect } from "chai"; +import { z } from "zod"; import { Controller, type RequestContext } from "@antelopejs/interface-api"; import { Schema } from "@antelopejs/interface-database"; import { @@ -55,6 +56,16 @@ class User extends Table { } class UserModel extends BasicDataModel(User, userTableName) {} +const userRowSchema = z.looseObject({ + _id: z.string(), + email: z.string().optional(), + password: z.string().optional(), + name: z.string().optional(), + age: z.number().optional(), +}); + +const userListSchema = z.looseObject({ results: z.array(userRowSchema) }); + const validUserDataset = { default: { name: "Bob", @@ -188,10 +199,10 @@ async function validateUserUsingResponse( id: string, validDataset: Partial, ) { - const data = await readJson<{ results: User[] }>(response); + const data = await readJson(userListSchema, response); expect(response.status).to.equal(200); - let found: User | undefined; + let found: z.infer | undefined; if (data.results && Array.isArray(data.results)) { for (const user of data.results) { if (user._id === id) { @@ -213,7 +224,7 @@ async function validateUserUsingModel( } async function validateUser( - user: User | undefined, + user: Partial | undefined, validDataset: Partial, id?: string, ) { @@ -267,7 +278,7 @@ async function expectUsingRouteNew() { getFunctionName(), validUserDataset.default, ); - const created = await readJson(response); + const created = await readJson(userRowSchema, response); expect(created._id).to.be.a("string"); expect(created.name).to.equal(validUserDataset.default.name); expect(created.email).to.equal(validUserDataset.default.email); @@ -292,7 +303,7 @@ async function expectUsingRouteEdit() { validUserDataset.alternative, { id }, ); - const stored = await readJson(response); + const stored = await readJson(userRowSchema, response); expect(stored._id).to.equal(id); expect(stored.name).to.equal(validUserDataset.alternative.name); expect(stored.email).to.equal(validUserDataset.alternative.email); @@ -345,7 +356,8 @@ async function expectWritesParsedBodyAsHanded() { }, ); - const created = await readJson( + const created = await readJson( + userRowSchema, await newRequest(getFunctionName(), validUserDataset.alternative), ); expect(created.name).to.equal(stampedName); @@ -354,7 +366,8 @@ async function expectWritesParsedBodyAsHanded() { name: stampedName, }); - const edited = await readJson( + const edited = await readJson( + userRowSchema, await editRequest(getFunctionName(), { age: 51 }, { id }), ); expect(edited.name).to.equal(stampedName); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 33c3832..fdb7cdc 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,7 +1,12 @@ import { expect } from "chai"; +import { z } from "zod"; import { toPlainData } from "@antelopejs/interface-database-decorators/modifiers/common"; -import { type ListResponse, readJson } from "./internal/http"; +import { + createdRowSchema, + listResponseSchema, + readJson, +} from "./internal/http"; import "./internal/shop-api"; import { cleanTables, @@ -16,7 +21,6 @@ import { listRequest, newRequest, request, - type CreatedRow, validateObject, } from "./utils"; @@ -24,6 +28,17 @@ function plainRow(row: Product | Customer | undefined) { return row ? toPlainData(row) : {}; } +const customerSchema = z.looseObject({ + _id: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + city: z.string(), +}); +const productSchema = z.looseObject({ _id: z.string() }); +const customerListSchema = listResponseSchema(customerSchema); +const productListSchema = listResponseSchema(productSchema); + describe("Integration tests", () => { before(async () => { await initializeDatabase(); @@ -68,13 +83,13 @@ async function expectCustomerWorkflow() { const createResponse = await newRequest("customers", newCustomer); expect(createResponse.status).to.equal(200); - const created = await readJson(createResponse); + const created = await readJson(createdRowSchema, createResponse); expect(created._id).to.be.a("string"); const createdIds = [created._id]; const getResponse = await getRequest("customers", { id: createdIds[0] }); expect(getResponse.status).to.equal(200); - const customer = await readJson(getResponse); + const customer = await readJson(customerSchema, getResponse); await validateObject(customer, newCustomer, [ "firstName", "lastName", @@ -102,7 +117,7 @@ async function expectCustomerWorkflow() { limit: "2", }); expect(listResponse.status).to.equal(200); - const listData = await readJson<{ results: Customer[] }>(listResponse); + const listData = await readJson(customerListSchema, listResponse); expect(listData.results).to.have.length(2); const [ascending, unknownDirection] = await Promise.all( @@ -110,8 +125,8 @@ async function expectCustomerWorkflow() { listRequest("customers", { sortKey: "registrationDate", sortDirection }), ), ); - expect(await readJson(unknownDirection)).to.deep.equal( - await readJson(ascending), + expect(await readJson(z.unknown(), unknownDirection)).to.deep.equal( + await readJson(z.unknown(), ascending), ); } @@ -134,7 +149,7 @@ async function expectProductWorkflow() { const createResponse = await newRequest("products", newProduct); expect(createResponse.status).to.equal(200); - const createdIds = [(await readJson(createResponse))._id]; + const createdIds = [(await readJson(createdRowSchema, createResponse))._id]; const stockUpdate = { ...newProduct, stockQuantity: 25 }; const updateResponse = await editRequest("products", stockUpdate, { @@ -151,14 +166,14 @@ async function expectProductWorkflow() { limit: "3", }); expect(listResponse.status).to.equal(200); - const listData = await readJson<{ results: Product[] }>(listResponse); + const listData = await readJson(productListSchema, listResponse); expect(listData.results).to.have.length(3); const categoryResponse = await listRequest("products", { category: "electronics", }); expect(categoryResponse.status).to.equal(200); - const categoryData = await readJson>(categoryResponse); + const categoryData = await readJson(productListSchema, categoryResponse); expect(categoryData.results.length).to.be.greaterThan(0); } @@ -182,7 +197,7 @@ async function expectOrderWorkflow() { const orderResponse = await newRequest("orders", orderData); expect(orderResponse.status).to.equal(200); - const orderIds = [(await readJson(orderResponse))._id]; + const orderIds = [(await readJson(createdRowSchema, orderResponse))._id]; const orderItem1 = { orderId: orderIds[0], @@ -283,7 +298,7 @@ async function expectSearchAndFilters() { sortDirection: "desc", }); expect(categoryResponse.status).to.equal(200); - const categoryData = await readJson>(categoryResponse); + const categoryData = await readJson(productListSchema, categoryResponse); expect(categoryData.results.length).to.be.greaterThan(0); const activeCustomersResponse = await listRequest("customers", { @@ -292,7 +307,8 @@ async function expectSearchAndFilters() { sortDirection: "desc", }); expect(activeCustomersResponse.status).to.equal(200); - const activeCustomersData = await readJson>( + const activeCustomersData = await readJson( + customerListSchema, activeCustomersResponse, ); expect(activeCustomersData.results.length).to.be.greaterThan(0); @@ -302,8 +318,7 @@ async function expectSearchAndFilters() { offset: "1", }); expect(paginatedResponse.status).to.equal(200); - const paginatedData = - await readJson>(paginatedResponse); + const paginatedData = await readJson(productListSchema, paginatedResponse); expect(paginatedData.results).to.have.length(2); expect(paginatedData.total).to.be.greaterThan(2); @@ -338,8 +353,10 @@ async function expectClientPreferences() { sortDirection: "asc", }); expect(electronicsResponse.status).to.equal(200); - const electronicsData = - await readJson>(electronicsResponse); + const electronicsData = await readJson( + productListSchema, + electronicsResponse, + ); expect(electronicsData.results.length).to.be.greaterThan(0); const loyaltyUpdate = { ...plainRow(updatedCustomer), loyaltyPoints: 200 }; diff --git a/src/tests/internal/http.ts b/src/tests/internal/http.ts index f85f7ca..1c5c238 100644 --- a/src/tests/internal/http.ts +++ b/src/tests/internal/http.ts @@ -1,6 +1,7 @@ /** @internal @packageDocumentation */ import { IncomingMessage, ServerResponse } from "node:http"; import { Socket } from "node:net"; +import { z } from "zod"; import { HTTPResult, type RequestContext } from "@antelopejs/interface-api"; /** @@ -23,20 +24,35 @@ export function createRequestContext( }; } -/** What the `list` route answers: one page of rows and where it sits. */ -export interface ListResponse { - results: Row[]; - total: number; - offset: number; - limit: number; +/** + * Schema of what the `list` route answers around a row schema: one page of + * rows and where it sits. Fields the row schema does not name pass through. + * + * @param row Schema of one listed row + * @returns Schema of one page of rows + */ +export function listResponseSchema(row: Row) { + return z.looseObject({ + results: z.array(row), + total: z.number(), + offset: z.number(), + limit: z.number(), + }); } +/** What the `new` route answers: the created row, with its key. */ +export const createdRowSchema = z.looseObject({ _id: z.string() }); + /** - * Reads a response body as the JSON shape the caller expects. + * Reads a response body and parses it with the schema the caller expects. * + * @param schema Shape the body must have * @param response The response whose body is read * @returns The parsed body */ -export async function readJson(response: Response): Promise { - return JSON.parse(await response.text()); +export async function readJson( + schema: Schema, + response: Response, +): Promise> { + return schema.parse(JSON.parse(await response.text())); } diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 4e0f803..d9f3e3b 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { expect } from "chai"; import { Schema } from "@antelopejs/interface-database"; -import type { FieldValue, FieldValues } from "../metadata"; +import type { FieldValue } from "../metadata"; import { URL_BASE } from "./constants"; @@ -128,10 +128,10 @@ export async function deleteRequest( ); } -export async function validateObject( +export async function validateObject( object: T, - expectedObject: Partial, - fieldsToCheck: (keyof T)[], + expectedObject: Partial, + fieldsToCheck: (keyof T & keyof E)[], ) { for (const field of fieldsToCheck) { expect(object[field]).to.deep.equal(expectedObject[field]); @@ -144,10 +144,13 @@ export function getSchemaInstance(schemaName: string) { return schema.instance(); } -export async function validateObjectList( +export async function validateObjectList< + T extends { _id: string }, + E extends { _id?: unknown }, +>( objectList: T[], - expectedObjectList: Partial[], - fieldsToCheck: (keyof T)[], + expectedObjectList: Partial[], + fieldsToCheck: (keyof T & keyof E)[], ) { for (const object of objectList) { const id = object._id; @@ -159,8 +162,3 @@ export async function validateObjectList( } } } - -/** What the `new` route answers: the created row, with its key. */ -export interface CreatedRow extends FieldValues { - _id: string; -}