diff --git a/tests/code-generator.test.ts b/tests/code-generator.test.ts new file mode 100644 index 0000000..4b3b2d4 --- /dev/null +++ b/tests/code-generator.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, it} from 'vitest'; +import {generateCode} from '../src/generators/code-generator'; +import type {TransformedSchema} from '../src/types'; + +describe('code generator', () => { + it('handles models without relationships and unknown type strings', () => { + const schema: TransformedSchema = { + enums: [], + models: [ + { + tableName: 'Weird', + originalTableName: null, + modelName: 'Weird', + zeroTableName: 'weirdTable', + columns: { + id: {type: 'Custom()', isOptional: false, mappedName: null}, + }, + relationships: undefined as any, + primaryKey: ['id'], + }, + ], + }; + + const output = generateCode(schema); + + expect(output).toContain('export const weirdTable = table("Weird")'); + expect(output).toContain('export const schema = createSchema({'); + expect(output).not.toContain('relationships('); + expect(output).toContain( + 'import {\n createBuilder,\n createSchema,\n table,\n} from "@rocicorp/zero";', + ); + }); + + it('omits relationships section when none exist', () => { + const schema: TransformedSchema = { + enums: [], + models: [], + }; + + const output = generateCode(schema); + + expect(output).toContain('tables: [\n ],'); + expect(output).not.toContain('relationships: ['); + }); + + it('only includes relationship entries for models that define them', () => { + const schema: TransformedSchema = { + enums: [], + models: [ + { + tableName: 'WithRel', + originalTableName: null, + modelName: 'WithRel', + zeroTableName: 'withRelTable', + columns: { + id: {type: 'string()', isOptional: false, mappedName: null}, + }, + relationships: { + plain: { + type: 'one', + sourceField: ['id'], + destField: ['id'], + destSchema: 'plainTable', + }, + }, + primaryKey: ['id'], + }, + { + tableName: 'Plain', + originalTableName: null, + modelName: 'Plain', + zeroTableName: 'plainTable', + columns: { + id: {type: 'string()', isOptional: false, mappedName: null}, + }, + relationships: {}, + primaryKey: ['id'], + }, + ], + }; + + const output = generateCode(schema); + + expect(output).toContain('withRelTableRelationships'); + expect(output).not.toContain('plainTableRelationships'); + }); +}); diff --git a/tests/generator-config.test.ts b/tests/generator-config.test.ts new file mode 100644 index 0000000..ece65dc --- /dev/null +++ b/tests/generator-config.test.ts @@ -0,0 +1,169 @@ +import type {DMMF, GeneratorOptions} from '@prisma/generator-helper'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {createEnum, createField, createMockDMMF, createModel} from './utils'; + +vi.mock('fs/promises', () => ({ + writeFile: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), +})); + +function createTestOptions(dmmf: DMMF.Document): GeneratorOptions { + return { + generator: { + output: {value: 'generated', fromEnvVar: null}, + name: 'test-generator', + config: {}, + provider: {value: 'test-provider', fromEnvVar: null}, + binaryTargets: [], + previewFeatures: [], + sourceFilePath: '', + }, + dmmf, + schemaPath: '', + datasources: [], + otherGenerators: [], + version: '0.0.0', + datamodel: '', + }; +} + +describe('Generator configuration', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.unmock('prettier'); + vi.unmock('@prisma/generator-helper'); + }); + + it('throws when output directory is missing', async () => { + const {onGenerate} = await import('../src/generator'); + + const options = createTestOptions(createMockDMMF([])); + (options.generator as any).output = undefined; + + await expect(onGenerate(options)).rejects.toThrow( + 'Output directory is required', + ); + }); + + it('formats output with prettier when enabled', async () => { + const format = vi.fn( + async (code: string, options?: unknown) => + `formatted:${code}-${JSON.stringify(options)}`, + ); + const resolveConfig = vi.fn(async () => ({semi: false})); + + vi.doMock('prettier', () => ({ + format, + resolveConfig, + })); + + const {onGenerate} = await import('../src/generator'); + const options = createTestOptions( + createMockDMMF( + [ + createModel('User', [ + createField('id', 'String', {isId: true}), + createField('role', 'Role', {kind: 'enum'}), + ]), + ], + [createEnum('Role', ['ADMIN'])], + ), + ); + options.generator.config.prettier = 'true'; + + await onGenerate(options); + + expect(resolveConfig).toHaveBeenCalledWith('schema.ts'); + expect(format).toHaveBeenCalled(); + const formatOptions = format.mock.calls[0]?.[1] as Record; + expect(formatOptions?.parser).toBe('typescript'); + }); + + it('throws a helpful error when prettier cannot be loaded', async () => { + vi.doMock('prettier', () => { + throw new Error('module not found'); + }); + + const {onGenerate} = await import('../src/generator'); + const options = createTestOptions(createMockDMMF([])); + options.generator.config.prettier = 'true'; + + await expect(onGenerate(options)).rejects.toThrow( + '⚠️ prisma-zero: prettier could not be found. Install it locally with\n npm i -D prettier', + ); + }); + + it('skips resolving prettier config when disabled', async () => { + const format = vi.fn(async (code: string) => code); + const resolveConfig = vi.fn(); + + vi.doMock('prettier', () => ({ + format, + resolveConfig, + })); + + const {onGenerate} = await import('../src/generator'); + const options = createTestOptions(createMockDMMF([])); + options.generator.config.prettier = 'true'; + options.generator.config.resolvePrettierConfig = 'false'; + + await onGenerate(options); + + expect(resolveConfig).not.toHaveBeenCalled(); + expect(format).toHaveBeenCalled(); + }); + + it('validates excludeTables configuration', async () => { + const {onGenerate} = await import('../src/generator'); + const options = createTestOptions(createMockDMMF([])); + options.generator.config.excludeTables = 'posts' as any; + + await expect(onGenerate(options)).rejects.toThrow( + 'excludeTables must be an array', + ); + }); + + it('passes excludeTables through to schema transformation', async () => { + const mapperModule = await import('../src/mappers/schema-mapper'); + const transformSpy = vi.spyOn(mapperModule, 'transformSchema'); + const {onGenerate} = await import('../src/generator'); + + const options = createTestOptions( + createMockDMMF([ + createModel('User', [ + createField('id', 'String', {isId: true}), + createField('name', 'String'), + ]), + ]), + ); + options.generator.config.excludeTables = ['User']; + + await onGenerate(options); + + expect(transformSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({excludeTables: ['User']}), + ); + }); + + it('exposes manifest information through generatorHandler', async () => { + const handler = vi.fn(); + vi.doMock('@prisma/generator-helper', () => ({ + default: {generatorHandler: handler}, + generatorHandler: handler, + })); + + const {version} = await import('../package.json'); + await import('../src/generator'); + + expect(handler).toHaveBeenCalledTimes(1); + const manifest = handler.mock.calls[0]?.[0]?.onManifest?.(); + expect(manifest).toEqual({ + version, + defaultOutput: 'generated/zero', + prettyName: 'Zero Schema', + }); + }); +}); diff --git a/tests/schema-mapper.test.ts b/tests/schema-mapper.test.ts index 08eb7ed..d4908a3 100644 --- a/tests/schema-mapper.test.ts +++ b/tests/schema-mapper.test.ts @@ -293,6 +293,38 @@ describe('Schema Mapper', () => { }); describe('Relationships', () => { + it('uses back-references when relationFromFields is only defined on the target', () => { + const userModel = createModel('User', [ + createField('id', 'String', {isId: true}), + createField('profile', 'Profile', { + relationName: 'UserProfile', + kind: 'object', + }), + ]); + + const profileModel = createModel('Profile', [ + createField('id', 'String', {isId: true}), + createField('userId', 'String'), + createField('user', 'User', { + relationName: 'UserProfile', + kind: 'object', + relationFromFields: ['userId'], + relationToFields: ['id'], + }), + ]); + + const dmmf = createMockDMMF([userModel, profileModel]); + const result = transformSchema(dmmf, baseConfig); + + const user = result.models.find(m => m.modelName === 'User'); + expect(user?.relationships.profile).toEqual({ + sourceField: ['id'], + destField: ['userId'], + destSchema: 'profileTable', + type: 'one', + }); + }); + it('should correctly map one-to-many relationship with composite key on parent', () => { const parentModel = createModel( 'Parent', @@ -480,4 +512,88 @@ describe('Schema Mapper', () => { ); }); }); + + describe('Error handling', () => { + it('throws when a model has no primary key', () => { + const model = createModel('NoPK', [createField('name', 'String')]); + + expect(() => + transformSchema(createMockDMMF([model]), baseConfig), + ).toThrow('No primary key found for NoPK'); + }); + + it('throws when a relationship target model is missing', () => { + const model = createModel('Post', [ + createField('id', 'String', {isId: true}), + createField('author', 'User', { + relationName: 'Author', + kind: 'object', + }), + ]); + + expect(() => + transformSchema(createMockDMMF([model]), baseConfig), + ).toThrow('Target model User not found for relationship author'); + }); + + it('throws when implicit many-to-many models are missing id fields', () => { + const postModel = createModel('Post', [ + createField('id', 'String', {isId: true}), + createField('tags', 'Tag', { + isList: true, + relationName: 'PostTags', + kind: 'object', + }), + ]); + + const tagModel = createModel( + 'Tag', + [ + createField('label', 'String'), + createField('posts', 'Post', { + isList: true, + relationName: 'PostTags', + kind: 'object', + }), + ], + {primaryKey: {fields: ['label'], name: null}}, + ); + + expect(() => + transformSchema(createMockDMMF([postModel, tagModel]), baseConfig), + ).toThrow('Implicit relation PostTags: Model Tag has no @id field.'); + }); + + it('throws when implicit many-to-many models have primary keys without @id', () => { + const alphaModel = createModel( + 'Alpha', + [ + createField('key', 'String'), + createField('betas', 'Beta', { + isList: true, + relationName: 'AlphaBeta', + kind: 'object', + }), + ], + {primaryKey: {fields: ['key'], name: null}}, + ); + + const betaModel = createModel( + 'Beta', + [ + createField('key', 'String'), + createField('alphas', 'Alpha', { + isList: true, + relationName: 'AlphaBeta', + kind: 'object', + }), + ], + {primaryKey: {fields: ['key'], name: null}}, + ); + + expect(() => + transformSchema(createMockDMMF([alphaModel, betaModel]), baseConfig), + ).toThrow('Implicit relation AlphaBeta: Model Alpha has no @id field.'); + }); + }); });