From fd2b60ca42da26bd0d996db74003bbf5a3a8a39c Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:54:42 +0530 Subject: [PATCH 1/7] test(authorization): cover voter functions and invalid decorator target - Interceptor: invoke voter functions declared directly in the @authorize metadata, for both allow and deny decisions - @authorize: throws when applied to a property Statement, function and line coverage of the package is now 100%. Signed-off-by: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> --- .../authorization.options.acceptance.ts | 31 +++++++++++++++++++ .../unit/authorize-decorator.test.ts | 10 ++++++ 2 files changed, 41 insertions(+) diff --git a/packages/authorization/src/__tests__/acceptance/authorization.options.acceptance.ts b/packages/authorization/src/__tests__/acceptance/authorization.options.acceptance.ts index 5a9d2078877b..928d436671fc 100644 --- a/packages/authorization/src/__tests__/acceptance/authorization.options.acceptance.ts +++ b/packages/authorization/src/__tests__/acceptance/authorization.options.acceptance.ts @@ -130,6 +130,27 @@ describe('Authorization', () => { }); }); + it('invokes voter functions declared in the authorization metadata', async () => { + givenRequestContext(); + setupAuthorization({}); + const result = await invokeMethod(controller, 'approveOrder', reqCtx, [ + 'order-01', + ]); + expect(result).to.equal('order-01'); + }); + + it('denies access when a voter function in the metadata denies', async () => { + givenRequestContext(); + setupAuthorization({}); + const result = invokeMethod(controller, 'rejectOrder', reqCtx, [ + 'order-01', + ]); + await expect(result).to.be.rejectedWith({ + statusCode: 403, + message: 'Access denied', + }); + }); + async function testCancelOrder(matrix: DecisionMatrix) { let index = 0; for (const row of matrix) { @@ -191,6 +212,16 @@ describe('Authorization', () => { async placeOrder(productId: string, quantity: number) { return '001'; } + + @authorize({voters: [async () => Allow]}) + async approveOrder(orderId: string) { + return orderId; + } + + @authorize({voters: [async () => Deny]}) + async rejectOrder(orderId: string) { + return orderId; + } } function setupAuthorization( diff --git a/packages/authorization/src/__tests__/unit/authorize-decorator.test.ts b/packages/authorization/src/__tests__/unit/authorize-decorator.test.ts index c202af6e3e4e..40d6c51360ff 100644 --- a/packages/authorization/src/__tests__/unit/authorize-decorator.test.ts +++ b/packages/authorization/src/__tests__/unit/authorize-decorator.test.ts @@ -221,5 +221,15 @@ describe('Authentication', () => { scopes: ['s1', 's2'], }); }); + + it('throws an error when used on a property', () => { + expect(() => { + class TestClass { + @authorize({allowedRoles: ['ADMIN']}) + prop: string; + } + return TestClass; + }).to.throw(/cannot be used on a property: TestClass\.prototype\.prop/); + }); }); }); From 098e2bff4dac8ba393be756d7d6c2cbf59f61837 Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:55:27 +0530 Subject: [PATCH 2/7] test(booter-lb3app): add unit tests for Lb3AppBooter constructor and configure Cover the guard for applications without mountExpressRouter, option merging with defaults, and appPath resolution. Signed-off-by: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> --- .../src/__tests__/unit/lb3app.booter.unit.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/booter-lb3app/src/__tests__/unit/lb3app.booter.unit.ts diff --git a/packages/booter-lb3app/src/__tests__/unit/lb3app.booter.unit.ts b/packages/booter-lb3app/src/__tests__/unit/lb3app.booter.unit.ts new file mode 100644 index 000000000000..cd79af83843c --- /dev/null +++ b/packages/booter-lb3app/src/__tests__/unit/lb3app.booter.unit.ts @@ -0,0 +1,48 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/booter-lb3app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application} from '@loopback/core'; +import {RestApplication} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; +import {Lb3AppBooter} from '../../lb3app.booter'; + +describe('Lb3AppBooter (unit)', () => { + describe('constructor', () => { + it('throws if the application does not support mountExpressRouter', () => { + const app = new Application() as unknown as RestApplication; + + expect(() => new Lb3AppBooter(app, '/project-root')).to.throw( + 'Lb3AppBooter requires RestApplication with mountExpressRouter API', + ); + }); + + it('merges custom options with the defaults', () => { + const app = {mountExpressRouter: () => {}} as unknown as RestApplication; + + const booter = new Lb3AppBooter(app, '/project-root', { + path: '../custom/server', + }); + + expect(booter.options).to.eql({ + path: '../custom/server', + mode: 'fullApp', + restApiRoot: '/api', + }); + }); + }); + + describe('configure()', () => { + it('resolves the lb3 app path against the project root', async () => { + const app = {mountExpressRouter: () => {}} as unknown as RestApplication; + const booter = new Lb3AppBooter(app, '/project-root', { + path: '../custom/server', + }); + + await booter.configure!(); + + expect(booter.appPath).to.equal('/custom/server'); + }); + }); +}); From 482e72248e6ec0ed33496cfb093e0a783f788854 Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:41:54 +0530 Subject: [PATCH 3/7] test(boot): cover model booters, bootstrapper context reuse and BootMixin bindings - ModelBooter: default and custom options, binding of model classes - ModelApiBooter: getApiBuilderForPattern lookup and error messages - BaseArtifactBooter: artifactName, empty dirs/extensions, discover with and without debug logging - Bootstrapper: reuse booter instances when a context is passed to boot() - BootMixin: PROJECT_ROOT and BOOT_OPTIONS dynamic bindings Signed-off-by: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> --- .../unit/booters/base-artifact.booter.unit.ts | 55 +++++++++++++++- .../unit/booters/model-api.booter.unit.ts | 50 +++++++++++++++ .../unit/booters/model.booter.unit.ts | 63 +++++++++++++++++++ .../src/__tests__/unit/bootstrapper.unit.ts | 19 ++++++ .../__tests__/unit/mixins/boot.mixin.unit.ts | 16 +++++ 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 packages/boot/src/__tests__/unit/booters/model-api.booter.unit.ts create mode 100644 packages/boot/src/__tests__/unit/booters/model.booter.unit.ts diff --git a/packages/boot/src/__tests__/unit/booters/base-artifact.booter.unit.ts b/packages/boot/src/__tests__/unit/booters/base-artifact.booter.unit.ts index 4426f14a22b8..ac76befbba29 100644 --- a/packages/boot/src/__tests__/unit/booters/base-artifact.booter.unit.ts +++ b/packages/boot/src/__tests__/unit/booters/base-artifact.booter.unit.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; +import debugFactory from 'debug'; import {resolve} from 'path'; import {ArtifactOptions, BaseArtifactBooter} from '../../..'; @@ -26,6 +27,13 @@ describe('base-artifact booter unit tests', () => { expect(booterInst.extensions).to.be.eql(['.test.js']); }); + it(`defaults 'dirs' / 'extensions' to empty arrays if not provided`, async () => { + const booterInst = givenBaseBooter({}); + await booterInst.configure(); + expect(booterInst.dirs).to.eql([]); + expect(booterInst.extensions).to.eql([]); + }); + it(`creates and sets 'glob' pattern`, async () => { const booterInst = givenBaseBooter(); const expected = '/{test,test2}/*@(.test.js|test2.js)'; @@ -52,6 +60,14 @@ describe('base-artifact booter unit tests', () => { }); }); + describe('artifactName', () => { + it('is derived from the class name without the Booter suffix', () => { + class WidgetBooter extends BaseArtifactBooter {} + const booterInst = new WidgetBooter(__dirname, TEST_OPTIONS); + expect(booterInst.artifactName).to.equal('Widget'); + }); + }); + describe('discover()', () => { it(`sets 'discovered' property`, async () => { const booterInst = givenBaseBooter(); @@ -60,6 +76,38 @@ describe('base-artifact booter unit tests', () => { await booterInst.discover(); expect(booterInst.discovered).to.eql([]); }); + + it('discovers files matching the glob', async () => { + const booterInst = givenBaseBooter( + TEST_OPTIONS, + resolve(__dirname, '../../fixtures'), + ); + booterInst.glob = '/multiple.artifact.js'; + await booterInst.discover(); + expect(booterInst.discovered).to.eql([ + resolve(__dirname, '../../fixtures/multiple.artifact.js'), + ]); + }); + + it('discovers files when debug logging is enabled', async () => { + const namespaces = debugFactory.disable(); + const originalLog = debugFactory.log; + // Keep the test output clean + debugFactory.log = () => {}; + debugFactory.enable('loopback:boot:base-artifact-booter'); + try { + const booterInst = givenBaseBooter( + TEST_OPTIONS, + resolve(__dirname, '../../fixtures'), + ); + booterInst.glob = '/multiple.artifact.js'; + await booterInst.discover(); + expect(booterInst.discovered).to.have.lengthOf(1); + } finally { + debugFactory.log = originalLog; + debugFactory.enable(namespaces); + } + }); }); describe('load()', () => { @@ -74,7 +122,10 @@ describe('base-artifact booter unit tests', () => { }); }); - function givenBaseBooter(options?: ArtifactOptions) { - return new BaseArtifactBooter(__dirname, options ?? TEST_OPTIONS); + function givenBaseBooter( + options?: ArtifactOptions, + projectRoot: string = __dirname, + ) { + return new BaseArtifactBooter(projectRoot, options ?? TEST_OPTIONS); } }); diff --git a/packages/boot/src/__tests__/unit/booters/model-api.booter.unit.ts b/packages/boot/src/__tests__/unit/booters/model-api.booter.unit.ts new file mode 100644 index 000000000000..11ba05d8ad3e --- /dev/null +++ b/packages/boot/src/__tests__/unit/booters/model-api.booter.unit.ts @@ -0,0 +1,50 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ModelApiBuilder} from '@loopback/model-api-builder'; +import {ApplicationWithRepositories} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {ModelApiBooter} from '../../..'; + +describe('model API booter unit tests', () => { + describe('getApiBuilderForPattern()', () => { + it('returns the builder matching the pattern', async () => { + const builder = givenBuilder('rest'); + const booter = givenBooter([givenBuilder('other'), builder]); + + expect(await booter.getApiBuilderForPattern('rest')).to.equal(builder); + }); + + it('lists the available patterns if no builder matches', async () => { + const booter = givenBooter([givenBuilder('rest'), givenBuilder('grpc')]); + + await expect( + booter.getApiBuilderForPattern('missing'), + ).to.be.rejectedWith( + 'Unsupported API pattern "missing". Available patterns: rest, grpc', + ); + }); + + it('reports if there are no builders registered', async () => { + const booter = givenBooter([]); + + await expect(booter.getApiBuilderForPattern('rest')).to.be.rejectedWith( + 'Unsupported API pattern "rest". Available patterns: ', + ); + }); + }); + + function givenBuilder(pattern: string): ModelApiBuilder { + return {pattern, build: async () => {}}; + } + + function givenBooter(builders: ModelApiBuilder[]) { + return new ModelApiBooter( + {} as ApplicationWithRepositories, + __dirname, + async () => builders, + ); + } +}); diff --git a/packages/boot/src/__tests__/unit/booters/model.booter.unit.ts b/packages/boot/src/__tests__/unit/booters/model.booter.unit.ts new file mode 100644 index 000000000000..8b291a5b6918 --- /dev/null +++ b/packages/boot/src/__tests__/unit/booters/model.booter.unit.ts @@ -0,0 +1,63 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application} from '@loopback/core'; +import { + ApplicationWithRepositories, + RepositoryMixin, +} from '@loopback/repository'; +import {expect, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {ModelBooter, ModelDefaults} from '../../..'; + +describe('model booter unit tests', () => { + const sandbox = new TestSandbox(resolve(__dirname, '../../../.sandbox')); + + const MODELS_PREFIX = 'models'; + const MODELS_TAG = 'model'; + + class AppWithRepo extends RepositoryMixin(Application) {} + + let app: ApplicationWithRepositories; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + + it(`uses ModelDefaults for 'options' if none are given`, () => { + const booterInst = new ModelBooter(app, sandbox.path); + expect(booterInst.options).to.deepEqual(ModelDefaults); + }); + + it('overrides defaults with provided options and uses defaults for the rest', () => { + const options = { + dirs: ['test'], + extensions: ['.ext1'], + }; + const expected = Object.assign({}, options, { + nested: ModelDefaults.nested, + }); + + const booterInst = new ModelBooter(app, sandbox.path, options); + expect(booterInst.options).to.deepEqual(expected); + }); + + it('binds model classes during the load phase', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/product.model.js'), + 'models/product.model.js', + ); + const booterInst = new ModelBooter(app, sandbox.path); + + booterInst.discovered = [resolve(sandbox.path, 'models/product.model.js')]; + await booterInst.load(); + + const keys = app.findByTag(MODELS_TAG).map(b => b.key); + expect(keys).to.eql([`${MODELS_PREFIX}.Product`]); + }); + + function getApp() { + app = new AppWithRepo(); + } +}); diff --git a/packages/boot/src/__tests__/unit/bootstrapper.unit.ts b/packages/boot/src/__tests__/unit/bootstrapper.unit.ts index 5a5406564d36..e4d209574af3 100644 --- a/packages/boot/src/__tests__/unit/bootstrapper.unit.ts +++ b/packages/boot/src/__tests__/unit/bootstrapper.unit.ts @@ -60,6 +60,25 @@ describe('boot-strapper unit tests', () => { expect(phasesCalled).to.eql(['AnotherBooter:configure']); }); + it('reuses the booter instances of a context passed to boot()', async () => { + const ctx = await bootstrapper.boot({ + filter: {booters: ['TestBooter'], phases: ['configure']}, + }); + const booterInst = await ctx.get(booterKey); + expect(booterInst.phasesCalled).to.eql(['TestBooter:configure']); + + const sameCtx = await bootstrapper.boot( + {filter: {booters: ['TestBooter'], phases: ['load']}}, + ctx, + ); + + expect(sameCtx).to.equal(ctx); + expect(booterInst.phasesCalled).to.eql([ + 'TestBooter:configure', + 'TestBooter:load', + ]); + }); + it('only runs phases passed in via BootOptions.filter.phases', async () => { const ctx = await bootstrapper.boot({filter: {phases: ['configure']}}); const booterInst = await ctx.get(booterKey); diff --git a/packages/boot/src/__tests__/unit/mixins/boot.mixin.unit.ts b/packages/boot/src/__tests__/unit/mixins/boot.mixin.unit.ts index f97ffbe7c607..889f9de1c6d4 100644 --- a/packages/boot/src/__tests__/unit/mixins/boot.mixin.unit.ts +++ b/packages/boot/src/__tests__/unit/mixins/boot.mixin.unit.ts @@ -26,6 +26,22 @@ describe('BootMixin unit tests', () => { expect(boundComponent).to.containEql('components.BootComponent'); }); + it('binds BOOT_OPTIONS to an empty object by default', async () => { + expect(await app.get(BootBindings.BOOT_OPTIONS)).to.eql({}); + }); + + it('binds BOOT_OPTIONS to the bootOptions of the app', async () => { + app.bootOptions = {controllers: {dirs: ['ctrl']}}; + expect(await app.get(BootBindings.BOOT_OPTIONS)).to.eql({ + controllers: {dirs: ['ctrl']}, + }); + }); + + it('binds PROJECT_ROOT to the projectRoot of the app', async () => { + app.projectRoot = '/my/project'; + expect(await app.get(BootBindings.PROJECT_ROOT)).to.equal('/my/project'); + }); + it('binds booter from app.booters()', async () => { app.booters(TestBooter); const booter = await app.get(`${BootBindings.BOOTERS}.TestBooter`); From 78b547bbe5fb6b2084d889c61176ce1dd3d8bd1d Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:49:25 +0530 Subject: [PATCH 4/7] test(context): cover rarely exercised paths in bindings, views, injection and interceptors Add unit tests for code paths that were not covered by the existing suites: - Binding: refresh without cache, unconfigured bindings, server scope resolution failures, once() - ContextView: singleValue(), open() reuse, once() - Context: findByTag with unknown or intersecting tags, observer subscribe/unsubscribe/close edge cases - Injection: hasInjections, inspectInjections, @inject.setter and @inject.binding rejecting binding filters, @config.getter/@config.view without a current binding - Interceptors: stacked @intercept on classes and methods, registering provider classes and grouped global interceptors, interception proxy - InvocationContext.toString(), invokeMethod with skipInterceptors, BindingKey.validate, isBindingAddress, uuid Signed-off-by: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> --- .../__tests__/unit/uncovered-paths.unit.ts | 527 ++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 packages/context/src/__tests__/unit/uncovered-paths.unit.ts diff --git a/packages/context/src/__tests__/unit/uncovered-paths.unit.ts b/packages/context/src/__tests__/unit/uncovered-paths.unit.ts new file mode 100644 index 000000000000..48db1535955b --- /dev/null +++ b/packages/context/src/__tests__/unit/uncovered-paths.unit.ts @@ -0,0 +1,527 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Binding, + BindingKey, + BindingScope, + config, + Constructor, + Context, + ContextView, + createProxyWithInterceptors, + Getter, + InvocationContext, + intercept, + registerInterceptor, + GenericInterceptorChain, + hasInjections, + inject, + injectable, + inspectInjections, + instantiateClass, + invokeMethod, + isBindingAddress, + Interceptor, + Provider, + uuid, +} from '../..'; + +/** + * Additional unit tests for rarely exercised code paths that are not covered + * by the feature-focused test suites. + */ +describe('context: additional coverage', () => { + describe('@injectable.provider', () => { + it('throws if the target is not a Provider class', () => { + class NotAProvider {} + expect(() => + injectable.provider()(NotAProvider as unknown as Constructor), + ).to.throw(/is not a Provider/); + }); + }); + + describe('BindingKey', () => { + it('validate() rejects an empty key', () => { + expect(() => BindingKey.validate('')).to.throw( + 'Binding key must be provided.', + ); + }); + }); + + describe('isBindingAddress', () => { + it('returns false for null selectors', () => { + expect(isBindingAddress(null as unknown as string)).to.be.false(); + }); + + it('returns false for non-object, non-string selectors', () => { + expect(isBindingAddress((() => true) as unknown as string)).to.be.false(); + }); + }); + + describe('Binding', () => { + it('refresh() is a no-op if nothing has been cached', () => { + const ctx = new Context(); + const binding = ctx.bind('foo').to('bar'); + expect(() => binding.refresh(ctx)).to.not.throw(); + }); + + it('rejects when no value is configured for the binding', async () => { + const ctx = new Context(); + ctx.add(new Binding('no-value')); + await expect(ctx.get('no-value')).to.be.rejectedWith( + /No value was configured for binding no-value/, + ); + }); + + it('returns undefined for an unconfigured optional binding', async () => { + const ctx = new Context(); + ctx.add(new Binding('no-value')); + expect(await ctx.get('no-value', {optional: true})).to.be.undefined(); + }); + + it('returns undefined for a server-scoped binding outside a server context if optional', async () => { + const ctx = new Context('app'); + ctx.bind('state').to('value').inScope(BindingScope.SERVER); + expect(await ctx.get('state', {optional: true})).to.be.undefined(); + }); + + it('throws for a server-scoped binding outside a server context', async () => { + const ctx = new Context('app'); + ctx.bind('state').to('value').inScope(BindingScope.SERVER); + await expect(ctx.get('state')).to.be.rejectedWith( + /Binding "state" in context "app" cannot be resolved in scope "Server"/, + ); + }); + + it('delegates once() to the event emitter', () => { + const binding = new Binding('foo'); + let called = 0; + binding.once('changed', () => called++); + binding.emit('changed'); + binding.emit('changed'); + expect(called).to.equal(1); + }); + }); + + describe('ContextView', () => { + it('delegates once() to the event emitter', () => { + const ctx = new Context(); + const view = new ContextView(ctx, () => true); + let called = 0; + view.once('refresh', () => called++); + view.emit('refresh'); + view.emit('refresh'); + expect(called).to.equal(1); + }); + + it('singleValue() returns undefined when there are no matches', async () => { + const ctx = new Context(); + const view = ctx.createView(b => b.key.startsWith('nothing')); + expect(await view.singleValue()).to.be.undefined(); + }); + + it('singleValue() returns the only matching value', async () => { + const ctx = new Context(); + ctx.bind('one.a').to('A').tag('one'); + const view = ctx.createView(b => b.tagMap['one'] != null); + expect(await view.singleValue()).to.equal('A'); + }); + + it('singleValue() fails when there is more than one match', async () => { + const ctx = new Context(); + ctx.bind('many.a').to('A').tag('many'); + ctx.bind('many.b').to('B').tag('many'); + const view = ctx.createView(b => b.tagMap['many'] != null); + await expect(view.singleValue()).to.be.rejectedWith( + /more than one value/, + ); + }); + + it('open() returns the existing subscription if already open', () => { + const ctx = new Context(); + const view = ctx.createView(() => true); + const first = view.open(); + const second = view.open(); + expect(second).to.equal(first); + view.close(); + }); + }); + + describe('Context.findByTag()', () => { + it('returns no bindings if one of the tag names is unknown', () => { + const ctx = new Context(); + ctx.bind('a').to('A').tag({known: 'yes'}); + expect(ctx.findByTag({known: 'yes', unknown: 'no'})).to.be.empty(); + }); + + it('intersects bindings that match multiple tag names', () => { + const ctx = new Context(); + ctx.bind('a').to('A').tag({x: '1', y: '1'}); + ctx.bind('b').to('B').tag({x: '1'}); + ctx.bind('c').to('C').tag({y: '1'}); + const keys = ctx.findByTag({x: '1', y: '1'}).map(b => b.key); + expect(keys).to.eql(['a']); + }); + + it('stops early when the tag intersection is empty', () => { + const ctx = new Context(); + ctx.bind('a').to('A').tag({x: '1'}); + ctx.bind('b').to('B').tag({y: '1'}); + ctx.bind('c').to('C').tag({z: '1'}); + expect(ctx.findByTag({x: '1', y: '1', z: '1'})).to.be.empty(); + }); + }); + + describe('hasInjections', () => { + it('detects classes with constructor injections', () => { + class WithCtor { + constructor(@inject('a') public a: string) {} + } + expect(hasInjections(WithCtor)).to.be.true(); + }); + + it('detects classes with property injections', () => { + class WithProp { + @inject('a') + a: string; + } + expect(hasInjections(WithProp)).to.be.true(); + }); + + it('detects classes with method injections', () => { + class WithMethod { + run(@inject('a') a: string) { + return a; + } + } + expect(hasInjections(WithMethod)).to.be.true(); + }); + + it('returns false for classes without injections', () => { + class Plain {} + expect(hasInjections(Plain)).to.be.false(); + }); + }); + + describe('@inject.setter and @inject.binding', () => { + it('@inject.setter rejects a binding filter', async () => { + class Controller { + constructor( + @inject.setter((() => true) as unknown as string) + public setter: Function, + ) {} + } + const ctx = new Context(); + ctx.bind('controller').toClass(Controller); + await expect(ctx.get('controller')).to.be.rejectedWith( + /does not allow BindingFilter/, + ); + }); + + it('@inject.binding rejects a binding filter', async () => { + class Controller { + constructor( + @inject.binding((() => true) as unknown as string) + public binding: Binding, + ) {} + } + const ctx = new Context(); + ctx.bind('controller').toClass(Controller); + await expect(ctx.get('controller')).to.be.rejectedWith( + /does not allow BindingFilter/, + ); + }); + }); + + describe('GenericInterceptorChain', () => { + it('invokes interceptors in order and then the final handler', async () => { + const events: string[] = []; + const chain = new GenericInterceptorChain(new Context(), [ + async (_ctx, next) => { + events.push('i1'); + return next(); + }, + async (_ctx, next) => { + events.push('i2'); + return next(); + }, + ]); + const result = await chain.invokeInterceptors(() => { + events.push('final'); + return 'done'; + }); + expect(result).to.equal('done'); + expect(events).to.eql(['i1', 'i2', 'final']); + }); + }); + + describe('invokeMethod() with skipInterceptors', () => { + class Greeter { + greet(@inject('name') name: string) { + return `Hello, ${name}`; + } + plain(name: string) { + return `Hi, ${name}`; + } + } + + it('invokes the method with injection', async () => { + const ctx = new Context(); + ctx.bind('name').to('Alice'); + const result = await invokeMethod(new Greeter(), 'greet', ctx, [], { + skipInterceptors: true, + }); + expect(result).to.equal('Hello, Alice'); + }); + + it('invokes the method directly without injection', async () => { + const ctx = new Context(); + const result = await invokeMethod(new Greeter(), 'plain', ctx, ['Bob'], { + skipInterceptors: true, + skipParameterInjection: true, + }); + expect(result).to.equal('Hi, Bob'); + }); + }); + + describe('Provider-based resolution', () => { + it('resolves a provider class bound with toProvider()', async () => { + class GreetingProvider implements Provider { + value() { + return 'hello'; + } + } + const ctx = new Context(); + ctx.bind('greeting').toProvider(GreetingProvider); + expect(await ctx.get('greeting')).to.equal('hello'); + }); + }); + + describe('InvocationContext', () => { + it('describes the invocation with toString()', () => { + class Greeter { + greet(name: string) { + return name; + } + } + const invocationCtx = new InvocationContext( + new Context(), + new Greeter(), + 'greet', + ['x'], + ); + expect(invocationCtx.toString()).to.equal(invocationCtx.description); + expect(invocationCtx.toString()).to.match( + /^InvocationContext\(.+\): Greeter\.prototype\.greet$/, + ); + }); + }); + + describe('interceptors', () => { + it('merges @intercept applied multiple times on a class', async () => { + const events: string[] = []; + @intercept(async (_ctx, next) => { + events.push('first'); + return next(); + }) + @intercept(async (_ctx, next) => { + events.push('second'); + return next(); + }) + class Greeter { + greet(name: string) { + return `Hello, ${name}`; + } + } + const proxy = createProxyWithInterceptors(new Greeter(), new Context()); + expect(await proxy.greet('Bob')).to.equal('Hello, Bob'); + expect(events).to.eql(['first', 'second']); + }); + + it('merges @intercept applied multiple times on a method', async () => { + const events: string[] = []; + class Greeter { + @intercept(async (_ctx, next) => { + events.push('first'); + return next(); + }) + @intercept(async (_ctx, next) => { + events.push('second'); + return next(); + }) + greet(name: string) { + return `Hello, ${name}`; + } + } + const proxy = createProxyWithInterceptors(new Greeter(), new Context()); + expect(await proxy.greet('Bob')).to.equal('Hello, Bob'); + expect(events).to.eql(['first', 'second']); + }); + + it('registers a global interceptor when group is set', () => { + const ctx = new Context(); + const binding = registerInterceptor(ctx, async (_c, next) => next(), { + group: 'g1', + }); + expect(binding.tagMap).to.containEql({ + globalInterceptor: 'globalInterceptor', + globalInterceptorGroup: 'g1', + }); + }); + + it('registers a provider class as an interceptor', () => { + class MyInterceptorProvider implements Provider { + value(): Interceptor { + return (_ctx, next) => next(); + } + } + const ctx = new Context(); + const binding = registerInterceptor(ctx, MyInterceptorProvider); + expect(binding.key).to.match(/^interceptors\./); + expect(ctx.contains(binding.key)).to.be.true(); + }); + }); + + describe('interception proxy', () => { + class Plain { + name = 'plain'; + echo(value: string) { + return value; + } + } + + it('returns non-function properties as is', () => { + const proxy = createProxyWithInterceptors(new Plain(), new Context()); + expect(proxy.name).to.equal('plain'); + }); + + it('returns symbol-keyed properties as is', () => { + const sym = Symbol('s'); + const target = Object.assign(new Plain(), {[sym]: 'sym-value'}); + const proxy = createProxyWithInterceptors(target, new Context()); + expect((proxy as unknown as Record)[sym]).to.equal( + 'sym-value', + ); + }); + }); + + describe('@config.getter and @config.view', () => { + it('@config.getter resolves the configuration lazily', async () => { + class Server { + constructor( + @config.getter() + public getConfig: Getter<{port: number}>, + ) {} + } + const ctx = new Context(); + ctx.configure('server').to({port: 3000}); + ctx.bind('server').toClass(Server); + const server = await ctx.get('server'); + expect(await server.getConfig()).to.eql({port: 3000}); + }); + + it('@config.getter resolves to undefined without a current binding', async () => { + class Server { + constructor( + @config.getter() + public getConfig: Getter, + ) {} + } + const ctx = new Context(); + const server = await instantiateClass(Server, ctx); + expect(await server.getConfig()).to.be.undefined(); + }); + + it('@config.view resolves to undefined without a current binding', async () => { + class Server { + constructor( + @config.view() + public view: ContextView, + ) {} + } + const ctx = new Context(); + const server = await instantiateClass(Server, ctx); + expect(server.view).to.be.undefined(); + }); + }); + + describe('context observers', () => { + it('unsubscribe() returns false if there are no observers', () => { + const ctx = new Context(); + expect(ctx.unsubscribe({observe() {}})).to.be.false(); + }); + + it('isSubscribed() returns false if there are no observers', () => { + const ctx = new Context(); + expect(ctx.isSubscribed({observe() {}})).to.be.false(); + }); + + it('notifies function observers of bind events', async () => { + const ctx = new Context(); + const events: string[] = []; + ctx.subscribe((type, binding) => { + events.push(`${type}:${binding.key}`); + }); + ctx.bind('foo').to('bar'); + await new Promise(resolve => setImmediate(resolve)); + expect(events).to.eql(['bind:foo']); + }); + + it('close() stops notifications and is safe to call repeatedly', async () => { + const parent = new Context('parent'); + const child = new Context(parent, 'child'); + const events: string[] = []; + child.subscribe((type, binding) => { + events.push(binding.key); + }); + child.close(); + parent.bind('after-close').to('x'); + await new Promise(resolve => setImmediate(resolve)); + expect(events).to.be.empty(); + expect(() => child.close()).to.not.throw(); + }); + }); + + describe('inspectInjections', () => { + it('describes injections that use binding filters', () => { + class Consumer { + constructor( + @inject( + function matchX(b) { + return b.key.startsWith('x'); + }, + {optional: true}, + ) + public values: unknown[], + ) {} + } + const binding = new Context().bind('consumer').toClass(Consumer); + expect(inspectInjections(binding)).to.eql({ + constructorArguments: [ + { + targetName: 'Consumer.constructor[0]', + bindingFilter: 'matchX', + optional: true, + }, + ], + }); + }); + + it('returns an empty object for bindings without a class', () => { + const binding = new Context().bind('value').to('x'); + expect(inspectInjections(binding)).to.eql({}); + }); + }); + + describe('uuid()', () => { + it('generates a v4 uuid', () => { + expect(uuid()).to.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + }); +}); From 2b9c7e0a81a73d455c17a65ca6a7e81addbb73d9 Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:52:19 +0530 Subject: [PATCH 5/7] test(express): cover ExpressServer, middleware views, mixin guards and invoke provider - ExpressServer: basePath normalization, Express settings, listening and url lifecycle, serving under basePath, parent context - toMiddleware and invokeExpressMiddleware: single/chained handlers and the empty-handler error - getMiddlewareContext: missing request and bound context - MiddlewareMixin: non-Context super class and empty handler list - MiddlewareView: group ordering, refresh, validate callback, debug logging - InvokeMiddlewareProvider: handler arrays, default and custom chains - sortListOfGroups: debug logging and short group lists Signed-off-by: Pranesh Sridhar <68075088+pranesh-sridhar@users.noreply.github.com> --- .../__tests__/unit/express-additional.unit.ts | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 packages/express/src/__tests__/unit/express-additional.unit.ts diff --git a/packages/express/src/__tests__/unit/express-additional.unit.ts b/packages/express/src/__tests__/unit/express-additional.unit.ts new file mode 100644 index 000000000000..5a194d48d13a --- /dev/null +++ b/packages/express/src/__tests__/unit/express-additional.unit.ts @@ -0,0 +1,384 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/express +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, Context, createBindingFromClass} from '@loopback/core'; +import {expect, givenHttpServerConfig, supertest} from '@loopback/testlab'; +import debugFactory from 'debug'; +import {NextFunction, Request, Response} from 'express'; +import { + ExpressServer, + getMiddlewareContext, + invokeExpressMiddleware, + InvokeMiddleware, + InvokeMiddlewareProvider, + MiddlewareContext, + MiddlewareMixin, + MiddlewareView, + sortListOfGroups, + toMiddleware, +} from '../..'; + +describe('express: additional unit tests', () => { + describe('ExpressServer', () => { + it('normalizes a basePath with a leading slash', () => { + const server = new ExpressServer({basePath: '/api'}); + expect(server.basePath).to.equal('/api'); + }); + + it('normalizes a basePath with a trailing slash', () => { + const server = new ExpressServer({basePath: 'api/'}); + expect(server.basePath).to.equal('/api'); + }); + + it('normalizes a basePath without slashes', () => { + const server = new ExpressServer({basePath: 'api'}); + expect(server.basePath).to.equal('/api'); + }); + + it('uses an empty basePath by default', () => { + const server = new ExpressServer(); + expect(server.basePath).to.equal(''); + }); + + it('applies Express settings from the config', () => { + const server = new ExpressServer({ + settings: {'x-powered-by': false, env: 'test'}, + }); + expect(server.expressApp.get('x-powered-by')).to.be.false(); + expect(server.expressApp.get('env')).to.equal('test'); + }); + + it('is not listening before start()', () => { + const server = new ExpressServer(); + expect(server.listening).to.be.false(); + }); + + it('exposes url with basePath and rootUrl once started', async () => { + const server = new ExpressServer({ + ...givenHttpServerConfig(), + basePath: '/api', + }); + await server.start(); + try { + expect(server.listening).to.be.true(); + expect(server.rootUrl).to.match(/^http:\/\//); + expect(server.url).to.equal(`${server.rootUrl}/api`); + } finally { + await server.stop(); + } + expect(server.listening).to.be.false(); + }); + + it('serves requests under basePath only', async () => { + const server = new ExpressServer({ + ...givenHttpServerConfig(), + basePath: '/api', + }); + server.expressApp.get('/api/ping', (_req, res) => { + res.send('pong'); + }); + await server.start(); + try { + await supertest(server.rootUrl!).get('/api/ping').expect(200, 'pong'); + await supertest(server.rootUrl!).get('/ping').expect(404); + } finally { + await server.stop(); + } + }); + + it('can be created with a parent context', () => { + const parent = new Application(); + const server = new ExpressServer(undefined, parent); + expect(server.parent).to.equal(parent); + }); + }); + + describe('toMiddleware()', () => { + function givenMiddlewareContext(): MiddlewareContext { + const req = {method: 'GET', url: '/'} as Request; + const res = {} as Response; + return new MiddlewareContext(req, res, new Context()); + } + + it('wraps a single Express handler', async () => { + const calls: string[] = []; + const middleware = toMiddleware((_req, _res, next) => { + calls.push('h1'); + next(); + }); + const ctx = givenMiddlewareContext(); + await middleware(ctx, async () => { + calls.push('next'); + return undefined; + }); + expect(calls).to.eql(['h1', 'next']); + }); + + it('chains multiple Express handlers in order', async () => { + const calls: string[] = []; + const handler = + (name: string) => + (_req: Request, _res: Response, next: NextFunction) => { + calls.push(name); + next(); + }; + const middleware = toMiddleware( + handler('h1'), + handler('h2'), + handler('h3'), + ); + const ctx = givenMiddlewareContext(); + await middleware(ctx, async () => { + calls.push('next'); + return undefined; + }); + expect(calls).to.eql(['h1', 'h2', 'h3', 'next']); + }); + }); + + describe('invokeExpressMiddleware()', () => { + it('throws if no handler is provided', () => { + const ctx = new MiddlewareContext( + {method: 'GET', url: '/'} as Request, + {} as Response, + new Context(), + ); + expect(() => invokeExpressMiddleware(ctx)).to.throw( + 'No Express middleware handler function is provided.', + ); + }); + }); + + describe('getMiddlewareContext()', () => { + it('returns undefined if the request is not provided', () => { + expect(getMiddlewareContext(undefined)).to.be.undefined(); + }); + + it('returns undefined if the request has no middleware context', () => { + expect(getMiddlewareContext({} as Request)).to.be.undefined(); + }); + + it('returns the middleware context bound to the request', () => { + const req = {method: 'GET', url: '/'} as Request; + const ctx = new MiddlewareContext(req, {} as Response, new Context()); + expect(getMiddlewareContext(req)).to.equal(ctx); + }); + }); + + describe('MiddlewareMixin', () => { + it('throws if the super class does not extend Context', () => { + class NotAContext {} + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MiddlewareMixin(NotAContext as any), + ).to.throw('The super class does not extend from Context'); + }); + + it('throws if an empty list of Express handlers is registered', () => { + class MyContext extends MiddlewareMixin(Context) {} + const ctx = new MyContext(); + expect(() => ctx.expressMiddleware('middleware.empty', [])).to.throw( + 'No Express middleware handler function is provided.', + ); + }); + + it('registers a list of Express handlers by binding key', () => { + class MyContext extends MiddlewareMixin(Context) {} + const ctx = new MyContext(); + const binding = ctx.expressMiddleware('middleware.list', [ + (_req, _res, next) => next(), + (_req, _res, next) => next(), + ]); + expect(binding.key).to.equal('middleware.list'); + }); + }); + + describe('MiddlewareView', () => { + it('exposes middleware binding keys sorted by group', () => { + class MyContext extends MiddlewareMixin(Context) {} + const ctx = new MyContext(); + ctx.expressMiddleware('middleware.b', [(_r, _s, next) => next()], { + key: 'middleware.b', + group: 'second', + }); + ctx.expressMiddleware('middleware.a', [(_r, _s, next) => next()], { + key: 'middleware.a', + group: 'first', + }); + const view = new MiddlewareView(ctx, { + orderedGroups: ['first', 'second'], + }); + try { + expect(view.middlewareBindingKeys).to.eql([ + 'middleware.a', + 'middleware.b', + ]); + } finally { + view.close(); + } + }); + + it('rebuilds the keys on refresh()', () => { + class MyContext extends MiddlewareMixin(Context) {} + const ctx = new MyContext(); + ctx.expressMiddleware('middleware.a', [(_r, _s, next) => next()], { + key: 'middleware.a', + group: 'first', + }); + const view = new MiddlewareView(ctx, { + orderedGroups: ['first', 'second'], + }); + try { + expect(view.middlewareBindingKeys).to.eql(['middleware.a']); + ctx.expressMiddleware('middleware.b', [(_r, _s, next) => next()], { + key: 'middleware.b', + group: 'second', + }); + view.refresh(); + expect(view.middlewareBindingKeys).to.eql([ + 'middleware.a', + 'middleware.b', + ]); + } finally { + view.close(); + } + }); + + it('invokes the validate() callback with the sorted groups', () => { + const ctx = new Context(); + let validated: string[] | undefined; + const view = new MiddlewareView(ctx, { + orderedGroups: ['first', 'second'], + validate: groups => { + validated = groups; + }, + }); + try { + expect(validated).to.eql(['first', 'second']); + } finally { + view.close(); + } + }); + + it('propagates errors thrown by validate()', () => { + const ctx = new Context(); + expect( + () => + new MiddlewareView(ctx, { + orderedGroups: ['first'], + validate: () => { + throw new Error('invalid groups'); + }, + }), + ).to.throw('invalid groups'); + }); + + it('builds the keys when debug logging is enabled', () => { + const ctx = new Context(); + const namespaces = debugFactory.disable(); + const originalLog = debugFactory.log; + // Keep the test output clean + debugFactory.log = () => {}; + debugFactory.enable('loopback:middleware'); + try { + const view = new MiddlewareView(ctx, {orderedGroups: ['first']}); + expect(view.middlewareBindingKeys).to.eql([]); + view.close(); + } finally { + debugFactory.log = originalLog; + debugFactory.enable(namespaces); + } + }); + }); + + describe('InvokeMiddlewareProvider', () => { + function givenMiddlewareContext(parent: Context): MiddlewareContext { + const req = {method: 'GET', url: '/'} as Request; + return new MiddlewareContext(req, {} as Response, parent); + } + + it('invokes a list of Express handlers when an array is provided', async () => { + const ctx = new Context(); + const binding = createBindingFromClass(InvokeMiddlewareProvider); + ctx.add(binding); + const invoke = await ctx.get(binding.key); + const calls: string[] = []; + + const result = await invoke(givenMiddlewareContext(ctx), [ + (_req, _res, next) => { + calls.push('h1'); + next(); + }, + (_req, _res, next) => { + calls.push('h2'); + next(); + }, + ]); + + expect(calls).to.eql(['h1', 'h2']); + // The response was not finished by the handlers + expect(result).to.be.false(); + }); + + it('falls back to the default chain and ordered groups', async () => { + const ctx = new Context(); + const binding = createBindingFromClass(InvokeMiddlewareProvider); + ctx.add(binding); + const invoke = await ctx.get(binding.key); + + // No middleware is registered - the default chain is a no-op + await expect( + Promise.resolve(invoke(givenMiddlewareContext(ctx))), + ).to.be.fulfilled(); + }); + + it('uses the chain and groups passed in the options', async () => { + const ctx = new Context(); + const binding = createBindingFromClass(InvokeMiddlewareProvider); + ctx.add(binding); + const invoke = await ctx.get(binding.key); + const calls: string[] = []; + const key = 'middleware.custom'; + ctx + .bind(key) + .to(async (_ctx: MiddlewareContext, next: () => unknown) => { + calls.push('custom'); + return next(); + }) + .tag({extensionFor: 'custom-chain', group: 'g1'}); + + await invoke(givenMiddlewareContext(ctx), { + chain: 'custom-chain', + orderedGroups: ['g1'], + }); + + expect(calls).to.eql(['custom']); + }); + }); + + describe('sortListOfGroups()', () => { + it('sorts groups when debug logging is enabled', () => { + const namespaces = debugFactory.disable(); + const originalLog = debugFactory.log; + // Keep the test output clean + debugFactory.log = () => {}; + debugFactory.enable('loopback:middleware'); + try { + expect(sortListOfGroups(['a', 'b'], ['b', 'c'])).to.eql([ + 'a', + 'b', + 'c', + ]); + } finally { + debugFactory.log = originalLog; + debugFactory.enable(namespaces); + } + }); + + it('ignores lists with fewer than two groups', () => { + expect(sortListOfGroups(['a'], ['b', 'c'])).to.eql(['b', 'c']); + }); + }); +}); From 733dca47cf5403798d49bb998a650fa400f9d087 Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar Date: Thu, 22 Oct 2026 17:21:32 +0530 Subject: [PATCH 6/7] test(core,metadata): improve unit test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests to cover code paths in `@loopback/core` and `@loopback/metadata` that the existing suites did not exercise. Coverage was measured with `nyc` over each package's `dist/` output. Only test files are changed; there are no source changes. `@loopback/authentication` is covered in #11853. `@loopback/authorization`, `boot`, `booter-lb3app`, `context`, and `express` are covered in #11854. | Package | Tests before | Tests after | Stmts before | Stmts after | |---|---|---|---|---| | `core` | 346 | 355 | 95.47% | 98.74% | | `metadata` | 162 | 171 | 96.18% | 98.09% | ### Tests added **`@loopback/core`** - `application-lifecycle.unit.ts`: `assertInStates()` — throws when the application is not in the expected state; does not throw when it is - `application.unit.ts`: `setMetadata()` — stores metadata under `CoreBindings.APPLICATION_METADATA`; overwrites on a second call - `component.unit.ts`: `mountComponent()` self-reference guard — skips a component instance placed directly in its own `components` array - `lifecycle-registry.unit.ts`: `getObserverGroupsByOrder()` — debug logging branch exercised by enabling the `loopback:core:lifecycle` namespace temporarily - `service.unit.ts`: `@service()` without design-time type metadata — throws "No design-time type metadata found" when `design:type` is absent; `createServiceBinding()` with a `DynamicValueProvider` class — trims the `Provider` suffix from the name; preserves a custom name **`@loopback/metadata`** - `decorator-factory.unit.ts`: Base-class abstract-method guards — `mergeWithInherited()`, `mergeWithOwn()`, and `create()` each throw "not implemented" when the subclass does not override them; `getTarget()` returns `undefined` for non-object specs; `MethodMultiDecoratorFactory` `mergeWithOwn` exercised on a standalone class with no base - `inspector.unit.ts`: `MetadataInspector.defineMetadata()` — stores and overwrites class-level metadata; `getMethodMetadata()` with `methodName` omitted defaults to the constructor key ### Verification All tests pass (`npm test` per package, `lb-tsc` build + `lb-mocha`). ESLint (`--report-unused-disable-directives`) and Prettier clean on all changed files. Signed-off-by: Pranesh Sridhar --- .../unit/application-lifecycle.unit.ts | 27 ++++ .../src/__tests__/unit/application.unit.ts | 23 ++++ .../core/src/__tests__/unit/component.unit.ts | 16 +++ .../__tests__/unit/lifecycle-registry.unit.ts | 19 +++ .../core/src/__tests__/unit/service.unit.ts | 65 ++++++++++ .../__tests__/unit/decorator-factory.unit.ts | 117 ++++++++++++++++++ .../src/__tests__/unit/inspector.unit.ts | 45 +++++++ 7 files changed, 312 insertions(+) diff --git a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts index 8c9ddea80866..b37abbc96166 100644 --- a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts +++ b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts @@ -477,6 +477,33 @@ describe('Application life cycle', () => { expect(invoked).to.deepEqual(['second', 'first']); }); }); + + describe('assertInStates()', () => { + it('throws when the application is not in the expected state', () => { + // Subclass to expose the protected method + class TestApp extends Application { + checkState(op: string, ...states: string[]) { + this.assertInStates(op, ...states); + } + } + const app = new TestApp(); + // State is 'created'; 'started' is not valid + expect(() => app.checkState('start', 'started')).to.throw( + /Cannot start the application as it is created/, + ); + }); + + it('does not throw when the application is in an expected state', () => { + class TestApp extends Application { + checkState(op: string, ...states: string[]) { + this.assertInStates(op, ...states); + } + } + const app = new TestApp(); + // State is 'created'; passing 'created' should be fine + expect(() => app.checkState('test', 'created')).to.not.throw(); + }); + }); }); class ObservingComponentWithServers implements Component, LifeCycleObserver { diff --git a/packages/core/src/__tests__/unit/application.unit.ts b/packages/core/src/__tests__/unit/application.unit.ts index e32fcdf0b9ff..e3a20cd3810c 100644 --- a/packages/core/src/__tests__/unit/application.unit.ts +++ b/packages/core/src/__tests__/unit/application.unit.ts @@ -47,6 +47,29 @@ describe('Application', () => { myAppConfig, ); }); + + it('sets application metadata via setMetadata()', () => { + app = new Application(); + app.setMetadata({name: 'my-app', version: '1.0.0', description: 'Test'}); + const metadata = app.getSync(CoreBindings.APPLICATION_METADATA); + expect(metadata).to.deepEqual({ + name: 'my-app', + version: '1.0.0', + description: 'Test', + }); + }); + + it('overwrites application metadata when setMetadata() is called again', () => { + app = new Application(); + app.setMetadata({name: 'first', version: '1.0.0', description: 'First'}); + app.setMetadata({ + name: 'second', + version: '2.0.0', + description: 'Second', + }); + const metadata = app.getSync(CoreBindings.APPLICATION_METADATA); + expect(metadata).to.have.property('name', 'second'); + }); }); describe('controller binding', () => { diff --git a/packages/core/src/__tests__/unit/component.unit.ts b/packages/core/src/__tests__/unit/component.unit.ts index 1154f4d748dd..2a27e3498c54 100644 --- a/packages/core/src/__tests__/unit/component.unit.ts +++ b/packages/core/src/__tests__/unit/component.unit.ts @@ -313,6 +313,22 @@ describe('Component', () => { expect(bindings).to.have.length(1); }); + it('skips a component instance that appears directly in its own components array', () => { + // Defensive guard: if the component instance is placed (via any cast) in + // its own `.components` array the loop should skip it to prevent + // infinite recursion. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component: any = {}; + component.components = [component]; + + // Should not throw or infinite-loop + mountComponent(app, component); + + // Nothing was added via app.component() for the self-reference + const bindings = app.findByTag(CoreTags.COMPONENT); + expect(bindings).to.be.empty(); + }); + it('mounts component with all artifact types', () => { class MyClass {} class MyProvider implements Provider { diff --git a/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts index ed53d6438c34..ca2013f500ec 100644 --- a/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts +++ b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts @@ -11,6 +11,7 @@ import { injectable, } from '@loopback/context'; import {expect} from '@loopback/testlab'; +import debugFactory from 'debug'; import {promisify} from 'util'; import { asLifeCycleObserver, @@ -163,6 +164,24 @@ describe('LifeCycleRegistry', () => { ]); }); + it('getObserverGroupsByOrder() logs debug output when debug logging is enabled', async () => { + await givenLifeCycleRegistry(); + givenObserver('obs1', 'g1'); + + const namespaces = debugFactory.disable(); + const originalLog = debugFactory.log; + // Suppress output to keep test results clean + debugFactory.log = () => {}; + debugFactory.enable('loopback:core:lifecycle'); + try { + const groups = registry.getOrderedGroups(); + expect(groups).to.containEql('g1'); + } finally { + debugFactory.log = originalLog; + debugFactory.enable(namespaces); + } + }); + function givenContext() { context = new Context('app'); } diff --git a/packages/core/src/__tests__/unit/service.unit.ts b/packages/core/src/__tests__/unit/service.unit.ts index 4edd01b2a5e1..be51e4b0c20e 100644 --- a/packages/core/src/__tests__/unit/service.unit.ts +++ b/packages/core/src/__tests__/unit/service.unit.ts @@ -9,6 +9,7 @@ import { Context, ContextTags, injectable, + isDynamicValueProviderClass, Provider, } from '@loopback/context'; import {expect} from '@loopback/testlab'; @@ -261,6 +262,36 @@ describe('Service', () => { ).to.be.rejectedWith(/Service class cannot be inferred from design type/); }); + it('throws "No design-time type metadata found" for property with deleted design type', async () => { + @injectable({scope: BindingScope.SINGLETON}) + class MyService { + getValue() { + return 'value'; + } + } + + class MyController { + @service() + public myService: MyService; + } + + // Simulate missing emitDecoratorMetadata by deleting the design:type + // metadata that TypeScript emits for decorated properties. + // Note: design:type is stored under the raw Reflect key, not the + // namespaced 'loopback:design:type' key that Reflector uses. + Reflect.deleteMetadata( + 'design:type', + MyController.prototype, + 'myService', + ); + + ctx.bind('controllers.MyController').toClass(MyController); + + await expect( + ctx.get('controllers.MyController'), + ).to.be.rejectedWith(/No design-time type metadata found/); + }); + it('works with property injection', async () => { @injectable({scope: BindingScope.SINGLETON}) class MyService { @@ -477,6 +508,40 @@ describe('Service', () => { expect(binding.scope).to.equal(BindingScope.SINGLETON); expect(Array.from(binding.tagNames)).to.containEql('custom-tag'); }); + + it('trims Provider suffix from a DynamicValueProvider class name', () => { + // A DynamicValueProvider has a static `value()` method rather than + // implementing the Provider interface. + class MyDynamicServiceProvider { + static value() { + return 'dynamic-value'; + } + } + + // Confirm the helper recognises it as a dynamic value provider + expect( + isDynamicValueProviderClass(MyDynamicServiceProvider), + ).to.be.true(); + + const binding = createServiceBinding(MyDynamicServiceProvider); + + // The binding key should have the Provider suffix stripped + expect(binding.key).to.match(/MyDynamicService$/); + }); + + it('preserves name for a DynamicValueProvider when a custom name is supplied', () => { + class MyDynamicServiceProvider { + static value() { + return 'dynamic-value'; + } + } + + const binding = createServiceBinding(MyDynamicServiceProvider, { + name: 'custom-dynamic', + }); + + expect(binding.key).to.equal('services.custom-dynamic'); + }); }); describe('asService', () => { diff --git a/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts b/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts index 6bad74c246b2..81b09b328b51 100644 --- a/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts +++ b/packages/metadata/src/__tests__/unit/decorator-factory.unit.ts @@ -884,3 +884,120 @@ describe('MethodParameterDecoratorFactory with invalid decorations', () => { ); }); }); + +describe('DecoratorFactory base class abstract methods', () => { + // These tests exercise the "not implemented" guard paths in the base class. + // They are hit when a concrete subclass forgets to implement the required + // override, or when a subclass accidentally calls super. + + it('mergeWithInherited() throws "not implemented" by default', () => { + // Extend the base factory minimally, without overriding mergeWithInherited + class StubFactory extends DecoratorFactory { + mergeWithOwn(ownMetadata: object): object { + return ownMetadata ?? {}; + } + + create(): ClassDecorator { + return (_target: Function) => {}; + } + } + + const factory = new StubFactory('test', {}); + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (factory as any).mergeWithInherited({}, class Foo {}), + ).to.throw(/mergeWithInherited\(\) is not implemented/); + }); + + it('mergeWithOwn() throws "not implemented" by default', () => { + class StubFactory extends DecoratorFactory { + mergeWithInherited(inheritedMetadata: object): object { + return inheritedMetadata ?? {}; + } + + create(): ClassDecorator { + return (_target: Function) => {}; + } + } + + const factory = new StubFactory('test', {}); + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (factory as any).mergeWithOwn({}, class Foo {}), + ).to.throw(/mergeWithOwn\(\) is not implemented/); + }); + + it('create() throws "not implemented" by default', () => { + class MinimalFactory extends DecoratorFactory< + object, + object, + ClassDecorator + > { + mergeWithInherited(inheritedMetadata: object): object { + return inheritedMetadata ?? {}; + } + + mergeWithOwn(ownMetadata: object): object { + return ownMetadata ?? {}; + } + } + + const factory = new MinimalFactory('test', {}); + expect(() => factory.create()).to.throw(/create\(\) is not implemented/); + }); + + it('getTarget() returns undefined for non-object spec', () => { + // A null or primitive spec has no TARGET property — the else branch + // in getTarget() must return undefined. + const factory = new ClassDecoratorFactory('test', {}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((factory as any).getTarget('not-an-object')).to.be.undefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((factory as any).getTarget(42)).to.be.undefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((factory as any).getTarget(null)).to.be.undefined(); + }); +}); + +describe('MethodMultiDecoratorFactory own-method decoration', () => { + // Applies the decorator on a concrete method with no base class so that + // mergeWithOwn (not mergeWithInherited) is the only path exercised. + it('records metadata when decorating a method with no base class', () => { + function multiDecorator(spec: object): MethodDecorator { + return MethodMultiDecoratorFactory.createDecorator('test-multi', spec); + } + + class StandaloneController { + @multiDecorator({a: 1}) + myMethod() {} + } + + const meta = Reflector.getOwnMetadata( + 'test-multi', + StandaloneController.prototype, + ); + expect(meta.myMethod).to.eql([{a: 1}]); + }); + + it('accumulates multiple decorators on the same method in a standalone class', () => { + function multiDecorator(spec: object): MethodDecorator { + return MethodMultiDecoratorFactory.createDecorator( + 'test-multi-acc', + spec, + ); + } + + class StandaloneController { + @multiDecorator({b: 2}) + @multiDecorator({a: 1}) + myMethod() {} + } + + const meta = Reflector.getOwnMetadata( + 'test-multi-acc', + StandaloneController.prototype, + ); + // Decorators run bottom-up, so {a:1} is applied first, then {b:2} + expect(meta.myMethod).to.eql([{a: 1}, {b: 2}]); + }); +}); diff --git a/packages/metadata/src/__tests__/unit/inspector.unit.ts b/packages/metadata/src/__tests__/unit/inspector.unit.ts index ff9b588970d9..761d742da139 100644 --- a/packages/metadata/src/__tests__/unit/inspector.unit.ts +++ b/packages/metadata/src/__tests__/unit/inspector.unit.ts @@ -285,6 +285,18 @@ describe('Inspector for instance methods', () => { ); expect(methodMeta).to.be.undefined(); }); + + it('defaults to constructor metadata when methodName is omitted', () => { + // When methodName is omitted, getMethodMetadata defaults to '' which + // represents the constructor. This exercises the `methodName ?? ''` branch. + const meta = MetadataInspector.getMethodMetadata( + 'test', + BaseController.prototype, + // methodName intentionally omitted + ); + // There is no constructor metadata, so this returns undefined + expect(meta).to.be.undefined(); + }); }); describe('Inspector for static methods', () => { @@ -770,3 +782,36 @@ describe('Inspector for design time metadata', () => { expect(meta).to.equal(undefined); }); }); + +describe('MetadataInspector.defineMetadata()', () => { + it('stores class metadata via a MetadataAccessor key', () => { + const key = MetadataAccessor.create<{role: string}, ClassDecorator>( + 'test.define-meta', + ); + + class MyTarget {} + + MetadataInspector.defineMetadata(key, {role: 'admin'}, MyTarget); + + const stored = MetadataInspector.getClassMetadata(key, MyTarget, { + ownMetadataOnly: true, + }); + expect(stored).to.deepEqual({role: 'admin'}); + }); + + it('overwrites existing class metadata when called a second time', () => { + const key = MetadataAccessor.create<{role: string}, ClassDecorator>( + 'test.define-meta-overwrite', + ); + + class MyTarget {} + + MetadataInspector.defineMetadata(key, {role: 'admin'}, MyTarget); + MetadataInspector.defineMetadata(key, {role: 'superuser'}, MyTarget); + + const stored = MetadataInspector.getClassMetadata(key, MyTarget, { + ownMetadataOnly: true, + }); + expect(stored).to.deepEqual({role: 'superuser'}); + }); +}); From b13a6ceaf1a8381c9f2a750245eea0b064937a36 Mon Sep 17 00:00:00 2001 From: Pranesh Sridhar Date: Thu, 22 Oct 2026 19:17:14 +0530 Subject: [PATCH 7/7] test: add unit tests to improve coverage for 9 core packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages covered and coverage improvements: | Package | Stmts before | Stmts after | Branches before | Branches after | |---|---|---|---|---| | model-api-builder | 0% (no tests) | 100% | 0% | 100% | | http-caching-proxy | 93.84% | 98.46% | 55.55% | 94.44% | | openapi-spec-builder | 88.60% | 100% | 72.13% | 100% | | rest-crud | 68.23% | 100% | 50% | 83.33% | | filter | 96.83% | 99.36% | 87.09% | 96.77% | | repository-json-schema | 95.48% | 98.87% | 92% | 94.66% | | repository (types) | object 68%, buffer 95% br | 100% | 75%/95% | 100% | | rest | redirect-route 71% | 100% stmts/lines | various | improved | | tsdocs | 86.39% | 88.43% | 57.62% | 62.71% | Tests added: - model-api-builder: asModelApiBuilder binding template, MODEL_API_BUILDER_PLUGINS constant - http-caching-proxy: constructor, stop() no-op, logError branches (true/false, error.stack vs error), non-ENOENT cache corruption integration test - openapi-spec-builder: withOperationReturningString, withRequestBody, withStringResponse default, OperationSpecBuilder/ComponentsSpecBuilder accumulation (??= branches), setupOperationId idempotency - rest-crud: CrudRestComponent binding, CrudRestApiBuilder.build() — missing basePath, non-Entity model, repo absent/present, readonly flag - filter: WhereBuilder.impose and FilterBuilder.impose with falsy internal state (defensive branch coverage) - repository-json-schema: getNavigationalPropertyForRelation targetsMany=false and =true, getJsonSchema with belongsTo keyFrom, multiple indexed properties - repository: BufferType.coerce with explicit options, ObjectType.coerce already-instance and invalid values, ObjectType.serialize toJSON/no-toJSON - rest: RedirectRoute.isRedirectRoute, describe, updateBindings, invokeHandler; RoutingTable.find throws NotFound; InfoSpecEnhancer.modifySpec with null pkg, overrideInfo flag, string/object/null author parsing - tsdocs: getUnscopedPackageName unscoped name, invokeExtractorForPackage silent:false logging branch Only test files are changed (plus model-api-builder package.json/tsconfig.json to wire up the test script and devDependencies, as no tests existed before). Signed-off-by: Pranesh Ravi --- .../src/__tests__/unit/query-extra.unit.ts | 58 +++++ .../http-caching-proxy-extra.integration.ts | 122 +++++++++ .../__tests__/unit/http-caching-proxy.unit.ts | 114 +++++++++ packages/model-api-builder/package.json | 5 +- .../__tests__/unit/model-api-builder.unit.ts | 53 ++++ packages/model-api-builder/tsconfig.json | 3 +- .../unit/openapi-spec-builder-extra.unit.ts | 242 ++++++++++++++++++ .../__tests__/unit/build-schema-extra.unit.ts | 111 ++++++++ .../__tests__/unit/type/type-extra.unit.ts | 75 ++++++ .../unit/crud-rest-api-builder.unit.ts | 167 ++++++++++++ .../src/__tests__/unit/rest-extra.unit.ts | 183 +++++++++++++ .../src/__tests__/unit/tsdocs-extra.unit.ts | 52 ++++ 12 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 packages/filter/src/__tests__/unit/query-extra.unit.ts create mode 100644 packages/http-caching-proxy/src/__tests__/integration/http-caching-proxy-extra.integration.ts create mode 100644 packages/http-caching-proxy/src/__tests__/unit/http-caching-proxy.unit.ts create mode 100644 packages/model-api-builder/src/__tests__/unit/model-api-builder.unit.ts create mode 100644 packages/openapi-spec-builder/src/__tests__/unit/openapi-spec-builder-extra.unit.ts create mode 100644 packages/repository-json-schema/src/__tests__/unit/build-schema-extra.unit.ts create mode 100644 packages/repository/src/__tests__/unit/type/type-extra.unit.ts create mode 100644 packages/rest-crud/src/__tests__/unit/crud-rest-api-builder.unit.ts create mode 100644 packages/rest/src/__tests__/unit/rest-extra.unit.ts create mode 100644 packages/tsdocs/src/__tests__/unit/tsdocs-extra.unit.ts diff --git a/packages/filter/src/__tests__/unit/query-extra.unit.ts b/packages/filter/src/__tests__/unit/query-extra.unit.ts new file mode 100644 index 000000000000..f50999199ba8 --- /dev/null +++ b/packages/filter/src/__tests__/unit/query-extra.unit.ts @@ -0,0 +1,58 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/filter +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {FilterBuilder, WhereBuilder} from '../..'; + +describe('WhereBuilder — uncovered branches', () => { + describe('impose() with no prior where (undefined internal state)', () => { + it('assigns the given where when internal where is falsy', () => { + const builder = new WhereBuilder(); + // Force the internal state to undefined to exercise the !this.where branch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any).where = undefined; + + builder.impose({name: 'John'}); + + expect(builder.build()).to.deepEqual({name: 'John'}); + }); + + it('assigns empty object when given where is falsy', () => { + const builder = new WhereBuilder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any).where = undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + builder.impose(undefined as any); + + expect(builder.build()).to.deepEqual({}); + }); + }); +}); + +describe('FilterBuilder — uncovered branches', () => { + describe('impose() with no prior filter (undefined internal state)', () => { + it('wraps a plain Where in a Filter when internal filter is falsy', () => { + const builder = new FilterBuilder(); + // Force internal state to undefined to exercise the !this.filter branch + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any).filter = undefined; + + builder.impose({name: 'John'}); + + expect(builder.build()).to.deepEqual({where: {name: 'John'}}); + }); + + it('uses a Filter directly when internal filter is falsy', () => { + const builder = new FilterBuilder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any).filter = undefined; + + builder.impose({where: {age: 30}}); + + expect(builder.build()).to.deepEqual({where: {age: 30}}); + }); + }); +}); diff --git a/packages/http-caching-proxy/src/__tests__/integration/http-caching-proxy-extra.integration.ts b/packages/http-caching-proxy/src/__tests__/integration/http-caching-proxy-extra.integration.ts new file mode 100644 index 000000000000..77a50f76b14c --- /dev/null +++ b/packages/http-caching-proxy/src/__tests__/integration/http-caching-proxy-extra.integration.ts @@ -0,0 +1,122 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/http-caching-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, sinon} from '@loopback/testlab'; +import axios, {AxiosProxyConfig, AxiosRequestConfig} from 'axios'; +import {once} from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import {AddressInfo} from 'node:net'; +import path from 'node:path'; +import {rimraf} from 'rimraf'; +import {HttpCachingProxy} from '../../http-caching-proxy'; + +const CACHE_DIR = path.join(__dirname, '.cache-extra'); + +describe('HttpCachingProxy (additional integration)', () => { + let stubServerUrl: string; + let stubServer: http.Server | undefined; + + before(async () => { + stubServer = http.createServer((_req, res) => { + res.writeHead(200); + res.end('ok'); + }); + stubServer.listen(0); + await once(stubServer, 'listening'); + const address = stubServer.address() as AddressInfo; + stubServerUrl = `http://127.0.0.1:${address.port}`; + }); + + after(async () => { + if (!stubServer) return; + stubServer.close(); + await once(stubServer, 'close'); + stubServer = undefined; + }); + + beforeEach('clean cache dir', async () => rimraf(CACHE_DIR)); + + let proxy: HttpCachingProxy; + + afterEach(async () => { + if (proxy) { + await proxy.stop(); + } + }); + + it('stop() is idempotent — second call is a no-op', async () => { + proxy = new HttpCachingProxy({cachePath: CACHE_DIR}); + await proxy.start(); + await proxy.stop(); + // Second stop should not throw + await proxy.stop(); + }); + + it('serves the request and falls back to backend when cache has a non-ENOENT error', async () => { + // Populate the cache with a valid entry first + proxy = new HttpCachingProxy({cachePath: CACHE_DIR}); + await proxy.start(); + + await makeRequest({url: stubServerUrl}); + + // Corrupt the content files to cause EINTEGRITY on the next read + const contentDir = path.join(CACHE_DIR, 'content-v2'); + corruptFiles(contentDir); + + // The proxy should handle the EINTEGRITY error gracefully (log a warning + // and fall back to fetching from the backend) rather than crashing. + // Stub console.warn so the test framework's console-log guard is satisfied. + await proxy.stop(); + proxy = new HttpCachingProxy({cachePath: CACHE_DIR, logError: false}); + await proxy.start(); + + const warnStub = sinon.stub(console, 'warn'); + let result; + try { + result = await makeRequest({url: stubServerUrl}); + } finally { + warnStub.restore(); + } + + expect(result!.status).to.equal(200); + }); + + // ── helpers ──────────────────────────────────────────────────────────────── + + const axiosInstance = axios.create({validateStatus: () => true}); + + function getProxyConfig(url: string): AxiosProxyConfig { + const parsed = new URL(url); + return { + protocol: parsed.protocol, + host: parsed.hostname, + port: parseInt(parsed.port), + }; + } + + async function makeRequest(config: AxiosRequestConfig) { + return axiosInstance({ + proxy: getProxyConfig(proxy.url), + ...config, + }); + } + + /** + * Overwrite all files under `dir` with garbage so that cacache returns + * an EINTEGRITY error instead of ENOENT. + */ + function corruptFiles(dir: string) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + corruptFiles(full); + } else { + fs.writeFileSync(full, 'corrupted'); + } + } + } +}); diff --git a/packages/http-caching-proxy/src/__tests__/unit/http-caching-proxy.unit.ts b/packages/http-caching-proxy/src/__tests__/unit/http-caching-proxy.unit.ts new file mode 100644 index 000000000000..7087dc4b95b2 --- /dev/null +++ b/packages/http-caching-proxy/src/__tests__/unit/http-caching-proxy.unit.ts @@ -0,0 +1,114 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/http-caching-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, sinon} from '@loopback/testlab'; +import {IncomingMessage} from 'node:http'; +import {Socket} from 'node:net'; +import {HttpCachingProxy} from '../../http-caching-proxy'; + +describe('HttpCachingProxy (unit)', () => { + describe('constructor', () => { + it('rejects missing cachePath option', () => { + expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => new HttpCachingProxy({cachePath: undefined} as any), + ).to.throwError(/required option.*cachePath/i); + }); + + it('applies DEFAULT_OPTIONS when none are provided', () => { + const proxy = new HttpCachingProxy({cachePath: '/tmp/test-cache'}); + expect(proxy.url).to.equal('http://proxy-not-running'); + }); + }); + + describe('stop()', () => { + it('is a no-op when the proxy is not running', async () => { + const proxy = new HttpCachingProxy({cachePath: '/tmp/test-cache'}); + // Should resolve without throwing + await proxy.stop(); + expect(proxy.url).to.equal('http://proxy-not-running'); + }); + }); + + describe('logError()', () => { + let proxy: HttpCachingProxy; + let consoleErrorStub: sinon.SinonStub; + let fakeRequest: IncomingMessage; + + beforeEach(() => { + fakeRequest = new IncomingMessage(new Socket()); + fakeRequest.method = 'GET'; + fakeRequest.url = 'http://example.com'; + }); + + afterEach(() => { + consoleErrorStub?.restore(); + }); + + it('calls console.error when logError option is true (default)', () => { + proxy = new HttpCachingProxy({cachePath: '/tmp/test-cache'}); + consoleErrorStub = sinon.stub(console, 'error'); + + const error = new Error('test error'); + proxy.logError(fakeRequest, error); + + sinon.assert.calledOnce(consoleErrorStub); + sinon.assert.calledWith( + consoleErrorStub, + 'Cannot proxy %s %s.', + 'GET', + 'http://example.com', + error.stack ?? error, + ); + }); + + it('does NOT call console.error when logError option is false', () => { + proxy = new HttpCachingProxy({ + cachePath: '/tmp/test-cache', + logError: false, + }); + consoleErrorStub = sinon.stub(console, 'error'); + + const error = new Error('test error'); + proxy.logError(fakeRequest, error); + + sinon.assert.notCalled(consoleErrorStub); + }); + + it('uses the error object itself when error.stack is undefined', () => { + proxy = new HttpCachingProxy({cachePath: '/tmp/test-cache'}); + consoleErrorStub = sinon.stub(console, 'error'); + + const error = new Error('no-stack error'); + delete error.stack; + + proxy.logError(fakeRequest, error); + + sinon.assert.calledOnce(consoleErrorStub); + const lastArg = + consoleErrorStub.firstCall.args[ + consoleErrorStub.firstCall.args.length - 1 + ]; + expect(lastArg).to.equal(error); + }); + + it('uses error.stack when it is defined', () => { + proxy = new HttpCachingProxy({cachePath: '/tmp/test-cache'}); + consoleErrorStub = sinon.stub(console, 'error'); + + const error = new Error('stack error'); + error.stack = 'Error: stack error\n at Test.stack'; + + proxy.logError(fakeRequest, error); + + sinon.assert.calledOnce(consoleErrorStub); + const lastArg = + consoleErrorStub.firstCall.args[ + consoleErrorStub.firstCall.args.length - 1 + ]; + expect(lastArg).to.equal(error.stack); + }); + }); +}); diff --git a/packages/model-api-builder/package.json b/packages/model-api-builder/package.json index 7aa5a708f9df..9dfd6e35f237 100644 --- a/packages/model-api-builder/package.json +++ b/packages/model-api-builder/package.json @@ -19,7 +19,8 @@ "build": "lb-tsc", "clean": "lb-clean loopback-model-api-builder*.tgz dist *.tsbuildinfo package", "pretest": "npm run build", - "test": "", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "unit": "lb-mocha \"dist/__tests__/unit/**/*.js\"", "verify": "npm pack && tar xf loopback-model-api-builder*.tgz && tree package && npm run clean" }, "publishConfig": { @@ -40,8 +41,10 @@ }, "devDependencies": { "@loopback/build": "^12.0.15", + "@loopback/context": "^8.0.15", "@loopback/core": "^7.0.15", "@loopback/repository": "^8.0.15", + "@loopback/testlab": "^8.0.15", "@types/node": "^20.19.43" } } diff --git a/packages/model-api-builder/src/__tests__/unit/model-api-builder.unit.ts b/packages/model-api-builder/src/__tests__/unit/model-api-builder.unit.ts new file mode 100644 index 000000000000..0dec9d38834e --- /dev/null +++ b/packages/model-api-builder/src/__tests__/unit/model-api-builder.unit.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/model-api-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding, BindingKey} from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import {asModelApiBuilder, MODEL_API_BUILDER_PLUGINS} from '../..'; + +describe('model-api-builder', () => { + describe('MODEL_API_BUILDER_PLUGINS', () => { + it('is the string "model-api-builders"', () => { + expect(MODEL_API_BUILDER_PLUGINS).to.equal('model-api-builders'); + }); + }); + + describe('asModelApiBuilder()', () => { + let binding: Binding; + + beforeEach(() => { + binding = new Binding(BindingKey.create('test.builder')); + }); + + it('tags the binding as an extension of MODEL_API_BUILDER_PLUGINS', () => { + asModelApiBuilder(binding); + + expect(binding.tagMap).to.containEql({ + extensionFor: MODEL_API_BUILDER_PLUGINS, + }); + }); + + it('adds a "namespace" tag equal to MODEL_API_BUILDER_PLUGINS', () => { + asModelApiBuilder(binding); + + expect(binding.tagMap).to.containEql({ + namespace: MODEL_API_BUILDER_PLUGINS, + }); + }); + + it('includes "extensionFor" and "namespace" in tagNames', () => { + asModelApiBuilder(binding); + + expect(binding.tagNames).to.containDeep(['extensionFor', 'namespace']); + }); + + it('applies the template without altering the binding key', () => { + const key = 'test.builder'; + asModelApiBuilder(binding); + + expect(binding.key).to.equal(key); + }); + }); +}); diff --git a/packages/model-api-builder/tsconfig.json b/packages/model-api-builder/tsconfig.json index 21a039f3f411..9d1d0cc535be 100644 --- a/packages/model-api-builder/tsconfig.json +++ b/packages/model-api-builder/tsconfig.json @@ -7,7 +7,8 @@ "composite": true }, "include": [ - "src" + "src", + "src/__tests__" ], "references": [ { diff --git a/packages/openapi-spec-builder/src/__tests__/unit/openapi-spec-builder-extra.unit.ts b/packages/openapi-spec-builder/src/__tests__/unit/openapi-spec-builder-extra.unit.ts new file mode 100644 index 000000000000..2f2c5a2f8332 --- /dev/null +++ b/packages/openapi-spec-builder/src/__tests__/unit/openapi-spec-builder-extra.unit.ts @@ -0,0 +1,242 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + aComponentsSpec, + anOpenApiSpec, + anOperationSpec, + OperationSpecBuilder, +} from '../../openapi-spec-builder'; + +describe('OpenAPI Spec Builder (additional coverage)', () => { + describe('BuilderBase#withExtension()', () => { + it('throws when the key does not start with "x-"', () => { + expect(() => + anOperationSpec().withExtension('invalid-key', 'value'), + ).to.throwError(/invalid extension.*invalid-key/i); + }); + }); + + describe('OpenApiSpecBuilder#withOperation()', () => { + it('accepts an OperationSpecBuilder and calls build() on it', () => { + const opBuilder = anOperationSpec().withOperationName('greet'); + const spec = anOpenApiSpec() + .withOperation('get', '/greet', opBuilder) + .build(); + + expect(spec.paths['/greet'].get).to.containEql({ + 'x-operation-name': 'greet', + }); + }); + + it('adds a second verb on an existing path without overwriting the first', () => { + const spec = anOpenApiSpec() + .withOperation('get', '/items', anOperationSpec().build()) + .withOperation('post', '/items', anOperationSpec().build()) + .build(); + + expect(spec.paths['/items']).to.have.properties(['get', 'post']); + }); + }); + + describe('OpenApiSpecBuilder#withOperationReturningString()', () => { + it('adds a string-response operation without an operation name', () => { + const spec = anOpenApiSpec() + .withOperationReturningString('get', '/hello') + .build(); + + expect(spec.paths['/hello'].get.responses['200']).to.containEql({ + description: 'The string result.', + }); + expect(spec.paths['/hello'].get['x-operation-name']).to.be.undefined(); + }); + + it('adds a string-response operation with an operation name', () => { + const spec = anOpenApiSpec() + .withOperationReturningString('get', '/hello', 'hello') + .build(); + + expect(spec.paths['/hello'].get['x-operation-name']).to.equal('hello'); + }); + }); + + describe('OpenApiSpecBuilder#withComponents()', () => { + it('accepts a ComponentsSpecBuilder and calls build() on it', () => { + const builder = aComponentsSpec().withSchema('Product', {type: 'object'}); + const spec = anOpenApiSpec().withComponents(builder).build(); + + expect(spec.components!.schemas).to.containEql({ + Product: {type: 'object'}, + }); + }); + + it('does not overwrite existing components on a second call', () => { + const first = aComponentsSpec().withSchema('A', {type: 'string'}); + const second = aComponentsSpec().withSchema('B', {type: 'number'}); + const spec = anOpenApiSpec() + .withComponents(first) + .withComponents(second) + .build(); + + // First assignment wins (??= semantics) + expect(spec.components!.schemas).to.have.property('A'); + expect(spec.components!.schemas).to.not.have.property('B'); + }); + }); + + describe('OperationSpecBuilder#withRequestBody()', () => { + it('sets the requestBody on the operation spec', () => { + const requestBody = {content: {'application/json': {}}}; + const spec = anOperationSpec().withRequestBody(requestBody).build(); + + expect(spec.requestBody).to.deepEqual(requestBody); + }); + }); + + describe('OperationSpecBuilder#withStringResponse()', () => { + it('uses status 200 as the default when no status is provided', () => { + const spec = anOperationSpec().withStringResponse().build(); + + expect(spec.responses['200']).to.containEql({ + description: 'The string result.', + }); + }); + }); + + describe('OperationSpecBuilder#setupOperationId()', () => { + it('does not overwrite an existing operationId when controller name is set later', () => { + const spec = anOperationSpec() + .withOperationId('my.custom.id') + .withControllerName('MyController') + .withOperationName('myOp') + .build(); + + // operationId was already set — setupOperationId should be a no-op + expect(spec.operationId).to.equal('my.custom.id'); + }); + }); + + describe('OperationSpecBuilder#withParameter()', () => { + it('appends to an existing parameters list on multiple calls', () => { + const p1 = {name: 'id', in: 'path' as const}; + const p2 = {name: 'filter', in: 'query' as const}; + const p3 = {name: 'token', in: 'header' as const}; + + const spec = anOperationSpec() + .withParameter(p1) + .withParameter(p2, p3) + .build(); + + expect(spec.parameters).to.deepEqual([p1, p2, p3]); + }); + }); + + describe('OperationSpecBuilder as OperationSpecBuilder instance check', () => { + it('is an instance of OperationSpecBuilder', () => { + expect(anOperationSpec()).to.be.instanceOf(OperationSpecBuilder); + }); + }); + + describe('ComponentsSpecBuilder — second call reuses existing sub-object (??= branch)', () => { + it('accumulates schemas across multiple withSchema calls', () => { + const spec = aComponentsSpec() + .withSchema('A', {type: 'string'}) + .withSchema('B', {type: 'number'}) + .build(); + expect(spec.schemas).to.have.properties(['A', 'B']); + }); + + it('accumulates responses across multiple withResponse calls', () => { + const spec = aComponentsSpec() + .withResponse('R1', {description: 'first'}) + .withResponse('R2', {description: 'second'}) + .build(); + expect(spec.responses).to.have.properties(['R1', 'R2']); + }); + + it('accumulates parameters across multiple withParameter calls', () => { + const spec = aComponentsSpec() + .withParameter('P1', {name: 'p1', in: 'query'}) + .withParameter('P2', {name: 'p2', in: 'header'}) + .build(); + expect(spec.parameters).to.have.properties(['P1', 'P2']); + }); + + it('accumulates examples across multiple withExample calls', () => { + const spec = aComponentsSpec() + .withExample('E1', {summary: 'first'}) + .withExample('E2', {summary: 'second'}) + .build(); + expect(spec.examples).to.have.properties(['E1', 'E2']); + }); + + it('accumulates requestBodies across multiple withRequestBody calls', () => { + const spec = aComponentsSpec() + .withRequestBody('RB1', {content: {'application/json': {}}}) + .withRequestBody('RB2', {content: {'text/plain': {}}}) + .build(); + expect(spec.requestBodies).to.have.properties(['RB1', 'RB2']); + }); + + it('accumulates headers across multiple withHeader calls', () => { + const spec = aComponentsSpec() + .withHeader('H1', {description: 'first'}) + .withHeader('H2', {description: 'second'}) + .build(); + expect(spec.headers).to.have.properties(['H1', 'H2']); + }); + + it('accumulates securitySchemes across multiple withSecurityScheme calls', () => { + const spec = aComponentsSpec() + .withSecurityScheme('SS1', {type: 'http', scheme: 'bearer'}) + .withSecurityScheme('SS2', { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }) + .build(); + expect(spec.securitySchemes).to.have.properties(['SS1', 'SS2']); + }); + + it('accumulates links across multiple withLink calls', () => { + const spec = aComponentsSpec() + .withLink('L1', {description: 'first'}) + .withLink('L2', {description: 'second'}) + .build(); + expect(spec.links).to.have.properties(['L1', 'L2']); + }); + + it('accumulates callbacks across multiple withCallback calls', () => { + const spec = aComponentsSpec() + .withCallback('CB1', {}) + .withCallback('CB2', {}) + .build(); + expect(spec.callbacks).to.have.properties(['CB1', 'CB2']); + }); + }); + + describe('OperationSpecBuilder — second withParameter call reuses existing array', () => { + it('appends to the existing parameters array on the second call', () => { + const p1 = {name: 'a', in: 'query' as const}; + const p2 = {name: 'b', in: 'query' as const}; + const spec = anOperationSpec() + .withParameter(p1) + .withParameter(p2) + .build(); + expect(spec.parameters).to.deepEqual([p1, p2]); + }); + }); + + describe('OpenApiSpecBuilder — second withOperation on an existing path', () => { + it('reuses the existing path object for a second verb', () => { + const spec = anOpenApiSpec() + .withOperation('get', '/things', anOperationSpec().build()) + .withOperation('delete', '/things', anOperationSpec().build()) + .build(); + expect(spec.paths['/things']).to.have.properties(['get', 'delete']); + }); + }); +}); diff --git a/packages/repository-json-schema/src/__tests__/unit/build-schema-extra.unit.ts b/packages/repository-json-schema/src/__tests__/unit/build-schema-extra.unit.ts new file mode 100644 index 000000000000..dc2b26afac42 --- /dev/null +++ b/packages/repository-json-schema/src/__tests__/unit/build-schema-extra.unit.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/repository-json-schema +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; +import {RelationType} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {getJsonSchema, getNavigationalPropertyForRelation} from '../..'; + +// --------------------------------------------------------------------------- +// getNavigationalPropertyForRelation +// --------------------------------------------------------------------------- + +describe('getNavigationalPropertyForRelation()', () => { + const targetRef = {$ref: '#/definitions/Target'}; + + it('returns the targetRef directly when targetsMany is false (hasOne/belongsTo)', () => { + const result = getNavigationalPropertyForRelation( + { + type: RelationType.hasOne, + name: 'address', + targetsMany: false, + } as never, + targetRef, + ); + expect(result).to.deepEqual(targetRef); + }); + + it('returns an array schema when targetsMany is true (hasMany)', () => { + const result = getNavigationalPropertyForRelation( + { + type: RelationType.hasMany, + name: 'orders', + targetsMany: true, + } as never, + targetRef, + ); + expect(result).to.deepEqual({type: 'array', items: targetRef}); + }); +}); + +// --------------------------------------------------------------------------- +// getJsonSchema — belongsTo (keyFrom branch at source line 619) +// --------------------------------------------------------------------------- + +describe('getJsonSchema() with includeRelations — belongsTo foreignKey', () => { + @model() + class Order extends Entity { + @property({id: true}) + id: number; + + @belongsTo(() => Customer) + customerId: number; + } + + @model() + class Customer extends Entity { + @property({id: true}) + id: number; + + @property() + name: string; + + @hasMany(() => Order) + orders: Order[]; + } + + it('includes foreignKey in the schema when the relation has a keyFrom', () => { + const schema = getJsonSchema(Order, {includeRelations: true}); + + // The belongsTo relation has keyFrom = 'customerId', so + // result.properties.foreignKey should be set to the keyFrom string. + expect(schema.properties).to.have.property('foreignKey'); + }); +}); + +// --------------------------------------------------------------------------- +// getJsonSchema — indexInfo with multiple indexed properties +// --------------------------------------------------------------------------- + +describe('getJsonSchema() — multiple indexed properties (indexInfo merge branch)', () => { + @model() + class MultiIndex extends Entity { + @property({id: true}) + id: number; + + @property({ + index: {unique: true}, + }) + email: string; + + @property({ + index: {unique: false}, + }) + username: string; + } + + it('merges indexInfo from multiple indexed properties into the description', () => { + const schema = getJsonSchema(MultiIndex); + // Both 'email' and 'username' have index info — the schema description + // should encode both (lines 494-519 of build-schema.ts). + expect(schema.description).to.match(/indexInfo/); + }); +}); diff --git a/packages/repository/src/__tests__/unit/type/type-extra.unit.ts b/packages/repository/src/__tests__/unit/type/type-extra.unit.ts new file mode 100644 index 000000000000..0ccc17f76a71 --- /dev/null +++ b/packages/repository/src/__tests__/unit/type/type-extra.unit.ts @@ -0,0 +1,75 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {BufferType, ObjectType} from '../../../'; + +describe('BufferType', () => { + const bufferType = new BufferType(); + + describe('coerce()', () => { + it('uses the provided options.encoding when options is given', () => { + const result = bufferType.coerce('68656c6c6f', {encoding: 'hex'}); + // 0x68=h, 0x65=e, 0x6c=l, 0x6c=l, 0x6f=o + expect(result.toString('utf-8')).to.equal('hello'); + }); + }); +}); + +describe('ObjectType', () => { + class Point { + constructor( + public x: number = 0, + public y: number = 0, + ) {} + + toJSON() { + return {x: this.x, y: this.y, serialized: true}; + } + } + + const objectType = new ObjectType(Point); + + describe('coerce()', () => { + it('returns the value as-is when it is already an instance of the type', () => { + const pt = new Point(1, 2); + const result = objectType.coerce(pt); + // Same reference — no new allocation + expect(result).to.exactly(pt); + }); + + it('throws TypeError for a primitive value', () => { + expect(() => objectType.coerce(42)).to.throwError(/Invalid object/); + }); + + it('throws TypeError for an array value', () => { + expect(() => objectType.coerce([1, 2])).to.throwError(/Invalid object/); + }); + }); + + describe('serialize()', () => { + it('calls toJSON() when the value has a toJSON method', () => { + const pt = new Point(3, 4); + const result = objectType.serialize(pt); + expect(result).to.deepEqual({x: 3, y: 4, serialized: true}); + }); + + it('returns the value directly when it has no toJSON method', () => { + // ObjectType with a plain class that has no toJSON + class Plain { + constructor(public val: number = 0) {} + } + const plainType = new ObjectType(Plain); + const obj = new Plain(7); + const result = plainType.serialize(obj); + expect(result).to.exactly(obj); + }); + + it('returns null/undefined as-is', () => { + expect(objectType.serialize(null)).to.be.null(); + expect(objectType.serialize(undefined)).to.be.undefined(); + }); + }); +}); diff --git a/packages/rest-crud/src/__tests__/unit/crud-rest-api-builder.unit.ts b/packages/rest-crud/src/__tests__/unit/crud-rest-api-builder.unit.ts new file mode 100644 index 000000000000..20189e4c78b6 --- /dev/null +++ b/packages/rest-crud/src/__tests__/unit/crud-rest-api-builder.unit.ts @@ -0,0 +1,167 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/rest-crud +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application} from '@loopback/core'; +import {Entity, model, property, RepositoryMixin} from '@loopback/repository'; +import {Model} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; +import {CrudRestApiBuilder, CrudRestComponent} from '../..'; + +// --------------------------------------------------------------------------- +// Minimal ApplicationWithRepositories used across all tests +// --------------------------------------------------------------------------- + +class TestApp extends RepositoryMixin(Application) {} + +// --------------------------------------------------------------------------- +// Shared model fixtures +// --------------------------------------------------------------------------- + +@model() +class Product extends Entity { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; +} + +@model() +class NonEntity extends Model { + @property({id: true}) + id: number; +} + +// --------------------------------------------------------------------------- +// CrudRestComponent +// --------------------------------------------------------------------------- + +describe('CrudRestComponent', () => { + it('registers CrudRestApiBuilder binding when added to an application', () => { + const app = new TestApp(); + app.component(CrudRestComponent); + + expect(app.isBound('model-api-builders.CrudRestApiBuilder')).to.be.true(); + }); +}); + +// --------------------------------------------------------------------------- +// CrudRestApiBuilder +// --------------------------------------------------------------------------- + +describe('CrudRestApiBuilder', () => { + let app: TestApp; + let builder: CrudRestApiBuilder; + + beforeEach(() => { + app = new TestApp(); + builder = new CrudRestApiBuilder(); + }); + + it('has pattern "CrudRest"', () => { + expect(builder.pattern).to.equal('CrudRest'); + }); + + describe('build()', () => { + it('throws when basePath is missing from config', async () => { + let caught: Error | undefined; + try { + await builder.build( + app, + Product as typeof Model & {prototype: Model}, + { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + // basePath intentionally omitted + } as never, + ); + } catch (err) { + caught = err as Error; + } + expect(caught).to.not.be.undefined(); + expect(caught!.message).to.match(/missing required field.*basePath/i); + }); + + it('throws when model does not extend Entity', async () => { + let caught: Error | undefined; + try { + await builder.build( + app, + NonEntity as typeof Model & {prototype: Model}, + { + model: NonEntity, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/non-entities', + }, + ); + } catch (err) { + caught = err as Error; + } + expect(caught).to.not.be.undefined(); + expect(caught!.message).to.match( + /requires a model that extends.*Entity/i, + ); + }); + + it('registers a repository and controller when the repo binding is absent', async () => { + await builder.build(app, Product as typeof Model & {prototype: Model}, { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', + }); + + // A controller binding should exist + const controllerBinding = app + .findByTag('controller') + .find(b => b.key.includes('Product')); + expect(controllerBinding).to.not.be.undefined(); + }); + + it('skips repository creation when the repo binding already exists', async () => { + // Bind the repository manually before calling build() + const repoBindingName = 'repositories.ProductRepository'; + // Use a minimal stub — the builder only checks isBound() + app.bind(repoBindingName).to({} as never); + + // Should not throw; should reuse the existing binding + await builder.build(app, Product as typeof Model & {prototype: Model}, { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', + }); + + // Controller should still be registered + const controllerBinding = app + .findByTag('controller') + .find(b => b.key.includes('Product')); + expect(controllerBinding).to.not.be.undefined(); + }); + + it('registers a readonly controller when readonly option is true', async () => { + await builder.build(app, Product as typeof Model & {prototype: Model}, { + model: Product, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/products', + readonly: true, + }); + + const controllerBinding = app + .findByTag('controller') + .find(b => b.key.includes('Product')); + expect(controllerBinding).to.not.be.undefined(); + + // Verify that the bound controller class prototype has no `create` + // (without resolving — that would require a real datasource). + const ControllerClass = controllerBinding!.valueConstructor; + expect(ControllerClass).to.not.be.undefined(); + expect(ControllerClass!.prototype.create).to.be.undefined(); + }); + }); +}); diff --git a/packages/rest/src/__tests__/unit/rest-extra.unit.ts b/packages/rest/src/__tests__/unit/rest-extra.unit.ts new file mode 100644 index 000000000000..46e7db273f34 --- /dev/null +++ b/packages/rest/src/__tests__/unit/rest-extra.unit.ts @@ -0,0 +1,183 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {DEFAULT_OPENAPI_SPEC_INFO} from '@loopback/openapi-v3'; +import {expect, sinon} from '@loopback/testlab'; +import { + InfoSpecEnhancer, + RedirectRoute, + RegExpRouter, + RoutingTable, + TrieRouter, +} from '../..'; + +// --------------------------------------------------------------------------- +// RedirectRoute +// --------------------------------------------------------------------------- + +describe('RedirectRoute', () => { + describe('isRedirectRoute()', () => { + it('returns true for a RedirectRoute instance', () => { + const route = new RedirectRoute('/old', '/new', 301); + expect(RedirectRoute.isRedirectRoute(route)).to.be.true(); + }); + + it('returns false for an object without targetLocation', () => { + expect( + RedirectRoute.isRedirectRoute({ + spec: {description: 'LoopBack Redirect route'}, + }), + ).to.be.false(); + }); + + it('returns false for an object without the correct spec description', () => { + expect( + RedirectRoute.isRedirectRoute({ + targetLocation: '/new', + spec: {description: 'Other'}, + }), + ).to.be.false(); + }); + + it('returns false for null', () => { + expect(RedirectRoute.isRedirectRoute(null)).to.be.false(); + }); + + it('returns false for undefined', () => { + expect(RedirectRoute.isRedirectRoute(undefined)).to.be.false(); + }); + }); + + describe('describe()', () => { + it('returns a human-readable route description', () => { + const route = new RedirectRoute('/source', '/target', 302); + expect(route.describe()).to.equal('Redirect: "/source" => "/target"'); + }); + }); + + describe('updateBindings()', () => { + it('is a no-op (does not throw)', () => { + const route = new RedirectRoute('/old', '/new'); + const fakeCtx = {response: {redirect: () => {}}} as never; + expect(() => route.updateBindings(fakeCtx)).to.not.throwError(); + }); + }); + + describe('invokeHandler()', () => { + it('calls response.redirect with statusCode and targetLocation', async () => { + const route = new RedirectRoute('/old', '/new', 301); + const redirectSpy = sinon.stub(); + const fakeCtx = {response: {redirect: redirectSpy}} as never; + await route.invokeHandler(fakeCtx, []); + sinon.assert.calledOnceWithExactly(redirectSpy, 301, '/new'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// RoutingTable — no external routes (throws NotFound instead of delegating) +// --------------------------------------------------------------------------- + +describe('RoutingTable without external routes', () => { + it('throws NotFound when no route matches and no external routes exist', () => { + const table = new RoutingTable(new TrieRouter()); + const request = {method: 'GET', path: '/unknown'} as never; + expect(() => table.find(request)).to.throwError(/not found/i); + }); + + it('throws NotFound for RegExpRouter as well', () => { + const table = new RoutingTable(new RegExpRouter()); + const request = {method: 'GET', path: '/also-unknown'} as never; + expect(() => table.find(request)).to.throwError(/not found/i); + }); +}); + +// --------------------------------------------------------------------------- +// InfoSpecEnhancer — parseAuthor branches +// --------------------------------------------------------------------------- + +describe('InfoSpecEnhancer', () => { + const defaultSpec = { + openapi: '3.0.0', + info: { + title: DEFAULT_OPENAPI_SPEC_INFO.title, + version: DEFAULT_OPENAPI_SPEC_INFO.version, + }, + paths: {}, + }; + + it('skips enhancing when pkg metadata is not set (null)', () => { + const enhancer = new InfoSpecEnhancer(undefined); + const result = enhancer.modifySpec(defaultSpec); + expect(result).to.deepEqual(defaultSpec); + }); + + it('uses pkg.name when metadata is provided and info is default', () => { + const enhancer = new InfoSpecEnhancer({ + name: 'my-app', + version: '2.0.0', + description: 'My App', + }); + const result = enhancer.modifySpec(defaultSpec); + expect(result.info.title).to.equal('my-app'); + expect(result.info.version).to.equal('2.0.0'); + }); + + it('keeps existing title/version when info is custom', () => { + const enhancer = new InfoSpecEnhancer({ + name: 'my-app', + version: '2.0.0', + }); + const customSpec = { + ...defaultSpec, + info: {title: 'Custom Title', version: '9.9.9'}, + }; + const result = enhancer.modifySpec(customSpec); + expect(result.info.title).to.equal('Custom Title'); + expect(result.info.version).to.equal('9.9.9'); + }); + + it('parses a string author field', () => { + const enhancer = new InfoSpecEnhancer({ + name: 'my-app', + version: '1.0.0', + author: 'Jane Doe (https://example.com)', + }); + const result = enhancer.modifySpec(defaultSpec); + expect(result.info.contact).to.containEql({ + name: 'Jane Doe', + email: 'jane@example.com', + url: 'https://example.com', + }); + }); + + it('parses an object author field', () => { + const enhancer = new InfoSpecEnhancer({ + name: 'my-app', + version: '1.0.0', + author: { + name: 'Jane Doe', + email: 'jane@example.com', + url: 'https://example.com', + }, + }); + const result = enhancer.modifySpec(defaultSpec); + expect(result.info.contact).to.containEql({ + name: 'Jane Doe', + email: 'jane@example.com', + }); + }); + + it('handles null author field gracefully', () => { + const enhancer = new InfoSpecEnhancer({ + name: 'my-app', + version: '1.0.0', + author: null, + }); + const result = enhancer.modifySpec(defaultSpec); + // null author → empty contact (no error thrown) + expect(result.info.title).to.equal('my-app'); + }); +}); diff --git a/packages/tsdocs/src/__tests__/unit/tsdocs-extra.unit.ts b/packages/tsdocs/src/__tests__/unit/tsdocs-extra.unit.ts new file mode 100644 index 000000000000..60eac7073d50 --- /dev/null +++ b/packages/tsdocs/src/__tests__/unit/tsdocs-extra.unit.ts @@ -0,0 +1,52 @@ +// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved. +// Node module: @loopback/tsdocs +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, sinon} from '@loopback/testlab'; +import path from 'path'; +import {getUnscopedPackageName} from '../../helper'; +import {runExtractorForPackage} from '../../monorepo-api-extractor'; + +// From dist/__tests__/unit, three levels up = packages/tsdocs +const TSDOCS_PKG_DIR = path.resolve(__dirname, '../../..'); + +describe('helper', () => { + describe('getUnscopedPackageName()', () => { + it('strips the @scope/ prefix from a scoped package name', () => { + expect(getUnscopedPackageName('@loopback/context')).to.equal('context'); + }); + + it('returns the name unchanged for an unscoped package', () => { + expect(getUnscopedPackageName('express')).to.equal('express'); + }); + }); +}); + +describe('runExtractorForPackage()', () => { + let consoleLogStub: sinon.SinonStub; + + beforeEach(() => { + // Stub console.log to avoid noisy output and capture calls + consoleLogStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + consoleLogStub.restore(); + }); + + it('logs the package name to console when silent is false (line 147)', () => { + // Run against the actual tsdocs package so package.json resolves. + // The extractor will succeed or throw — we only care about the log call. + try { + runExtractorForPackage(TSDOCS_PKG_DIR, {silent: false}); + } catch { + // ignore extractor errors — we only want to verify the log branch + } + + // console.log('> %s', pkg.name) should have been called + sinon.assert.called(consoleLogStub); + const firstCallArg = consoleLogStub.firstCall.args[0]; + expect(firstCallArg).to.equal('> %s'); + }); +});