zenstack/packages/server/test/api/options-validation.test.ts
2026-03-23 18:25:36 -07:00

638 lines
22 KiB
TypeScript

import { ClientContract } from '@zenstackhq/orm';
import { SchemaDef } from '@zenstackhq/orm/schema';
import { createTestClient } from '@zenstackhq/testtools';
import { beforeEach, describe, expect, it } from 'vitest';
import { RestApiHandler } from '../../src/api/rest';
import { RPCApiHandler } from '../../src/api/rpc';
describe('API Handler Options Validation', () => {
let client: ClientContract<SchemaDef>;
const testSchema = `
model User {
id String @id @default(cuid())
email String @unique
name String
}
`;
beforeEach(async () => {
client = await createTestClient(testSchema);
});
describe('RestApiHandler Options Validation', () => {
it('should accept valid options', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
}).not.toThrow();
});
it('should accept valid options with all optional fields', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: ['debug', 'info', 'warn', 'error'],
pageSize: 50,
idDivider: '-',
urlSegmentCharset: 'a-zA-Z0-9-_~',
modelNameMapping: { User: 'users' },
});
}).not.toThrow();
});
it('should accept custom log function', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: (level: string, message: string) => {
console.log(`[${level}] ${message}`);
},
});
}).not.toThrow();
});
it('should throw error when schema is missing', () => {
expect(() => {
new RestApiHandler({
endpoint: 'http://localhost/api',
} as any);
}).toThrow('Invalid options');
});
it('should throw error when endpoint is missing', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
} as any);
}).toThrow('Invalid options');
});
it('should throw error when endpoint is empty string', () => {
// Note: Zod z.string() validation allows empty strings
// The endpoint validation doesn't enforce non-empty string
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: '',
});
}).toThrow('Invalid options');
});
it('should throw error when endpoint is not a string', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 123,
} as any);
}).toThrow('Invalid options');
});
it('should throw error when pageSize is not a number', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when pageSize is zero', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: 0,
});
}).toThrow('Invalid options');
});
it('should throw error when pageSize is negative', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: -10,
});
}).toThrow('Invalid options');
});
it('should throw error when idDivider is empty string', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
idDivider: '',
});
}).toThrow('Invalid options');
});
it('should throw error when idDivider is not a string', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
idDivider: 123 as any,
});
}).toThrow('Invalid options');
});
it('should throw error when urlSegmentCharset is empty string', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
urlSegmentCharset: '',
});
}).toThrow('Invalid options');
});
it('should throw error when urlSegmentCharset is not a string', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
urlSegmentCharset: [] as any,
});
}).toThrow('Invalid options');
});
it('should throw error when modelNameMapping is not an object', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
modelNameMapping: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when modelNameMapping values are not strings', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
modelNameMapping: { User: 123 } as any,
});
}).toThrow('Invalid options');
});
it('should throw error when externalIdMapping is not an object', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
externalIdMapping: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when externalIdMapping values are not strings', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
externalIdMapping: { User: 123 } as any,
});
}).toThrow('Invalid options');
});
it('should throw error when nestedRoutes is not a boolean', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
nestedRoutes: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log is invalid type', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log array contains invalid values', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: ['debug', 'invalid'] as any,
});
}).toThrow('Invalid options');
});
it('should throw error when schema is not an object', () => {
expect(() => {
new RestApiHandler({
schema: 'invalid' as any,
endpoint: 'http://localhost/api',
});
}).toThrow('Invalid options');
});
it('should throw error when schema is null', () => {
expect(() => {
new RestApiHandler({
schema: null as any,
endpoint: 'http://localhost/api',
});
}).toThrow('Invalid options');
});
it('should accept valid pageSize of 1', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: 1,
});
}).not.toThrow();
});
it('should accept large pageSize', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: 10000,
});
}).not.toThrow();
});
it('should accept Infinity as pageSize to disable pagination', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: Infinity,
});
}).not.toThrow();
});
it('should throw error when pageSize is a decimal', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: 10.5,
});
}).toThrow('Invalid options');
});
it('should throw error when pageSize is NaN', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: NaN,
});
}).toThrow('Invalid options');
});
it('should accept single character idDivider', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
idDivider: '|',
});
}).not.toThrow();
});
it('should accept multi-character idDivider', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
idDivider: '---',
});
}).not.toThrow();
});
});
describe('RPCApiHandler Options Validation', () => {
it('should accept valid options', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
});
}).not.toThrow();
});
it('should accept valid options with log array', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: ['debug', 'info', 'warn', 'error'],
});
}).not.toThrow();
});
it('should accept valid options with log function', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: (level: string, message: string) => {
console.log(`[${level}] ${message}`);
},
});
}).not.toThrow();
});
it('should throw error when schema is missing', () => {
expect(() => {
new RPCApiHandler({} as any);
}).toThrow('Invalid options');
});
it('should throw error when schema is not an object', () => {
expect(() => {
new RPCApiHandler({
schema: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when schema is null', () => {
expect(() => {
new RPCApiHandler({
schema: null as any,
});
}).toThrow('Invalid options');
});
it('should throw error when schema is undefined', () => {
expect(() => {
new RPCApiHandler({
schema: undefined as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log is invalid type', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: 'invalid' as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log array contains invalid values', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: ['debug', 'invalid'] as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log is a number', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: 123 as any,
});
}).toThrow('Invalid options');
});
it('should throw error when log is an object', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: {} as any,
});
}).toThrow('Invalid options');
});
it('should accept empty log array', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: [],
});
}).not.toThrow();
});
it('should accept single log level', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: ['error'],
});
}).not.toThrow();
});
it('should throw error with extra unknown options', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
unknownOption: 'value',
} as any);
}).toThrow('Invalid options'); // z.strictObject() rejects extra properties
});
});
describe('strictObject validation - extra properties', () => {
it('RestApiHandler should reject extra unknown properties', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
extraProperty: 'should-fail',
} as any);
}).toThrow('Invalid options');
});
it('RPCApiHandler should reject extra unknown properties', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
extraProperty: 'should-fail',
} as any);
}).toThrow('Invalid options');
});
it('RestApiHandler should reject multiple extra unknown properties', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
extra1: 'value1',
extra2: 'value2',
} as any);
}).toThrow('Invalid options');
});
it('RPCApiHandler should reject multiple extra unknown properties', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
extra1: 'value1',
extra2: 'value2',
} as any);
}).toThrow('Invalid options');
});
});
describe('Edge Cases and Type Safety', () => {
it('RestApiHandler should handle undefined optional fields gracefully', () => {
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: undefined,
pageSize: undefined,
idDivider: undefined,
});
expect(handler).toBeDefined();
});
it('RPCApiHandler should handle undefined optional fields gracefully', () => {
const handler = new RPCApiHandler({
schema: client.$schema,
log: undefined,
});
expect(handler).toBeDefined();
});
it('RestApiHandler should expose schema property', () => {
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
});
expect(handler.schema).toBe(client.$schema);
});
it('RPCApiHandler should expose schema property', () => {
const handler = new RPCApiHandler({
schema: client.$schema,
});
expect(handler.schema).toBe(client.$schema);
});
it('RestApiHandler should expose log property', () => {
const logConfig = ['debug', 'error'] as const;
const handler = new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
log: logConfig,
});
expect(handler.log).toBe(logConfig);
});
it('RPCApiHandler should expose log property', () => {
const logConfig = ['debug', 'error'] as const;
const handler = new RPCApiHandler({
schema: client.$schema,
log: logConfig,
});
expect(handler.log).toBe(logConfig);
});
it('RestApiHandler should handle empty modelNameMapping', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
modelNameMapping: {},
});
}).not.toThrow();
});
it('RestApiHandler should handle empty externalIdMapping', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
externalIdMapping: {},
});
}).not.toThrow();
});
});
describe('Real-world Scenarios', () => {
it('RestApiHandler with production-like configuration', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'https://api.example.com/v1',
log: (level, message) => {
if (level === 'error') {
console.error(message);
}
},
pageSize: 100,
idDivider: '_',
modelNameMapping: {
User: 'users',
},
});
}).not.toThrow();
});
it('RPCApiHandler with production-like configuration', () => {
expect(() => {
new RPCApiHandler({
schema: client.$schema,
log: (level, message) => {
if (level === 'error') {
console.error(message);
}
},
});
}).not.toThrow();
});
it('RestApiHandler with disabled pagination (Infinity pageSize)', () => {
expect(() => {
new RestApiHandler({
schema: client.$schema,
endpoint: 'http://localhost/api',
pageSize: Infinity,
});
}).not.toThrow();
});
});
describe('Schema validation', () => {
it('RestApiHandler should validate schema structure', () => {
const validSchema = client.$schema;
expect(() => {
new RestApiHandler({
schema: validSchema,
endpoint: 'http://localhost/api',
});
}).not.toThrow();
});
it('RPCApiHandler should validate schema structure', () => {
const validSchema = client.$schema;
expect(() => {
new RPCApiHandler({
schema: validSchema,
});
}).not.toThrow();
});
it('RestApiHandler should handle empty schema object but will error when building type map', () => {
// Empty schema passes Zod validation (z.object()) but fails when building type map
expect(() => {
new RestApiHandler({
schema: {} as any,
endpoint: 'http://localhost/api',
});
}).toThrow(); // Throws when trying to build type map from empty schema
});
});
});