diff --git a/packages/oc/src/registry/routes/helpers/component-parameter-processor.ts b/packages/oc/src/registry/routes/helpers/component-parameter-processor.ts new file mode 100644 index 00000000..fb950f56 --- /dev/null +++ b/packages/oc/src/registry/routes/helpers/component-parameter-processor.ts @@ -0,0 +1,276 @@ +import strings from '../../../resources'; +import type { OcParameter } from '../../../types'; + +export interface ValidationResult { + isValid: boolean; + errors: { + mandatory: Record; + types: Record; + message: string; + }; +} + +export interface CompiledParameterSchema { + isEmpty: boolean; + defaults: Array<[string, string | number | boolean]>; + mandatory: string[]; + types: Record; + coercions: Record; + enums: Record>; +} + +const emptyCompiledSchema: CompiledParameterSchema = { + isEmpty: true, + defaults: [], + mandatory: [], + types: Object.create(null), + coercions: Object.create(null), + enums: Object.create(null) +}; + +export function compileParameterSchema( + expectedParameters?: Record | null +): CompiledParameterSchema { + if ( + !expectedParameters || + typeof expectedParameters !== 'object' || + Array.isArray(expectedParameters) + ) { + return emptyCompiledSchema; + } + + const keys = Object.keys(expectedParameters); + if (keys.length === 0) { + return emptyCompiledSchema; + } + + const defaults: Array<[string, string | number | boolean]> = []; + const mandatory: string[] = []; + const types: Record = Object.create(null); + const coercions: Record = + Object.create(null); + const enums: Record< + string, + ReadonlyArray + > = Object.create(null); + + for (const name in expectedParameters) { + if (!Object.hasOwn(expectedParameters, name)) { + continue; + } + const param = (expectedParameters as Record)[name]; + if (!param || typeof param !== 'object') { + throw new TypeError(`Invalid parameter schema for "${name}"`); + } + + const rawType = (param as OcParameter).type as unknown as string; + const normalizedType = + typeof rawType === 'string' ? rawType.toLowerCase() : ''; + types[name] = normalizedType; + if (rawType === 'boolean' || rawType === 'number' || rawType === 'string') { + coercions[name] = rawType; + } + + const enumValues = (param as OcParameter).enum; + if (typeof enumValues !== 'undefined') { + enums[name] = enumValues as ReadonlyArray; + } + + if (param.mandatory) { + mandatory.push(name); + } else if (typeof param.default !== 'undefined') { + defaults.push([name, param.default as string | number | boolean]); + } + } + + return { + isEmpty: false, + defaults, + mandatory, + types, + coercions, + enums + }; +} + +export function processParameters( + requestParameters: + | Record + | null + | undefined, + compiled: CompiledParameterSchema +): { + params: Record; + validation: ValidationResult; +} { + if (compiled.isEmpty) { + const source = requestParameters as + | Record + | null + | undefined; + if (!source || typeof source !== 'object') { + return { + params: {}, + validation: { + isValid: true, + errors: { mandatory: {}, types: {}, message: '' } + } + }; + } + const params: Record = {}; + for (const key in source) { + if (!Object.hasOwn(source, key)) { + continue; + } + if (key === '__ocAcceptLanguage') { + continue; + } + params[key] = (source as Record)[key]; + } + return { + params, + validation: { + isValid: true, + errors: { mandatory: {}, types: {}, message: '' } + } + }; + } + + let source: Record; + if (requestParameters == null || typeof requestParameters !== 'object') { + source = {}; + } else { + source = requestParameters as Record; + } + + const defaults = compiled.defaults; + for (let i = 0; i < defaults.length; i++) { + const entry = defaults[i]!; + const key = entry[0]; + const defVal = entry[1]; + const cur = source[key]; + if (cur === null || cur === undefined) { + source[key] = defVal; + } + } + + const params: Record = {}; + let mandatoryErrors: Record | null = null; + let typeErrors: Record | null = null; + let isValid = true; + + const types = compiled.types; + const coercions = compiled.coercions; + const enums = compiled.enums; + + for (const key in source) { + if (!Object.hasOwn(source, key)) { + continue; + } + const isDeclared = Object.hasOwn(types, key); + if (isDeclared) { + const rawVal = source[key] as string | number | boolean; + const type = types[key]!; + const coercion = coercions[key]; + let sanitised: string | number | boolean; + + if (coercion === 'boolean') { + if (typeof rawVal === 'string') { + if (rawVal === 'true') { + sanitised = true; + } else if (rawVal === 'false') { + sanitised = false; + } else { + sanitised = rawVal; + } + } else { + sanitised = rawVal; + } + } else if (coercion === 'number') { + sanitised = Number(rawVal as any); + } else if (coercion === 'string') { + sanitised = rawVal == null ? '' : (rawVal as any); + } else { + sanitised = rawVal; + } + + params[key] = sanitised as string | number | boolean; + + let typeValid: boolean; + if (type === 'boolean') { + typeValid = typeof sanitised === 'boolean'; + } else if (type === 'number') { + typeValid = typeof sanitised === 'number'; + } else if (type === 'string') { + typeValid = typeof sanitised === 'string'; + } else { + typeValid = false; + } + + if (!typeValid) { + isValid = false; + if (!typeErrors) { + typeErrors = {}; + } + typeErrors[key] = strings.errors.registry.PARAMETER_WRONG_FORMAT_CODE; + continue; + } + + const enumVals = enums[key]; + if (enumVals && !(enumVals as any).includes(sanitised)) { + isValid = false; + if (!typeErrors) { + typeErrors = {}; + } + typeErrors[key] = strings.errors.registry.PARAMETER_WRONG_VALUE( + key, + enumVals as unknown as string[] + ); + } + } else { + if (key === '__ocAcceptLanguage') { + continue; + } + params[key] = source[key] as string | number | boolean; + } + } + + const mandatory = compiled.mandatory; + for (let i = 0; i < mandatory.length; i++) { + const name = mandatory[i]!; + if (!(name in params)) { + isValid = false; + if (!mandatoryErrors) { + mandatoryErrors = {}; + } + mandatoryErrors[name] = + strings.errors.registry.MANDATORY_PARAMETER_MISSING_CODE; + } + } + + let message = ''; + if (mandatoryErrors) { + const keys = Object.keys(mandatoryErrors); + const joined = keys.join(', '); + message += strings.errors.registry.MANDATORY_PARAMETER_MISSING(joined); + } + if (typeErrors) { + if (message.length > 0) { + message += '; '; + } + const keys = Object.keys(typeErrors); + const joined = keys.join(', '); + message += strings.errors.registry.PARAMETER_WRONG_FORMAT(joined); + } + + const validation: ValidationResult = { + isValid, + errors: { + mandatory: mandatoryErrors || {}, + types: typeErrors || {}, + message + } + }; + + return { params, validation }; +} diff --git a/packages/oc/src/registry/routes/helpers/get-component.ts b/packages/oc/src/registry/routes/helpers/get-component.ts index da36b9fd..21765333 100644 --- a/packages/oc/src/registry/routes/helpers/get-component.ts +++ b/packages/oc/src/registry/routes/helpers/get-component.ts @@ -8,7 +8,13 @@ import emptyResponseHandler from 'oc-empty-response-handler'; import { fromPromise } from 'universalify'; import strings from '../../../resources'; import settings from '../../../resources/settings'; -import type { Component, Config, PluginContext, Plugins } from '../../../types'; +import type { + Component, + Config, + OcParameter, + PluginContext, + Plugins +} from '../../../types'; import BoundedCache from '../../../utils/bounded-cache'; import isTemplateLegacy from '../../../utils/is-template-legacy'; import eventsHandler from '../../domain/events-handler'; @@ -16,11 +22,13 @@ import type { CookieOptions } from '../../domain/http-server/types'; import NestedRenderer from '../../domain/nested-renderer'; import type { Repository } from '../../domain/repository'; import RequireWrapper from '../../domain/require-wrapper'; -import * as sanitiser from '../../domain/sanitiser'; import * as urlBuilder from '../../domain/url-builder'; import * as validator from '../../domain/validators'; import { validateTemplateOcVersion } from '../../domain/validators'; -import applyDefaultValues from './apply-default-values'; +import { + compileParameterSchema, + processParameters +} from './component-parameter-processor'; import { processStackTrace } from './format-error-stack'; import * as getComponentFallback from './get-component-fallback'; @@ -169,6 +177,32 @@ export default function getComponent( return names; }; const inFlight = new Map>(); + const parameterSchemaCache = new WeakMap< + object, + ReturnType + >(); + const emptyParameterSchema = compileParameterSchema(undefined); + const getCompiledParameterSchema = ( + expectedParameters: Record | undefined + ) => { + if (!expectedParameters || typeof expectedParameters !== 'object') { + return emptyParameterSchema; + } + let compiled = parameterSchemaCache.get(expectedParameters as object); + if (compiled) { + return compiled; + } + if (Object.keys(expectedParameters).length === 0) { + parameterSchemaCache.set( + expectedParameters as object, + emptyParameterSchema + ); + return emptyParameterSchema; + } + compiled = compileParameterSchema(expectedParameters); + parameterSchemaCache.set(expectedParameters as object, compiled); + return compiled; + }; const singleFlight = (key: string, operation: () => Promise) => { const pending = inFlight.get(key) as Promise | undefined; @@ -366,18 +400,16 @@ export default function getComponent( }); } - // sanitise and check params - const appliedParams = applyDefaultValues( - requestedComponent.parameters, - component.oc.parameters - ); - const params = sanitiser.sanitiseComponentParameters( - appliedParams, - component.oc.parameters + // sanitise and check params (compiled schema + single scan) + const compiledParameterSchema = getCompiledParameterSchema( + component.oc.parameters as unknown as Record ); - const validationResult = validator.validateComponentParameters( - params, - component.oc.parameters + const { params, validation: validationResult } = processParameters( + requestedComponent.parameters as unknown as Record< + string, + string | number | boolean + >, + compiledParameterSchema ); if (!options.action && !validationResult.isValid) { diff --git a/packages/oc/test/unit/registry-routes-helpers-component-parameter-processor.js b/packages/oc/test/unit/registry-routes-helpers-component-parameter-processor.js new file mode 100644 index 00000000..9e722239 --- /dev/null +++ b/packages/oc/test/unit/registry-routes-helpers-component-parameter-processor.js @@ -0,0 +1,622 @@ +const expect = require('chai').expect; +const sinon = require('sinon'); +const injectr = require('injectr'); + +describe('registry : routes : helpers : component-parameter-processor', () => { + const applyDefaultValues = + require('../../dist/registry/routes/helpers/apply-default-values').default; + const sanitiser = require('../../dist/registry/domain/sanitiser'); + const validator = + require('../../dist/registry/domain/validators').validateComponentParameters; + const processor = require('../../dist/registry/routes/helpers/component-parameter-processor'); + const { compileParameterSchema, processParameters } = processor; + + const originalPipeline = (reqParams, expected) => { + const applied = applyDefaultValues(reqParams, expected); + const sanitised = sanitiser.sanitiseComponentParameters(applied, expected); + const validation = validator(sanitised, expected); + return { params: sanitised, validation }; + }; + + const newPipeline = (reqParams, expected) => { + const compiled = compileParameterSchema(expected); + return processParameters(reqParams, compiled); + }; + + describe('parity with original three-stage pipeline', () => { + const cases = [ + { + name: 'empty/undefined schema with no request parameters', + req: undefined, + exp: undefined + }, + { + name: 'empty schema with no request parameters', + req: {}, + exp: {} + }, + { + name: 'empty schema with ordinary undeclared parameters', + req: { age: 123, foo: 'bar' }, + exp: {} + }, + { + name: 'removal of __ocAcceptLanguage from provider params', + req: { foo: 'bar', __ocAcceptLanguage: 'en-US' }, + exp: {} + }, + { + name: 'array-shaped request parameters', + req: ['hello'], + exp: { 0: { type: 'string', mandatory: true } } + }, + { + name: 'optional defaults for string', + req: {}, + exp: { + opt: { type: 'string', mandatory: false, default: 'def' } + } + }, + { + name: 'optional defaults for false', + req: {}, + exp: { + opt: { type: 'boolean', mandatory: false, default: false } + } + }, + { + name: 'optional defaults for zero', + req: {}, + exp: { + opt: { type: 'number', mandatory: false, default: 0 } + } + }, + { + name: 'optional defaults for empty string', + req: {}, + exp: { + opt: { type: 'string', mandatory: false, default: '' } + } + }, + { + name: 'explicit request values overriding defaults', + req: { opt: 'custom' }, + exp: { + opt: { type: 'string', mandatory: false, default: 'def' }, + opt2: { type: 'boolean', mandatory: false, default: false } + } + }, + { + name: 'missing mandatory parameters and exact error text/order', + req: {}, + exp: { userId: { type: 'string', mandatory: true } } + }, + { + name: 'string/number/boolean coercion', + req: { a: 'true', b: '123', c: null }, + exp: { + a: { type: 'boolean', mandatory: false }, + b: { type: 'number', mandatory: false }, + c: { type: 'string', mandatory: false } + } + }, + { + name: 'mixed-case types validate without enabling coercion', + req: { a: 'true', b: '123', c: null }, + exp: { + a: { type: 'BoOlEaN', mandatory: false }, + b: { type: 'NUMBER', mandatory: false }, + c: { type: 'String', mandatory: false } + } + }, + { + name: 'invalid type', + req: { flag: 123 }, + exp: { flag: { type: 'boolean', mandatory: true } } + }, + { + name: 'invalid enum values', + req: { status: 'invalid' }, + exp: { + status: { + type: 'string', + mandatory: true, + enum: ['active', 'inactive'] + } + } + }, + { + name: 'multiple missing/invalid parameters and exact joined message', + req: { flag: 123 }, + exp: { + name: { type: 'string', mandatory: true }, + flag: { type: 'boolean', mandatory: false }, + status: { + type: 'string', + mandatory: true, + enum: ['active', 'inactive'] + } + } + }, + { + name: 'defaults with null should be replaced', + req: { opt: null }, + exp: { + opt: { type: 'string', mandatory: false, default: 'def' } + } + }, + { + name: 'defaults with undefined should be replaced', + req: { opt: undefined }, + exp: { + opt: { type: 'string', mandatory: false, default: 'def' } + } + }, + { + name: 'number coercion of string numeric', + req: { age: '123' }, + exp: { age: { type: 'number', mandatory: true } } + }, + { + name: 'boolean coercion true', + req: { flag: 'true' }, + exp: { flag: { type: 'boolean', mandatory: true } } + }, + { + name: 'boolean coercion false', + req: { flag: 'false' }, + exp: { flag: { type: 'boolean', mandatory: true } } + }, + { + name: 'string null becomes empty string', + req: { myString: null }, + exp: { myString: { type: 'string', mandatory: false } } + }, + { + name: 'mandatory missing order preserved', + req: {}, + exp: { + z: { type: 'string', mandatory: true }, + a: { type: 'string', mandatory: true }, + m: { type: 'string', mandatory: true } + } + }, + { + name: 'type errors order preserved', + req: { a: 123, b: 123, c: 123 }, + exp: { + a: { type: 'string', mandatory: false }, + b: { type: 'string', mandatory: false }, + c: { type: 'string', mandatory: false } + } + }, + { + name: 'combined mandatory and type errors message', + req: { flag: 123 }, + exp: { + name: { type: 'string', mandatory: true }, + flag: { type: 'boolean', mandatory: false } + } + } + ]; + + for (const { name, req, exp } of cases) { + it(`should match original for: ${name}`, () => { + const cloneReqForOrig = (() => { + if (req === undefined) return undefined; + if (req === null) return null; + if (Array.isArray(req)) return req.slice(); + const c = {}; + for (const k in req) { + if (Object.prototype.hasOwnProperty.call(req, k)) c[k] = req[k]; + } + return c; + })(); + const cloneExpForOrig = (() => { + if (exp === undefined) return undefined; + if (exp === null) return null; + const c = {}; + for (const k in exp) { + if (Object.prototype.hasOwnProperty.call(exp, k)) c[k] = { ...exp[k] }; + } + return c; + })(); + const cloneReqForNew = (() => { + if (req === undefined) return undefined; + if (req === null) return null; + if (Array.isArray(req)) return req.slice(); + const c = {}; + for (const k in req) { + if (Object.prototype.hasOwnProperty.call(req, k)) c[k] = req[k]; + } + return c; + })(); + const cloneExpForNew = (() => { + if (exp === undefined) return undefined; + if (exp === null) return null; + const c = {}; + for (const k in exp) { + if (Object.prototype.hasOwnProperty.call(exp, k)) c[k] = { ...exp[k] }; + } + return c; + })(); + + const orig = originalPipeline(cloneReqForOrig, cloneExpForOrig); + const nw = newPipeline(cloneReqForNew, cloneExpForNew); + expect(nw.params).to.eql(orig.params); + expect(nw.validation).to.eql(orig.validation); + }); + } + + it('should preserve mutation behavior: applying defaults mutates original request object', () => { + const expected = { + opt: { type: 'string', mandatory: false, default: 'def' }, + opt2: { type: 'boolean', mandatory: false, default: false } + }; + const reqOrig = { mandatory: 'x' }; + const reqNew = { mandatory: 'x' }; + const compiled = compileParameterSchema(expected); + // original mutates + applyDefaultValues(reqOrig, expected); + processParameters(reqNew, compiled); + expect(reqOrig).to.eql(reqNew); + expect(reqOrig).to.have.property('opt', 'def'); + expect(reqOrig).to.have.property('opt2', false); + }); + + it('should preserve distinct object: sanitized params are a distinct object from input', () => { + const expected = { a: { type: 'string', mandatory: false } }; + const req = { a: 'hello', b: 'world' }; + const compiled = compileParameterSchema(expected); + const { params } = processParameters(req, compiled); + expect(params).to.not.equal(req); + expect(params).to.eql({ a: 'hello', b: 'world' }); + }); + + it('should keep __ocAcceptLanguage available to language selection (original not mutated for that key)', () => { + const expected = {}; + const req = { foo: 'bar', __ocAcceptLanguage: 'fr' }; + const compiled = compileParameterSchema(expected); + const { params } = processParameters(req, compiled); + expect(params).to.not.have.property('__ocAcceptLanguage'); + expect(req).to.have.property('__ocAcceptLanguage', 'fr'); + }); + + it('should preserve __ocAcceptLanguage when it is a declared parameter', () => { + const expected = { + __ocAcceptLanguage: { type: 'string', mandatory: false } + }; + const req = { __ocAcceptLanguage: 'en', foo: 'bar' }; + const compiled = compileParameterSchema(expected); + const { params } = processParameters(req, compiled); + expect(params).to.have.property('__ocAcceptLanguage', 'en'); + }); + }); + + describe('schema compilation', () => { + it('should compile empty/missing schemas to shared singleton', () => { + const c1 = compileParameterSchema(undefined); + const c2 = compileParameterSchema(null); + const c3 = compileParameterSchema({}); + const c4 = compileParameterSchema({} ); + expect(c1).to.equal(c2); + expect(c1).to.equal(c3); + expect(c1).to.equal(c4); + expect(c1.isEmpty).to.be.true; + }); + + it('should reject malformed parameter descriptors', () => { + expect(() => compileParameterSchema({ token: null })).to.throw( + TypeError, + 'Invalid parameter schema for "token"' + ); + }); + + it('should compile non-empty schema with ordered defaults/mandatory/types', () => { + const expected = { + b: { type: 'string', mandatory: false, default: 'x' }, + a: { type: 'number', mandatory: true }, + c: { type: 'boolean', mandatory: false, default: true } + }; + const compiled = compileParameterSchema(expected); + expect(compiled.isEmpty).to.be.false; + expect(compiled.defaults).to.eql([ + ['b', 'x'], + ['c', true] + ]); + expect(compiled.mandatory).to.eql(['a']); + expect(compiled.types).to.have.property('b', 'string'); + expect(compiled.types).to.have.property('a', 'number'); + expect(compiled.types).to.have.property('c', 'boolean'); + }); + + it('should normalize type names during compilation', () => { + const expected = { + p: { type: 'String', mandatory: false }, + q: { type: 'NUMBER', mandatory: false }, + r: { type: 'BoOlEaN', mandatory: false } + }; + const compiled = compileParameterSchema(expected); + expect(compiled.types.p).to.equal('string'); + expect(compiled.types.q).to.equal('number'); + expect(compiled.types.r).to.equal('boolean'); + }); + + it('should not capture request data in compiled object', () => { + const expected = { + a: { type: 'string', mandatory: false, default: 'def' } + }; + const compiled = compileParameterSchema(expected); + const req = { a: 'requestValue', b: 'extra' }; + processParameters(req, compiled); + // compiled should only contain config-derived data + expect(compiled.defaults).to.eql([['a', 'def']]); + expect(JSON.stringify(compiled)).to.not.include('requestValue'); + expect(JSON.stringify(compiled)).to.not.include('extra'); + }); + }); + + describe('empty-schema fast lane', () => { + it('should skip defaults/mandatory/type/enum and preserve undeclared params', () => { + const compiled = compileParameterSchema({}); + const req = { foo: 'bar', num: 123, __ocAcceptLanguage: 'fr' }; + const { params, validation } = processParameters(req, compiled); + expect(params).to.eql({ foo: 'bar', num: 123 }); + expect(validation.isValid).to.be.true; + expect(validation.errors.message).to.equal(''); + }); + + it('should produce distinct objects for empty schema', () => { + const compiled = compileParameterSchema({}); + const req = { a: 1 }; + const r1 = processParameters({ ...req }, compiled); + const r2 = processParameters({ ...req }, compiled); + expect(r1.params).to.not.equal(r2.params); + expect(r1.validation).to.not.equal(r2.validation); + expect(r1.validation.errors).to.not.equal(r2.validation.errors); + }); + }); + + describe('output and error objects are not shared between requests', () => { + it('should return fresh params and errors each call', () => { + const expected = { + name: { type: 'string', mandatory: true }, + flag: { type: 'boolean', mandatory: false } + }; + const compiled = compileParameterSchema(expected); + const r1 = processParameters({ flag: 123 }, compiled); + const r2 = processParameters({ flag: 123 }, compiled); + expect(r1.params).to.not.equal(r2.params); + expect(r1.validation).to.not.equal(r2.validation); + expect(r1.validation.errors).to.not.equal(r2.validation.errors); + expect(r1.validation.errors.mandatory).to.not.equal(r2.validation.errors.mandatory); + expect(r1.validation.errors.types).to.not.equal(r2.validation.errors.types); + // mutating first should not affect second + r1.params.flag = 'mutated'; + r1.validation.errors.types.extra = 'x'; + expect(r2.params.flag).to.equal(123); + expect(r2.validation.errors.types).to.not.have.property('extra'); + }); + + it('should return fresh objects for valid case as well', () => { + const expected = { + opt: { type: 'string', mandatory: false, default: 'def' } + }; + const compiled = compileParameterSchema(expected); + const r1 = processParameters({}, compiled); + const r2 = processParameters({}, compiled); + expect(r1.params).to.not.equal(r2.params); + expect(r1.validation.errors).to.not.equal(r2.validation.errors); + r1.params.opt = 'mutated'; + expect(r2.params.opt).to.equal('def'); + }); + }); + + describe('hot-reload schema identity', () => { + it('should produce new defaults/validation immediately for new schema identity', () => { + const schemaV1 = { + opt: { type: 'string', mandatory: false, default: 'v1' } + }; + const schemaV2 = { + opt: { type: 'string', mandatory: false, default: 'v2' }, + extra: { type: 'string', mandatory: true } + }; + const c1 = compileParameterSchema(schemaV1); + const c2 = compileParameterSchema(schemaV2); + expect(c1).to.not.equal(c2); + const r1 = processParameters({}, c1); + expect(r1.params).to.eql({ opt: 'v1' }); + expect(r1.validation.isValid).to.be.true; + const r2 = processParameters({}, c2); + expect(r2.params).to.eql({ opt: 'v2' }); + expect(r2.validation.isValid).to.be.false; + expect(r2.validation.errors.mandatory).to.have.property('extra'); + }); + }); + + describe('compilation caching via get-component WeakMap', () => { + const getProcessorPath = '../../dist/registry/routes/helpers/component-parameter-processor'; + const getComponentPath = '../../dist/registry/routes/helpers/get-component.js'; + + it('should compile same schema object once across repeated renders', (done) => { + const processor = require(getProcessorPath); + const spy = sinon.spy(processor, 'compileParameterSchema'); + + const GetComponent = injectr( + getComponentPath, + { + '../../domain/events-handler': { + on: () => {}, + fire: () => {}, + hasListeners: () => false + } + }, + { console, Buffer, clearTimeout, setTimeout, process } + ).default; + + const schema = { + name: { type: 'string', mandatory: true }, + opt: { type: 'string', mandatory: false, default: 'def' } + }; + + const component = { + name: 'test-comp', + version: '1.0.0', + oc: { + container: false, + renderInfo: false, + files: { + template: { + type: 'jade', + hashKey: 'hash', + src: 'template.js' + } + }, + parameters: schema + } + }; + + const mockedRepository = { + getComponent: sinon.stub().resolves(component), + getEnv: sinon.stub().rejects(), + getDataProvider: sinon.stub().resolves({ content: 'module.exports.data=function(c,cb){cb(null,{})}', filePath: '/tmp/server.js' }), + getCompiledView: sinon.stub().resolves('oc.components["hash"]=function(){return""}'), + getTemplatesInfo: () => [], + getTemplate: () => ({ getCompiledTemplate: (s) => () => s }), + getStaticFilePath: () => '//cdn/' + }; + + const getComponent = GetComponent({ baseUrl: 'http://components.com/', templates: [], plugins: {}, env: {} }, mockedRepository); + + let count = 0; + const checkDone = () => { + count++; + if (count === 2) { + try { + // compile should have been called once for this schema identity + // It may have been called once for empty singleton at init plus once for schema + // So filter calls where first arg is schema + const callsForSchema = spy + .getCalls() + .filter((c) => c.args[0] === schema); + expect(callsForSchema.length).to.equal(1); + spy.restore(); + done(); + } catch (e) { + spy.restore(); + done(e); + } + } + }; + + getComponent( + { name: 'test-comp', headers: {}, parameters: {}, version: '1.0.0', conf: { baseUrl: 'http://components.com/' } }, + () => checkDone() + ); + // second render with same schema identity + setTimeout(() => { + getComponent( + { name: 'test-comp', headers: {}, parameters: {}, version: '1.0.0', conf: { baseUrl: 'http://components.com/' } }, + () => checkDone() + ); + }, 20); + }); + + it('should compile different schema identities independently', (done) => { + const processor = require(getProcessorPath); + const spy = sinon.spy(processor, 'compileParameterSchema'); + + const GetComponent = injectr( + getComponentPath, + { + '../../domain/events-handler': { + on: () => {}, + fire: () => {}, + hasListeners: () => false + } + }, + { console, Buffer, clearTimeout, setTimeout, process } + ).default; + + const schema1 = { + a: { type: 'string', mandatory: true } + }; + const schema2 = { + b: { type: 'string', mandatory: true } + }; + + const comp1 = { + name: 'comp1', + version: '1.0.0', + oc: { + container: false, + renderInfo: false, + files: { template: { type: 'jade', hashKey: 'h1', src: 't.js' } }, + parameters: schema1 + } + }; + const comp2 = { + name: 'comp2', + version: '1.0.0', + oc: { + container: false, + renderInfo: false, + files: { template: { type: 'jade', hashKey: 'h2', src: 't.js' } }, + parameters: schema2 + } + }; + + const mockedRepository = { + getComponent: (name) => { + if (name === 'comp1') return Promise.resolve(comp1); + return Promise.resolve(comp2); + }, + getEnv: sinon.stub().rejects(), + getDataProvider: sinon.stub().resolves({ content: 'module.exports.data=function(c,cb){cb(null,{})}', filePath: '/tmp/server.js' }), + getCompiledView: sinon.stub().resolves('oc.components["h1"]=function(){return""}'), + getTemplatesInfo: () => [], + getTemplate: () => ({ getCompiledTemplate: (s) => () => s }), + getStaticFilePath: () => '//cdn/' + }; + + const getComponent = GetComponent({ baseUrl: 'http://components.com/', templates: [], plugins: {}, env: {} }, mockedRepository); + + let doneCount = 0; + const maybeDone = () => { + doneCount++; + if (doneCount === 2) { + try { + const c1Calls = spy.getCalls().filter((c) => c.args[0] === schema1); + const c2Calls = spy.getCalls().filter((c) => c.args[0] === schema2); + expect(c1Calls.length).to.equal(1); + expect(c2Calls.length).to.equal(1); + spy.restore(); + done(); + } catch (e) { + spy.restore(); + done(e); + } + } + }; + + getComponent({ name: 'comp1', headers: {}, parameters: {}, version: '1.0.0', conf: { baseUrl: 'http://components.com/' } }, maybeDone); + setTimeout(() => { + getComponent({ name: 'comp2', headers: {}, parameters: {}, version: '1.0.0', conf: { baseUrl: 'http://components.com/' } }, maybeDone); + }, 20); + }); + }); + + describe('enum behavior preservation', () => { + it('should use strict equality (includes) for enum checks', () => { + const expected = { + num: { type: 'number', mandatory: false, enum: [1, 2, 3] } + }; + const compiled = compileParameterSchema(expected); + const r1 = processParameters({ num: '1' }, compiled); // '1' string -> Number('1')=1 => should be valid + expect(r1.validation.isValid).to.be.true; + const r2 = processParameters({ num: 5 }, compiled); + expect(r2.validation.isValid).to.be.false; + expect(r2.validation.errors.types.num).to.include('1, 2, 3'); + }); + }); +}); diff --git a/plans/README.md b/plans/README.md index dd28a778..816d64b7 100644 --- a/plans/README.md +++ b/plans/README.md @@ -7,7 +7,7 @@ Generated on 2026-07-23 and extended on 2026-08-17. Each numbered plan is intend | Plan | Title | Priority | Effort | Depends on | Status | |------|-------|----------|--------|------------|--------| | 006 | Keep the successful render path synchronous and allocation-light | P1 | M | - | DONE | -| 007 | Compile component parameter schemas and add an empty-schema fast lane | P2 | M | 006 | TODO | +| 007 | Compile component parameter schemas and add an empty-schema fast lane | P2 | M | 006 | DONE | | 008 | Enforce one storage concurrency budget during legacy reconciliation | P1 | M | - | TODO | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with reason) | REJECTED (with rationale)