Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/cli/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,23 @@ describe('generation through a live server', () => {
expect(code).toContain('name')
}, 30_000)

// Plan 6 audit, finding 8: this used to be a 200 with `code: ""`, and the
// panel's Copy types put that empty string on the clipboard.
it('400s types for a `[]` body with a message, rather than printing an empty string', async () => {
writeMocks({
'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: [] } } },
})
handle = await startServer({ root, config })

const res = await get(
`/__laqi/api/endpoints/${encodeURIComponent('GET /todos')}/types?response=ok`,
)
expect(res.status).toBe(400)
const { message } = (await res.json()) as { message: string }
expect(message).toMatch(/printed no declaration for Todos/)
expect(message).toMatch(/empty array/)
}, 30_000)

it('404s types for an endpoint or response that does not exist', async () => {
writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } })
handle = await startServer({ root, config })
Expand Down
2 changes: 1 addition & 1 deletion apps/documentation/src/content/docs/trying-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export interface Todo {

Click **Create**. The endpoint is written to `laqi/api.json` immediately,
with a response body already filled with seeded data generated from that
shape — `title` reads like a real sentence, `createdAt` is a date, `id` is
shape — `title` is a few lorem-ipsum words, `createdAt` is a date, `id` is
sequential — and the panel drops you straight into the endpoint's detail
view.

Expand Down
24 changes: 24 additions & 0 deletions packages/generate/src/export-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ describe('exporting a stored schema', () => {
it('says which language it does not know, rather than printing the wrong one', async () => {
await expect(exportTypes(OBJECT, 'cobol')).rejects.toThrow(/unknown language/)
}, 30_000)

// Plan 6 audit, finding 8, re-verified against the schema path: a stored
// schema whose root is an array of anything has nothing quicktype can
// declare, so every translated target fails with a message rather than
// handing back an empty string. TypeScript is laqi's own printer and has
// no such gap.
describe('a schema whose root is an array of anything', () => {
const ANYTHING_LIST = snapshotOf({ type: 'array', items: {} }, 'Todos')

it('fails with a message in a translated target instead of exporting nothing', async () => {
await expect(exportTypes(ANYTHING_LIST, 'typescript-zod')).rejects.toThrow(
/printed no declaration for Todos/,
)
await expect(exportTypes(ANYTHING_LIST, 'python')).rejects.toThrow(
/printed no declaration for Todos/,
)
}, 30_000)

it('still exports as TypeScript, which laqi prints itself', async () => {
const exported = await exportTypes(ANYTHING_LIST)
expect(exported.code).toContain('Todos')
expect(exported.code).toContain('unknown[]')
})
})
})

describe('what the exporter cannot express', () => {
Expand Down
59 changes: 37 additions & 22 deletions packages/generate/src/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,40 +83,55 @@ describe('generate', () => {
// so it degraded to plain "ends with id" — paid/valid/void/rapid were all
// id-classified. This test failed before the rule-table refactor and
// passes after it.
//
// The Plan 6 audit found an earlier version of this test vacuous: it
// asserted `>= 0`, which a sequential id satisfies too. An id-classified
// field is a per-field counter, so across three array items it reads
// exactly 1, 2, 3 — and that is what is asserted NOT to happen, next to
// the real `id` for which it must.
it('does not match paid/valid/void/rapid with endsWith("id") — word boundaries only', async () => {
const lookalikes = ['paid', 'valid', 'void', 'rapid', 'identifier']
for (const name of lookalikes) {
expect(ruleFor(name, 'integer'), `expected "${name}" to be a plain number`).toBe('number')
}

// Test with INTEGER type so the id branch is actually reached
const shape: Shape = {
const item: Shape = {
kind: 'object',
fields: [
{ name: 'paid', shape: primitive('integer'), optional: false },
{ name: 'valid', shape: primitive('integer'), optional: false },
{ name: 'void', shape: primitive('integer'), optional: false },
{ name: 'rapid', shape: primitive('integer'), optional: false },
{ name: 'identifier', shape: primitive('integer'), optional: false },
...lookalikes.map((name) => ({ name, shape: primitive('integer'), optional: false })),
{ name: 'userId', shape: primitive('integer'), optional: false },
{ name: 'user_id', shape: primitive('integer'), optional: false },
{ name: 'orderId', shape: primitive('integer'), optional: false },
{ name: 'id', shape: primitive('integer'), optional: false },
{ name: '_id', shape: primitive('integer'), optional: false },
],
}
const value = (await generate(shape, { seed: 42 })) as Record<string, unknown>
// paid, valid, void, rapid, identifier should NOT be sequential IDs
expect(value.paid).toBeGreaterThanOrEqual(0)
expect(value.valid).toBeGreaterThanOrEqual(0)
expect(value.void).toBeGreaterThanOrEqual(0)
expect(value.rapid).toBeGreaterThanOrEqual(0)
expect(value.identifier).toBeGreaterThanOrEqual(0)
// id and _id should be sequential (1, 2, ...)
expect(value.id).toBe(1)
expect(value._id).toBe(2)
const list = (await generate(
{ kind: 'array', items: item },
{ seed: 42, arrayLength: 3 },
)) as Record<string, unknown>[]
const column = (name: string) => list.map((value) => value[name])

// id and _id share one counter (same normalized name), so the sequence
// interleaves: 1, 3, 5 for id and 2, 4, 6 for _id.
expect(column('id')).toEqual([1, 3, 5])
expect(column('_id')).toEqual([2, 4, 6])
// The lookalikes are plain numbers: not a counter, never 1, 2, 3.
for (const name of lookalikes) {
expect(column(name), `"${name}" was generated as a sequential id`).not.toEqual([1, 2, 3])
for (const value of column(name)) {
expect(value).toBeGreaterThanOrEqual(0)
expect(value).toBeLessThanOrEqual(1000)
}
}
// Foreign keys should be in range [1, 1000]
expect(value.userId).toBeGreaterThanOrEqual(1)
expect(value.userId).toBeLessThanOrEqual(1000)
expect(value.user_id).toBeGreaterThanOrEqual(1)
expect(value.user_id).toBeLessThanOrEqual(1000)
expect(value.orderId).toBeGreaterThanOrEqual(1)
expect(value.orderId).toBeLessThanOrEqual(1000)
for (const name of ['userId', 'user_id', 'orderId']) {
for (const value of column(name)) {
expect(value).toBeGreaterThanOrEqual(1)
expect(value).toBeLessThanOrEqual(1000)
}
}
})

it('combined fixture with paid, valid, emailVerifiedAt, filename, username, id, userId, orderId', async () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/generate/src/print-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ describe('printTypes', () => {
expect(code).toMatch(/string/)
})

// Plan 6 audit, finding 8: a `[]` body is `array<unknown>`, which
// quicktype prints as nothing — no declaration, no error — so Copy types
// put an empty string on the clipboard with a 200.
describe('a root quicktype cannot name', () => {
const emptyBody: Shape = { kind: 'array', items: { kind: 'unknown' } }

it('fails with a message instead of printing an empty string', async () => {
await expect(printTypes(emptyBody, { typeName: 'Todos' })).rejects.toThrow(
/printed no declaration for Todos.*empty array/s,
)
}, 30_000)

it('fails the same way where quicktype prints only an import line', async () => {
await expect(
printTypes(emptyBody, { typeName: 'Todos', lang: 'typescript-zod' }),
).rejects.toThrow(/printed no declaration for Todos/)
}, 30_000)

it('still prints an empty array nested inside an object', async () => {
const shape: Shape = {
kind: 'object',
fields: [{ name: 'items', shape: emptyBody, optional: false }],
}
const { code } = await printTypes(shape, { typeName: 'Todos' })
expect(code).toContain('Todos')
expect(code).toContain('items')
}, 30_000)
})

it('smoke-emits every advertised language', async () => {
// Deep assertions only for TS and Zod; the rest must at least emit
// non-empty code without throwing.
Expand Down
30 changes: 30 additions & 0 deletions packages/generate/src/print-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,39 @@ export const printDocumentEffect = (
catch: (e) => new PrintError({ message: String(e) }),
})

// quicktype declares object types and nothing else. Hand it a root that
// is not one — an empty array, an array of bare values, a lone value —
// and most targets print nothing at all, Zod prints a bare import line,
// and none of them errors. A `[]` body is exactly that root, so without
// this check the panel's Copy types put an empty string on the clipboard
// with a 200.
if (!declares(code, options.typeName)) {
return yield* Effect.fail(
new PrintError({
message:
`${lang} printed no declaration for ${options.typeName}: this target only names ` +
'object types, and the root of this schema is not one (an empty array, an array ' +
'of bare values, or a lone value — typically a body that is `[]`). Give the body ' +
'an object to describe, or build a schema from a model, and export again.',
}),
)
}

return { code, language: lang }
})

/**
* Whether printed code names the type at all. Folded to lowercase
* alphanumerics on both sides, because a target may render `TodoItem` as
* `todo_item` or `TodoItemElement` and still have declared it; what no
* target does is declare a type without its name appearing somewhere.
*/
function declares(code: string, typeName: string): boolean {
const fold = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '')
const name = fold(typeName)
return name.length === 0 || fold(code).includes(name)
}

/** A Shape prints as the document it maps to. */
export const printTypesEffect = (
shape: Shape,
Expand Down
Loading