From d6591922c169b53523f24938bd061c2365148d7a Mon Sep 17 00:00:00 2001 From: leader Date: Thu, 5 Nov 2026 02:43:20 +0900 Subject: [PATCH 1/3] fix(generator): preserve nullable refs and clean action imports --- .../changepack_log_xK3eYh84WZThW7BPAezyD.json | 7 + .../__tests__/generate-server-actions.test.ts | 6 + .../generated-client-compile.test.ts | 165 ++++++++++++++++++ .../src/__tests__/nullable-ref.test.ts | 122 +++++++++++++ packages/generator/src/generate-schema.ts | 15 +- .../generator/src/generate-server-actions.ts | 46 +++-- packages/generator/src/openapi-utils.ts | 8 +- 7 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 .changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json create mode 100644 packages/generator/src/__tests__/generated-client-compile.test.ts create mode 100644 packages/generator/src/__tests__/nullable-ref.test.ts diff --git a/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json new file mode 100644 index 0000000..1bc08cb --- /dev/null +++ b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/generator/package.json": "Patch" + }, + "note": "Preserve nullable referenced enum and object types and omit unused imports from generated server actions", + "date": "2026-11-04T17:39:34.411812800Z" +} diff --git a/packages/generator/src/__tests__/generate-server-actions.test.ts b/packages/generator/src/__tests__/generate-server-actions.test.ts index 2d8b6e1..6fec471 100644 --- a/packages/generator/src/__tests__/generate-server-actions.test.ts +++ b/packages/generator/src/__tests__/generate-server-actions.test.ts @@ -69,6 +69,8 @@ describe('generateServerActionCode', () => { expect(result).toContain("'use server'") expect(result).toContain('export async function getUsers') + expect(result).toContain('DevupGetApiStructScope') + expect(result).not.toContain('DevupPostApiStructScope') }) test('does not generate actions when serverActions is explicitly disabled', () => { @@ -90,6 +92,8 @@ describe('generateServerActionCode', () => { expect(result).toContain("'use server'") expect(result).not.toContain('export async function getUsers') + expect(result).not.toContain('import ') + expect(result).toContain('export {}') }) test('does not generate actions when serverActions object is disabled', () => { @@ -111,6 +115,8 @@ describe('generateServerActionCode', () => { expect(result).toContain("'use server'") expect(result).not.toContain('export async function getUsers') + expect(result).not.toContain('import ') + expect(result).toContain('export {}') }) test('generates all operationIds with empty serverActions config', () => { diff --git a/packages/generator/src/__tests__/generated-client-compile.test.ts b/packages/generator/src/__tests__/generated-client-compile.test.ts new file mode 100644 index 0000000..a4006f4 --- /dev/null +++ b/packages/generator/src/__tests__/generated-client-compile.test.ts @@ -0,0 +1,165 @@ +import { describe, test } from 'bun:test' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import type { DevupApiOptions } from '@devup-api/core' +import type { OpenAPIV3_1 } from 'openapi-types' +import { generateInterface } from '../generate-interface' +import { + generateServerActionCode, + generateServerActionTypes, +} from '../generate-server-actions' + +const require = createRequire(import.meta.url) +const typescriptManifest = require.resolve('typescript/package.json') +const typescript = JSON.parse(readFileSync(typescriptManifest, 'utf8')) +const compiler = path.resolve( + path.dirname(typescriptManifest), + typescript.bin.tsc, +) +// Resolve @devup-api/fetch through its package self-reference in the fixture. +const cacheRoot = path.resolve(import.meta.dirname, '../../../fetch/.cache') + +function checkGeneratedClient(options?: DevupApiOptions, operationId = true) { + const nullable = { + anyOf: [ + { $ref: '#/components/schemas/ServiceType' }, + { type: 'null' as const }, + ], + } + const schemas: Record = { + 'openapi.json': { + openapi: '3.1.0', + info: { title: 'Nullable request', version: '1.0.0' }, + paths: { + '/inputs': { + post: { + ...(operationId ? { operationId: 'createInput' } : {}), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Input' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + ServiceType: { type: 'string', enum: ['B', 'E'] }, + Input: { + type: 'object', + required: ['requiredServiceType', 'nonNullable'], + properties: { + serviceType: nullable, + requiredServiceType: nullable, + nonNullable: { $ref: '#/components/schemas/ServiceType' }, + }, + }, + }, + }, + }, + } + mkdirSync(cacheRoot, { recursive: true }) + const directory = mkdtempSync(path.join(cacheRoot, 'generated-client-')) + const files = { + 'api.d.ts': generateInterface(schemas, options), + 'server.ts': generateServerActionCode(schemas, options), + 'server-module.d.ts': generateServerActionTypes(schemas, options), + 'client.ts': `import { createApi, type DevupObject } from '@devup-api/fetch' +type Input = DevupObject<'request'>['Input'] +export const input: Input = { serviceType: null, requiredServiceType: null, nonNullable: 'B' } +export const api = createApi('https://example.invalid') +export function checkTypes() { + void api.post('/inputs', { body: input }) + void api.post('/inputs', { body: { requiredServiceType: 'E', nonNullable: 'B' } }) + // @ts-expect-error values outside the enum must remain invalid + const badValue: Input = { ...input, serviceType: 'INVALID' } + // @ts-expect-error a required nullable field must still be present + const missing: Input = { nonNullable: 'B' } + // @ts-expect-error null must remain invalid on non-nullable references + const badNull: Input = { ...input, nonNullable: null } + void [badValue, missing, badNull] +} +`, + 'runtime.ts': `import { expect, spyOn } from 'bun:test' +import { api, input } from './client' +const fetcher = spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })) +await api.post('/inputs', { body: input }) +expect(fetcher).toHaveBeenCalledTimes(1) +const request = fetcher.mock.calls[0]?.[0] +if (!(request instanceof Request)) throw new Error('Expected a Request') +expect(request.url).toBe('https://example.invalid/inputs') +expect(await request.json()).toEqual({ serviceType: null, requiredServiceType: null, nonNullable: 'B' }) +fetcher.mockRestore() +`, + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'bundler', + lib: ['ESNext', 'DOM'], + types: [], + strict: true, + noEmit: true, + noUnusedLocals: true, + noUnusedParameters: true, + skipLibCheck: true, + }, + files: ['api.d.ts', 'client.ts', 'server.ts', 'server-module.d.ts'], + }), + } + try { + for (const [file, content] of Object.entries(files)) { + writeFileSync(path.join(directory, file), content) + } + for (const command of [ + [ + process.execPath, + compiler, + '--project', + path.join(directory, 'tsconfig.json'), + ], + [process.execPath, path.join(directory, 'runtime.ts')], + ]) { + const result = Bun.spawnSync(command, { cwd: directory, timeout: 20_000 }) + if (result.exitCode !== 0) { + throw new Error( + `${result.stdout.toString()}\n${result.stderr.toString()}`, + ) + } + } + } finally { + for (const file of Object.keys(files)) { + const target = path.join(directory, file) + if (existsSync(target)) unlinkSync(target) + } + rmdirSync(directory) + } +} + +describe('generated client compatibility', () => { + test.each([false, { enabled: false }] as const)( + 'compiles disabled server actions without unused imports: %j', + (serverActions) => checkGeneratedClient({ serverActions }), + ) + + test('compiles generated actions with only the used method scopes', () => { + checkGeneratedClient() + }) + + test('compiles an empty action module when operations have no operationId', () => { + checkGeneratedClient(undefined, false) + }) +}) diff --git a/packages/generator/src/__tests__/nullable-ref.test.ts b/packages/generator/src/__tests__/nullable-ref.test.ts new file mode 100644 index 0000000..eca4380 --- /dev/null +++ b/packages/generator/src/__tests__/nullable-ref.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'bun:test' +import type { OpenAPIV3_1 } from 'openapi-types' +import { generateInterface } from '../generate-interface' +import { formatTypeValue, getTypeFromSchema } from '../generate-schema' + +const reference = { $ref: '#/components/schemas/ServiceType' } +const nullable = { anyOf: [reference, { type: 'null' as const }] } + +const document: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'Nullable enum reference', version: '1.0.0' }, + paths: { + '/inputs': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Input' }, + }, + }, + }, + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Input' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ServiceType: { type: 'string', enum: ['B', 'E'] }, + Item: { type: 'object', properties: { id: { type: 'string' } } }, + Input: { + type: 'object', + required: ['requiredServiceType'], + properties: { + serviceType: nullable, + requiredServiceType: nullable, + nonNullable: reference, + }, + }, + }, + }, +} + +describe('nullable references', () => { + test.each([false, true])( + 'preserves optional and required enum nullability with requestDefaultNonNullable=%s', + (requestDefaultNonNullable) => { + const output = generateInterface( + { 'openapi.json': document }, + { requestDefaultNonNullable }, + ) + // Both request and response component types must preserve the contract. + expect(output.match(/serviceType\?: ServiceType \| null/g)).toHaveLength( + 2, + ) + expect( + output.match(/requiredServiceType: ServiceType \| null/g), + ).toHaveLength(2) + expect(output.match(/nonNullable\?: ServiceType;/g)).toHaveLength(2) + }, + ) + + test.each(['anyOf', 'oneOf'] as const)( + 'preserves %s nullability when resolving a reference inline', + (keyword) => { + const { type } = getTypeFromSchema( + { [keyword]: [reference, { type: 'null' }] }, + document, + ) + expect(formatTypeValue(type)).toBe('"B" | "E" | null') + }, + ) + + for (const componentType of ['request', 'response', 'error'] as const) { + test(`${componentType} references preserve enum and object nullability`, () => { + const options = { + serverName: 'openapi.json', + componentType, + usedSchemaNames: new Set(['ServiceType', 'Item']), + } + expect( + formatTypeValue(getTypeFromSchema(nullable, document, options).type), + ).toBe('ServiceType | null') + expect( + formatTypeValue( + getTypeFromSchema( + { + oneOf: [{ $ref: '#/components/schemas/Item' }, { type: 'null' }], + }, + document, + options, + ).type, + ), + ).toBe(`DevupObject<'${componentType}', 'openapi.json'>['Item'] | null`) + }) + } + + test('preserves nullability declared on a referenced enum', () => { + const schema = { type: 'string' as const, enum: ['B', 'E'], nullable: true } + const result = getTypeFromSchema( + reference, + { + ...document, + components: { schemas: { ServiceType: schema } }, + }, + { + serverName: 'openapi.json', + componentType: 'request', + usedSchemaNames: new Set(['ServiceType']), + }, + ) + expect(formatTypeValue(result.type)).toBe('ServiceType | null') + }) +}) diff --git a/packages/generator/src/generate-schema.ts b/packages/generator/src/generate-schema.ts index 83a0669..a8aefd5 100644 --- a/packages/generator/src/generate-schema.ts +++ b/packages/generator/src/generate-schema.ts @@ -140,6 +140,9 @@ export function getTypeFromSchema( return { type: 'unknown', default: undefined } } + // Normalized nullable unions carry nullability beside $ref, not on its target. + const nullable = isNullableSchema(schema) || isNullableSchema(resolved) + // If this $ref points to a used component schema, decide how to reference it if ( refSchemaName && @@ -152,7 +155,7 @@ export function getTypeFromSchema( if ('enum' in resolved && resolved.enum) { // Return the enum type name directly (e.g., "Gender" instead of DevupObject<...>['Gender']) return { - type: refSchemaName, + type: nullable ? `${refSchemaName} | null` : refSchemaName, default: undefined, } } @@ -164,6 +167,7 @@ export function getTypeFromSchema( schemaName: refSchemaName, serverName: options.serverName, componentType: options.componentType, + ...(nullable ? { __nullable: true } : {}), }, default: undefined, } @@ -176,7 +180,10 @@ export function getTypeFromSchema( ? { ...context, schemaName: refSchemaName, propertyPath: [] } : context - return getTypeFromSchema(resolved, document, { + const referencedSchema = nullable + ? { ...resolved, nullable: true } + : resolved + return getTypeFromSchema(referencedSchema, document, { ...options, context: updatedContext, propertyName: undefined, // Don't double-push property name @@ -563,6 +570,7 @@ function isComponentRef(value: unknown): value is { schemaName: string serverName: string componentType: 'request' | 'response' | 'error' + __nullable?: boolean } { return ( typeof value === 'object' && @@ -582,7 +590,8 @@ export function formatTypeValue(value: unknown, indent: number = 0): string { // Handle component reference marker if (isComponentRef(value)) { - return `DevupObject<'${value.componentType}', '${value.serverName}'>['${value.schemaName}']` + const reference = `DevupObject<'${value.componentType}', '${value.serverName}'>['${value.schemaName}']` + return value.__nullable ? `${reference} | null` : reference } // Handle array type marker diff --git a/packages/generator/src/generate-server-actions.ts b/packages/generator/src/generate-server-actions.ts index 7b57ccd..2df35f6 100644 --- a/packages/generator/src/generate-server-actions.ts +++ b/packages/generator/src/generate-server-actions.ts @@ -134,22 +134,25 @@ export function generateServerActionCode( const operations = isServerActionsEnabled(options) ? collectOperations(schemas, options) : [] - const apiDeclarations = generateApiDeclarations(operations, options) - const actions = operations.map(generateAction).join('\n\n') - - return `// Auto-generated Server Actions from OpenAPI specs + const header = `// Auto-generated Server Actions from OpenAPI specs // Do not edit this file directly 'use server' -import type { +` + if (operations.length === 0) return `${header}export {}\n` + const apiDeclarations = generateApiDeclarations(operations, options) + const actions = operations.map(generateAction).join('\n\n') + const methodScopes = [ + ...new Set( + operations.map((operation) => getMethodScopeType(operation.method)), + ), + ] + + return `${header}import type { Additional, ApiOption, ExtractValue, - DevupDeleteApiStructScope, - DevupGetApiStructScope, - DevupPatchApiStructScope, - DevupPostApiStructScope, - DevupPutApiStructScope, + ${methodScopes.join(',\n ')}, DevupApiResponse, SerializedResponse, } from '@devup-api/fetch' @@ -169,21 +172,26 @@ export function generateServerActionTypes( const operations = isServerActionsEnabled(options) ? collectOperations(schemas, options) : [] - const declarations = operations.map(generateActionDeclaration).join('\n\n') - - return `// Auto-generated Server Action types from OpenAPI specs + const header = `// Auto-generated Server Action types from OpenAPI specs // Do not edit this file directly -declare module '@devup-api/fetch/server-generated' { +` + if (operations.length === 0) { + return `${header}declare module '@devup-api/fetch/server-generated' {}\n` + } + const declarations = operations.map(generateActionDeclaration).join('\n\n') + const methodScopes = [ + ...new Set( + operations.map((operation) => getMethodScopeType(operation.method)), + ), + ] + + return `${header}declare module '@devup-api/fetch/server-generated' { import type { Additional, ApiOption, ExtractValue, - DevupDeleteApiStructScope, - DevupGetApiStructScope, - DevupPatchApiStructScope, - DevupPostApiStructScope, - DevupPutApiStructScope, + ${methodScopes.join(',\n ')}, DevupApiResponse, SerializedResponse, } from '@devup-api/fetch' diff --git a/packages/generator/src/openapi-utils.ts b/packages/generator/src/openapi-utils.ts index 9b87fb6..aa52bdb 100644 --- a/packages/generator/src/openapi-utils.ts +++ b/packages/generator/src/openapi-utils.ts @@ -123,11 +123,15 @@ export type MaybeNullableSchema = ( * OpenAPI 3.0: uses `nullable: true` * OpenAPI 3.1: uses type array like `["string", "null"]` */ -export function isNullableSchema(schema: OpenAPIV3_1.SchemaObject): boolean { +export function isNullableSchema(schema: MaybeNullableSchema): boolean { if ('nullable' in schema && schema.nullable === true) { return true } - if (Array.isArray(schema.type) && schema.type.includes('null')) { + if ( + 'type' in schema && + Array.isArray(schema.type) && + schema.type.includes('null') + ) { return true } return false From 23a08364fe1442ac22d90e138686804e529423dd Mon Sep 17 00:00:00 2001 From: leader Date: Thu, 5 Nov 2026 08:19:47 +0900 Subject: [PATCH 2/3] test(utils): restore filesystem spies between tests --- .changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json | 5 +++-- packages/utils/src/__tests__/create-tmp-dir.test.ts | 8 +++++++- packages/utils/src/__tests__/read-openapi.test.ts | 7 ++++++- packages/utils/src/__tests__/write-interface.test.ts | 7 ++++++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json index 1bc08cb..da87c6b 100644 --- a/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json +++ b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json @@ -1,7 +1,8 @@ { "changes": { - "packages/generator/package.json": "Patch" + "packages/generator/package.json": "Patch", + "packages/utils/package.json": "Patch" }, - "note": "Preserve nullable referenced enum and object types and omit unused imports from generated server actions", + "note": "Preserve nullable referenced enum and object types, omit unused imports from generated server actions, and restore filesystem test spies so generated-client checks do not depend on test file order", "date": "2026-11-04T17:39:34.411812800Z" } diff --git a/packages/utils/src/__tests__/create-tmp-dir.test.ts b/packages/utils/src/__tests__/create-tmp-dir.test.ts index 446fc25..94032b1 100644 --- a/packages/utils/src/__tests__/create-tmp-dir.test.ts +++ b/packages/utils/src/__tests__/create-tmp-dir.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { createTmpDir, createTmpDirAsync } from '../create-tmp-dir' @@ -13,6 +13,12 @@ beforeEach(() => { mockMkdir = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) }) +afterEach(() => { + mockExistsSync.mockRestore() + mockMkdirSync.mockRestore() + mockMkdir.mockRestore() +}) + test.each([ ['test-dir-1'], ['test-dir-2'], diff --git a/packages/utils/src/__tests__/read-openapi.test.ts b/packages/utils/src/__tests__/read-openapi.test.ts index a42f998..3f7083d 100644 --- a/packages/utils/src/__tests__/read-openapi.test.ts +++ b/packages/utils/src/__tests__/read-openapi.test.ts @@ -1,5 +1,5 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: test */ -import { beforeEach, expect, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import type { OpenAPIV3_1 } from 'openapi-types' @@ -30,6 +30,11 @@ beforeEach(() => { ) }) +afterEach(() => { + mockReadFileSync.mockRestore() + mockReadFile.mockRestore() +}) + test('readOpenapi reads and parses OpenAPI file', () => { const filePath = 'openapi.json' const result = readOpenapis([filePath]) diff --git a/packages/utils/src/__tests__/write-interface.test.ts b/packages/utils/src/__tests__/write-interface.test.ts index f1c018f..70dc914 100644 --- a/packages/utils/src/__tests__/write-interface.test.ts +++ b/packages/utils/src/__tests__/write-interface.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { writeInterface, writeInterfaceAsync } from '../write-interface' @@ -11,6 +11,11 @@ beforeEach(() => { mockWriteFile = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) }) +afterEach(() => { + mockWriteFileSync.mockRestore() + mockWriteFile.mockRestore() +}) + test('writeInterface writes content to file', () => { const filePath = 'output.ts' const content = 'export interface Test {}' From d49ea63c28ae98a83d96d44cc4d265584dbe1139 Mon Sep 17 00:00:00 2001 From: leader Date: Thu, 5 Nov 2026 08:23:56 +0900 Subject: [PATCH 3/3] test: cover request and schema fallbacks with Bun 1.4.4 --- .../changepack_log_xK3eYh84WZThW7BPAezyD.json | 3 ++- packages/fetch/src/__tests__/api.test.ts | 12 ++++++++++ .../src/__tests__/generate-interface.test.ts | 8 +++++++ .../src/__tests__/nullable-ref.test.ts | 22 ++++++++++++++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json index da87c6b..e43d382 100644 --- a/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json +++ b/.changepacks/changepack_log_xK3eYh84WZThW7BPAezyD.json @@ -1,8 +1,9 @@ { "changes": { "packages/generator/package.json": "Patch", + "packages/fetch/package.json": "Patch", "packages/utils/package.json": "Patch" }, - "note": "Preserve nullable referenced enum and object types, omit unused imports from generated server actions, and restore filesystem test spies so generated-client checks do not depend on test file order", + "note": "Preserve nullable referenced enum and object types, omit unused imports from generated server actions, restore filesystem test spies, and verify request defaults and empty-schema generation with Bun 1.4.4 coverage", "date": "2026-11-04T17:39:34.411812800Z" } diff --git a/packages/fetch/src/__tests__/api.test.ts b/packages/fetch/src/__tests__/api.test.ts index 6c6ad86..370d306 100644 --- a/packages/fetch/src/__tests__/api.test.ts +++ b/packages/fetch/src/__tests__/api.test.ts @@ -20,6 +20,18 @@ afterEach(() => { globalThis.fetch = originalFetch }) +test('request defaults to GET when no method is specified', async () => { + const api = new DevupApi('https://api.example.com', undefined, 'openapi.json') + const fetcher = globalThis.fetch as unknown as ReturnType + + await api.request('/test' as never) + + expect(fetcher).toHaveBeenCalledTimes(1) + const request = fetcher.mock.calls[0]?.[0] as Request + expect(request.method).toBe('GET') + expect(request.url).toBe('https://api.example.com/test') +}) + test.each([ ['https://api.example.com', 'https://api.example.com'], ['https://api.example.com/', 'https://api.example.com'], diff --git a/packages/generator/src/__tests__/generate-interface.test.ts b/packages/generator/src/__tests__/generate-interface.test.ts index 638c033..b3a062c 100644 --- a/packages/generator/src/__tests__/generate-interface.test.ts +++ b/packages/generator/src/__tests__/generate-interface.test.ts @@ -3,6 +3,14 @@ import { expect, test } from 'bun:test' import type { OpenAPIV3_1 } from 'openapi-types' import { generateInterface } from '../generate-interface' +test('omits precomputed mappings when no OpenAPI documents are supplied', () => { + const output = generateInterface({}) + + expect(output).toContain('interface DevupApiServers') + expect(output).not.toContain('interface DevupPrecomputedMethodKeys') + expect(output).not.toContain('interface DevupPrecomputedScopes') +}) + const createDocument = ( document: Partial = {}, ): OpenAPIV3_1.Document => diff --git a/packages/generator/src/__tests__/nullable-ref.test.ts b/packages/generator/src/__tests__/nullable-ref.test.ts index eca4380..3ed9b99 100644 --- a/packages/generator/src/__tests__/nullable-ref.test.ts +++ b/packages/generator/src/__tests__/nullable-ref.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test' import type { OpenAPIV3_1 } from 'openapi-types' import { generateInterface } from '../generate-interface' -import { formatTypeValue, getTypeFromSchema } from '../generate-schema' +import { + createSchemaContext, + formatTypeValue, + getTypeFromSchema, +} from '../generate-schema' const reference = { $ref: '#/components/schemas/ServiceType' } const nullable = { anyOf: [reference, { type: 'null' as const }] } @@ -50,6 +54,22 @@ const document: OpenAPIV3_1.Document = { } describe('nullable references', () => { + test('uses the referenced enum name without leaking the caller context', () => { + const context = createSchemaContext('Wrapper') + context.propertyPath.push('parent') + + const result = getTypeFromSchema(nullable, document, { + context, + propertyName: 'selected', + }) + + expect(formatTypeValue(result.type)).toBe('ServiceType | null') + expect(context.schemaName).toBe('Wrapper') + expect(context.propertyPath).toEqual(['parent']) + expect([...context.enums.keys()]).toEqual(['ServiceType']) + expect(context.enums.get('ServiceType')?.nullable).toBe(true) + }) + test.each([false, true])( 'preserves optional and required enum nullability with requestDefaultNonNullable=%s', (requestDefaultNonNullable) => {