diff --git a/.nycrc b/.nycrc index c13c60ea21b8..8421fcbfc4c6 100644 --- a/.nycrc +++ b/.nycrc @@ -17,7 +17,9 @@ ".ts" ], "reporter": [ - "html" + "html", + "lcov", + "text-summary" ], "exclude-after-remap": false } diff --git a/examples/metrics-prometheus/src/__tests__/unit/controllers/greeting.controller.unit.ts b/examples/metrics-prometheus/src/__tests__/unit/controllers/greeting.controller.unit.ts new file mode 100644 index 000000000000..31a21ca7f1b7 --- /dev/null +++ b/examples/metrics-prometheus/src/__tests__/unit/controllers/greeting.controller.unit.ts @@ -0,0 +1,178 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-metrics-prometheus +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {GreetingController} from '../../../controllers'; +import {GreetingService} from '../../../services'; + +describe('GreetingController (unit)', () => { + let controller: GreetingController; + let greetingService: StubbedInstanceWithSinonAccessor; + + beforeEach(() => { + greetingService = createStubInstance(GreetingService); + controller = new GreetingController(greetingService); + }); + + describe('greet', () => { + it('calls greeting service with name', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 50] Hello, World'; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet('World'); + + expect(result).to.deepEqual([greeting]); + sinon.assert.calledWith(greetingService.stubs.greet, 'World'); + }); + + it('returns single greeting by default', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 25] Hello, Alice'; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet('Alice'); + + expect(result).to.have.length(1); + expect(result[0]).to.equal(greeting); + }); + + it('returns multiple greetings when count is specified', async () => { + const greeting1 = '[2026-02-11T12:00:00.000Z: 10] Hello, Bob'; + const greeting2 = '[2026-02-11T12:00:00.100Z: 20] Hello, Bob'; + const greeting3 = '[2026-02-11T12:00:00.200Z: 30] Hello, Bob'; + + greetingService.stubs.greet + .onFirstCall() + .resolves(greeting1) + .onSecondCall() + .resolves(greeting2) + .onThirdCall() + .resolves(greeting3); + + const result = await controller.greet('Bob', 3); + + expect(result).to.have.length(3); + expect(result).to.deepEqual([greeting1, greeting2, greeting3]); + sinon.assert.calledThrice(greetingService.stubs.greet); + }); + + it('handles count parameter of 1', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 15] Hello, Charlie'; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet('Charlie', 1); + + expect(result).to.have.length(1); + sinon.assert.calledOnce(greetingService.stubs.greet); + }); + + it('handles count parameter of 0', async () => { + const result = await controller.greet('Dave', 0); + + expect(result).to.have.length(0); + sinon.assert.notCalled(greetingService.stubs.greet); + }); + + it('handles large count values', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 40] Hello, Eve'; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet('Eve', 10); + + expect(result).to.have.length(10); + sinon.assert.callCount(greetingService.stubs.greet, 10); + }); + + it('calls service concurrently for multiple greetings', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 35] Hello, Frank'; + greetingService.stubs.greet.resolves(greeting); + + await controller.greet('Frank', 5); + + // All calls should be made concurrently (Promise.all) + sinon.assert.callCount(greetingService.stubs.greet, 5); + greetingService.stubs.greet.getCalls().forEach(call => { + sinon.assert.calledWith(call, 'Frank'); + }); + }); + + it('handles different names', async () => { + greetingService.stubs.greet + .withArgs('Alice') + .resolves('[2026-02-11T12:00:00.000Z: 10] Hello, Alice'); + greetingService.stubs.greet + .withArgs('Bob') + .resolves('[2026-02-11T12:00:00.000Z: 20] Hello, Bob'); + + const result1 = await controller.greet('Alice'); + const result2 = await controller.greet('Bob'); + + expect(result1[0]).to.match(/Hello, Alice/); + expect(result2[0]).to.match(/Hello, Bob/); + }); + + it('handles empty name', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 5] Hello, '; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet(''); + + expect(result).to.have.length(1); + sinon.assert.calledWith(greetingService.stubs.greet, ''); + }); + + it('handles special characters in name', async () => { + const greeting = '[2026-02-11T12:00:00.000Z: 45] Hello, Test@123'; + greetingService.stubs.greet.resolves(greeting); + + const result = await controller.greet('Test@123'); + + expect(result[0]).to.equal(greeting); + sinon.assert.calledWith(greetingService.stubs.greet, 'Test@123'); + }); + }); + + describe('service injection', () => { + it('injects greeting service', () => { + expect(controller).to.have.property('greetingService'); + }); + + it('uses service with interceptors', () => { + // The controller is configured to use asProxyWithInterceptors + // This ensures metrics interceptor can track the service calls + expect(controller).to.be.instanceOf(GreetingController); + }); + }); + + describe('error handling', () => { + it('propagates service errors', async () => { + const error = new Error('Service error'); + greetingService.stubs.greet.rejects(error); + + await expect(controller.greet('ErrorTest')).to.be.rejectedWith( + 'Service error', + ); + }); + + it('handles rejection in one of multiple calls', async () => { + const error = new Error('One call failed'); + greetingService.stubs.greet + .onFirstCall() + .resolves('[2026-02-11T12:00:00.000Z: 10] Hello, Test') + .onSecondCall() + .rejects(error); + + await expect(controller.greet('Test', 2)).to.be.rejectedWith( + 'One call failed', + ); + }); + }); +}); + +// Made with Bob diff --git a/examples/metrics-prometheus/src/__tests__/unit/services/greeting.service.unit.ts b/examples/metrics-prometheus/src/__tests__/unit/services/greeting.service.unit.ts new file mode 100644 index 000000000000..14956e3c1c84 --- /dev/null +++ b/examples/metrics-prometheus/src/__tests__/unit/services/greeting.service.unit.ts @@ -0,0 +1,128 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-metrics-prometheus +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {GreetingService} from '../../../services'; + +describe('GreetingService (unit)', () => { + let service: GreetingService; + + beforeEach(() => { + service = new GreetingService(); + }); + + describe('greet', () => { + it('returns a greeting message', async () => { + const result = await service.greet('World'); + + expect(result).to.be.a.String(); + expect(result).to.match(/Hello, World/); + }); + + it('includes timestamp in greeting', async () => { + const result = await service.greet('Alice'); + + expect(result).to.match( + /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z: \d+\]/, + ); + }); + + it('includes delay information', async () => { + const result = await service.greet('Bob'); + + // Extract delay from message format: [timestamp: delay] Hello, name + const delayMatch = result.match(/: (\d+)\]/); + expect(delayMatch).to.not.be.null(); + + const delay = parseInt(delayMatch![1], 10); + expect(delay).to.be.greaterThanOrEqual(0); + expect(delay).to.be.lessThan(100); + }); + + it('greets different names', async () => { + const result1 = await service.greet('Alice'); + const result2 = await service.greet('Bob'); + const result3 = await service.greet('Charlie'); + + expect(result1).to.match(/Hello, Alice/); + expect(result2).to.match(/Hello, Bob/); + expect(result3).to.match(/Hello, Charlie/); + }); + + it('handles empty name', async () => { + const result = await service.greet(''); + + expect(result).to.match(/Hello, $/); + }); + + it('handles special characters in name', async () => { + const result = await service.greet('Alice & Bob'); + + expect(result).to.match(/Hello, Alice & Bob/); + }); + + it('handles unicode characters in name', async () => { + const result = await service.greet('世界'); + + expect(result).to.match(/Hello, 世界/); + }); + + it('introduces random delay', async () => { + const delays: number[] = []; + + // Call greet multiple times to collect delays + for (let i = 0; i < 10; i++) { + const result = await service.greet('Test'); + const delayMatch = result.match(/: (\d+)\]/); + if (delayMatch) { + delays.push(parseInt(delayMatch[1], 10)); + } + } + + // Check that we got different delays (not all the same) + const uniqueDelays = new Set(delays); + expect(uniqueDelays.size).to.be.greaterThan(1); + }); + + it('completes within reasonable time', async () => { + const startTime = Date.now(); + await service.greet('Performance Test'); + const endTime = Date.now(); + + const duration = endTime - startTime; + // Should complete within 150ms (max delay is 100ms + overhead) + expect(duration).to.be.lessThan(150); + }); + }); + + describe('service injection', () => { + it('is injectable', () => { + expect(service).to.be.instanceOf(GreetingService); + }); + + it('has greet method', () => { + expect(service.greet).to.be.a.Function(); + }); + }); + + describe('concurrent greetings', () => { + it('handles multiple concurrent greetings', async () => { + const promises = [ + service.greet('User1'), + service.greet('User2'), + service.greet('User3'), + ]; + + const results = await Promise.all(promises); + + expect(results).to.have.length(3); + expect(results[0]).to.match(/Hello, User1/); + expect(results[1]).to.match(/Hello, User2/); + expect(results[2]).to.match(/Hello, User3/); + }); + }); +}); + +// Made with Bob diff --git a/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/keys.unit.ts b/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/keys.unit.ts new file mode 100644 index 000000000000..4e53c02f0959 --- /dev/null +++ b/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/keys.unit.ts @@ -0,0 +1,150 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-multi-tenancy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + MULTI_TENANCY_STRATEGIES, + MultiTenancyBindings, +} from '../../../multi-tenancy/keys'; + +describe('Multi-tenancy keys (unit)', () => { + describe('MultiTenancyBindings', () => { + describe('MIDDLEWARE', () => { + it('has correct binding key', () => { + expect(MultiTenancyBindings.MIDDLEWARE.key).to.equal( + 'middleware.multi-tenancy', + ); + }); + + it('is a BindingKey', () => { + expect(MultiTenancyBindings.MIDDLEWARE).to.have.property('key'); + }); + }); + + describe('CURRENT_TENANT', () => { + it('has correct binding key', () => { + expect(MultiTenancyBindings.CURRENT_TENANT.key).to.equal( + 'multi-tenancy.currentTenant', + ); + }); + + it('is a BindingKey', () => { + expect(MultiTenancyBindings.CURRENT_TENANT).to.have.property('key'); + }); + }); + + it('exports both binding keys', () => { + expect(MultiTenancyBindings).to.have.property('MIDDLEWARE'); + expect(MultiTenancyBindings).to.have.property('CURRENT_TENANT'); + }); + }); + + describe('MULTI_TENANCY_STRATEGIES', () => { + it('has correct value', () => { + expect(MULTI_TENANCY_STRATEGIES).to.equal('multi-tenancy.strategies'); + }); + + it('is a string constant', () => { + expect(MULTI_TENANCY_STRATEGIES).to.be.a.String(); + }); + }); + + describe('Binding key uniqueness', () => { + it('MIDDLEWARE and CURRENT_TENANT have different keys', () => { + expect(MultiTenancyBindings.MIDDLEWARE.key).to.not.equal( + MultiTenancyBindings.CURRENT_TENANT.key, + ); + }); + + it('MIDDLEWARE key is different from MULTI_TENANCY_STRATEGIES', () => { + expect(MultiTenancyBindings.MIDDLEWARE.key).to.not.equal( + MULTI_TENANCY_STRATEGIES, + ); + }); + + it('CURRENT_TENANT key is different from MULTI_TENANCY_STRATEGIES', () => { + expect(MultiTenancyBindings.CURRENT_TENANT.key).to.not.equal( + MULTI_TENANCY_STRATEGIES, + ); + }); + }); + + describe('Binding key naming conventions', () => { + it('MIDDLEWARE follows middleware naming pattern', () => { + expect(MultiTenancyBindings.MIDDLEWARE.key).to.match(/^middleware\./); + }); + + it('CURRENT_TENANT follows multi-tenancy naming pattern', () => { + expect(MultiTenancyBindings.CURRENT_TENANT.key).to.match( + /^multi-tenancy\./, + ); + }); + + it('MULTI_TENANCY_STRATEGIES follows multi-tenancy naming pattern', () => { + expect(MULTI_TENANCY_STRATEGIES).to.match(/^multi-tenancy\./); + }); + }); + + describe('Type safety', () => { + it('MIDDLEWARE is typed for Middleware', () => { + // The binding key should be typed to accept Middleware + const key = MultiTenancyBindings.MIDDLEWARE; + expect(key).to.not.be.undefined(); + }); + + it('CURRENT_TENANT is typed for Tenant', () => { + // The binding key should be typed to accept Tenant + const key = MultiTenancyBindings.CURRENT_TENANT; + expect(key).to.not.be.undefined(); + }); + }); + + describe('Usage patterns', () => { + it('can be used for binding lookups', () => { + const middlewareKey = MultiTenancyBindings.MIDDLEWARE.key; + const tenantKey = MultiTenancyBindings.CURRENT_TENANT.key; + + expect(middlewareKey).to.be.a.String(); + expect(tenantKey).to.be.a.String(); + }); + + it('can be used with string concatenation', () => { + const strategyKey = `${MULTI_TENANCY_STRATEGIES}.header`; + expect(strategyKey).to.equal('multi-tenancy.strategies.header'); + }); + + it('supports creating strategy-specific keys', () => { + const headerStrategy = `${MULTI_TENANCY_STRATEGIES}.header`; + const jwtStrategy = `${MULTI_TENANCY_STRATEGIES}.jwt`; + const queryStrategy = `${MULTI_TENANCY_STRATEGIES}.query`; + + expect(headerStrategy).to.match(/multi-tenancy\.strategies\./); + expect(jwtStrategy).to.match(/multi-tenancy\.strategies\./); + expect(queryStrategy).to.match(/multi-tenancy\.strategies\./); + }); + }); + + describe('Immutability', () => { + it('binding keys are read-only', () => { + const originalMiddlewareKey = MultiTenancyBindings.MIDDLEWARE.key; + const originalTenantKey = MultiTenancyBindings.CURRENT_TENANT.key; + + // Keys should remain unchanged + expect(MultiTenancyBindings.MIDDLEWARE.key).to.equal( + originalMiddlewareKey, + ); + expect(MultiTenancyBindings.CURRENT_TENANT.key).to.equal( + originalTenantKey, + ); + }); + + it('MULTI_TENANCY_STRATEGIES constant is read-only', () => { + const original = MULTI_TENANCY_STRATEGIES; + expect(MULTI_TENANCY_STRATEGIES).to.equal(original); + }); + }); +}); + +// Made with Bob diff --git a/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/types.unit.ts b/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/types.unit.ts new file mode 100644 index 000000000000..52e02dac21ec --- /dev/null +++ b/examples/multi-tenancy/src/__tests__/unit/multi-tenancy/types.unit.ts @@ -0,0 +1,222 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-multi-tenancy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RequestContext} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; +import { + MultiTenancyMiddlewareOptions, + MultiTenancyStrategy, + Tenant, +} from '../../../multi-tenancy/types'; + +describe('Multi-tenancy types (unit)', () => { + describe('Tenant interface', () => { + it('has required id property', () => { + const tenant: Tenant = { + id: 'tenant1', + }; + + expect(tenant.id).to.equal('tenant1'); + }); + + it('supports additional attributes', () => { + const tenant: Tenant = { + id: 'tenant1', + name: 'Tenant One', + database: 'db1', + active: true, + }; + + expect(tenant.id).to.equal('tenant1'); + expect(tenant.name).to.equal('Tenant One'); + expect(tenant.database).to.equal('db1'); + expect(tenant.active).to.equal(true); + }); + + it('supports nested attributes', () => { + const tenant: Tenant = { + id: 'tenant1', + config: { + theme: 'dark', + features: ['feature1', 'feature2'], + }, + }; + + expect(tenant.config).to.be.an.Object(); + }); + + it('supports various id formats', () => { + const tenant1: Tenant = {id: 'string-id'}; + const tenant2: Tenant = {id: '123'}; + const tenant3: Tenant = {id: 'uuid-1234-5678'}; + + expect(tenant1.id).to.be.a.String(); + expect(tenant2.id).to.be.a.String(); + expect(tenant3.id).to.be.a.String(); + }); + }); + + describe('MultiTenancyMiddlewareOptions interface', () => { + it('has strategyNames array', () => { + const options: MultiTenancyMiddlewareOptions = { + strategyNames: ['header', 'jwt'], + }; + + expect(options.strategyNames).to.be.an.Array(); + expect(options.strategyNames).to.have.length(2); + }); + + it('supports single strategy', () => { + const options: MultiTenancyMiddlewareOptions = { + strategyNames: ['header'], + }; + + expect(options.strategyNames).to.deepEqual(['header']); + }); + + it('supports multiple strategies', () => { + const options: MultiTenancyMiddlewareOptions = { + strategyNames: ['header', 'jwt', 'query', 'host'], + }; + + expect(options.strategyNames).to.have.length(4); + }); + + it('supports empty strategy array', () => { + const options: MultiTenancyMiddlewareOptions = { + strategyNames: [], + }; + + expect(options.strategyNames).to.be.empty(); + }); + }); + + describe('MultiTenancyStrategy interface', () => { + it('defines required properties and methods', () => { + const strategy: MultiTenancyStrategy = { + name: 'test-strategy', + identifyTenant: async () => ({id: 'tenant1'}), + bindResources: async () => {}, + }; + + expect(strategy.name).to.equal('test-strategy'); + expect(strategy.identifyTenant).to.be.a.Function(); + expect(strategy.bindResources).to.be.a.Function(); + }); + + it('identifyTenant can return tenant', async () => { + const strategy: MultiTenancyStrategy = { + name: 'test', + identifyTenant: async () => ({id: 'tenant1', name: 'Test Tenant'}), + bindResources: async () => {}, + }; + + const mockContext = {} as unknown as RequestContext; + const tenant = await strategy.identifyTenant(mockContext); + + expect(tenant).to.not.be.undefined(); + expect(tenant!.id).to.equal('tenant1'); + }); + + it('identifyTenant can return undefined', async () => { + const strategy: MultiTenancyStrategy = { + name: 'test', + identifyTenant: async () => undefined, + bindResources: async () => {}, + }; + + const mockContext = {} as unknown as RequestContext; + const tenant = await strategy.identifyTenant(mockContext); + + expect(tenant).to.be.undefined(); + }); + + it('identifyTenant can be synchronous', () => { + const strategy: MultiTenancyStrategy = { + name: 'test', + identifyTenant: () => ({id: 'tenant1'}), + bindResources: async () => {}, + }; + + const mockContext = {} as unknown as RequestContext; + const tenant = strategy.identifyTenant(mockContext); + + expect(tenant).to.not.be.undefined(); + }); + + it('bindResources can be synchronous', () => { + const strategy: MultiTenancyStrategy = { + name: 'test', + identifyTenant: async () => ({id: 'tenant1'}), + bindResources: () => {}, + }; + + const mockContext = {} as unknown as RequestContext; + const result = strategy.bindResources(mockContext, {id: 'tenant1'}); + + expect(result).to.be.undefined(); + }); + + it('supports different strategy names', () => { + const headerStrategy: MultiTenancyStrategy = { + name: 'header', + identifyTenant: async () => ({id: 'tenant1'}), + bindResources: async () => {}, + }; + + const jwtStrategy: MultiTenancyStrategy = { + name: 'jwt', + identifyTenant: async () => ({id: 'tenant2'}), + bindResources: async () => {}, + }; + + expect(headerStrategy.name).to.equal('header'); + expect(jwtStrategy.name).to.equal('jwt'); + }); + }); + + describe('Type compatibility', () => { + it('Tenant works with different attribute types', () => { + const tenant: Tenant = { + id: 'tenant1', + stringAttr: 'value', + numberAttr: 123, + booleanAttr: true, + arrayAttr: [1, 2, 3], + objectAttr: {nested: 'value'}, + nullAttr: null, + undefinedAttr: undefined, + }; + + expect(tenant.id).to.equal('tenant1'); + expect(tenant.stringAttr).to.be.a.String(); + expect(tenant.numberAttr).to.be.a.Number(); + expect(tenant.booleanAttr).to.be.a.Boolean(); + expect(tenant.arrayAttr).to.be.an.Array(); + expect(tenant.objectAttr).to.be.an.Object(); + }); + + it('MultiTenancyStrategy methods accept RequestContext', () => { + const strategy: MultiTenancyStrategy = { + name: 'test', + identifyTenant: ctx => { + // ctx should be RequestContext + expect(ctx).to.not.be.undefined(); + return {id: 'tenant1'}; + }, + bindResources: (ctx, tenant) => { + // ctx should be RequestContext, tenant should be Tenant + expect(ctx).to.not.be.undefined(); + expect(tenant).to.not.be.undefined(); + expect(tenant.id).to.be.a.String(); + }, + }; + + expect(strategy).to.be.an.Object(); + }); + }); +}); + +// Made with Bob diff --git a/examples/soap-calculator/src/__tests__/unit/controllers/calculator.controller.unit.ts b/examples/soap-calculator/src/__tests__/unit/controllers/calculator.controller.unit.ts new file mode 100644 index 000000000000..c33b03092cfd --- /dev/null +++ b/examples/soap-calculator/src/__tests__/unit/controllers/calculator.controller.unit.ts @@ -0,0 +1,167 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2026. All Rights Reserved. +// Node module: @loopback/example-soap-calculator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {CalculatorController} from '../../../controllers/calculator.controller'; +import { + AddResponse, + CalculatorParameters, + CalculatorService, + DivideResponse, + MultiplyResponse, + SubtractResponse, +} from '../../../services/calculator.service'; + +describe('CalculatorController (unit)', () => { + let controller: CalculatorController; + let calculatorService: CalculatorService; + + beforeEach(givenController); + + describe('multiply', () => { + it('calls the service with correct parameters', async () => { + const result = await controller.multiply(5, 3); + expect(result).to.deepEqual({result: {value: 15}}); + }); + + it('handles zero multiplication', async () => { + const result = await controller.multiply(0, 10); + expect(result).to.deepEqual({result: {value: 0}}); + }); + + it('handles negative numbers', async () => { + const result = await controller.multiply(-5, 3); + expect(result).to.deepEqual({result: {value: -15}}); + }); + + it('handles large numbers', async () => { + const result = await controller.multiply(1000, 1000); + expect(result).to.deepEqual({result: {value: 1000000}}); + }); + }); + + describe('add', () => { + it('calls the service with correct parameters', async () => { + const result = await controller.add(10, 5); + expect(result).to.deepEqual({result: {value: 15}}); + }); + + it('handles zero addition', async () => { + const result = await controller.add(0, 0); + expect(result).to.deepEqual({result: {value: 0}}); + }); + + it('handles negative numbers', async () => { + const result = await controller.add(-5, 3); + expect(result).to.deepEqual({result: {value: -2}}); + }); + + it('handles large numbers', async () => { + const result = await controller.add(999999, 1); + expect(result).to.deepEqual({result: {value: 1000000}}); + }); + }); + + describe('subtract', () => { + it('calls the service with correct parameters', async () => { + const result = await controller.subtract(10, 5); + expect(result).to.deepEqual({result: {value: 5}}); + }); + + it('handles zero subtraction', async () => { + const result = await controller.subtract(10, 0); + expect(result).to.deepEqual({result: {value: 10}}); + }); + + it('handles negative results', async () => { + const result = await controller.subtract(5, 10); + expect(result).to.deepEqual({result: {value: -5}}); + }); + + it('handles negative numbers', async () => { + const result = await controller.subtract(-5, -3); + expect(result).to.deepEqual({result: {value: -2}}); + }); + }); + + describe('divide', () => { + it('calls the service with correct parameters', async () => { + const result = await controller.divide(10, 2); + expect(result).to.deepEqual({result: {value: 5}}); + }); + + it('throws error when dividing by zero', async () => { + await expect(controller.divide(10, 0)).to.be.rejectedWith( + /Cannot divide by zero/, + ); + }); + + it('handles division resulting in decimal', async () => { + const result = await controller.divide(10, 3); + expect(result.result.value).to.be.approximately(3.33, 0.01); + }); + + it('handles negative numbers', async () => { + const result = await controller.divide(-10, 2); + expect(result).to.deepEqual({result: {value: -5}}); + }); + + it('handles division by one', async () => { + const result = await controller.divide(42, 1); + expect(result).to.deepEqual({result: {value: 42}}); + }); + + it('handles division of zero', async () => { + const result = await controller.divide(0, 5); + expect(result).to.deepEqual({result: {value: 0}}); + }); + }); + + describe('edge cases', () => { + it('multiply handles very small numbers', async () => { + const result = await controller.multiply(1, 1); + expect(result).to.deepEqual({result: {value: 1}}); + }); + + it('add handles same numbers', async () => { + const result = await controller.add(7, 7); + expect(result).to.deepEqual({result: {value: 14}}); + }); + + it('subtract results in zero', async () => { + const result = await controller.subtract(5, 5); + expect(result).to.deepEqual({result: {value: 0}}); + }); + + it('divide handles equal numbers', async () => { + const result = await controller.divide(8, 8); + expect(result).to.deepEqual({result: {value: 1}}); + }); + }); + + function givenController() { + calculatorService = givenCalculatorService(); + controller = new CalculatorController(calculatorService); + } + + function givenCalculatorService(): CalculatorService { + return { + async multiply(args: CalculatorParameters): Promise { + return {result: {value: args.intA * args.intB}}; + }, + async add(args: CalculatorParameters): Promise { + return {result: {value: args.intA + args.intB}}; + }, + async subtract(args: CalculatorParameters): Promise { + return {result: {value: args.intA - args.intB}}; + }, + async divide(args: CalculatorParameters): Promise { + return {result: {value: args.intA / args.intB}}; + }, + }; + } +}); + +// Made with Bob diff --git a/examples/todo-list/src/__tests__/unit/models/todo-list-image.model.unit.ts b/examples/todo-list/src/__tests__/unit/models/todo-list-image.model.unit.ts new file mode 100644 index 000000000000..e9b12c91b5bd --- /dev/null +++ b/examples/todo-list/src/__tests__/unit/models/todo-list-image.model.unit.ts @@ -0,0 +1,169 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-todo-list +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {TodoListImage} from '../../../models'; + +describe('TodoListImage (unit)', () => { + describe('constructor', () => { + it('creates a todo list image with required properties', () => { + const image = new TodoListImage({ + value: 'base64encodedimage', + todoListId: 1, + }); + + expect(image.value).to.equal('base64encodedimage'); + expect(image.todoListId).to.equal(1); + }); + + it('creates a todo list image with optional id', () => { + const image = new TodoListImage({ + id: 1, + value: 'imagedata', + todoListId: 1, + }); + + expect(image.id).to.equal(1); + expect(image.value).to.equal('imagedata'); + expect(image.todoListId).to.equal(1); + }); + }); + + describe('properties', () => { + it('has value property', () => { + const image = new TodoListImage({ + value: 'test-image-data', + todoListId: 1, + }); + + expect(image).to.have.property('value'); + expect(image.value).to.be.a.String(); + }); + + it('has todoListId property', () => { + const image = new TodoListImage({ + value: 'test-image-data', + todoListId: 123, + }); + + expect(image).to.have.property('todoListId'); + expect(image.todoListId).to.be.a.Number(); + }); + + it('has optional id property', () => { + const image = new TodoListImage({ + id: 456, + value: 'test-image-data', + todoListId: 123, + }); + + expect(image).to.have.property('id'); + expect(image.id).to.be.a.Number(); + }); + }); + + describe('toJSON', () => { + it('serializes to JSON', () => { + const image = new TodoListImage({ + id: 1, + value: 'imagedata', + todoListId: 2, + }); + + const json = image.toJSON(); + + expect(json).to.have.property('id', 1); + expect(json).to.have.property('value', 'imagedata'); + expect(json).to.have.property('todoListId', 2); + }); + }); + + describe('foreign key relationship', () => { + it('references a todo list via todoListId', () => { + const image = new TodoListImage({ + value: 'imagedata', + todoListId: 5, + }); + + expect(image.todoListId).to.equal(5); + }); + + it('can be associated with different todo lists', () => { + const image1 = new TodoListImage({ + value: 'image1', + todoListId: 1, + }); + + const image2 = new TodoListImage({ + value: 'image2', + todoListId: 2, + }); + + expect(image1.todoListId).to.not.equal(image2.todoListId); + }); + }); + + describe('edge cases', () => { + it('handles empty value', () => { + const image = new TodoListImage({ + value: '', + todoListId: 1, + }); + + expect(image.value).to.equal(''); + }); + + it('handles very long image data', () => { + const longData = 'A'.repeat(10000); + const image = new TodoListImage({ + value: longData, + todoListId: 1, + }); + + expect(image.value).to.have.length(10000); + }); + + it('handles base64 encoded data', () => { + const base64Data = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const image = new TodoListImage({ + value: base64Data, + todoListId: 1, + }); + + expect(image.value).to.equal(base64Data); + }); + + it('handles special characters in value', () => { + const specialData = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + const image = new TodoListImage({ + value: specialData, + todoListId: 1, + }); + + expect(image.value).to.equal(specialData); + }); + }); + + describe('validation', () => { + it('requires value property', () => { + const image = new TodoListImage({ + todoListId: 1, + } as Partial); + + expect(image.value).to.be.undefined(); + }); + + it('requires todoListId property', () => { + const image = new TodoListImage({ + value: 'imagedata', + } as Partial); + + expect(image.todoListId).to.be.undefined(); + }); + }); +}); + +// Made with Bob diff --git a/examples/todo-list/src/__tests__/unit/models/todo-list.model.unit.ts b/examples/todo-list/src/__tests__/unit/models/todo-list.model.unit.ts new file mode 100644 index 000000000000..4e491774ad9e --- /dev/null +++ b/examples/todo-list/src/__tests__/unit/models/todo-list.model.unit.ts @@ -0,0 +1,166 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-todo-list +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {TodoList} from '../../../models'; + +describe('TodoList (unit)', () => { + describe('constructor', () => { + it('creates a todo list with required properties', () => { + const todoList = new TodoList({ + title: 'My Todo List', + }); + + expect(todoList.title).to.equal('My Todo List'); + }); + + it('creates a todo list with optional properties', () => { + const todoList = new TodoList({ + id: 1, + title: 'My Todo List', + color: 'blue', + }); + + expect(todoList.id).to.equal(1); + expect(todoList.title).to.equal('My Todo List'); + expect(todoList.color).to.equal('blue'); + }); + + it('creates a todo list without id (auto-generated)', () => { + const todoList = new TodoList({ + title: 'Auto ID List', + }); + + expect(todoList.title).to.equal('Auto ID List'); + expect(todoList.id).to.be.undefined(); + }); + }); + + describe('properties', () => { + it('has title property', () => { + const todoList = new TodoList({ + title: 'Test List', + }); + + expect(todoList).to.have.property('title'); + expect(todoList.title).to.be.a.String(); + }); + + it('has optional id property', () => { + const todoList = new TodoList({ + id: 123, + title: 'Test List', + }); + + expect(todoList).to.have.property('id'); + expect(todoList.id).to.be.a.Number(); + }); + + it('has optional color property', () => { + const todoList = new TodoList({ + title: 'Test List', + color: 'red', + }); + + expect(todoList).to.have.property('color'); + expect(todoList.color).to.be.a.String(); + }); + }); + + describe('toJSON', () => { + it('serializes to JSON', () => { + const todoList = new TodoList({ + id: 1, + title: 'My List', + color: 'green', + }); + + const json = todoList.toJSON(); + + expect(json).to.have.property('id', 1); + expect(json).to.have.property('title', 'My List'); + expect(json).to.have.property('color', 'green'); + }); + }); + + describe('validation', () => { + it('requires title property', () => { + const todoList = new TodoList({} as Partial); + + expect(todoList.title).to.be.undefined(); + }); + + it('accepts various title formats', () => { + const shortTitle = new TodoList({title: 'A'}); + const longTitle = new TodoList({ + title: 'A very long todo list title with many words', + }); + const specialChars = new TodoList({title: 'List #1 - Important!'}); + + expect(shortTitle.title).to.equal('A'); + expect(longTitle.title).to.be.a.String(); + expect(specialChars.title).to.equal('List #1 - Important!'); + }); + }); + + describe('relations', () => { + it('can have todos relation', () => { + const todoList = new TodoList({ + title: 'My List', + }); + + // The model should support todos relation + // This is defined through decorators in the actual model + expect(todoList).to.be.instanceOf(TodoList); + }); + + it('can have todoListImage relation', () => { + const todoList = new TodoList({ + title: 'My List', + }); + + // The model should support todoListImage relation + // This is defined through decorators in the actual model + expect(todoList).to.be.instanceOf(TodoList); + }); + }); + + describe('edge cases', () => { + it('handles empty title', () => { + const todoList = new TodoList({ + title: '', + }); + + expect(todoList.title).to.equal(''); + }); + + it('handles very long titles', () => { + const longTitle = 'A'.repeat(1000); + const todoList = new TodoList({ + title: longTitle, + }); + + expect(todoList.title).to.have.length(1000); + }); + + it('handles special characters in title', () => { + const todoList = new TodoList({ + title: '🎯 Important Tasks! @#$%', + }); + + expect(todoList.title).to.equal('🎯 Important Tasks! @#$%'); + }); + + it('handles unicode characters', () => { + const todoList = new TodoList({ + title: '日本語のタイトル', + }); + + expect(todoList.title).to.equal('日本語のタイトル'); + }); + }); +}); + +// Made with Bob diff --git a/examples/todo/src/__tests__/unit/models/todo.model.unit.ts b/examples/todo/src/__tests__/unit/models/todo.model.unit.ts new file mode 100644 index 000000000000..dd79db56957d --- /dev/null +++ b/examples/todo/src/__tests__/unit/models/todo.model.unit.ts @@ -0,0 +1,216 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2026. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Todo} from '../../../models'; + +describe('Todo (unit)', () => { + describe('constructor', () => { + it('creates an instance with no data', () => { + const todo = new Todo(); + expect(todo).to.be.instanceOf(Todo); + }); + + it('creates an instance with partial data', () => { + const todo = new Todo({ + title: 'Test Todo', + }); + expect(todo.title).to.equal('Test Todo'); + expect(todo.id).to.be.undefined(); + expect(todo.desc).to.be.undefined(); + }); + + it('creates an instance with complete data', () => { + const data = { + id: 1, + title: 'Test Todo', + desc: 'Test Description', + isComplete: false, + remindAtAddress: '123 Main St, City, 12345', + remindAtGeo: '40.7128,-74.0060', + tag: {priority: 'high'}, + }; + const todo = new Todo(data); + expect(todo.id).to.equal(1); + expect(todo.title).to.equal('Test Todo'); + expect(todo.desc).to.equal('Test Description'); + expect(todo.isComplete).to.be.false(); + expect(todo.remindAtAddress).to.equal('123 Main St, City, 12345'); + expect(todo.remindAtGeo).to.equal('40.7128,-74.0060'); + expect(todo.tag).to.deepEqual({priority: 'high'}); + }); + }); + + describe('properties', () => { + it('has optional id property', () => { + const todo = new Todo({title: 'Test'}); + expect(todo).to.not.have.property('id'); + + todo.id = 1; + expect(todo.id).to.equal(1); + }); + + it('has required title property', () => { + const todo = new Todo({title: 'Required Title'}); + expect(todo.title).to.equal('Required Title'); + }); + + it('has optional desc property', () => { + const todo = new Todo({title: 'Test'}); + expect(todo.desc).to.be.undefined(); + + todo.desc = 'Description'; + expect(todo.desc).to.equal('Description'); + }); + + it('has optional isComplete property', () => { + const todo = new Todo({title: 'Test'}); + expect(todo.isComplete).to.be.undefined(); + + todo.isComplete = true; + expect(todo.isComplete).to.be.true(); + }); + + it('has optional remindAtAddress property', () => { + const todo = new Todo({title: 'Test'}); + expect(todo.remindAtAddress).to.be.undefined(); + + todo.remindAtAddress = '123 Main St'; + expect(todo.remindAtAddress).to.equal('123 Main St'); + }); + + it('has optional remindAtGeo property', () => { + const todo = new Todo({title: 'Test'}); + expect(todo.remindAtGeo).to.be.undefined(); + + todo.remindAtGeo = '40.7128,-74.0060'; + expect(todo.remindAtGeo).to.equal('40.7128,-74.0060'); + }); + + it('has optional tag property of any type', () => { + const todo = new Todo({title: 'Test'}); + expect(todo.tag).to.be.undefined(); + + todo.tag = {priority: 'high', category: 'work'}; + expect(todo.tag).to.deepEqual({priority: 'high', category: 'work'}); + + todo.tag = 'simple-tag'; + expect(todo.tag).to.equal('simple-tag'); + + todo.tag = ['tag1', 'tag2']; + expect(todo.tag).to.deepEqual(['tag1', 'tag2']); + }); + }); + + describe('data validation', () => { + it('allows empty description', () => { + const todo = new Todo({ + title: 'Test', + desc: '', + }); + expect(todo.desc).to.equal(''); + }); + + it('allows false for isComplete', () => { + const todo = new Todo({ + title: 'Test', + isComplete: false, + }); + expect(todo.isComplete).to.be.false(); + }); + + it('allows true for isComplete', () => { + const todo = new Todo({ + title: 'Test', + isComplete: true, + }); + expect(todo.isComplete).to.be.true(); + }); + + it('handles complex tag objects', () => { + const complexTag = { + priority: 'high', + labels: ['urgent', 'important'], + metadata: { + createdBy: 'user1', + assignedTo: ['user2', 'user3'], + }, + }; + const todo = new Todo({ + title: 'Test', + tag: complexTag, + }); + expect(todo.tag).to.deepEqual(complexTag); + }); + }); + + describe('edge cases', () => { + it('handles very long titles', () => { + const longTitle = 'A'.repeat(1000); + const todo = new Todo({title: longTitle}); + expect(todo.title).to.equal(longTitle); + expect(todo.title.length).to.equal(1000); + }); + + it('handles very long descriptions', () => { + const longDesc = 'B'.repeat(5000); + const todo = new Todo({ + title: 'Test', + desc: longDesc, + }); + expect(todo.desc).to.equal(longDesc); + expect(todo.desc!.length).to.equal(5000); + }); + + it('handles special characters in title', () => { + const specialTitle = 'Test @#$%^&*() 测试 🎉'; + const todo = new Todo({title: specialTitle}); + expect(todo.title).to.equal(specialTitle); + }); + + it('handles multiline descriptions', () => { + const multilineDesc = 'Line 1\nLine 2\nLine 3'; + const todo = new Todo({ + title: 'Test', + desc: multilineDesc, + }); + expect(todo.desc).to.equal(multilineDesc); + }); + + it('handles null-like values gracefully', () => { + const todo = new Todo({ + title: 'Test', + desc: undefined, + isComplete: undefined, + }); + expect(todo.desc).to.be.undefined(); + expect(todo.isComplete).to.be.undefined(); + }); + }); + + describe('model inheritance', () => { + it('extends Entity class', () => { + const todo = new Todo({title: 'Test'}); + expect(todo).to.have.property('toJSON'); + expect(todo).to.have.property('toObject'); + }); + + it('can be serialized to JSON', () => { + const todo = new Todo({ + id: 1, + title: 'Test Todo', + desc: 'Description', + isComplete: false, + }); + const json = todo.toJSON(); + expect(json).to.have.property('id', 1); + expect(json).to.have.property('title', 'Test Todo'); + expect(json).to.have.property('desc', 'Description'); + expect(json).to.have.property('isComplete', false); + }); + }); +}); + +// Made with Bob diff --git a/examples/todo/src/__tests__/unit/repositories/todo.repository.unit.ts b/examples/todo/src/__tests__/unit/repositories/todo.repository.unit.ts new file mode 100644 index 000000000000..145b657224a1 --- /dev/null +++ b/examples/todo/src/__tests__/unit/repositories/todo.repository.unit.ts @@ -0,0 +1,238 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {Todo} from '../../../models'; +import {TodoRepository} from '../../../repositories'; + +describe('TodoRepository (unit)', () => { + let repository: TodoRepository; + let dataSource: juggler.DataSource; + + beforeEach(() => { + dataSource = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); + repository = new TodoRepository(dataSource); + }); + + describe('constructor', () => { + it('creates repository with datasource', () => { + expect(repository).to.be.instanceOf(TodoRepository); + }); + + it('uses Todo model', () => { + expect(repository.entityClass).to.equal(Todo); + }); + }); + + describe('CRUD operations', () => { + it('creates a todo', async () => { + const todo = new Todo({ + title: 'Test Todo', + desc: 'Test Description', + isComplete: false, + }); + + const created = await repository.create(todo); + + expect(created).to.have.property('id'); + expect(created.title).to.equal('Test Todo'); + expect(created.desc).to.equal('Test Description'); + expect(created.isComplete).to.equal(false); + }); + + it('finds all todos', async () => { + await repository.create( + new Todo({ + title: 'Todo 1', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Todo 2', + isComplete: true, + }), + ); + + const todos = await repository.find(); + + expect(todos).to.have.length(2); + }); + + it('finds todos by filter', async () => { + await repository.create( + new Todo({ + title: 'Incomplete Todo', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Complete Todo', + isComplete: true, + }), + ); + + const incompleteTodos = await repository.find({ + where: {isComplete: false}, + }); + + expect(incompleteTodos).to.have.length(1); + expect(incompleteTodos[0].title).to.equal('Incomplete Todo'); + }); + + it('finds todo by id', async () => { + const created = await repository.create( + new Todo({ + title: 'Find Me', + isComplete: false, + }), + ); + + const found = await repository.findById(created.id); + + expect(found.id).to.equal(created.id); + expect(found.title).to.equal('Find Me'); + }); + + it('updates todo by id', async () => { + const created = await repository.create( + new Todo({ + title: 'Update Me', + isComplete: false, + }), + ); + + await repository.updateById(created.id, {isComplete: true}); + + const updated = await repository.findById(created.id); + expect(updated.isComplete).to.equal(true); + }); + + it('deletes todo by id', async () => { + const created = await repository.create( + new Todo({ + title: 'Delete Me', + isComplete: false, + }), + ); + + await repository.deleteById(created.id); + + const count = await repository.count(); + expect(count.count).to.equal(0); + }); + + it('counts todos', async () => { + await repository.create( + new Todo({ + title: 'Todo 1', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Todo 2', + isComplete: false, + }), + ); + + const count = await repository.count(); + + expect(count.count).to.equal(2); + }); + + it('counts todos with where clause', async () => { + await repository.create( + new Todo({ + title: 'Incomplete', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Complete', + isComplete: true, + }), + ); + + const count = await repository.count({isComplete: true}); + + expect(count.count).to.equal(1); + }); + }); + + describe('advanced queries', () => { + it('supports ordering', async () => { + await repository.create( + new Todo({ + title: 'B Todo', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'A Todo', + isComplete: false, + }), + ); + + const todos = await repository.find({order: ['title ASC']}); + + expect(todos[0].title).to.equal('A Todo'); + expect(todos[1].title).to.equal('B Todo'); + }); + + it('supports limiting results', async () => { + await repository.create( + new Todo({ + title: 'Todo 1', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Todo 2', + isComplete: false, + }), + ); + await repository.create( + new Todo({ + title: 'Todo 3', + isComplete: false, + }), + ); + + const todos = await repository.find({limit: 2}); + + expect(todos).to.have.length(2); + }); + + it('supports field selection', async () => { + await repository.create( + new Todo({ + title: 'Test Todo', + desc: 'Test Description', + isComplete: false, + }), + ); + + const todos = await repository.find({ + fields: {title: true, isComplete: true}, + }); + + expect(todos[0]).to.have.property('title'); + expect(todos[0]).to.have.property('isComplete'); + // desc should not be included + expect(todos[0].desc).to.be.undefined(); + }); + }); +}); + +// Made with Bob diff --git a/examples/validation-app/src/__tests__/unit/controllers/coffee-shop.controller.unit.ts b/examples/validation-app/src/__tests__/unit/controllers/coffee-shop.controller.unit.ts new file mode 100644 index 000000000000..3222e451f59b --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/controllers/coffee-shop.controller.unit.ts @@ -0,0 +1,271 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + createStubInstance, + expect, + sinon, + StubbedInstanceWithSinonAccessor, +} from '@loopback/testlab'; +import {CoffeeShopController} from '../../../controllers'; +import {CoffeeShop} from '../../../models'; +import {CoffeeShopRepository} from '../../../repositories'; + +describe('CoffeeShopController (unit)', () => { + let repository: StubbedInstanceWithSinonAccessor; + let controller: CoffeeShopController; + + beforeEach(() => { + repository = createStubInstance(CoffeeShopRepository); + controller = new CoffeeShopController(repository); + }); + + describe('create', () => { + it('creates a new coffee shop', async () => { + const coffeeShopData = { + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + }; + + const savedCoffeeShop = new CoffeeShop({ + shopId: '1', + ...coffeeShopData, + rating: '4', + }); + + repository.stubs.create.resolves(savedCoffeeShop); + + const result = await controller.create( + coffeeShopData as Omit, + ); + + expect(result).to.equal(savedCoffeeShop); + sinon.assert.called(repository.stubs.create); + }); + + it('validates phone number format through interceptor', async () => { + const coffeeShopData = { + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + }; + + const savedCoffeeShop = new CoffeeShop({ + shopId: '1', + ...coffeeShopData, + rating: '4', + }); + + repository.stubs.create.resolves(savedCoffeeShop); + + const result = await controller.create( + coffeeShopData as Omit, + ); + + expect(result.phoneNum).to.match(/^\d{3}-\d{3}-\d{4}$/); + }); + }); + + describe('count', () => { + it('returns count of coffee shops', async () => { + repository.stubs.count.resolves({count: 5}); + + const result = await controller.count(); + + expect(result).to.deepEqual({count: 5}); + sinon.assert.called(repository.stubs.count); + }); + + it('returns count with where filter', async () => { + const where = {city: 'Toronto'}; + repository.stubs.count.resolves({count: 3}); + + const result = await controller.count(where); + + expect(result).to.deepEqual({count: 3}); + sinon.assert.calledWith(repository.stubs.count, where); + }); + }); + + describe('find', () => { + it('returns array of coffee shops', async () => { + const coffeeShops = [ + new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + new CoffeeShop({ + shopId: '2', + city: 'Vancouver', + phoneNum: '604-123-4567', + capacity: 40, + rating: '5', + }), + ]; + + repository.stubs.find.resolves(coffeeShops); + + const result = await controller.find(); + + expect(result).to.deepEqual(coffeeShops); + sinon.assert.called(repository.stubs.find); + }); + + it('returns filtered coffee shops', async () => { + const filter = {where: {city: 'Toronto'}}; + const coffeeShops = [ + new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ]; + + repository.stubs.find.resolves(coffeeShops); + + const result = await controller.find(filter); + + expect(result).to.deepEqual(coffeeShops); + sinon.assert.calledWith(repository.stubs.find, filter); + }); + }); + + describe('updateAll', () => { + it('updates all coffee shops', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 60, + rating: '4', + }); + + repository.stubs.updateAll.resolves({count: 2}); + + const result = await controller.updateAll(coffeeShop); + + expect(result).to.deepEqual({count: 2}); + sinon.assert.called(repository.stubs.updateAll); + }); + + it('updates coffee shops with where clause', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 60, + rating: '4', + }); + const where = {city: 'Toronto'}; + + repository.stubs.updateAll.resolves({count: 1}); + + const result = await controller.updateAll(coffeeShop, where); + + expect(result).to.deepEqual({count: 1}); + sinon.assert.calledWith(repository.stubs.updateAll, coffeeShop, where); + }); + }); + + describe('findById', () => { + it('returns a coffee shop by id', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }); + + repository.stubs.findById.resolves(coffeeShop); + + const result = await controller.findById('1'); + + expect(result).to.equal(coffeeShop); + sinon.assert.calledWith(repository.stubs.findById, '1'); + }); + + it('returns a coffee shop with filter', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }); + const filter = {fields: {city: true, phoneNum: true}}; + + repository.stubs.findById.resolves(coffeeShop); + + const result = await controller.findById('1', filter); + + expect(result).to.equal(coffeeShop); + sinon.assert.calledWith(repository.stubs.findById, '1', filter); + }); + }); + + describe('updateById', () => { + it('updates a coffee shop by id', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 60, + rating: '4', + }); + + repository.stubs.updateById.resolves(); + + await controller.updateById('1', coffeeShop); + + sinon.assert.calledWith(repository.stubs.updateById, '1', coffeeShop); + }); + }); + + describe('replaceById', () => { + it('replaces a coffee shop by id', async () => { + const coffeeShop = new CoffeeShop({ + shopId: '1', + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 60, + rating: '4', + }); + + repository.stubs.replaceById.resolves(); + + await controller.replaceById('1', coffeeShop); + + sinon.assert.calledWith(repository.stubs.replaceById, '1', coffeeShop); + }); + }); + + describe('deleteById', () => { + it('deletes a coffee shop by id', async () => { + repository.stubs.deleteById.resolves(); + + await controller.deleteById('1'); + + sinon.assert.calledWith(repository.stubs.deleteById, '1'); + }); + }); + + describe('interceptor integration', () => { + it('controller uses ValidatePhoneNumInterceptor', () => { + // The controller class is decorated with @intercept(ValidatePhoneNumInterceptor.BINDING_KEY) + // This is verified by checking that the controller class exists and can be instantiated + expect(CoffeeShopController).to.be.a.Function(); + expect(controller).to.be.instanceOf(CoffeeShopController); + }); + }); +}); + +// Made with Bob diff --git a/examples/validation-app/src/__tests__/unit/interceptors/validate-phone-num.interceptor.unit.ts b/examples/validation-app/src/__tests__/unit/interceptors/validate-phone-num.interceptor.unit.ts new file mode 100644 index 000000000000..d8cbd75e7f46 --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/interceptors/validate-phone-num.interceptor.unit.ts @@ -0,0 +1,240 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, InvocationContext} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import {ValidatePhoneNumInterceptor} from '../../../interceptors'; +import {CoffeeShop} from '../../../models'; + +describe('ValidatePhoneNumInterceptor (unit)', () => { + let interceptor: ValidatePhoneNumInterceptor; + + beforeEach(() => { + interceptor = new ValidatePhoneNumInterceptor(); + }); + + describe('value()', () => { + it('returns the intercept function', () => { + const interceptFn = interceptor.value(); + expect(interceptFn).to.be.a.Function(); + }); + }); + + describe('isAreaCodeValid()', () => { + it('returns true for Toronto with 416 area code', () => { + const result = interceptor.isAreaCodeValid('416-111-1111', 'Toronto'); + expect(result).to.be.true(); + }); + + it('returns true for Toronto with 647 area code', () => { + const result = interceptor.isAreaCodeValid('647-222-2222', 'Toronto'); + expect(result).to.be.true(); + }); + + it('returns false for Toronto with invalid area code', () => { + const result = interceptor.isAreaCodeValid('905-333-3333', 'Toronto'); + expect(result).to.be.false(); + }); + + it('returns false for Toronto with 999 area code', () => { + const result = interceptor.isAreaCodeValid('999-444-4444', 'Toronto'); + expect(result).to.be.false(); + }); + + it('returns true for non-Toronto cities (always passes)', () => { + const result = interceptor.isAreaCodeValid('123-456-7890', 'Vancouver'); + expect(result).to.be.true(); + }); + + it('returns true for non-Toronto cities with any area code', () => { + const result = interceptor.isAreaCodeValid('999-999-9999', 'Montreal'); + expect(result).to.be.true(); + }); + + it('is case-insensitive for city name', () => { + const result1 = interceptor.isAreaCodeValid('416-111-1111', 'toronto'); + const result2 = interceptor.isAreaCodeValid('416-111-1111', 'TORONTO'); + const result3 = interceptor.isAreaCodeValid('416-111-1111', 'ToRoNtO'); + expect(result1).to.be.true(); + expect(result2).to.be.true(); + expect(result3).to.be.true(); + }); + + it('extracts area code correctly from phone number', () => { + const result = interceptor.isAreaCodeValid('416-123-4567', 'Toronto'); + expect(result).to.be.true(); + }); + }); + + describe('intercept()', () => { + it('allows valid coffee shop creation for Toronto with 416', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const next = async () => coffeeShop; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.equal(coffeeShop); + }); + + it('allows valid coffee shop creation for Toronto with 647', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '647-222-2222', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const next = async () => coffeeShop; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.equal(coffeeShop); + }); + + it('throws error for Toronto with invalid area code', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '905-333-3333', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const next = async () => coffeeShop; + + await expect( + interceptor.intercept(invocationCtx, next), + ).to.be.rejectedWith('Area code and city do not match'); + }); + + it('throws error with statusCode 400 for invalid area code', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '999-444-4444', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const next = async () => coffeeShop; + + try { + await interceptor.intercept(invocationCtx, next); + throw new Error('Should have thrown an error'); + } catch (err) { + expect(err.message).to.equal('Area code and city do not match'); + expect(err.statusCode).to.equal(400); + } + }); + + it('allows valid coffee shop for non-Toronto cities', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Vancouver', + phoneNum: '604-555-5555', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const next = async () => coffeeShop; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.equal(coffeeShop); + }); + + it('validates on updateById method', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '905-666-6666', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('updateById', [ + '123', + coffeeShop, + ]); + const next = async () => undefined; + + await expect( + interceptor.intercept(invocationCtx, next), + ).to.be.rejectedWith('Area code and city do not match'); + }); + + it('allows valid updateById for Toronto with 416', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-777-7777', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('updateById', [ + '123', + coffeeShop, + ]); + const next = async () => undefined; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.be.undefined(); + }); + + it('skips validation for methods other than create and updateById', async () => { + const invocationCtx = givenInvocationContext('find', []); + const next = async () => []; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.deepEqual([]); + }); + + it('skips validation when coffee shop is undefined', async () => { + const invocationCtx = givenInvocationContext('create', [undefined]); + const next = async () => undefined; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.be.undefined(); + }); + + it('calls next() and returns its result on success', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-888-8888', + capacity: 50, + }); + + const invocationCtx = givenInvocationContext('create', [coffeeShop]); + const expectedResult = {id: '123', ...coffeeShop}; + const next = async () => expectedResult; + + const result = await interceptor.intercept(invocationCtx, next); + expect(result).to.equal(expectedResult); + }); + }); + + describe('BINDING_KEY', () => { + it('has correct binding key', () => { + expect(ValidatePhoneNumInterceptor.BINDING_KEY).to.equal( + 'interceptors.ValidatePhoneNumInterceptor', + ); + }); + }); + + function givenInvocationContext( + methodName: string, + args: unknown[], + ): InvocationContext { + const context = new Context(); + return { + target: {}, + methodName, + args, + getBinding: context.getBinding.bind(context), + getConfigAsValueOrPromise: + context.getConfigAsValueOrPromise.bind(context), + getValueOrPromise: context.getValueOrPromise.bind(context), + get: context.get.bind(context), + getSync: context.getSync.bind(context), + } as InvocationContext; + } +}); diff --git a/examples/validation-app/src/__tests__/unit/middleware/validation-error.middleware.unit.ts b/examples/validation-app/src/__tests__/unit/middleware/validation-error.middleware.unit.ts new file mode 100644 index 000000000000..e15f7cc9c48b --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/middleware/validation-error.middleware.unit.ts @@ -0,0 +1,262 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context} from '@loopback/core'; +import {HttpErrors, Request, RestBindings} from '@loopback/rest'; +import {expect, sinon} from '@loopback/testlab'; +import {ValidationErrorMiddlewareProvider} from '../../../middleware/validation-error.middleware'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('ValidationErrorMiddleware (unit)', () => { + let provider: ValidationErrorMiddlewareProvider; + let logErrorStub: sinon.SinonStub; + let middlewareContext: any; + let response: any; + + beforeEach(() => { + logErrorStub = sinon.stub(); + provider = new ValidationErrorMiddlewareProvider(logErrorStub); + response = givenResponse(); + middlewareContext = givenMiddlewareContext(); + }); + + describe('value()', () => { + it('returns a middleware function', async () => { + const middleware = await provider.value(); + expect(middleware).to.be.a.Function(); + }); + }); + + describe('middleware execution', () => { + it('passes through when no error occurs', async () => { + const middleware = await provider.value(); + const next = sinon.stub().resolves('success'); + + const result = await middleware(middlewareContext, next); + + expect(result).to.equal('success'); + expect(next.calledOnce).to.be.true(); + }); + }); + + describe('handleError() for /coffee-shops endpoint', () => { + beforeEach(() => { + middlewareContext.request.url = '/coffee-shops'; + }); + + it('customizes 422 validation error for PATCH method', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + sinon.assert.calledWith(response.status, 422); + sinon.assert.calledOnce(response.send); + + const sentData = response.send.getCall(0).args[0]; + expect(sentData).to.containEql({ + statusCode: 422, + message: 'My customized validation error message', + resolution: 'Contact your admin for troubleshooting.', + code: 'VALIDATION_FAILED', + }); + expect(logErrorStub.calledOnce).to.be.true(); + }); + + it('includes stack trace when debug mode is enabled', async () => { + provider = new ValidationErrorMiddlewareProvider(logErrorStub, { + debug: true, + }); + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + const sentData = response.send.getCall(0).args[0]; + expect(sentData).to.have.property('stack'); + }); + + it('does not customize 422 error for non-PATCH methods', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'POST'; + + try { + await middleware(middlewareContext, next); + throw new Error('Should have thrown'); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('does not customize non-422 errors for PATCH method', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.BadRequest('Bad request'); + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + try { + await middleware(middlewareContext, next); + throw new Error('Should have thrown'); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('logs the error with correct parameters', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + expect(logErrorStub.calledOnce).to.be.true(); + expect(logErrorStub.firstCall.args[0]).to.equal(error); + expect(logErrorStub.firstCall.args[1]).to.equal(422); + expect(logErrorStub.firstCall.args[2]).to.equal( + middlewareContext.request, + ); + }); + + it('returns the response after handling error', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + const result = await middleware(middlewareContext, next); + + expect(result).to.equal(response); + }); + }); + + describe('handleError() for /pets endpoint', () => { + beforeEach(() => { + middlewareContext.request.url = '/pets'; + }); + + it('customizes 422 validation error for PATCH method', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + sinon.assert.calledWith(response.status, 422); + sinon.assert.calledOnce(response.send); + + const sentData = response.send.getCall(0).args[0]; + expect(sentData).to.containEql({ + statusCode: 422, + message: 'My customized validation error message', + resolution: 'Contact your admin for troubleshooting.', + code: 'VALIDATION_FAILED', + }); + }); + + it('does not customize for non-PATCH methods', async () => { + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.method = 'GET'; + + try { + await middleware(middlewareContext, next); + throw new Error('Should have thrown'); + } catch (err) { + expect(err).to.equal(error); + } + }); + }); + + describe('error writer options', () => { + it('works without error writer options', async () => { + provider = new ValidationErrorMiddlewareProvider(logErrorStub); + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.url = '/coffee-shops'; + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + const sentData = response.send.getCall(0).args[0]; + expect(sentData).to.not.have.property('stack'); + }); + + it('includes stack when debug is true', async () => { + provider = new ValidationErrorMiddlewareProvider(logErrorStub, { + debug: true, + }); + const middleware = await provider.value(); + const error = new HttpErrors.UnprocessableEntity('Validation failed'); + error.statusCode = 422; + const next = sinon.stub().rejects(error); + + middlewareContext.request.url = '/coffee-shops'; + middlewareContext.request.method = 'PATCH'; + + await middleware(middlewareContext, next); + + const sentData = response.send.getCall(0).args[0]; + expect(sentData).to.have.property('stack'); + }); + }); + + function givenResponse() { + return { + status: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + }; + } + + function givenMiddlewareContext() { + const context = new Context(); + const request = { + url: '/coffee-shops', + method: 'POST', + } as Request; + + context.bind(RestBindings.Http.REQUEST).to(request); + context.bind(RestBindings.Http.RESPONSE).to(response); + + return { + request, + response, + getBinding: context.getBinding.bind(context), + getConfigAsValueOrPromise: + context.getConfigAsValueOrPromise.bind(context), + getValueOrPromise: context.getValueOrPromise.bind(context), + get: context.get.bind(context), + getSync: context.getSync.bind(context), + }; + } +}); diff --git a/examples/validation-app/src/__tests__/unit/models/coffee-shop.model.unit.ts b/examples/validation-app/src/__tests__/unit/models/coffee-shop.model.unit.ts new file mode 100644 index 000000000000..4ad9fe42397b --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/models/coffee-shop.model.unit.ts @@ -0,0 +1,177 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {CoffeeShop} from '../../../models'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('CoffeeShop (unit)', () => { + describe('constructor', () => { + it('creates an instance with valid data', () => { + const data = { + shopId: '1', + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + rating: '4', + }; + const coffeeShop = new CoffeeShop(data); + expect(coffeeShop).to.be.instanceOf(CoffeeShop); + expect(coffeeShop.shopId).to.equal('1'); + expect(coffeeShop.city).to.equal('Toronto'); + expect(coffeeShop.phoneNum).to.equal('416-111-1111'); + expect(coffeeShop.capacity).to.equal(50); + expect(coffeeShop.rating).to.equal('4'); + }); + + it('creates an instance without optional shopId', () => { + const data = { + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + }; + const coffeeShop = new CoffeeShop(data); + expect(coffeeShop).to.be.instanceOf(CoffeeShop); + expect(coffeeShop.shopId).to.be.undefined(); + }); + + it('creates an instance without optional rating', () => { + const data = { + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + }; + const coffeeShop = new CoffeeShop(data); + expect(coffeeShop).to.be.instanceOf(CoffeeShop); + expect(coffeeShop.rating).to.be.undefined(); + }); + + it('creates an instance with partial data', () => { + const data = { + city: 'Tokyo', + }; + const coffeeShop = new CoffeeShop(data); + expect(coffeeShop).to.be.instanceOf(CoffeeShop); + expect(coffeeShop.city).to.equal('Tokyo'); + }); + + it('creates an instance with empty data', () => { + const coffeeShop = new CoffeeShop(); + expect(coffeeShop).to.be.instanceOf(CoffeeShop); + }); + }); + + describe('property validation schemas', () => { + it('has correct jsonSchema for city property', () => { + const cityProperty = (CoffeeShop.definition.properties.city as any) + .jsonSchema; + expect(cityProperty).to.containEql({ + maxLength: 10, + minLength: 5, + errorMessage: 'City name must be between 5 and 10 characters', + }); + }); + + it('has correct jsonSchema for phoneNum property', () => { + const phoneNumProperty = ( + CoffeeShop.definition.properties.phoneNum as any + ).jsonSchema; + expect(phoneNumProperty).to.containEql({ + pattern: '\\d{3}-\\d{3}-\\d{4}', + errorMessage: 'Invalid phone number', + }); + }); + + it('has correct jsonSchema for capacity property', () => { + const capacityProperty = ( + CoffeeShop.definition.properties.capacity as any + ).jsonSchema; + expect(capacityProperty.maximum).to.equal(100); + expect(capacityProperty.minimum).to.equal(10); + expect(capacityProperty.errorMessage).to.containEql({ + maximum: 'Capacity cannot exceed 100', + minimum: 'Capacity cannot be less than 1', + }); + }); + + it('has correct jsonSchema for rating property', () => { + const ratingProperty = (CoffeeShop.definition.properties.rating as any) + .jsonSchema; + expect(ratingProperty).to.containEql({ + range: [1, 5], + errorMessage: 'Rating must be between 1 and 5', + }); + }); + }); + + describe('property types', () => { + it('has shopId as string type', () => { + expect(CoffeeShop.definition.properties.shopId.type).to.equal('string'); + }); + + it('has city as required string type', () => { + expect(CoffeeShop.definition.properties.city.type).to.equal('string'); + expect(CoffeeShop.definition.properties.city.required).to.be.true(); + }); + + it('has phoneNum as required string type', () => { + expect(CoffeeShop.definition.properties.phoneNum.type).to.equal('string'); + expect(CoffeeShop.definition.properties.phoneNum.required).to.be.true(); + }); + + it('has capacity as required number type', () => { + expect(CoffeeShop.definition.properties.capacity.type).to.equal('number'); + expect(CoffeeShop.definition.properties.capacity.required).to.be.true(); + }); + + it('has rating as optional number type', () => { + expect(CoffeeShop.definition.properties.rating.type).to.equal('number'); + expect( + CoffeeShop.definition.properties.rating.required, + ).to.be.undefined(); + }); + }); + + describe('model metadata', () => { + it('has shopId marked as id property', () => { + expect(CoffeeShop.definition.properties.shopId.id).to.be.true(); + }); + + it('has shopId marked as generated', () => { + expect(CoffeeShop.definition.properties.shopId.generated).to.be.true(); + }); + + it('has correct model name', () => { + expect(CoffeeShop.modelName).to.equal('CoffeeShop'); + }); + }); + + describe('toJSON', () => { + it('serializes to JSON correctly', () => { + const data = { + shopId: '1', + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + rating: '4', + }; + const coffeeShop = new CoffeeShop(data); + const json = coffeeShop.toJSON(); + expect(json).to.deepEqual(data); + }); + + it('serializes without optional properties', () => { + const data = { + city: 'Toronto', + phoneNum: '416-111-1111', + capacity: 50, + }; + const coffeeShop = new CoffeeShop(data); + const json = coffeeShop.toJSON(); + expect(json).to.deepEqual(data); + }); + }); +}); diff --git a/examples/validation-app/src/__tests__/unit/models/pet.model.unit.ts b/examples/validation-app/src/__tests__/unit/models/pet.model.unit.ts new file mode 100644 index 000000000000..077c26aa1f8c --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/models/pet.model.unit.ts @@ -0,0 +1,237 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Cat, CatProperties, Dog, DogProperties, Pet} from '../../../models'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('Pet Models (unit)', () => { + describe('Pet', () => { + it('creates a Pet instance with required properties', () => { + const pet = new Pet(); + pet.name = 'Buddy'; + pet.weight = 10; + pet.kind = 'Dog'; + expect(pet).to.be.instanceOf(Pet); + expect(pet.name).to.equal('Buddy'); + expect(pet.weight).to.equal(10); + expect(pet.kind).to.equal('Dog'); + }); + + it('creates a Pet instance without optional weight', () => { + const pet = new Pet(); + pet.name = 'Buddy'; + pet.kind = 'Dog'; + expect(pet).to.be.instanceOf(Pet); + expect(pet.weight).to.be.undefined(); + }); + + it('has name as required string property', () => { + expect(Pet.definition.properties.name.type).to.equal(String); + expect(Pet.definition.properties.name.required).to.be.true(); + }); + + it('has weight as optional number property', () => { + expect(Pet.definition.properties.weight.type).to.equal(Number); + expect(Pet.definition.properties.weight.required).to.be.false(); + }); + }); + + describe('Dog', () => { + it('creates a Dog instance with valid properties', () => { + const dogProps = new DogProperties(); + dogProps.breed = 'Labrador'; + dogProps.barkVolume = 8; + const dog = new Dog(); + dog.name = 'Rex'; + dog.weight = 30; + dog.kind = 'Dog'; + dog.animalProperties = dogProps; + expect(dog).to.be.instanceOf(Dog); + expect(dog).to.be.instanceOf(Pet); + expect(dog.name).to.equal('Rex'); + expect(dog.weight).to.equal(30); + expect(dog.kind).to.equal('Dog'); + expect(dog.animalProperties).to.equal(dogProps); + }); + + it('has kind property with Dog enum constraint', () => { + const kindProperty = Dog.definition.properties.kind as any; + expect(kindProperty.type).to.equal(String); + expect(kindProperty.required).to.be.true(); + expect(kindProperty.jsonSchema.enum).to.deepEqual(['Dog']); + }); + + it('has animalProperties as required DogProperties type', () => { + const animalPropsProperty = Dog.definition.properties + .animalProperties as any; + expect(animalPropsProperty.type).to.equal(DogProperties); + expect(animalPropsProperty.required).to.be.true(); + }); + + it('serializes to JSON correctly', () => { + const dogProps = new DogProperties(); + dogProps.breed = 'Labrador'; + dogProps.barkVolume = 8; + const dog = new Dog(); + dog.name = 'Rex'; + dog.weight = 30; + dog.kind = 'Dog'; + dog.animalProperties = dogProps; + const json = dog.toJSON(); + expect((json as any).name).to.equal('Rex'); + expect((json as any).kind).to.equal('Dog'); + }); + }); + + describe('Cat', () => { + it('creates a Cat instance with valid properties', () => { + const catProps = new CatProperties(); + catProps.color = 'orange'; + catProps.whiskerLength = 3; + const cat = new Cat(); + cat.name = 'Whiskers'; + cat.weight = 5; + cat.kind = 'Cat'; + cat.animalProperties = catProps; + expect(cat).to.be.instanceOf(Cat); + expect(cat).to.be.instanceOf(Pet); + expect(cat.name).to.equal('Whiskers'); + expect(cat.weight).to.equal(5); + expect(cat.kind).to.equal('Cat'); + expect(cat.animalProperties).to.equal(catProps); + }); + + it('has kind property with Cat enum constraint', () => { + const kindProperty = Cat.definition.properties.kind as any; + expect(kindProperty.type).to.equal(String); + expect(kindProperty.required).to.be.true(); + expect(kindProperty.jsonSchema.enum).to.deepEqual(['Cat']); + }); + + it('has animalProperties as required CatProperties type', () => { + const animalPropsProperty = Cat.definition.properties + .animalProperties as any; + expect(animalPropsProperty.type).to.equal(CatProperties); + expect(animalPropsProperty.required).to.be.true(); + }); + + it('serializes to JSON correctly', () => { + const catProps = new CatProperties(); + catProps.color = 'orange'; + catProps.whiskerLength = 3; + const cat = new Cat(); + cat.name = 'Whiskers'; + cat.weight = 5; + cat.kind = 'Cat'; + cat.animalProperties = catProps; + const json = cat.toJSON(); + expect((json as any).name).to.equal('Whiskers'); + expect((json as any).kind).to.equal('Cat'); + }); + }); + + describe('DogProperties', () => { + it('creates a DogProperties instance', () => { + const dogProps = new DogProperties(); + dogProps.breed = 'Poodle'; + dogProps.barkVolume = 5; + expect(dogProps).to.be.instanceOf(DogProperties); + expect(dogProps.breed).to.equal('Poodle'); + expect(dogProps.barkVolume).to.equal(5); + }); + + it('has breed as required string property', () => { + expect(DogProperties.definition.properties.breed.type).to.equal(String); + expect(DogProperties.definition.properties.breed.required).to.be.true(); + }); + + it('has barkVolume as required number property', () => { + expect(DogProperties.definition.properties.barkVolume.type).to.equal( + Number, + ); + expect( + DogProperties.definition.properties.barkVolume.required, + ).to.be.true(); + }); + + it('serializes to JSON correctly', () => { + const dogProps = new DogProperties(); + dogProps.breed = 'Poodle'; + dogProps.barkVolume = 5; + const json = dogProps.toJSON(); + expect(json).to.deepEqual({ + breed: 'Poodle', + barkVolume: 5, + }); + }); + }); + + describe('CatProperties', () => { + it('creates a CatProperties instance', () => { + const catProps = new CatProperties(); + catProps.color = 'black'; + catProps.whiskerLength = 4; + expect(catProps).to.be.instanceOf(CatProperties); + expect(catProps.color).to.equal('black'); + expect(catProps.whiskerLength).to.equal(4); + }); + + it('has color as required string property', () => { + expect(CatProperties.definition.properties.color.type).to.equal(String); + expect(CatProperties.definition.properties.color.required).to.be.true(); + }); + + it('has whiskerLength as required number property', () => { + expect(CatProperties.definition.properties.whiskerLength.type).to.equal( + Number, + ); + expect( + CatProperties.definition.properties.whiskerLength.required, + ).to.be.true(); + }); + + it('serializes to JSON correctly', () => { + const catProps = new CatProperties(); + catProps.color = 'black'; + catProps.whiskerLength = 4; + const json = catProps.toJSON(); + expect(json).to.deepEqual({ + color: 'black', + whiskerLength: 4, + }); + }); + }); + + describe('Discriminator pattern', () => { + it('allows Dog and Cat to extend Pet with different properties', () => { + const dogProps = new DogProperties(); + dogProps.breed = 'Labrador'; + dogProps.barkVolume = 8; + const dog = new Dog(); + dog.name = 'Rex'; + dog.kind = 'Dog'; + dog.animalProperties = dogProps; + + const catProps = new CatProperties(); + catProps.color = 'orange'; + catProps.whiskerLength = 3; + const cat = new Cat(); + cat.name = 'Whiskers'; + cat.kind = 'Cat'; + cat.animalProperties = catProps; + + expect(dog.kind).to.equal('Dog'); + expect(cat.kind).to.equal('Cat'); + expect((dog.animalProperties as DogProperties).breed).to.equal( + 'Labrador', + ); + expect((cat.animalProperties as CatProperties).color).to.equal('orange'); + }); + }); +}); + +// Made with Bob diff --git a/examples/validation-app/src/__tests__/unit/repositories/coffee-shop.repository.unit.ts b/examples/validation-app/src/__tests__/unit/repositories/coffee-shop.repository.unit.ts new file mode 100644 index 000000000000..1d08c6be722a --- /dev/null +++ b/examples/validation-app/src/__tests__/unit/repositories/coffee-shop.repository.unit.ts @@ -0,0 +1,157 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/example-validation-app +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {CoffeeShop} from '../../../models'; +import {CoffeeShopRepository} from '../../../repositories'; + +describe('CoffeeShopRepository (unit)', () => { + let repository: CoffeeShopRepository; + let dataSource: juggler.DataSource; + + beforeEach(() => { + dataSource = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); + repository = new CoffeeShopRepository(dataSource); + }); + + describe('constructor', () => { + it('creates repository with datasource', () => { + expect(repository).to.be.instanceOf(CoffeeShopRepository); + }); + + it('uses CoffeeShop model', () => { + expect(repository.entityClass).to.equal(CoffeeShop); + }); + }); + + describe('CRUD operations', () => { + it('creates a coffee shop', async () => { + const coffeeShop = new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }); + + const created = await repository.create(coffeeShop); + + expect(created).to.have.property('shopId'); + expect(created.city).to.equal('Toronto'); + expect(created.phoneNum).to.equal('416-123-4567'); + expect(created.capacity).to.equal(50); + }); + + it('finds all coffee shops', async () => { + await repository.create( + new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ); + await repository.create( + new CoffeeShop({ + city: 'Vancouver', + phoneNum: '604-123-4567', + capacity: 40, + rating: '5', + }), + ); + + const shops = await repository.find(); + + expect(shops).to.have.length(2); + }); + + it('finds coffee shops by filter', async () => { + await repository.create( + new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ); + await repository.create( + new CoffeeShop({ + city: 'Vancouver', + phoneNum: '604-123-4567', + capacity: 40, + rating: '5', + }), + ); + + const shops = await repository.find({where: {city: 'Toronto'}}); + + expect(shops).to.have.length(1); + expect(shops[0].city).to.equal('Toronto'); + }); + + it('counts coffee shops', async () => { + await repository.create( + new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ); + + const count = await repository.count(); + + expect(count.count).to.equal(1); + }); + + it('updates coffee shop by id', async () => { + const created = await repository.create( + new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ); + + await repository.updateById(created.shopId, {capacity: 60}); + + const updated = await repository.findById(created.shopId); + expect(updated.capacity).to.equal(60); + }); + + it('deletes coffee shop by id', async () => { + const created = await repository.create( + new CoffeeShop({ + city: 'Toronto', + phoneNum: '416-123-4567', + capacity: 50, + rating: '4', + }), + ); + + await repository.deleteById(created.shopId); + + const count = await repository.count(); + expect(count.count).to.equal(0); + }); + }); + + describe('validation', () => { + it('enforces required fields', async () => { + const invalidShop = new CoffeeShop({ + city: 'Toronto', + // missing phoneNum and capacity + } as Partial); + + await expect(repository.create(invalidShop)).to.be.rejected(); + }); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/artifact-generator.unit.js b/packages/cli/test/unit/artifact-generator.unit.js new file mode 100644 index 000000000000..78857c5fd4f4 --- /dev/null +++ b/packages/cli/test/unit/artifact-generator.unit.js @@ -0,0 +1,107 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const expect = require('@loopback/testlab').expect; +const ArtifactGenerator = require('../../lib/artifact-generator'); + +describe('ArtifactGenerator', () => { + let generator; + + beforeEach(() => { + generator = Object.create(ArtifactGenerator.prototype); + generator.artifactInfo = { + type: 'model', + name: undefined, + outDir: 'src/models', + defaultName: 'MyModel', + }; + generator.options = {}; + generator.log = () => {}; + generator.destinationPath = () => '/test/path'; + }); + + describe('setOptions', () => { + it('validates class name when provided', () => { + generator.options.name = '2InvalidName'; + expect(() => generator.setOptions()).to.throw( + /Class name cannot start with a number/, + ); + }); + + it('accepts valid class name', () => { + generator.options.name = 'ValidName'; + generator.setOptions(); + expect(generator.artifactInfo.name).to.equal('ValidName'); + }); + + it('sets relative path correctly', () => { + generator.options.name = 'TestModel'; + generator.setOptions(); + expect(generator.artifactInfo.relPath).to.be.a.String(); + }); + }); + + describe('promptClassFileName', () => { + it('logs the correct file creation message', () => { + let loggedMessage = ''; + generator.log = msg => { + loggedMessage += msg; + }; + + generator.promptClassFileName('model', 'models', 'MyModel'); + expect(loggedMessage).to.match(/Model MyModel will be created/); + expect(loggedMessage).to.match(/src\/models\/my-model\.model\.ts/); + }); + }); + + describe('scaffold', () => { + it('capitalizes artifact name', () => { + generator.artifactInfo.name = 'myModel'; + generator.shouldExit = () => false; + generator.copyTemplatedFiles = () => {}; + generator.templatePath = () => ''; + generator.destinationPath = () => ''; + + generator.scaffold(); + expect(generator.artifactInfo.name).to.equal('MyModel'); + }); + + it('does not scaffold when shouldExit is true', () => { + generator.shouldExit = () => true; + const result = generator.scaffold(); + expect(result).to.equal(false); + }); + }); + + describe('_updateIndexFiles', () => { + it('skips update when disableIndexUpdate is true', async () => { + generator.artifactInfo.disableIndexUpdate = true; + await generator._updateIndexFiles(); + // Should complete without error + }); + + it('creates default index update array when outDir and outFile exist', async () => { + generator.artifactInfo.outDir = 'src/models'; + generator.artifactInfo.outFile = 'my-model.model.ts'; + generator.artifactInfo.indexesToBeUpdated = []; + generator._updateIndexFile = () => Promise.resolve(); + + await generator._updateIndexFiles(); + expect(generator.artifactInfo.indexesToBeUpdated).to.have.length(1); + expect(generator.artifactInfo.indexesToBeUpdated[0]).to.deepEqual({ + dir: 'src/models', + file: 'my-model.model.ts', + }); + }); + }); + + describe('classNameSeparator', () => { + it('has default separator', () => { + expect(generator.classNameSeparator).to.equal(', '); + }); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/cli.unit.js b/packages/cli/test/unit/cli.unit.js new file mode 100644 index 000000000000..e97b04008878 --- /dev/null +++ b/packages/cli/test/unit/cli.unit.js @@ -0,0 +1,209 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const {expect, sinon} = require('@loopback/testlab'); +const cli = require('../../lib/cli'); + +describe('cli unit tests', () => { + let logStub; + + beforeEach(() => { + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('main function', () => { + it('prints version when --version flag is passed', () => { + const opts = {version: true}; + cli(opts, logStub); + sinon.assert.calledWith(logStub, sinon.match(/@loopback\/cli version:/)); + }); + + it('prints commands when --commands flag is passed', () => { + const opts = {commands: true}; + cli(opts, logStub); + sinon.assert.calledWith(logStub, 'Available commands:'); + }); + + it('uses console.log as default log function', () => { + const opts = {version: true}; + cli(opts); + // Should not throw + }); + + it('handles dry-run option', () => { + const opts = { + 'dry-run': true, + _: [], + }; + // Should not throw and not execute actual commands + cli(opts, logStub); + }); + + it('handles dryRun option (camelCase)', () => { + const opts = { + dryRun: true, + _: [], + }; + // Should not throw and not execute actual commands + cli(opts, logStub); + }); + + it('handles meta option for generating command metadata', () => { + const opts = { + meta: true, + 'dry-run': true, + }; + const result = cli(opts, logStub); + expect(result).to.be.an.Object(); + expect(result).to.have.property('base'); + }); + + it('handles empty options object', () => { + const opts = {_: []}; + // Should not throw + cli(opts, logStub); + }); + }); + + describe('command handling', () => { + it('defaults to app command when no command specified', () => { + const opts = { + _: [], + 'dry-run': true, + }; + cli(opts, logStub); + // Should default to app generator + }); + + it('handles help flag without command', () => { + const opts = { + _: [], + help: true, + 'dry-run': true, + }; + cli(opts, logStub); + sinon.assert.calledWith(logStub, 'Available commands:'); + }); + + it('handles unknown command by defaulting to app', () => { + const opts = { + _: ['unknown-command'], + 'dry-run': true, + }; + // Should not throw and default to app + cli(opts, logStub); + }); + + it('handles valid command names', () => { + const commands = [ + 'app', + 'controller', + 'datasource', + 'model', + 'repository', + 'service', + ]; + commands.forEach(cmd => { + const opts = { + _: [cmd], + 'dry-run': true, + }; + // Should not throw + cli(opts, logStub); + }); + }); + }); + + describe('option handling', () => { + it('handles skip-cache option', () => { + const opts = { + _: ['model'], + 'skip-cache': true, + 'dry-run': true, + }; + cli(opts, logStub); + // Should not throw + }); + + it('handles skip-install option', () => { + const opts = { + _: ['app'], + 'skip-install': true, + 'dry-run': true, + }; + cli(opts, logStub); + // Should not throw + }); + + it('handles force-install option', () => { + const opts = { + _: ['app'], + 'force-install': true, + 'dry-run': true, + }; + cli(opts, logStub); + // Should not throw + }); + + it('handles ask-answered option', () => { + const opts = { + _: ['model'], + 'ask-answered': true, + 'dry-run': true, + }; + cli(opts, logStub); + // Should not throw + }); + + it('handles multiple options together', () => { + const opts = { + _: ['controller'], + 'skip-cache': true, + 'skip-install': true, + help: true, + 'dry-run': true, + }; + cli(opts, logStub); + // Should not throw + }); + }); + + describe('generator registration', () => { + it('registers all expected generators', () => { + const opts = {commands: true}; + cli(opts, logStub); + + const output = logStub.args.join('\n'); + const expectedGenerators = [ + 'app', + 'extension', + 'controller', + 'datasource', + 'model', + 'repository', + 'service', + 'example', + 'openapi', + 'observer', + 'interceptor', + 'discover', + 'relation', + 'update', + 'rest-crud', + 'copyright', + ]; + + expectedGenerators.forEach(gen => { + expect(output).to.match(new RegExp(`lb4 ${gen}`)); + }); + }); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/debug.unit.js b/packages/cli/test/unit/debug.unit.js new file mode 100644 index 000000000000..685970f63137 --- /dev/null +++ b/packages/cli/test/unit/debug.unit.js @@ -0,0 +1,43 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const {expect} = require('@loopback/testlab'); +const debugFactory = require('../../lib/debug'); + +describe('debug unit tests', () => { + it('returns a debug function', () => { + const debug = debugFactory('test'); + expect(debug).to.be.a.Function(); + }); + + it('creates debug function with correct namespace', () => { + const debug = debugFactory('test-scope'); + expect(debug.namespace).to.equal('loopback:cli:test-scope'); + }); + + it('creates debug function without scope', () => { + const debug = debugFactory(); + expect(debug.namespace).to.equal('loopback:cli'); + }); + + it('extends the base namespace with provided scope', () => { + const debug1 = debugFactory('scope1'); + const debug2 = debugFactory('scope2'); + expect(debug1.namespace).to.equal('loopback:cli:scope1'); + expect(debug2.namespace).to.equal('loopback:cli:scope2'); + }); + + it('handles empty string scope', () => { + const debug = debugFactory(''); + expect(debug.namespace).to.equal('loopback:cli:'); + }); + + it('handles special characters in scope', () => { + const debug = debugFactory('test:sub-scope'); + expect(debug.namespace).to.equal('loopback:cli:test:sub-scope'); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/globalize.unit.js b/packages/cli/test/unit/globalize.unit.js new file mode 100644 index 000000000000..774952a01a01 --- /dev/null +++ b/packages/cli/test/unit/globalize.unit.js @@ -0,0 +1,84 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const {expect} = require('@loopback/testlab'); +const g = require('../../lib/globalize'); + +describe('globalize unit tests', () => { + it('exports a globalize instance', () => { + expect(g).to.be.an.Object(); + }); + + it('has format method', () => { + expect(g.f).to.be.a.Function(); + }); + + it('formats simple strings', () => { + const result = g.f('Hello World'); + expect(result).to.equal('Hello World'); + }); + + it('formats strings with placeholders', () => { + const result = g.f('Hello %s', 'World'); + expect(result).to.equal('Hello World'); + }); + + it('formats strings with multiple placeholders', () => { + const result = g.f('Hello %s, you are %d years old', 'John', 25); + expect(result).to.equal('Hello John, you are 25 years old'); + }); + + it('handles missing placeholders gracefully', () => { + const result = g.f('Hello %s'); + expect(result).to.be.a.String(); + }); + + it('has t method for translation', () => { + expect(g.t).to.be.a.Function(); + }); + + it('translates simple keys', () => { + const result = g.t('test'); + expect(result).to.be.a.String(); + }); + + it('has m method for message formatting', () => { + expect(g.m).to.be.a.Function(); + }); + + it('formats messages', () => { + const result = g.m('test message'); + expect(result).to.be.a.String(); + }); + + it('has c method for currency formatting', () => { + expect(g.c).to.be.a.Function(); + }); + + it('has n method for number formatting', () => { + expect(g.n).to.be.a.Function(); + }); + + it('has d method for date formatting', () => { + expect(g.d).to.be.a.Function(); + }); + + it('handles empty strings', () => { + const result = g.f(''); + expect(result).to.equal(''); + }); + + it('handles special characters', () => { + const result = g.f('Test: %s!', 'special@#$'); + expect(result).to.match(/special/); + }); + + it('handles unicode characters', () => { + const result = g.f('Hello %s', '世界'); + expect(result).to.equal('Hello 世界'); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/model-discoverer.unit.js b/packages/cli/test/unit/model-discoverer.unit.js new file mode 100644 index 000000000000..526f324953f4 --- /dev/null +++ b/packages/cli/test/unit/model-discoverer.unit.js @@ -0,0 +1,147 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const {expect} = require('@loopback/testlab'); +const path = require('path'); +const { + sanitizeProperty, + DEFAULT_DATASOURCE_DIRECTORY, + MODEL_TEMPLATE_PATH, +} = require('../../lib/model-discoverer'); + +describe('model-discoverer unit tests', () => { + describe('sanitizeProperty', () => { + it('removes null properties', () => { + const obj = { + name: 'test', + value: null, + description: 'A test object', + }; + sanitizeProperty(obj); + expect(obj).to.not.have.property('value'); + expect(obj).to.have.property('name', 'test'); + expect(obj).to.have.property('description', 'A test object'); + }); + + it('stringifies object properties', () => { + const obj = { + name: 'test', + metadata: {key: 'value', nested: {prop: 'data'}}, + }; + sanitizeProperty(obj); + expect(obj.metadata).to.be.a.String(); + expect(obj.metadata).to.match(/key.*value/); + }); + + it('stringifies array properties', () => { + const obj = { + name: 'test', + items: ['item1', 'item2', 'item3'], + }; + sanitizeProperty(obj); + expect(obj.items).to.be.a.String(); + expect(obj.items).to.match(/item1/); + expect(obj.items).to.match(/item2/); + }); + + it('adds tsType property from type', () => { + const obj = { + name: 'test', + type: 'string', + }; + sanitizeProperty(obj); + expect(obj).to.have.property('tsType', 'string'); + }); + + it('handles empty objects', () => { + const obj = {}; + sanitizeProperty(obj); + expect(obj).to.have.property('tsType', undefined); + }); + + it('handles objects with only null values', () => { + const obj = { + prop1: null, + prop2: null, + }; + sanitizeProperty(obj); + expect(obj).to.not.have.property('prop1'); + expect(obj).to.not.have.property('prop2'); + }); + + it('preserves string properties', () => { + const obj = { + name: 'test', + description: 'A description', + type: 'number', + }; + sanitizeProperty(obj); + expect(obj.name).to.equal('test'); + expect(obj.description).to.equal('A description'); + expect(obj.type).to.equal('number'); + }); + + it('preserves number properties', () => { + const obj = { + id: 123, + count: 456, + type: 'integer', + }; + sanitizeProperty(obj); + expect(obj.id).to.equal(123); + expect(obj.count).to.equal(456); + }); + + it('preserves boolean properties', () => { + const obj = { + required: true, + nullable: false, + type: 'boolean', + }; + sanitizeProperty(obj); + expect(obj.required).to.equal(true); + expect(obj.nullable).to.equal(false); + }); + + it('handles nested objects correctly', () => { + const obj = { + name: 'test', + config: { + option1: 'value1', + option2: null, + nested: { + deep: 'property', + }, + }, + type: 'object', + }; + sanitizeProperty(obj); + expect(obj.config).to.be.a.String(); + expect(obj.config).to.match(/option1/); + expect(obj.config).to.not.match(/option2/); + }); + }); + + describe('constants', () => { + it('exports DEFAULT_DATASOURCE_DIRECTORY', () => { + expect(DEFAULT_DATASOURCE_DIRECTORY).to.equal('./dist/datasources'); + }); + + it('exports MODEL_TEMPLATE_PATH', () => { + expect(MODEL_TEMPLATE_PATH).to.be.a.String(); + expect(MODEL_TEMPLATE_PATH).to.match(/model\.ts\.ejs$/); + }); + + it('MODEL_TEMPLATE_PATH points to existing template location', () => { + const expectedPath = path.resolve( + __dirname, + '../../generators/model/templates/model.ts.ejs', + ); + expect(MODEL_TEMPLATE_PATH).to.equal(expectedPath); + }); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/project-generator.unit.js b/packages/cli/test/unit/project-generator.unit.js new file mode 100644 index 000000000000..d5768bea525c --- /dev/null +++ b/packages/cli/test/unit/project-generator.unit.js @@ -0,0 +1,187 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const expect = require('@loopback/testlab').expect; +const ProjectGenerator = require('../../lib/project-generator'); + +describe('ProjectGenerator', () => { + let generator; + + beforeEach(() => { + generator = Object.create(ProjectGenerator.prototype); + generator.options = {}; + generator.projectInfo = {}; + generator.log = () => {}; + generator.exit = () => {}; + generator.shouldExit = () => false; + generator.user = { + git: { + name: () => 'Test User', + email: () => 'test@example.com', + }, + }; + generator.config = { + get: () => 'npm', + set: () => {}, + }; + generator.destinationPath = path => path || '/test/path'; + generator.templatePath = path => path || '/template/path'; + generator.fs = { + move: () => {}, + delete: () => {}, + }; + generator.copyTemplatedFiles = () => {}; + }); + + describe('constructor', () => { + it('initializes buildOptions', () => { + const gen = new ProjectGenerator([], {}); + expect(gen.buildOptions).to.be.an.Array(); + expect(gen.buildOptions.length).to.be.greaterThan(0); + }); + + it('includes expected build options', () => { + const gen = new ProjectGenerator([], {}); + const optionNames = gen.buildOptions.map(opt => opt.name); + expect(optionNames).to.containEql('eslint'); + expect(optionNames).to.containEql('prettier'); + expect(optionNames).to.containEql('mocha'); + expect(optionNames).to.containEql('loopbackBuild'); + expect(optionNames).to.containEql('editorconfig'); + expect(optionNames).to.containEql('vscode'); + }); + }); + + describe('setOptions', () => { + it('validates project name when provided', async () => { + generator.options.name = 'Invalid Name With Spaces'; + let exitCalled = false; + generator.exit = () => { + exitCalled = true; + }; + + await generator.setOptions(); + expect(exitCalled).to.equal(true); + }); + + it('sets projectInfo with valid name', async () => { + generator.options.name = 'valid-project-name'; + generator.options.description = 'Test description'; + + await generator.setOptions(); + expect(generator.projectInfo.name).to.equal('valid-project-name'); + expect(generator.projectInfo.description).to.equal('Test description'); + }); + + it('includes dependencies in projectInfo', async () => { + await generator.setOptions(); + expect(generator.projectInfo.dependencies).to.be.an.Object(); + }); + }); + + describe('scaffold', () => { + it('does not scaffold when shouldExit is true', () => { + generator.shouldExit = () => true; + const result = generator.scaffold(); + expect(result).to.equal(false); + }); + + it('sets destination root to project outdir', () => { + generator.projectInfo.outdir = 'my-project'; + let destinationSet = false; + generator.destinationRoot = dir => { + destinationSet = dir === 'my-project'; + }; + + generator.scaffold(); + expect(destinationSet).to.equal(true); + }); + + it('deletes eslint files when eslint is disabled', () => { + generator.projectInfo.eslint = false; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.eslintrc'))).to.equal(true); + expect(deletedFiles.some(f => f.includes('.eslintignore'))).to.equal( + true, + ); + }); + + it('deletes prettier files when prettier is disabled', () => { + generator.projectInfo.prettier = false; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.prettier'))).to.equal(true); + }); + + it('deletes mocha config when mocha is disabled', () => { + generator.projectInfo.mocha = false; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.mocharc.json'))).to.equal( + true, + ); + }); + + it('deletes editorconfig when editorconfig is disabled', () => { + generator.projectInfo.editorconfig = false; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.editorconfig'))).to.equal( + true, + ); + }); + + it('deletes vscode folder when vscode is disabled', () => { + generator.projectInfo.vscode = false; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.vscode'))).to.equal(true); + }); + + it('deletes .npmrc when using yarn', () => { + generator.options.packageManager = 'yarn'; + const deletedFiles = []; + generator.fs.delete = file => { + deletedFiles.push(file); + }; + + generator.scaffold(); + expect(deletedFiles.some(f => f.includes('.npmrc'))).to.equal(true); + }); + }); + + describe('buildOptions', () => { + it('each option has name and description', () => { + const gen = new ProjectGenerator([], {}); + gen.buildOptions.forEach(opt => { + expect(opt.name).to.be.a.String(); + expect(opt.description).to.be.a.String(); + }); + }); + }); +}); + +// Made with Bob diff --git a/packages/cli/test/unit/utils.unit.js b/packages/cli/test/unit/utils.unit.js index fffdbb138821..7250db399f47 100644 --- a/packages/cli/test/unit/utils.unit.js +++ b/packages/cli/test/unit/utils.unit.js @@ -527,4 +527,277 @@ describe('Utils', () => { ); }); }); + + describe('validateNotExisting', () => { + it('returns true if directory does not exist', () => { + expect(utils.validateNotExisting('/non/existent/path')).to.be.True(); + }); + + it('returns error message if directory exists', () => { + expect(utils.validateNotExisting(__dirname)).to.match( + /Directory .* already exists/, + ); + }); + }); + + describe('checkPropertyName', () => { + it('validates valid property names', () => { + expect(utils.checkPropertyName('validName')).to.be.True(); + expect(utils.checkPropertyName('name123')).to.be.True(); + expect(utils.checkPropertyName('_privateName')).to.be.True(); + }); + + it('rejects reserved keywords', () => { + expect(utils.checkPropertyName('constructor')).to.match( + /constructor is a reserved keyword/, + ); + }); + + it('rejects empty names', () => { + expect(utils.checkPropertyName('')).to.match(/Name is required/); + }); + + it('rejects names with special characters', () => { + expect(utils.checkPropertyName('name@test')).to.match( + /Name cannot contain special characters/, + ); + }); + }); + + describe('validateRequiredName', () => { + it('validates valid names', () => { + expect(utils.validateRequiredName('validName')).to.be.True(); + expect(utils.validateRequiredName('name-with-dash')).to.be.True(); + }); + + it('rejects empty names', () => { + expect(utils.validateRequiredName('')).to.match(/Name is required/); + expect(utils.validateRequiredName(null)).to.match(/Name is required/); + }); + + it('rejects names with special characters', () => { + expect(utils.validateRequiredName('name@test')).to.match( + /Name cannot contain special characters/, + ); + expect(utils.validateRequiredName('name/test')).to.match( + /Name cannot contain special characters/, + ); + expect(utils.validateRequiredName('name test')).to.match( + /Name cannot contain special characters/, + ); + }); + }); + + describe('getModelFileName', () => { + it('returns correct model file name', () => { + expect(utils.getModelFileName('MyModel')).to.equal('my-model.model.ts'); + expect(utils.getModelFileName('User')).to.equal('user.model.ts'); + expect(utils.getModelFileName('ProductReview')).to.equal( + 'product-review.model.ts', + ); + }); + }); + + describe('getRepositoryFileName', () => { + it('returns correct repository file name', () => { + expect(utils.getRepositoryFileName('MyRepository')).to.equal( + 'my-repository.repository.ts', + ); + expect(utils.getRepositoryFileName('UserRepository')).to.equal( + 'user-repository.repository.ts', + ); + }); + }); + + describe('getRestConfigFileName', () => { + it('returns correct rest config file name', () => { + expect(utils.getRestConfigFileName('MyModel')).to.equal( + 'my-model.rest-config.ts', + ); + expect(utils.getRestConfigFileName('Product')).to.equal( + 'product.rest-config.ts', + ); + }); + }); + + describe('getObserverFileName', () => { + it('returns correct observer file name', () => { + expect(utils.getObserverFileName('MyObserver')).to.equal( + 'my-observer.observer.ts', + ); + expect(utils.getObserverFileName('LogObserver')).to.equal( + 'log-observer.observer.ts', + ); + }); + }); + + describe('getInterceptorFileName', () => { + it('returns correct interceptor file name', () => { + expect(utils.getInterceptorFileName('MyInterceptor')).to.equal( + 'my-interceptor.interceptor.ts', + ); + expect(utils.getInterceptorFileName('AuthInterceptor')).to.equal( + 'auth-interceptor.interceptor.ts', + ); + }); + }); + + describe('validate npm package name', () => { + it('validates valid package names', () => { + expect(utils.validate('my-package')).to.be.True(); + expect(utils.validate('my-package-name')).to.be.True(); + expect(utils.validate('@scope/package')).to.be.True(); + }); + + it('rejects invalid package names', () => { + expect(utils.validate('My Package')).to.match(/Invalid npm package name/); + expect(utils.validate('package_name')).to.match( + /Invalid npm package name/, + ); + }); + }); + + describe('wrapLine', () => { + it('wraps long lines at specified length', () => { + const line = 'This is a very long line that should be wrapped'; + const wrapped = utils.wrapLine(line, 20); + const lines = wrapped.split('\n'); + expect(lines.length).to.be.greaterThan(1); + lines.forEach(l => { + expect(l.length).to.be.lessThanOrEqual(20); + }); + }); + + it('does not wrap short lines', () => { + const line = 'Short line'; + const wrapped = utils.wrapLine(line, 80); + expect(wrapped).to.equal(line); + }); + + it('handles empty lines', () => { + expect(utils.wrapLine('', 80)).to.equal(''); + }); + }); + + describe('wrapText', () => { + it('wraps multiple lines', () => { + const text = + 'Line one\nLine two that is very long and should be wrapped\nLine three'; + const wrapped = utils.wrapText(text, 20); + expect(wrapped).to.be.a.String(); + expect(wrapped.split('\n').length).to.be.greaterThan(3); + }); + + it('preserves line breaks', () => { + const text = 'Line one\n\nLine three'; + const wrapped = utils.wrapText(text, 80); + expect(wrapped.split('\n').length).to.equal(3); + }); + }); + + describe('isYarnAvailable', () => { + it('returns a boolean', () => { + const result = utils.isYarnAvailable(); + expect(result).to.be.a.Boolean(); + }); + + it('caches the result', () => { + const result1 = utils.isYarnAvailable(); + const result2 = utils.isYarnAvailable(); + expect(result1).to.equal(result2); + }); + }); + + describe('stringifyObject', () => { + it('stringifies objects with default options', () => { + const obj = {name: 'test', value: 123}; + const result = utils.stringifyObject(obj); + expect(result).to.be.a.String(); + expect(result).to.match(/name: 'test'/); + expect(result).to.match(/value: 123/); + }); + + it('uses single quotes by default', () => { + const obj = {name: 'test'}; + const result = utils.stringifyObject(obj); + expect(result).to.match(/'/); + }); + + it('accepts custom options', () => { + const obj = {name: 'test'}; + const result = utils.stringifyObject(obj, {singleQuotes: false}); + expect(result).to.match(/"/); + }); + }); + + describe('stringifyModelSettings', () => { + it('returns empty string for empty settings', () => { + expect(utils.stringifyModelSettings({})).to.equal(''); + expect(utils.stringifyModelSettings(null)).to.equal(''); + expect(utils.stringifyModelSettings(undefined)).to.equal(''); + }); + + it('stringifies model settings', () => { + const settings = {strict: true, idInjection: false}; + const result = utils.stringifyModelSettings(settings); + expect(result).to.be.a.String(); + expect(result).to.match(/settings:/); + expect(result).to.match(/strict: true/); + }); + }); + + describe('pluralize', () => { + it('pluralizes words correctly', () => { + expect(utils.pluralize('model')).to.equal('models'); + expect(utils.pluralize('repository')).to.equal('repositories'); + expect(utils.pluralize('person')).to.equal('people'); + }); + + it('handles already plural words', () => { + expect(utils.pluralize('models')).to.equal('models'); + }); + }); + + describe('camelCase', () => { + it('converts to camelCase', () => { + expect(utils.camelCase('my-name')).to.equal('myName'); + expect(utils.camelCase('my_name')).to.equal('myName'); + expect(utils.camelCase('MyName')).to.equal('myName'); + }); + }); + + describe('pascalCase', () => { + it('converts to PascalCase', () => { + expect(utils.pascalCase('my-name')).to.equal('MyName'); + expect(utils.pascalCase('my_name')).to.equal('MyName'); + expect(utils.pascalCase('myName')).to.equal('MyName'); + }); + }); + + describe('lowerCase', () => { + it('converts to lower case with spaces', () => { + expect(utils.lowerCase('MyName')).to.equal('my name'); + expect(utils.lowerCase('my-name')).to.equal('my name'); + }); + }); + + describe('urlSlug', () => { + it('converts to URL slug', () => { + expect(utils.urlSlug('My Name')).to.equal('my-name'); + expect(utils.urlSlug('Product Review')).to.equal('product-review'); + }); + }); + + describe('directory constants', () => { + it('exports correct directory names', () => { + expect(utils.repositoriesDir).to.equal('repositories'); + expect(utils.datasourcesDir).to.equal('datasources'); + expect(utils.servicesDir).to.equal('services'); + expect(utils.modelsDir).to.equal('models'); + expect(utils.observersDir).to.equal('observers'); + expect(utils.interceptorsDir).to.equal('interceptors'); + expect(utils.modelEndpointsDir).to.equal('model-endpoints'); + expect(utils.sourceRootDir).to.equal('src'); + }); + }); }); diff --git a/packages/cli/test/unit/version-helper.unit.js b/packages/cli/test/unit/version-helper.unit.js new file mode 100644 index 000000000000..cb124090a933 --- /dev/null +++ b/packages/cli/test/unit/version-helper.unit.js @@ -0,0 +1,100 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2026. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const {expect, sinon} = require('@loopback/testlab'); +const {printVersions, cliPkg} = require('../../lib/version-helper'); + +describe('version-helper unit tests', () => { + let logStub; + + beforeEach(() => { + logStub = sinon.stub(); + }); + + describe('printVersions', () => { + it('prints CLI version', () => { + printVersions(logStub); + sinon.assert.calledWith( + logStub, + '@loopback/cli version: %s', + cliPkg.version, + ); + }); + + it('prints @loopback/* dependencies', () => { + printVersions(logStub); + sinon.assert.calledWith(logStub, '\n@loopback/* dependencies:'); + }); + + it('uses console.log as default logger', () => { + const consoleStub = sinon.stub(console, 'log'); + printVersions(); + sinon.assert.called(consoleStub); + consoleStub.restore(); + }); + + it('prints template dependencies', () => { + printVersions(logStub); + const templateDeps = cliPkg.config.templateDependencies; + for (const dep in templateDeps) { + if (dep.startsWith('@loopback/') && dep !== '@loopback/cli') { + sinon.assert.calledWith( + logStub, + ' - %s: %s', + dep, + templateDeps[dep], + ); + } + } + }); + + it('does not print non-loopback dependencies', () => { + printVersions(logStub); + const calls = logStub.getCalls(); + const nonLoopbackCalls = calls.filter(call => { + const args = call.args; + return ( + args.length > 1 && + typeof args[1] === 'string' && + !args[1].startsWith('@loopback/') + ); + }); + expect(nonLoopbackCalls.length).to.equal(0); + }); + + it('does not print @loopback/cli in dependencies list', () => { + printVersions(logStub); + const calls = logStub.getCalls(); + const cliCalls = calls.filter(call => { + const args = call.args; + return args.length > 1 && args[1] === '@loopback/cli'; + }); + expect(cliCalls.length).to.equal(0); + }); + }); + + describe('cliPkg', () => { + it('exports package.json data', () => { + expect(cliPkg).to.be.an.Object(); + expect(cliPkg).to.have.property('name', '@loopback/cli'); + expect(cliPkg).to.have.property('version'); + }); + + it('has config with templateDependencies', () => { + expect(cliPkg.config).to.be.an.Object(); + expect(cliPkg.config.templateDependencies).to.be.an.Object(); + }); + + it('templateDependencies includes @loopback packages', () => { + const deps = cliPkg.config.templateDependencies; + const loopbackDeps = Object.keys(deps).filter(d => + d.startsWith('@loopback/'), + ); + expect(loopbackDeps.length).to.be.greaterThan(0); + }); + }); +}); + +// Made with Bob diff --git a/packages/repository-tests/src/__tests__/unit/helpers.unit.ts b/packages/repository-tests/src/__tests__/unit/helpers.unit.ts new file mode 100644 index 000000000000..0974fd04e30b --- /dev/null +++ b/packages/repository-tests/src/__tests__/unit/helpers.unit.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + deleteAllModelsInDefaultDataSource, + getCrudContext, + MixedIdType, + withCrudCtx, +} from '../../helpers.repository-tests'; + +describe('helpers.repository-tests', () => { + describe('getCrudContext', () => { + it('is exported as a function', () => { + expect(getCrudContext).to.be.a.Function(); + }); + }); + + describe('withCrudCtx', () => { + it('is exported as a function', () => { + expect(withCrudCtx).to.be.a.Function(); + }); + + it('returns a function', () => { + const wrappedFn = withCrudCtx(() => { + // no-op + }); + expect(wrappedFn).to.be.a.Function(); + }); + }); + + describe('deleteAllModelsInDefaultDataSource', () => { + it('is exported as a function', () => { + expect(deleteAllModelsInDefaultDataSource).to.be.a.Function(); + }); + }); + + describe('MixedIdType', () => { + it('accepts string values', () => { + const id: MixedIdType = 'abc123'; + expect(id).to.be.a.String(); + }); + + it('accepts number values', () => { + const id: MixedIdType = 123; + expect(id).to.be.a.Number(); + }); + }); +}); + +// Made with Bob diff --git a/packages/repository-tests/src/__tests__/unit/types.unit.ts b/packages/repository-tests/src/__tests__/unit/types.unit.ts new file mode 100644 index 000000000000..94acafed22ce --- /dev/null +++ b/packages/repository-tests/src/__tests__/unit/types.unit.ts @@ -0,0 +1,223 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + CrudFeatures, + CrudRepositoryCtor, + DataSourceOptions, + TransactionalRepositoryCtor, +} from '../../types.repository-tests'; + +describe('types.repository-tests', () => { + describe('CrudFeatures', () => { + it('defines idType as string or number', () => { + const features: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(features.idType).to.be.oneOf(['string', 'number']); + }); + + it('supports number idType', () => { + const features: CrudFeatures = { + idType: 'number', + freeFormProperties: false, + emptyValue: null, + supportsTransactions: true, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(features.idType).to.equal('number'); + }); + + it('defines freeFormProperties flag', () => { + const features: CrudFeatures = { + idType: 'string', + freeFormProperties: false, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(features.freeFormProperties).to.be.false(); + }); + + it('defines emptyValue as undefined or null', () => { + const featuresWithUndefined: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + const featuresWithNull: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: null, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(featuresWithUndefined.emptyValue).to.be.undefined(); + expect(featuresWithNull.emptyValue).to.be.null(); + }); + + it('defines supportsTransactions flag', () => { + const features: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: true, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(features.supportsTransactions).to.be.true(); + }); + + it('defines supportsInclusionResolvers flag', () => { + const features: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: false, + hasRevisionToken: false, + }; + + expect(features.supportsInclusionResolvers).to.be.false(); + }); + + it('defines hasRevisionToken flag', () => { + const features: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: true, + }; + + expect(features.hasRevisionToken).to.be.true(); + }); + }); + + describe('DataSourceOptions', () => { + it('accepts connector configuration', () => { + const options: DataSourceOptions = { + connector: 'memory', + }; + + expect(options.connector).to.equal('memory'); + }); + + it('accepts connection string', () => { + const options: DataSourceOptions = { + connector: 'mongodb', + url: 'mongodb://localhost:27017/testdb', + }; + + expect(options.url).to.equal('mongodb://localhost:27017/testdb'); + }); + + it('accepts additional properties', () => { + const options: DataSourceOptions = { + connector: 'postgresql', + host: 'localhost', + port: 5432, + database: 'testdb', + user: 'testuser', + password: 'testpass', + }; + + expect(options.host).to.equal('localhost'); + expect(options.port).to.equal(5432); + }); + }); + + describe('CrudRepositoryCtor', () => { + it('defines constructor signature for CRUD repositories', () => { + // The type should accept a constructor that creates repositories + const isValidType = (ctor: CrudRepositoryCtor) => { + expect(ctor).to.be.a.Function(); + }; + + // We can't easily test the actual constructor without a concrete implementation, + // but we can verify the type definition is valid + expect(isValidType).to.be.a.Function(); + }); + }); + + describe('TransactionalRepositoryCtor', () => { + it('defines constructor signature for transactional repositories', () => { + // The type should accept a constructor that creates transactional repositories + const isValidType = (ctor: TransactionalRepositoryCtor) => { + expect(ctor).to.be.a.Function(); + }; + + // We can't easily test the actual constructor without a concrete implementation, + // but we can verify the type definition is valid + expect(isValidType).to.be.a.Function(); + }); + }); + + describe('Feature combinations', () => { + it('supports SQL database features', () => { + const sqlFeatures: CrudFeatures = { + idType: 'number', + freeFormProperties: false, + emptyValue: null, + supportsTransactions: true, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(sqlFeatures.idType).to.equal('number'); + expect(sqlFeatures.freeFormProperties).to.be.false(); + expect(sqlFeatures.supportsTransactions).to.be.true(); + }); + + it('supports NoSQL database features', () => { + const nosqlFeatures: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: false, + }; + + expect(nosqlFeatures.idType).to.equal('string'); + expect(nosqlFeatures.freeFormProperties).to.be.true(); + expect(nosqlFeatures.supportsTransactions).to.be.false(); + }); + + it('supports Cloudant-specific features', () => { + const cloudantFeatures: CrudFeatures = { + idType: 'string', + freeFormProperties: true, + emptyValue: undefined, + supportsTransactions: false, + supportsInclusionResolvers: true, + hasRevisionToken: true, + }; + + expect(cloudantFeatures.hasRevisionToken).to.be.true(); + }); + }); +}); + +// Made with Bob diff --git a/packages/repository/src/__tests__/unit/common-types.unit.ts b/packages/repository/src/__tests__/unit/common-types.unit.ts index e71a0e72cccd..be4eff0466f9 100644 --- a/packages/repository/src/__tests__/unit/common-types.unit.ts +++ b/packages/repository/src/__tests__/unit/common-types.unit.ts @@ -3,36 +3,229 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {AnyObject, DeepPartial} from '../../common-types'; +import {expect} from '@loopback/testlab'; +import { + AnyObject, + Callback, + Class, + Command, + Constructor, + ConstructorFunction, + Count, + CountSchema, + DataObject, + DeepPartial, + NamedParameters, + Options, + PositionalParameters, + PrototypeOf, +} from '../..'; -describe('common types', () => { - describe('DeepPartial', () => { - it('works for strict models', () => { - class Product { - name: string; +describe('common-types', () => { + describe('CountSchema', () => { + it('has correct structure', () => { + expect(CountSchema.type).to.equal('object'); + expect(CountSchema.title).to.equal('loopback.Count'); + expect(CountSchema['x-typescript-type']).to.equal( + '@loopback/repository#Count', + ); + expect(CountSchema.properties).to.have.property('count'); + expect(CountSchema.properties.count.type).to.equal('number'); + }); + + it('is compatible with Count interface', () => { + const count: Count = {count: 5}; + expect(count.count).to.equal(5); + }); + }); + + describe('Count interface', () => { + it('accepts valid count objects', () => { + const count: Count = {count: 0}; + expect(count.count).to.equal(0); + }); + + it('accepts positive numbers', () => { + const count: Count = {count: 100}; + expect(count.count).to.equal(100); + }); + }); + + describe('AnyObject type', () => { + it('accepts objects with any properties', () => { + const obj: AnyObject = { + name: 'test', + value: 123, + nested: {prop: true}, + }; + expect(obj.name).to.equal('test'); + expect(obj.value).to.equal(123); + expect(obj.nested.prop).to.equal(true); + }); + }); + + describe('Command type', () => { + it('accepts string commands', () => { + const cmd: Command = 'SELECT * FROM users'; + expect(cmd).to.be.a.String(); + }); + + it('accepts object commands', () => { + const cmd: Command = {operation: 'find', collection: 'users'}; + expect(cmd).to.be.an.Object(); + }); + }); + + describe('NamedParameters type', () => { + it('accepts named parameter objects', () => { + const params: NamedParameters = { + id: 1, + name: 'John', + active: true, + }; + expect(params.id).to.equal(1); + expect(params.name).to.equal('John'); + expect(params.active).to.equal(true); + }); + }); + + describe('PositionalParameters type', () => { + it('accepts arrays of parameters', () => { + const params: PositionalParameters = [1, 'John', true]; + expect(params).to.have.length(3); + expect(params[0]).to.equal(1); + expect(params[1]).to.equal('John'); + expect(params[2]).to.equal(true); + }); + }); + + describe('Callback type', () => { + it('accepts callback with error', done => { + const callback: Callback = (err, result) => { + expect(err).to.be.instanceOf(Error); + expect(result).to.be.undefined(); + done(); + }; + callback(new Error('Test error')); + }); + + it('accepts callback with result', done => { + const callback: Callback = (err, result) => { + expect(err).to.be.null(); + expect(result).to.equal('success'); + done(); + }; + callback(null, 'success'); + }); + }); + + describe('Class type', () => { + it('represents a class constructor', () => { + class TestClass { + constructor(public value: string) {} } - check({name: 'a name'}); - // the test passes when the compiler is happy + const ClassRef: Class = TestClass; + const instance = new ClassRef('test'); + expect(instance).to.be.instanceOf(TestClass); + expect(instance.value).to.equal('test'); }); + }); - it('works for free-form models', () => { - class FreeForm { - id: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; + describe('ConstructorFunction type', () => { + it('represents a constructor function', () => { + function testConstructor(this: {value: string}, value: string) { + this.value = value; } - check({id: 1, name: 'a name'}); - // the test passes when the compiler is happy + const ctor: ConstructorFunction<{value: string}> = + testConstructor as unknown as ConstructorFunction<{value: string}>; + expect(ctor).to.be.a.Function(); + }); + }); + + describe('Constructor type', () => { + it('accepts class constructors', () => { + class TestClass { + constructor(public value: string) {} + } + const ctor: Constructor = TestClass; + const instance = new ctor('test'); + expect(instance.value).to.equal('test'); + }); + }); + + describe('DeepPartial type', () => { + interface NestedObject { + level1: { + level2: { + value: string; + }; + }; + } + + it('allows partial nested objects', () => { + const partial: DeepPartial = { + level1: { + level2: {}, + }, + }; + expect(partial.level1).to.be.an.Object(); + }); + + it('allows completely empty objects', () => { + const partial: DeepPartial = {}; + expect(partial).to.be.an.Object(); }); + }); - it('works for AnyObject', () => { - check({id: 'some id', name: 'a name'}); - // the test passes when the compiler is happy + describe('DataObject type', () => { + interface TestModel { + id: number; + name: string; + } + + it('accepts full objects', () => { + const data: DataObject = { + id: 1, + name: 'Test', + }; + expect(data.id).to.equal(1); + expect(data.name).to.equal('Test'); + }); + + it('accepts partial objects', () => { + const data: DataObject = { + name: 'Test', + }; + expect(data.name).to.equal('Test'); + }); + }); + + describe('Options type', () => { + it('accepts any object as options', () => { + const options: Options = { + timeout: 5000, + retries: 3, + verbose: true, + }; + expect(options.timeout).to.equal(5000); + expect(options.retries).to.equal(3); + expect(options.verbose).to.equal(true); + }); + }); + + describe('PrototypeOf type', () => { + it('infers prototype from constructor', () => { + class TestEntity { + id: number; + constructor(id: number) { + this.id = id; + } + } + type EntityPrototype = PrototypeOf; + const entity: EntityPrototype = new TestEntity(1); + expect(entity.id).to.equal(1); }); }); }); -function check(data?: DeepPartial) { - // dummy function to run compile-time checks - return data; -} +// Made with Bob diff --git a/packages/repository/src/__tests__/unit/datasource.unit.ts b/packages/repository/src/__tests__/unit/datasource.unit.ts new file mode 100644 index 000000000000..9c9f05fb64e2 --- /dev/null +++ b/packages/repository/src/__tests__/unit/datasource.unit.ts @@ -0,0 +1,138 @@ +// Copyright IBM Corp. and LoopBack contributors 2017,2026. 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 {DataSource, SchemaMigrationOptions} from '../../datasource'; + +describe('DataSource', () => { + describe('interface', () => { + it('defines required name property', () => { + const ds: DataSource = { + name: 'testDB', + settings: {}, + }; + expect(ds.name).to.equal('testDB'); + }); + + it('defines optional connector property', () => { + const ds: DataSource = { + name: 'testDB', + settings: {}, + connector: { + name: 'memory', + connect: async () => {}, + disconnect: async () => {}, + ping: async () => {}, + }, + }; + expect(ds.connector).to.be.ok(); + expect(ds.connector!.name).to.equal('memory'); + }); + + it('defines settings property', () => { + const settings = {host: 'localhost', port: 3306}; + const ds: DataSource = { + name: 'testDB', + settings, + }; + expect(ds.settings).to.deepEqual(settings); + }); + + it('allows arbitrary properties', () => { + const ds: DataSource = { + name: 'testDB', + settings: {}, + customProp: 'customValue', + anotherProp: 123, + }; + expect(ds.customProp).to.equal('customValue'); + expect(ds.anotherProp).to.equal(123); + }); + + it('supports empty settings', () => { + const ds: DataSource = { + name: 'testDB', + settings: {}, + }; + expect(ds.settings).to.deepEqual({}); + }); + + it('supports complex settings', () => { + const ds: DataSource = { + name: 'testDB', + settings: { + host: 'localhost', + port: 3306, + database: 'mydb', + user: 'admin', + password: 'secret', + options: { + ssl: true, + timeout: 5000, + }, + }, + }; + expect(ds.settings.host).to.equal('localhost'); + expect(ds.settings.options.ssl).to.be.true(); + }); + }); + + describe('SchemaMigrationOptions', () => { + it('supports existingSchema drop option', () => { + const options: SchemaMigrationOptions = { + existingSchema: 'drop', + }; + expect(options.existingSchema).to.equal('drop'); + }); + + it('supports existingSchema alter option', () => { + const options: SchemaMigrationOptions = { + existingSchema: 'alter', + }; + expect(options.existingSchema).to.equal('alter'); + }); + + it('supports models array option', () => { + const options: SchemaMigrationOptions = { + models: ['User', 'Product', 'Order'], + }; + expect(options.models).to.deepEqual(['User', 'Product', 'Order']); + }); + + it('supports empty models array', () => { + const options: SchemaMigrationOptions = { + models: [], + }; + expect(options.models).to.deepEqual([]); + }); + + it('supports combined options', () => { + const options: SchemaMigrationOptions = { + existingSchema: 'alter', + models: ['User', 'Product'], + }; + expect(options.existingSchema).to.equal('alter'); + expect(options.models).to.have.length(2); + }); + + it('allows no options', () => { + const options: SchemaMigrationOptions = {}; + expect(options.existingSchema).to.be.undefined(); + expect(options.models).to.be.undefined(); + }); + + it('extends Options interface', () => { + const options: SchemaMigrationOptions = { + existingSchema: 'drop', + models: ['User'], + // Can include other options + timeout: 5000, + }; + expect(options.timeout).to.equal(5000); + }); + }); +}); + +// Made with Bob diff --git a/packages/repository/src/__tests__/unit/keys.unit.ts b/packages/repository/src/__tests__/unit/keys.unit.ts new file mode 100644 index 000000000000..674fd0ddb6b3 --- /dev/null +++ b/packages/repository/src/__tests__/unit/keys.unit.ts @@ -0,0 +1,152 @@ +// Copyright IBM Corp. 2026. 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 {RepositoryBindings, RepositoryTags} from '../../keys'; + +describe('Repository Keys and Tags', () => { + describe('RepositoryTags', () => { + it('defines MODEL tag', () => { + expect(RepositoryTags.MODEL).to.equal('model'); + }); + + it('defines REPOSITORY tag', () => { + expect(RepositoryTags.REPOSITORY).to.equal('repository'); + }); + + it('defines DATASOURCE tag', () => { + expect(RepositoryTags.DATASOURCE).to.equal('datasource'); + }); + + it('has exactly 3 tags', () => { + const tags = Object.keys(RepositoryTags); + expect(tags).to.have.length(3); + }); + + it('all tags are strings', () => { + expect(RepositoryTags.MODEL).to.be.a.String(); + expect(RepositoryTags.REPOSITORY).to.be.a.String(); + expect(RepositoryTags.DATASOURCE).to.be.a.String(); + }); + + it('tags are unique', () => { + const tags = [ + RepositoryTags.MODEL, + RepositoryTags.REPOSITORY, + RepositoryTags.DATASOURCE, + ]; + const uniqueTags = new Set(tags); + expect(uniqueTags.size).to.equal(tags.length); + }); + + it('tags are lowercase', () => { + expect(RepositoryTags.MODEL).to.equal(RepositoryTags.MODEL.toLowerCase()); + expect(RepositoryTags.REPOSITORY).to.equal( + RepositoryTags.REPOSITORY.toLowerCase(), + ); + expect(RepositoryTags.DATASOURCE).to.equal( + RepositoryTags.DATASOURCE.toLowerCase(), + ); + }); + }); + + describe('RepositoryBindings', () => { + it('defines MODELS namespace', () => { + expect(RepositoryBindings.MODELS).to.equal('models'); + }); + + it('defines REPOSITORIES namespace', () => { + expect(RepositoryBindings.REPOSITORIES).to.equal('repositories'); + }); + + it('defines DATASOURCES namespace', () => { + expect(RepositoryBindings.DATASOURCES).to.equal('datasources'); + }); + + it('has exactly 3 namespaces', () => { + const namespaces = Object.keys(RepositoryBindings); + expect(namespaces).to.have.length(3); + }); + + it('all namespaces are strings', () => { + expect(RepositoryBindings.MODELS).to.be.a.String(); + expect(RepositoryBindings.REPOSITORIES).to.be.a.String(); + expect(RepositoryBindings.DATASOURCES).to.be.a.String(); + }); + + it('namespaces are unique', () => { + const namespaces = [ + RepositoryBindings.MODELS, + RepositoryBindings.REPOSITORIES, + RepositoryBindings.DATASOURCES, + ]; + const uniqueNamespaces = new Set(namespaces); + expect(uniqueNamespaces.size).to.equal(namespaces.length); + }); + + it('namespaces are lowercase', () => { + expect(RepositoryBindings.MODELS).to.equal( + RepositoryBindings.MODELS.toLowerCase(), + ); + expect(RepositoryBindings.REPOSITORIES).to.equal( + RepositoryBindings.REPOSITORIES.toLowerCase(), + ); + expect(RepositoryBindings.DATASOURCES).to.equal( + RepositoryBindings.DATASOURCES.toLowerCase(), + ); + }); + + it('namespaces are plural forms', () => { + expect(RepositoryBindings.MODELS).to.match(/s$/); + expect(RepositoryBindings.REPOSITORIES).to.match(/s$/); + expect(RepositoryBindings.DATASOURCES).to.match(/s$/); + }); + }); + + describe('Tags and Bindings relationship', () => { + it('MODEL tag matches MODELS namespace pattern', () => { + expect(RepositoryBindings.MODELS).to.match( + new RegExp(RepositoryTags.MODEL), + ); + }); + + it('DATASOURCE tag matches DATASOURCES namespace pattern', () => { + expect(RepositoryBindings.DATASOURCES).to.match( + new RegExp(RepositoryTags.DATASOURCE), + ); + }); + + it('tags and namespaces have same count', () => { + const tagCount = Object.keys(RepositoryTags).length; + const namespaceCount = Object.keys(RepositoryBindings).length; + expect(tagCount).to.equal(namespaceCount); + }); + }); + + describe('Usage patterns', () => { + it('can be used to construct binding keys', () => { + const modelKey = `${RepositoryBindings.MODELS}.User`; + expect(modelKey).to.equal('models.User'); + }); + + it('can be used to construct repository binding keys', () => { + const repoKey = `${RepositoryBindings.REPOSITORIES}.UserRepository`; + expect(repoKey).to.equal('repositories.UserRepository'); + }); + + it('can be used to construct datasource binding keys', () => { + const dsKey = `${RepositoryBindings.DATASOURCES}.db`; + expect(dsKey).to.equal('datasources.db'); + }); + + it('tags can be used for filtering bindings', () => { + const tag = RepositoryTags.MODEL; + expect(tag).to.be.a.String(); + // In real usage: context.findByTag(tag) + }); + }); +}); + +// Made with Bob diff --git a/packages/repository/src/__tests__/unit/repositories/constraint-utils.unit.ts b/packages/repository/src/__tests__/unit/repositories/constraint-utils.unit.ts index 7a6772f2fe55..c8b9e799e522 100644 --- a/packages/repository/src/__tests__/unit/repositories/constraint-utils.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/constraint-utils.unit.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved. +// Copyright IBM Corp. and LoopBack contributors 2019,2026. All Rights Reserved. // Node module: @loopback/repository // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT @@ -159,6 +159,87 @@ describe('constraint utility functions', () => { }); }); + describe('additional edge cases', () => { + it('constrainFilter handles undefined input filter', () => { + const constraint = {id: '5'}; + const result = constrainFilter(undefined, constraint); + expect(result).to.containEql({where: constraint}); + }); + + it('constrainWhere handles undefined input where', () => { + const constraint = {id: '5'}; + const result = constrainWhere(undefined, constraint); + expect(result).to.deepEqual(constraint); + }); + + it('constrainWhere handles empty constraint', () => { + const inputWhere = {x: 'x'}; + const constraint = {}; + const result = constrainWhere(inputWhere, constraint); + expect(result).to.deepEqual(inputWhere); + }); + + it('constrainWhereOr handles undefined input where', () => { + const constraint = [{id: '5'}, {y: 'y'}]; + const result = constrainWhereOr(undefined, constraint); + expect(result).to.deepEqual({or: constraint}); + }); + + it('constrainWhereOr handles empty constraint array', () => { + const inputWhere = {x: 'x'}; + const constraint: Where<{x: string}>[] = []; + const result = constrainWhereOr(inputWhere, constraint); + expect(result).to.deepEqual({...inputWhere, or: constraint}); + }); + + it('constrainDataObject handles empty constraint', () => { + const input = new Order({id: 1, description: 'order 1'}); + const constraint: Partial = {}; + const result = constrainDataObject(input, constraint); + expect(result).to.deepEqual(input); + }); + + it('constrainDataObjects handles empty array', () => { + const input: Order[] = []; + const constraint: Partial = {id: 2}; + const result = constrainDataObjects(input, constraint); + expect(result).to.deepEqual([]); + }); + + it('constrainDataObject preserves nested objects', () => { + class ComplexOrder extends Entity { + id: number; + metadata: {tags: string[]; priority: number}; + constructor(data?: Partial) { + super(data); + } + } + const input = new ComplexOrder({ + id: 1, + metadata: {tags: ['urgent'], priority: 1}, + }); + const constraint: Partial = {id: 1}; + const result = constrainDataObject(input, constraint); + expect(result.metadata).to.deepEqual({tags: ['urgent'], priority: 1}); + }); + + it('constrainFilter with multiple constraints', () => { + const inputFilter = filterBuilderHelper({where: {x: 'x'}}); + const constraint = {id: '5', status: 'active'}; + const result = constrainFilter(inputFilter, constraint); + expect(result.where).to.containEql(constraint); + }); + + it('constrainWhere with nested where conditions', () => { + const inputWhere: Where<{x: string; y: string; id: string}> = { + and: [{x: 'x'}, {y: 'y'}], + }; + const constraint = {id: '5'}; + const result = constrainWhere(inputWhere, constraint); + expect(result).to.have.property('id', '5'); + }); + }); + /*---------------HELPERS----------------*/ function filterBuilderHelper(filter: Filter) { diff --git a/packages/repository/src/__tests__/unit/transaction.unit.ts b/packages/repository/src/__tests__/unit/transaction.unit.ts new file mode 100644 index 000000000000..91de6d984582 --- /dev/null +++ b/packages/repository/src/__tests__/unit/transaction.unit.ts @@ -0,0 +1,134 @@ +// Copyright IBM Corp. 2019,2026. 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 {IsolationLevel, Transaction} from '../../transaction'; + +describe('Transaction', () => { + describe('interface', () => { + it('defines commit method', async () => { + const tx: Transaction = { + id: 'tx-1', + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + await tx.commit(); + // Should not throw + }); + + it('defines rollback method', async () => { + const tx: Transaction = { + id: 'tx-1', + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + await tx.rollback(); + // Should not throw + }); + + it('defines isActive method', () => { + const tx: Transaction = { + id: 'tx-1', + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + expect(tx.isActive()).to.be.true(); + }); + + it('defines id property', () => { + const tx: Transaction = { + id: 'tx-123', + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + expect(tx.id).to.equal('tx-123'); + }); + + it('supports inactive transaction', () => { + const tx: Transaction = { + id: 'tx-1', + commit: async () => {}, + rollback: async () => {}, + isActive: () => false, + }; + expect(tx.isActive()).to.be.false(); + }); + + it('supports transaction with numeric id', () => { + const tx: Transaction = { + id: '12345', + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + expect(tx.id).to.equal('12345'); + }); + + it('supports transaction with UUID id', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + const tx: Transaction = { + id: uuid, + commit: async () => {}, + rollback: async () => {}, + isActive: () => true, + }; + expect(tx.id).to.equal(uuid); + }); + }); + + describe('IsolationLevel', () => { + it('defines READ_COMMITTED level', () => { + expect(IsolationLevel.READ_COMMITTED).to.equal('READ COMMITTED'); + }); + + it('defines READ_UNCOMMITTED level', () => { + expect(IsolationLevel.READ_UNCOMMITTED).to.equal('READ UNCOMMITTED'); + }); + + it('defines SERIALIZABLE level', () => { + expect(IsolationLevel.SERIALIZABLE).to.equal('SERIALIZABLE'); + }); + + it('defines REPEATABLE_READ level', () => { + expect(IsolationLevel.REPEATABLE_READ).to.equal('REPEATABLE READ'); + }); + + it('has exactly 4 isolation levels', () => { + const levels = Object.keys(IsolationLevel); + expect(levels).to.have.length(4); + }); + + it('all levels are strings', () => { + for (const level in IsolationLevel) { + expect( + IsolationLevel[level as keyof typeof IsolationLevel], + ).to.be.a.String(); + } + }); + + it('can be used in type annotations', () => { + const level: IsolationLevel = IsolationLevel.READ_COMMITTED; + expect(level).to.equal('READ COMMITTED'); + }); + + it('supports comparison', () => { + const level1 = IsolationLevel.READ_COMMITTED; + const level2 = IsolationLevel.READ_COMMITTED; + expect(level1).to.equal(level2); + }); + + it('different levels are not equal', () => { + const level1 = IsolationLevel.READ_COMMITTED; + const level2 = IsolationLevel.SERIALIZABLE; + expect(level1).to.not.equal(level2); + }); + }); +}); + +// Made with Bob diff --git a/packages/repository/src/__tests__/unit/type-resolver.unit.ts b/packages/repository/src/__tests__/unit/type-resolver.unit.ts index 1fa0b0a6ed89..8e7b39563555 100644 --- a/packages/repository/src/__tests__/unit/type-resolver.unit.ts +++ b/packages/repository/src/__tests__/unit/type-resolver.unit.ts @@ -4,130 +4,137 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import { - isBuiltinType, - isTypeResolver, - resolveType, - TypeResolver, -} from '../../type-resolver'; - -class SomeModel { - constructor(public name: string) {} -} - -const A_DATE_STRING = '2018-01-01T00:00:00.000Z'; - -describe('isTypeResolver', () => { - it('returns false when the arg is a class', () => { - expect(isTypeResolver(SomeModel)).to.be.false(); - }); - - it('returns false when the arg is not a function', () => { - expect(isTypeResolver(123)).to.be.false(); - }); - - it('returns false when the arg is String type', () => { - expect(isTypeResolver(String)).to.be.false(); - }); - - it('returns false when the arg is Number type', () => { - expect(isTypeResolver(Number)).to.be.false(); - }); - - it('returns false when the arg is Boolean type', () => { - expect(isTypeResolver(Boolean)).to.be.false(); - }); - - it('returns false when the arg is Object type', () => { - expect(isTypeResolver(Object)).to.be.false(); - }); - - it('returns false when the arg is Array type', () => { - expect(isTypeResolver(Object)).to.be.false(); - }); - - it('returns false when the arg is Date type', () => { - expect(isTypeResolver(Date)).to.be.false(); - }); - - it('returns false when the arg is RegExp type', () => { - expect(isTypeResolver(RegExp)).to.be.false(); - }); - - it('returns false when the arg is Buffer type', () => { - expect(isTypeResolver(Buffer)).to.be.false(); - }); - - it('returns true when the arg is any other function', () => { - expect(isTypeResolver(() => SomeModel)).to.be.true(); +import {isTypeResolver, resolveType, TypeResolver} from '../../type-resolver'; + +describe('type-resolver', () => { + class TestModel { + id: number; + name: string; + } + + describe('isTypeResolver', () => { + it('returns true for function type resolvers', () => { + const resolver: TypeResolver = () => TestModel; + expect(isTypeResolver(resolver)).to.be.true(); + }); + + it('returns false for class constructors', () => { + expect(isTypeResolver(TestModel)).to.be.false(); + }); + + it('returns false for non-function values', () => { + expect(isTypeResolver('string')).to.be.false(); + expect(isTypeResolver(123)).to.be.false(); + expect(isTypeResolver(null)).to.be.false(); + expect(isTypeResolver(undefined)).to.be.false(); + expect(isTypeResolver({})).to.be.false(); + }); + + it('returns true for arrow functions (potential type resolvers)', () => { + const potentialResolver = () => 'not a type'; + // isTypeResolver returns true for any function that's not a class or built-in + expect(isTypeResolver(potentialResolver)).to.be.true(); + }); + }); + + describe('resolveType', () => { + it('resolves function type resolvers', () => { + const resolver: TypeResolver = () => TestModel; + const resolved = resolveType(resolver); + expect(resolved).to.equal(TestModel); + }); + + it('returns class constructors as-is', () => { + const resolved = resolveType(TestModel); + expect(resolved).to.equal(TestModel); + }); + + it('handles type resolver functions', () => { + const resolver: TypeResolver = () => TestModel; + const resolved = resolveType(resolver); + expect(resolved).to.equal(TestModel); + expect(typeof resolved).to.equal('function'); + }); + + it('resolves to the actual type from resolver function', () => { + class AnotherModel { + value: string; + } + const resolver: TypeResolver = () => AnotherModel; + const resolved = resolveType(resolver); + expect(resolved).to.equal(AnotherModel); + + const instance = new resolved(); + expect(instance).to.be.instanceOf(AnotherModel); + }); + }); + + describe('TypeResolver type', () => { + it('accepts functions returning types', () => { + const resolver: TypeResolver = () => TestModel; + expect(resolver).to.be.a.Function(); + expect(resolver()).to.equal(TestModel); + }); + + it('works with generic types', () => { + interface GenericModel { + data: T; + } + + class StringModel implements GenericModel { + data: string; + } + + const resolver: TypeResolver> = () => StringModel; + const resolved = resolveType(resolver); + expect(resolved).to.equal(StringModel); + }); + }); + + describe('edge cases', () => { + it('handles undefined gracefully', () => { + const resolved = resolveType( + undefined as unknown as TypeResolver, + ); + expect(resolved).to.be.undefined(); + }); + + it('handles null gracefully', () => { + const resolved = resolveType(null as unknown as TypeResolver); + expect(resolved).to.be.null(); + }); + + it('resolves complex type hierarchies', () => { + class BaseModel { + id: number; + } + + class DerivedModel extends BaseModel { + name: string; + } + + const resolver: TypeResolver = () => DerivedModel; + const resolved = resolveType(resolver); + expect(resolved).to.equal(DerivedModel); + + const instance = new resolved(); + expect(instance).to.be.instanceOf(DerivedModel); + expect(instance).to.be.instanceOf(BaseModel); + }); + }); + + describe('type resolution with decorators', () => { + it('resolves types used in model decorators', () => { + class RelatedModel { + id: number; + } + + const typeResolver: TypeResolver = () => RelatedModel; + const resolved = resolveType(typeResolver); + + expect(resolved).to.equal(RelatedModel); + }); }); }); -describe('resolveType', () => { - it('resolves the arg when the value is a resolver', () => { - const resolver: TypeResolver = () => SomeModel; - const ctor = resolveType(resolver); - expect(ctor).to.eql(SomeModel); - - const inst = new ctor('a-name'); - expect(inst).to.have.property('name', 'a-name'); - }); - - it('returns the arg when the value is a type', () => { - const ctor = resolveType(SomeModel); - expect(ctor).to.eql(SomeModel); - - const inst = new ctor('a-name'); - expect(inst).to.have.property('name', 'a-name'); - }); - - it('supports Date type', () => { - const ctor = resolveType(Date); - expect(ctor).to.eql(Date); - - const inst = new ctor(A_DATE_STRING); - expect(inst.toISOString()).to.equal(A_DATE_STRING); - }); - - it('supports Date resolver', () => { - const ctor = resolveType(() => Date); - expect(ctor).to.eql(Date); - - const inst = new ctor(A_DATE_STRING); - expect(inst.toISOString()).to.equal(A_DATE_STRING); - }); -}); - -describe('isBuiltinType', () => { - it('returns true for Number', () => { - expect(isBuiltinType(Number)).to.eql(true); - }); - - it('returns true for String', () => { - expect(isBuiltinType(String)).to.eql(true); - }); - - it('returns true for Boolean', () => { - expect(isBuiltinType(Boolean)).to.eql(true); - }); - - it('returns true for Object', () => { - expect(isBuiltinType(Object)).to.eql(true); - }); - - it('returns true for Function', () => { - expect(isBuiltinType(Function)).to.eql(true); - }); - - it('returns true for Date', () => { - expect(isBuiltinType(Date)).to.eql(true); - }); - - it('returns true for Array', () => { - expect(isBuiltinType(Array)).to.eql(true); - }); - - it('returns false for any other function', () => { - expect(isBuiltinType(SomeModel)).to.eql(false); - }); -}); +// Made with Bob diff --git a/packages/rest/src/__tests__/unit/keys.unit.ts b/packages/rest/src/__tests__/unit/keys.unit.ts new file mode 100644 index 000000000000..4a22663f8c3f --- /dev/null +++ b/packages/rest/src/__tests__/unit/keys.unit.ts @@ -0,0 +1,255 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. 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 {expect} from '@loopback/testlab'; +import {RestBindings, RestTags} from '../../keys'; + +describe('RestBindings', () => { + describe('binding keys', () => { + it('has HOST binding key', () => { + expect(RestBindings.HOST.key).to.equal('rest.host'); + }); + + it('has PORT binding key', () => { + expect(RestBindings.PORT.key).to.equal('rest.port'); + }); + + it('has PATH binding key', () => { + expect(RestBindings.PATH.key).to.equal('rest.path'); + }); + + it('has URL binding key', () => { + expect(RestBindings.URL.key).to.equal('rest.url'); + }); + + it('has PROTOCOL binding key', () => { + expect(RestBindings.PROTOCOL.key).to.equal('rest.protocol'); + }); + + it('has HTTPS_OPTIONS binding key', () => { + expect(RestBindings.HTTPS_OPTIONS.key).to.equal('rest.httpsOptions'); + }); + + it('has SERVER binding key', () => { + expect(RestBindings.SERVER.key).to.equal('servers.RestServer'); + }); + + it('has BASE_PATH binding key', () => { + expect(RestBindings.BASE_PATH.key).to.equal('rest.basePath'); + }); + + it('has HANDLER binding key', () => { + expect(RestBindings.HANDLER.key).to.equal('rest.handler'); + }); + + it('has ROUTER binding key', () => { + expect(RestBindings.ROUTER.key).to.equal('rest.router'); + }); + + it('has ROUTER_OPTIONS binding key', () => { + expect(RestBindings.ROUTER_OPTIONS.key).to.equal('rest.router.options'); + }); + + it('has ERROR_WRITER_OPTIONS binding key', () => { + expect(RestBindings.ERROR_WRITER_OPTIONS.key).to.equal( + 'rest.errorWriterOptions', + ); + }); + + it('has REQUEST_BODY_PARSER_OPTIONS binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_OPTIONS.key).to.equal( + 'rest.requestBodyParserOptions', + ); + }); + + it('has REQUEST_BODY_PARSER binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER.key).to.equal( + 'rest.requestBodyParser', + ); + }); + + it('has REQUEST_BODY_PARSER_JSON binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_JSON.key).to.equal( + 'rest.requestBodyParser.JsonBodyParser', + ); + }); + + it('has REQUEST_BODY_PARSER_URLENCODED binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_URLENCODED.key).to.equal( + 'rest.requestBodyParser.UrlEncodedBodyParser', + ); + }); + + it('has REQUEST_BODY_PARSER_TEXT binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_TEXT.key).to.equal( + 'rest.requestBodyParser.TextBodyParser', + ); + }); + + it('has REQUEST_BODY_PARSER_RAW binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_RAW.key).to.equal( + 'rest.requestBodyParser.RawBodyParser', + ); + }); + + it('has REQUEST_BODY_PARSER_STREAM binding key', () => { + expect(RestBindings.REQUEST_BODY_PARSER_STREAM.key).to.equal( + 'rest.requestBodyParser.StreamBodyParser', + ); + }); + + it('has AJV_FACTORY binding key', () => { + expect(RestBindings.AJV_FACTORY.key).to.equal( + 'rest.requestBodyParser.rest.ajvFactory', + ); + }); + + it('has API_SPEC binding key', () => { + expect(RestBindings.API_SPEC.key).to.equal('rest.apiSpec'); + }); + + it('has OPERATION_SPEC_CURRENT binding key', () => { + expect(RestBindings.OPERATION_SPEC_CURRENT.key).to.equal( + 'rest.operationSpec.current', + ); + }); + + it('has SEQUENCE binding key', () => { + expect(RestBindings.SEQUENCE.key).to.equal('rest.sequence'); + }); + + it('has INVOKE_MIDDLEWARE_SERVICE binding key', () => { + expect(RestBindings.INVOKE_MIDDLEWARE_SERVICE.key).to.equal( + 'rest.invokeMiddleware', + ); + }); + }); + + describe('SequenceActions binding keys', () => { + it('has INVOKE_MIDDLEWARE binding key', () => { + expect(RestBindings.SequenceActions.INVOKE_MIDDLEWARE.key).to.equal( + 'rest.sequence.actions.invokeMiddleware', + ); + }); + + it('has FIND_ROUTE binding key', () => { + expect(RestBindings.SequenceActions.FIND_ROUTE.key).to.equal( + 'rest.sequence.actions.findRoute', + ); + }); + + it('has PARSE_PARAMS binding key', () => { + expect(RestBindings.SequenceActions.PARSE_PARAMS.key).to.equal( + 'rest.sequence.actions.parseParams', + ); + }); + + it('has INVOKE_METHOD binding key', () => { + expect(RestBindings.SequenceActions.INVOKE_METHOD.key).to.equal( + 'rest.sequence.actions.invokeMethod', + ); + }); + + it('has LOG_ERROR binding key', () => { + expect(RestBindings.SequenceActions.LOG_ERROR.key).to.equal( + 'rest.sequence.actions.logError', + ); + }); + + it('has SEND binding key', () => { + expect(RestBindings.SequenceActions.SEND.key).to.equal( + 'rest.sequence.actions.send', + ); + }); + + it('has REJECT binding key', () => { + expect(RestBindings.SequenceActions.REJECT.key).to.equal( + 'rest.sequence.actions.reject', + ); + }); + }); + + describe('Operation binding keys', () => { + it('has ROUTE binding key', () => { + expect(RestBindings.Operation.ROUTE.key).to.equal('rest.operation.route'); + }); + + it('has PARAMS binding key', () => { + expect(RestBindings.Operation.PARAMS.key).to.equal( + 'rest.operation.params', + ); + }); + + it('has RETURN_VALUE binding key', () => { + expect(RestBindings.Operation.RETURN_VALUE.key).to.equal( + 'rest.operation.returnValue', + ); + }); + }); + + describe('Http binding keys', () => { + it('has REQUEST binding key', () => { + expect(RestBindings.Http.REQUEST.key).to.equal('rest.http.request'); + }); + + it('has RESPONSE binding key', () => { + expect(RestBindings.Http.RESPONSE.key).to.equal('rest.http.response'); + }); + + it('has CONTEXT binding key', () => { + expect(RestBindings.Http.CONTEXT.key).to.equal( + 'rest.http.request.context', + ); + }); + }); + + describe('ROUTES namespace', () => { + it('has correct value', () => { + expect(RestBindings.ROUTES).to.equal('routes'); + }); + }); +}); + +describe('RestTags', () => { + it('has REST_ROUTE tag', () => { + expect(RestTags.REST_ROUTE).to.equal('restRoute'); + }); + + it('has ROUTE_VERB tag', () => { + expect(RestTags.ROUTE_VERB).to.equal('restRouteVerb'); + }); + + it('has ROUTE_PATH tag', () => { + expect(RestTags.ROUTE_PATH).to.equal('restRoutePath'); + }); + + it('has CONTROLLER_ROUTE tag', () => { + expect(RestTags.CONTROLLER_ROUTE).to.equal('controllerRoute'); + }); + + it('has CONTROLLER_BINDING tag', () => { + expect(RestTags.CONTROLLER_BINDING).to.equal('controllerBinding'); + }); + + it('has AJV_KEYWORD tag', () => { + expect(RestTags.AJV_KEYWORD).to.equal('ajvKeyword'); + }); + + it('has AJV_FORMAT tag', () => { + expect(RestTags.AJV_FORMAT).to.equal('ajvFormat'); + }); + + it('has REST_MIDDLEWARE_CHAIN tag', () => { + expect(RestTags.REST_MIDDLEWARE_CHAIN).to.equal('middlewareChain.default'); + }); + + it('has ACTION_MIDDLEWARE_CHAIN tag', () => { + expect(RestTags.ACTION_MIDDLEWARE_CHAIN).to.equal( + 'middlewareChain.rest.actions', + ); + }); +}); + +// Made with Bob diff --git a/packages/rest/src/__tests__/unit/rest-http-error.unit.ts b/packages/rest/src/__tests__/unit/rest-http-error.unit.ts new file mode 100644 index 000000000000..55e0abfb61fe --- /dev/null +++ b/packages/rest/src/__tests__/unit/rest-http-error.unit.ts @@ -0,0 +1,298 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. 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 {expect} from '@loopback/testlab'; +import {RestHttpErrors} from '../../rest-http-error'; + +describe('RestHttpErrors', () => { + describe('invalidData()', () => { + it('creates error with invalid data message', () => { + const error = RestHttpErrors.invalidData({foo: 'bar'}, 'testParam'); + expect(error.message).to.equal( + 'Invalid data {"foo":"bar"} for parameter "testParam".', + ); + expect(error.statusCode).to.equal(400); + }); + + it('includes code property', () => { + const error = RestHttpErrors.invalidData('test', 'param'); + expect(error).to.have.property('code', 'INVALID_PARAMETER_VALUE'); + }); + + it('includes parameterName property', () => { + const error = RestHttpErrors.invalidData('test', 'myParam'); + expect(error).to.have.property('parameterName', 'myParam'); + }); + + it('includes extra properties when provided', () => { + const error = RestHttpErrors.invalidData('test', 'param', { + customProp: 'value', + }); + expect(error).to.have.property('customProp', 'value'); + }); + + it('handles null data', () => { + const error = RestHttpErrors.invalidData(null, 'param'); + expect(error.message).to.match(/null/); + }); + + it('handles undefined data', () => { + const error = RestHttpErrors.invalidData(undefined, 'param'); + expect(error.message).to.match(/undefined/); + }); + + it('handles number data', () => { + const error = RestHttpErrors.invalidData(123, 'param'); + expect(error.message).to.match(/123/); + }); + + it('handles boolean data', () => { + const error = RestHttpErrors.invalidData(true, 'param'); + expect(error.message).to.match(/true/); + }); + + it('handles array data', () => { + const error = RestHttpErrors.invalidData([1, 2, 3], 'param'); + expect(error.message).to.match(/\[1,2,3\]/); + }); + + it('handles complex object data', () => { + const data = {nested: {value: 'test'}}; + const error = RestHttpErrors.invalidData(data, 'param'); + expect(error.message).to.match(/nested/); + expect(error.message).to.match(/test/); + }); + }); + + describe('unsupportedMediaType()', () => { + it('creates error with content type message', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain'); + expect(error.message).to.equal( + 'Content-type text/plain is not supported.', + ); + expect(error.statusCode).to.equal(415); + }); + + it('includes allowed types in message when provided', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain', [ + 'application/json', + 'application/xml', + ]); + expect(error.message).to.equal( + 'Content-type text/plain does not match [application/json,application/xml].', + ); + }); + + it('includes code property', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain'); + expect(error).to.have.property('code', 'UNSUPPORTED_MEDIA_TYPE'); + }); + + it('includes contentType property', () => { + const error = RestHttpErrors.unsupportedMediaType('text/html'); + expect(error).to.have.property('contentType', 'text/html'); + }); + + it('includes allowedMediaTypes property', () => { + const allowed = ['application/json']; + const error = RestHttpErrors.unsupportedMediaType('text/plain', allowed); + expect(error).to.have.property('allowedMediaTypes', allowed); + }); + + it('handles empty allowed types array', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain', []); + expect(error.message).to.equal( + 'Content-type text/plain is not supported.', + ); + }); + + it('handles single allowed type', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain', [ + 'application/json', + ]); + expect(error.message).to.match(/application\/json/); + }); + + it('handles multiple allowed types', () => { + const error = RestHttpErrors.unsupportedMediaType('text/plain', [ + 'application/json', + 'application/xml', + 'text/html', + ]); + expect(error.message).to.match(/application\/json/); + expect(error.message).to.match(/application\/xml/); + expect(error.message).to.match(/text\/html/); + }); + }); + + describe('missingRequired()', () => { + it('creates error with missing parameter message', () => { + const error = RestHttpErrors.missingRequired('userId'); + expect(error.message).to.equal('Required parameter userId is missing!'); + expect(error.statusCode).to.equal(400); + }); + + it('includes code property', () => { + const error = RestHttpErrors.missingRequired('param'); + expect(error).to.have.property('code', 'MISSING_REQUIRED_PARAMETER'); + }); + + it('includes parameterName property', () => { + const error = RestHttpErrors.missingRequired('testParam'); + expect(error).to.have.property('parameterName', 'testParam'); + }); + + it('handles parameter names with special characters', () => { + const error = RestHttpErrors.missingRequired('user-id'); + expect(error.message).to.match(/user-id/); + }); + + it('handles parameter names with spaces', () => { + const error = RestHttpErrors.missingRequired('user name'); + expect(error.message).to.match(/user name/); + }); + }); + + describe('invalidParamLocation()', () => { + it('creates error with invalid location message', () => { + const error = RestHttpErrors.invalidParamLocation('cookie'); + expect(error.message).to.equal( + 'Parameters with "in: cookie" are not supported yet.', + ); + expect(error.statusCode).to.equal(501); + }); + + it('handles different location values', () => { + const error = RestHttpErrors.invalidParamLocation('header'); + expect(error.message).to.match(/header/); + }); + + it('handles custom location values', () => { + const error = RestHttpErrors.invalidParamLocation('custom'); + expect(error.message).to.match(/custom/); + }); + }); + + describe('invalidRequestBody()', () => { + it('creates error with validation details', () => { + const details = [ + { + path: '/name', + code: 'required', + message: 'must have required property name', + info: {}, + }, + ]; + const error = RestHttpErrors.invalidRequestBody(details); + expect(error.message).to.equal( + RestHttpErrors.INVALID_REQUEST_BODY_MESSAGE, + ); + expect(error.statusCode).to.equal(422); + }); + + it('includes code property', () => { + const error = RestHttpErrors.invalidRequestBody([]); + expect(error).to.have.property('code', 'VALIDATION_FAILED'); + }); + + it('includes details property', () => { + const details = [ + { + path: '/email', + code: 'format', + message: 'must match format "email"', + info: {format: 'email'}, + }, + ]; + const error = RestHttpErrors.invalidRequestBody(details); + expect(error).to.have.property('details', details); + }); + + it('handles multiple validation errors', () => { + const details = [ + { + path: '/name', + code: 'required', + message: 'must have required property name', + info: {}, + }, + { + path: '/age', + code: 'type', + message: 'must be number', + info: {type: 'number'}, + }, + ]; + const error = RestHttpErrors.invalidRequestBody(details); + expect(error.details).to.have.length(2); + }); + + it('handles empty details array', () => { + const error = RestHttpErrors.invalidRequestBody([]); + expect(error.details).to.be.an.Array(); + expect(error.details).to.have.length(0); + }); + + it('preserves all detail properties', () => { + const details = [ + { + path: '/user/email', + code: 'format', + message: 'Invalid email format', + info: {format: 'email', value: 'invalid'}, + }, + ]; + const error = RestHttpErrors.invalidRequestBody(details); + expect(error.details[0]).to.have.property('path', '/user/email'); + expect(error.details[0]).to.have.property('code', 'format'); + expect(error.details[0]).to.have.property( + 'message', + 'Invalid email format', + ); + expect(error.details[0].info).to.have.property('format', 'email'); + expect(error.details[0].info).to.have.property('value', 'invalid'); + }); + }); + + describe('INVALID_REQUEST_BODY_MESSAGE constant', () => { + it('has correct message', () => { + expect(RestHttpErrors.INVALID_REQUEST_BODY_MESSAGE).to.equal( + 'The request body is invalid. See error object `details` property for more info.', + ); + }); + }); + + describe('ValidationErrorDetails interface', () => { + it('validates structure with all required fields', () => { + const detail: RestHttpErrors.ValidationErrorDetails = { + path: '/field', + code: 'required', + message: 'Field is required', + info: {}, + }; + expect(detail).to.have.property('path'); + expect(detail).to.have.property('code'); + expect(detail).to.have.property('message'); + expect(detail).to.have.property('info'); + }); + + it('allows complex info objects', () => { + const detail: RestHttpErrors.ValidationErrorDetails = { + path: '/user/age', + code: 'minimum', + message: 'must be >= 18', + info: { + minimum: 18, + actual: 15, + comparison: '>=', + }, + }; + expect(detail.info).to.have.property('minimum', 18); + expect(detail.info).to.have.property('actual', 15); + }); + }); +}); + +// Made with Bob diff --git a/packages/security/src/__tests__/unit/keys.unit.ts b/packages/security/src/__tests__/unit/keys.unit.ts new file mode 100644 index 000000000000..d5e64ee72d2e --- /dev/null +++ b/packages/security/src/__tests__/unit/keys.unit.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/security +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {SecurityBindings} from '../../keys'; + +describe('SecurityBindings', () => { + describe('binding keys', () => { + it('has SUBJECT binding key', () => { + expect(SecurityBindings.SUBJECT.key).to.equal('security.subject'); + }); + + it('has USER binding key', () => { + expect(SecurityBindings.USER.key).to.equal('security.user'); + }); + + it('SUBJECT and USER keys are different', () => { + expect(SecurityBindings.SUBJECT.key).to.not.equal( + SecurityBindings.USER.key, + ); + }); + + it('binding keys are immutable', () => { + const subjectKey = SecurityBindings.SUBJECT.key; + const userKey = SecurityBindings.USER.key; + expect(SecurityBindings.SUBJECT.key).to.equal(subjectKey); + expect(SecurityBindings.USER.key).to.equal(userKey); + }); + }); +}); + +// Made with Bob diff --git a/packages/security/src/__tests__/unit/types.unit.ts b/packages/security/src/__tests__/unit/types.unit.ts new file mode 100644 index 000000000000..9e2c7ea11065 --- /dev/null +++ b/packages/security/src/__tests__/unit/types.unit.ts @@ -0,0 +1,331 @@ +// Copyright IBM Corp. and LoopBack contributors 2026. All Rights Reserved. +// Node module: @loopback/security +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + DefaultSubject, + Permission, + securityId, + TypedPrincipal, + UserProfile, +} from '../../types'; + +describe('Security Types', () => { + describe('securityId symbol', () => { + it('is a symbol', () => { + expect(typeof securityId).to.equal('symbol'); + }); + + it('has correct description', () => { + expect(securityId.toString()).to.equal('Symbol(securityId)'); + }); + }); + + describe('TypedPrincipal', () => { + it('creates a typed principal with user type', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const typedPrincipal = new TypedPrincipal(user, 'USER'); + expect(typedPrincipal.principal).to.equal(user); + expect(typedPrincipal.type).to.equal('USER'); + }); + + it('generates security id with type prefix', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const typedPrincipal = new TypedPrincipal(user, 'USER'); + expect(typedPrincipal[securityId]).to.equal('USER:user-123'); + }); + + it('handles different principal types', () => { + const app = { + [securityId]: 'app-456', + clientId: 'my-app', + }; + const typedPrincipal = new TypedPrincipal(app, 'APPLICATION'); + expect(typedPrincipal[securityId]).to.equal('APPLICATION:app-456'); + }); + + it('preserves principal properties', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'Jane Smith', + email: 'jane@example.com', + }; + const typedPrincipal = new TypedPrincipal(user, 'USER'); + expect(typedPrincipal.principal.name).to.equal('Jane Smith'); + expect(typedPrincipal.principal.email).to.equal('jane@example.com'); + }); + }); + + describe('Permission', () => { + it('creates a permission with action and resource type', () => { + const permission = new Permission(); + permission.action = 'read'; + permission.resourceType = 'Order'; + expect(permission.action).to.equal('read'); + expect(permission.resourceType).to.equal('Order'); + }); + + it('generates security id for resource-level permission', () => { + const permission = new Permission(); + permission.action = 'create'; + permission.resourceType = 'User'; + expect(permission[securityId]).to.equal('User:create'); + }); + + it('generates security id for property-level permission', () => { + const permission = new Permission(); + permission.action = 'update'; + permission.resourceType = 'User'; + permission.resourceProperty = 'email'; + expect(permission[securityId]).to.equal('User.email:update'); + }); + + it('generates security id for instance-level permission', () => { + const permission = new Permission(); + permission.action = 'delete'; + permission.resourceType = 'Order'; + permission.resourceId = 'order-0001'; + expect(permission[securityId]).to.equal('Order:delete:order-0001'); + }); + + it('generates security id for property instance permission', () => { + const permission = new Permission(); + permission.action = 'read'; + permission.resourceType = 'User'; + permission.resourceProperty = 'email'; + permission.resourceId = 'user-123'; + expect(permission[securityId]).to.equal('User.email:read:user-123'); + }); + + it('handles different actions', () => { + const actions = ['create', 'read', 'update', 'delete', 'execute']; + actions.forEach(action => { + const permission = new Permission(); + permission.action = action; + permission.resourceType = 'Resource'; + expect(permission[securityId]).to.equal(`Resource:${action}`); + }); + }); + }); + + describe('DefaultSubject', () => { + let subject: DefaultSubject; + + beforeEach(() => { + subject = new DefaultSubject(); + }); + + describe('initialization', () => { + it('initializes with empty sets', () => { + expect(subject.principals.size).to.equal(0); + expect(subject.authorities.size).to.equal(0); + expect(subject.credentials.size).to.equal(0); + }); + }); + + describe('addUser()', () => { + it('adds a single user', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + subject.addUser(user); + expect(subject.principals.size).to.equal(1); + }); + + it('adds multiple users', () => { + const user1: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const user2: UserProfile = { + [securityId]: 'user-456', + name: 'Jane Smith', + }; + subject.addUser(user1, user2); + expect(subject.principals.size).to.equal(2); + }); + + it('wraps users in TypedPrincipal with USER type', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + subject.addUser(user); + const principals = Array.from(subject.principals); + expect(principals[0].type).to.equal('USER'); + expect(principals[0].principal).to.equal(user); + }); + }); + + describe('addApplication()', () => { + it('adds an application', () => { + const app = { + [securityId]: 'app-123', + clientId: 'my-app', + }; + subject.addApplication(app); + expect(subject.principals.size).to.equal(1); + }); + + it('wraps application in TypedPrincipal with APPLICATION type', () => { + const app = { + [securityId]: 'app-123', + clientId: 'my-app', + }; + subject.addApplication(app); + const principals = Array.from(subject.principals); + expect(principals[0].type).to.equal('APPLICATION'); + expect(principals[0].principal).to.equal(app); + }); + }); + + describe('addAuthority()', () => { + it('adds a single authority', () => { + const permission = new Permission(); + permission.action = 'read'; + permission.resourceType = 'Order'; + subject.addAuthority(permission); + expect(subject.authorities.size).to.equal(1); + }); + + it('adds multiple authorities', () => { + const perm1 = new Permission(); + perm1.action = 'read'; + perm1.resourceType = 'Order'; + const perm2 = new Permission(); + perm2.action = 'create'; + perm2.resourceType = 'User'; + subject.addAuthority(perm1, perm2); + expect(subject.authorities.size).to.equal(2); + }); + }); + + describe('addCredential()', () => { + it('adds a single credential', () => { + const credential = {token: 'abc123'}; + subject.addCredential(credential); + expect(subject.credentials.size).to.equal(1); + }); + + it('adds multiple credentials', () => { + const cred1 = {token: 'abc123'}; + const cred2 = {apiKey: 'xyz789'}; + subject.addCredential(cred1, cred2); + expect(subject.credentials.size).to.equal(2); + }); + }); + + describe('getPrincipal()', () => { + it('returns principal by type', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + subject.addUser(user); + const principal = subject.getPrincipal('USER'); + expect(principal).to.equal(user); + }); + + it('returns undefined for non-existent type', () => { + const principal = subject.getPrincipal('ADMIN'); + expect(principal).to.be.undefined(); + }); + + it('returns first principal of matching type', () => { + const user1: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const user2: UserProfile = { + [securityId]: 'user-456', + name: 'Jane Smith', + }; + subject.addUser(user1, user2); + const principal = subject.getPrincipal('USER'); + expect(principal).to.equal(user1); + }); + + it('distinguishes between different principal types', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const app = { + [securityId]: 'app-456', + clientId: 'my-app', + }; + subject.addUser(user); + subject.addApplication(app); + expect(subject.getPrincipal('USER')).to.equal(user); + expect(subject.getPrincipal('APPLICATION')).to.equal(app); + }); + }); + + describe('user getter', () => { + it('returns user profile', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + email: 'john@example.com', + }; + subject.addUser(user); + expect(subject.user).to.equal(user); + }); + + it('returns undefined when no user exists', () => { + expect(subject.user).to.be.undefined(); + }); + + it('returns first user when multiple users exist', () => { + const user1: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const user2: UserProfile = { + [securityId]: 'user-456', + name: 'Jane Smith', + }; + subject.addUser(user1, user2); + expect(subject.user).to.equal(user1); + }); + }); + + describe('complex scenarios', () => { + it('handles subject with user, app, authorities, and credentials', () => { + const user: UserProfile = { + [securityId]: 'user-123', + name: 'John Doe', + }; + const app = { + [securityId]: 'app-456', + clientId: 'my-app', + }; + const permission = new Permission(); + permission.action = 'read'; + permission.resourceType = 'Order'; + const credential = {token: 'abc123'}; + + subject.addUser(user); + subject.addApplication(app); + subject.addAuthority(permission); + subject.addCredential(credential); + + expect(subject.principals.size).to.equal(2); + expect(subject.authorities.size).to.equal(1); + expect(subject.credentials.size).to.equal(1); + expect(subject.user).to.equal(user); + }); + }); + }); +}); + +// Made with Bob