@apistudio/apim-cli
Version:
CLI for API Management Products
584 lines (517 loc) • 19.3 kB
text/typescript
/**
* 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'
);
});
});
});