UNPKG

@apistudio/apim-cli

Version:

CLI for API Management Products

584 lines (517 loc) 19.3 kB
/** * Copyright Super iPaaS Integration LLC, an IBM Company 2024 */ import AdmZip from 'adm-zip'; import fs from 'node:fs'; import { bundleApiDependency, addApiDependencyAsset, resolveAndAddApiSpec } from './api-build-helper.js'; import { AssetCacheModel } from '../../model/asset-cache-model.js'; import { BaseAsset } from '../../model/assets-model.js'; import * as messageHelper from '../common/message-helper.js'; // Mock dependencies jest.mock('adm-zip'); jest.mock('../common/message-helper.js', () => ({ showInfo: jest.fn(), showWarning: jest.fn(), showError: jest.fn(), })); describe('API Build Helper Test Suite', () => { let mockZip: jest.Mocked<AdmZip>; let existsSyncSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); mockZip = { addLocalFile: jest.fn(), } as any; existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true); }); afterEach(() => { existsSyncSpy.mockRestore(); }); describe('addApiDependencyAsset', () => { it('should add API dependency with correct nested folder structure', () => { const mockDirent = { name: 'payment-api.yml', parentPath: '/root/ProjectB/api-assets', path: '/root/ProjectB/api-assets', isDirectory: () => false, isFile: () => true, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false, [Symbol.toStringTag]: 'Dirent', } as unknown as fs.Dirent; const result = addApiDependencyAsset( mockDirent, mockZip, 'ProjectB', 'ProjectA', '/root', 0 ); expect(result).toBe('api-assets/payment-api.yml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/payment-api.yml', 'ProjectA/ProjectB_0/api-assets' ); }); it('should handle API in nested subdirectories', () => { const mockDirent = { name: 'api.yml', parentPath: '/root/ProjectB/apis/v1/payment', path: '/root/ProjectB/apis/v1/payment', isDirectory: () => false, isFile: () => true, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false, [Symbol.toStringTag]: 'Dirent', } as unknown as fs.Dirent; const result = addApiDependencyAsset( mockDirent, mockZip, 'ProjectB', 'ProjectA', '/root', 0 ); expect(result).toBe('apis/v1/payment/api.yml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/apis/v1/payment/api.yml', 'ProjectA/ProjectB_0/apis/v1/payment' ); }); it('should handle API in project root directory', () => { const mockDirent = { name: 'root-api.yml', parentPath: '/root/ProjectB', path: '/root/ProjectB', isDirectory: () => false, isFile: () => true, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false, [Symbol.toStringTag]: 'Dirent', } as unknown as fs.Dirent; const result = addApiDependencyAsset( mockDirent, mockZip, 'ProjectB', 'ProjectA', '/root', 1 ); expect(result).toBe('root-api.yml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/root-api.yml', 'ProjectA/ProjectB_1' ); }); }); describe('resolveAndAddApiSpec', () => { const mockApiAsset = { kind: 'api', apiVersion: 'api.webmethods.io/v1', metadata: { name: 'PaymentAPI', namespace: 'dev', version: '1.0', }, spec: { 'api-spec': { $path: '../specs/petstore.yaml', }, }, } as unknown as BaseAsset; beforeEach(() => { existsSyncSpy.mockClear(); existsSyncSpy.mockReturnValue(true); }); it('should resolve and add spec with relative path using ../', () => { resolveAndAddApiSpec( mockApiAsset, mockZip, 'api-assets/payment-api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); expect(fs.existsSync).toHaveBeenCalledWith('/root/ProjectB/specs/petstore.yaml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/specs/petstore.yaml', 'ProjectA/ProjectB_0/specs' ); expect(messageHelper.showInfo).toHaveBeenCalledWith( 'Spec added: ProjectA/ProjectB_0/specs/petstore.yaml' ); }); it('should resolve and add spec with path relative to project root', () => { const apiAssetProjectRoot = { ...mockApiAsset, spec: { 'api-spec': { $path: 'specs/petstore.yaml', }, }, } as unknown as BaseAsset; resolveAndAddApiSpec( apiAssetProjectRoot, mockZip, 'api-assets/payment-api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); // Without ../ or ./ prefix, path is relative to project root expect(fs.existsSync).toHaveBeenCalledWith('/root/ProjectB/specs/petstore.yaml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/specs/petstore.yaml', 'ProjectA/ProjectB_0/specs' ); }); it('should resolve and add spec with multiple ../ in path', () => { const apiAssetMultipleUp = { ...mockApiAsset, spec: { 'api-spec': { $path: '../../common/specs/api.yaml', }, }, } as unknown as BaseAsset; resolveAndAddApiSpec( apiAssetMultipleUp, mockZip, 'apis/v1/payment/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); // From apis/v1/payment, going up ../../ lands in apis/, then common/specs/api.yaml expect(fs.existsSync).toHaveBeenCalledWith('/root/ProjectB/apis/common/specs/api.yaml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/apis/common/specs/api.yaml', 'ProjectA/ProjectB_0/apis/common/specs' ); }); it('should handle absolute path for spec', () => { const apiAssetAbsolute = { ...mockApiAsset, spec: { 'api-spec': { $path: '/absolute/path/to/spec.yaml', }, }, } as unknown as BaseAsset; resolveAndAddApiSpec( apiAssetAbsolute, mockZip, 'api-assets/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); expect(fs.existsSync).toHaveBeenCalledWith('/root/ProjectB/absolute/path/to/spec.yaml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/absolute/path/to/spec.yaml', 'ProjectA/ProjectB_0/absolute/path/to' ); }); it('should show warning when spec file does not exist', () => { existsSyncSpy.mockReturnValue(false); resolveAndAddApiSpec( mockApiAsset, mockZip, 'api-assets/payment-api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); expect(messageHelper.showWarning).toHaveBeenCalledWith( 'API spec not found: /root/ProjectB/specs/petstore.yaml for API dev:PaymentAPI:1.0' ); expect(mockZip.addLocalFile).not.toHaveBeenCalled(); }); it('should throw error when spec is not defined', () => { const apiAssetNoSpec = { kind: 'api', metadata: { name: 'PaymentAPI', }, spec: null, } as unknown as BaseAsset; expect(() => { resolveAndAddApiSpec( apiAssetNoSpec, mockZip, 'api-assets/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); }).toThrow("Spec is not defined for the asset with kind 'API' and name 'PaymentAPI'"); }); it('should throw error when api-spec attribute is not defined', () => { const apiAssetNoApiSpec = { kind: 'api', metadata: { name: 'PaymentAPI', }, spec: {}, } as unknown as BaseAsset; expect(() => { resolveAndAddApiSpec( apiAssetNoApiSpec, mockZip, 'api-assets/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); }).toThrow("Attribute 'api-spec' is not defined"); }); it('should throw error when $path is not defined', () => { const apiAssetNoPath = { kind: 'api', metadata: { name: 'PaymentAPI', }, spec: { 'api-spec': {}, }, } as unknown as BaseAsset; expect(() => { resolveAndAddApiSpec( apiAssetNoPath, mockZip, 'api-assets/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); }).toThrow('API Definition Path is not found'); }); it('should handle spec in nested subdirectories with ./', () => { const apiAssetDotSlash = { ...mockApiAsset, spec: { 'api-spec': { $path: './specs/nested/api.yaml', }, }, } as unknown as BaseAsset; resolveAndAddApiSpec( apiAssetDotSlash, mockZip, 'api-assets/api.yml', 'ProjectB', 'ProjectA', '/root', 0 ); expect(fs.existsSync).toHaveBeenCalledWith('/root/ProjectB/api-assets/specs/nested/api.yaml'); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/specs/nested/api.yaml', 'ProjectA/ProjectB_0/api-assets/specs/nested' ); }); }); describe('bundleApiDependency', () => { const mockDirent = { name: 'payment-api.yml', parentPath: '/root/ProjectB/api-assets', path: '/root/ProjectB/api-assets', isDirectory: () => false, isFile: () => true, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false, [Symbol.toStringTag]: 'Dirent', } as unknown as fs.Dirent; const mockApiAsset = { kind: 'api', apiVersion: 'api.webmethods.io/v1', metadata: { name: 'PaymentAPI', namespace: 'dev', version: '1.0', }, spec: { 'api-spec': { $path: '../specs/petstore.yaml', }, }, } as unknown as BaseAsset; const mockCachedAsset: AssetCacheModel = { kind: 'api', ref: 'dev:PaymentAPI:1.0', isNewlyAdded: false, sourceProject: 'ProjectB', }; beforeEach(() => { existsSyncSpy.mockClear(); existsSyncSpy.mockReturnValue(true); jest.spyOn(Date, 'now').mockReturnValue(0); }); afterEach(() => { jest.restoreAllMocks(); }); it('should bundle API dependency with spec successfully', () => { bundleApiDependency( mockApiAsset, mockDirent, mockCachedAsset, '/root', 'ProjectB', // target project where API is located mockZip ); // Verify API was added expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/payment-api.yml', 'ProjectB/ProjectB_0/api-assets' ); // Verify spec was added expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/specs/petstore.yaml', 'ProjectB/ProjectB_0/specs' ); // Verify info messages expect(messageHelper.showInfo).toHaveBeenCalledWith( 'API added: ProjectB/api-assets/payment-api.yml' ); expect(messageHelper.showInfo).toHaveBeenCalledWith( 'Spec added: ProjectB/ProjectB_0/specs/petstore.yaml' ); }); it('should show error when source project is not found', () => { const cachedAssetNoSource: AssetCacheModel = { kind: 'api', ref: 'dev:PaymentAPI:1.0', isNewlyAdded: false, sourceProject: undefined, }; bundleApiDependency( mockApiAsset, mockDirent, cachedAssetNoSource, '/root', 'ProjectB', mockZip ); expect(messageHelper.showError).toHaveBeenCalledWith( 'Source project not found for API dependency dev:PaymentAPI:1.0' ); expect(mockZip.addLocalFile).not.toHaveBeenCalled(); }); it('should bundle API even when spec is missing', () => { existsSyncSpy.mockReturnValue(false); bundleApiDependency( mockApiAsset, mockDirent, mockCachedAsset, '/root', 'ProjectB', mockZip ); // Verify API was added expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/payment-api.yml', 'ProjectB/ProjectB_0/api-assets' ); // Verify warning for missing spec expect(messageHelper.showWarning).toHaveBeenCalledWith( 'API spec not found: /root/ProjectB/specs/petstore.yaml for API dev:PaymentAPI:1.0' ); // Spec should not be added expect(mockZip.addLocalFile).toHaveBeenCalledTimes(1); }); it('should handle API with complex nested structure', () => { const nestedDirent = { name: 'api.yml', parentPath: '/root/ProjectB/apis/v1/payment', path: '/root/ProjectB/apis/v1/payment', isDirectory: () => false, isFile: () => true, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false, [Symbol.toStringTag]: 'Dirent', } as unknown as fs.Dirent; const nestedApiAsset = { ...mockApiAsset, spec: { 'api-spec': { $path: '../../specs/payment.yaml', }, }, } as unknown as BaseAsset; bundleApiDependency( nestedApiAsset, nestedDirent, mockCachedAsset, '/root', 'ProjectB', mockZip ); // Verify API was added with correct path expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/apis/v1/payment/api.yml', 'ProjectB/ProjectB_0/apis/v1/payment' ); // Verify spec was resolved correctly // From apis/v1/payment, going up ../../ lands in apis/, then specs/payment.yaml expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/apis/specs/payment.yaml', 'ProjectB/ProjectB_0/apis/specs' ); }); it('should use unique timestamp for each bundle', () => { const timestamps = [1, 2]; let callCount = 0; jest.spyOn(Date, 'now').mockImplementation(() => timestamps[callCount++]); // First call bundleApiDependency( mockApiAsset, mockDirent, mockCachedAsset, '/root', 'ProjectB', mockZip ); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/payment-api.yml', 'ProjectB/ProjectB_1/api-assets' ); jest.clearAllMocks(); // Second call bundleApiDependency( mockApiAsset, mockDirent, mockCachedAsset, '/root', 'ProjectB', mockZip ); expect(mockZip.addLocalFile).toHaveBeenCalledWith( '/root/ProjectB/api-assets/payment-api.yml', 'ProjectB/ProjectB_2/api-assets' ); }); }); });