@capawesome/cli
Version:
The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.
327 lines (326 loc) • 16.3 kB
JavaScript
import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
import authorizationService from '../../../services/authorization-service.js';
import { isInteractive } from '../../../utils/environment.js';
import { isReadable, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file.js';
import userConfig from '../../../utils/user-config.js';
import consola from 'consola';
import nock from 'nock';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import uploadCommand from './upload.js';
// Mock dependencies
vi.mock('@/utils/user-config.js');
vi.mock('@/utils/prompt.js');
vi.mock('@/services/authorization-service.js');
vi.mock('@/utils/file.js');
vi.mock('@/utils/zip.js');
vi.mock('@/utils/buffer.js');
vi.mock('@/utils/private-key.js');
vi.mock('@/utils/hash.js');
vi.mock('@/utils/signature.js');
vi.mock('@/utils/manifest.js');
vi.mock('@/utils/environment.js');
vi.mock('consola');
describe('apps-liveupdates-upload', () => {
const mockUserConfig = vi.mocked(userConfig);
const mockAuthorizationService = vi.mocked(authorizationService);
const mockIsReadable = vi.mocked(isReadable);
const mockGetFilesInDirectoryAndSubdirectories = vi.mocked(getFilesInDirectoryAndSubdirectories);
const mockIsDirectory = vi.mocked(isDirectory);
const mockIsInteractive = vi.mocked(isInteractive);
const mockConsola = vi.mocked(consola);
beforeEach(() => {
vi.clearAllMocks();
mockUserConfig.read.mockReturnValue({ token: 'test-token' });
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
mockIsInteractive.mockReturnValue(false);
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`Process exited with code ${code}`);
});
});
afterEach(() => {
nock.cleanAll();
vi.restoreAllMocks();
});
it('should require authentication', async () => {
const appId = 'app-123';
const options = { appId, path: './dist', artifactType: 'zip', rollout: 1 };
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(false);
await expect(uploadCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
expect(mockConsola.error).toHaveBeenCalledWith('You must be logged in to run this command. Set the `CAPAWESOME_TOKEN` environment variable or use the `--token` option.');
});
it('should handle path validation errors', async () => {
const appId = 'app-123';
const nonexistentPath = './nonexistent';
const options = { appId, path: nonexistentPath, artifactType: 'zip', rollout: 1 };
mockIsReadable.mockResolvedValue(false);
await expect(uploadCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
expect(mockConsola.error).toHaveBeenCalledWith(`The path does not exist or is not accessible: ${nonexistentPath}`);
});
it('should validate manifest artifact type requires directory', async () => {
const appId = 'app-123';
const bundlePath = './bundle.zip';
const options = {
appId,
path: bundlePath,
artifactType: 'manifest',
rollout: 1,
};
mockIsReadable.mockResolvedValue(true);
mockIsDirectory.mockResolvedValue(false);
// Mock zip utility to return true so path validation passes
const mockZip = await import('../../../utils/zip.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
await expect(uploadCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
expect(mockConsola.error).toHaveBeenCalledWith('The path must be a folder when creating a bundle with an artifact type of `manifest`.');
});
it('should upload bundle successfully', async () => {
const appId = 'app-123';
const bundlePath = './dist';
const bundleId = 'bundle-456';
const testToken = 'test-token';
const testBuffer = Buffer.from('test');
const options = {
appId,
path: bundlePath,
artifactType: 'zip',
rollout: 1,
};
mockIsReadable.mockResolvedValue(true);
mockIsDirectory.mockResolvedValue(true);
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
]);
// Mock utility functions
const mockZip = await import('../../../utils/zip.js');
const mockBuffer = await import('../../../utils/buffer.js');
const mockHash = await import('../../../utils/hash.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
vi.mocked(mockHash.createHash).mockResolvedValue('test-hash');
const appScope = nock(DEFAULT_API_BASE_URL)
.get(`/v1/apps/${appId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: appId, name: 'Test App' });
const bundleScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles`, {
appId,
artifactType: 'zip',
rolloutPercentage: 1,
})
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: bundleId, appBuildId: 'build-789' });
const uploadScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles/${bundleId}/files`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: 'file-123' });
const updateScope = nock(DEFAULT_API_BASE_URL)
.patch(`/v1/apps/${appId}/bundles/${bundleId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: bundleId });
await uploadCommand.action(options, undefined);
expect(appScope.isDone()).toBe(true);
expect(bundleScope.isDone()).toBe(true);
expect(uploadScope.isDone()).toBe(true);
expect(updateScope.isDone()).toBe(true);
expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
});
it('should pass gitRef to API when provided', async () => {
const appId = 'app-123';
const bundlePath = './dist';
const bundleId = 'bundle-456';
const testToken = 'test-token';
const testBuffer = Buffer.from('test');
const gitRef = 'main';
const options = {
appId,
path: bundlePath,
artifactType: 'zip',
rollout: 1,
gitRef,
};
mockIsReadable.mockResolvedValue(true);
mockIsDirectory.mockResolvedValue(true);
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
]);
// Mock utility functions
const mockZip = await import('../../../utils/zip.js');
const mockHash = await import('../../../utils/hash.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
vi.mocked(mockHash.createHash).mockResolvedValue('test-hash');
const appScope = nock(DEFAULT_API_BASE_URL)
.get(`/v1/apps/${appId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: appId, name: 'Test App' });
const bundleScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles`, {
appId,
artifactType: 'zip',
gitRef,
rolloutPercentage: 1,
})
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: bundleId, appBuildId: 'build-789' });
const uploadScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles/${bundleId}/files`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: 'file-123' });
const updateScope = nock(DEFAULT_API_BASE_URL)
.patch(`/v1/apps/${appId}/bundles/${bundleId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: bundleId });
await uploadCommand.action(options, undefined);
expect(appScope.isDone()).toBe(true);
expect(bundleScope.isDone()).toBe(true);
expect(uploadScope.isDone()).toBe(true);
expect(updateScope.isDone()).toBe(true);
expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
});
it('should handle private key file path', async () => {
const appId = 'app-123';
const bundlePath = './dist';
const privateKeyPath = 'private-key.pem';
const testHash = 'test-hash';
const testSignature = 'test-signature';
const bundleId = 'bundle-456';
const testToken = 'test-token';
const testBuffer = Buffer.from('test');
const options = {
appId,
path: bundlePath,
privateKey: privateKeyPath,
artifactType: 'zip',
rollout: 1,
};
mockIsReadable.mockImplementation((path) => {
if (path === privateKeyPath)
return Promise.resolve(true);
if (path === bundlePath)
return Promise.resolve(true);
return Promise.resolve(false);
});
mockIsDirectory.mockResolvedValue(true);
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
]);
// Mock utility functions
const mockZip = await import('../../../utils/zip.js');
const mockBuffer = await import('../../../utils/buffer.js');
const mockPrivateKey = await import('../../../utils/private-key.js');
const mockHash = await import('../../../utils/hash.js');
const mockSignature = await import('../../../utils/signature.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
vi.mocked(mockBuffer.createBufferFromPath).mockResolvedValue(testBuffer);
vi.mocked(mockBuffer.isPrivateKeyContent).mockReturnValue(false);
vi.mocked(mockPrivateKey.formatPrivateKey).mockReturnValue('formatted-private-key');
vi.mocked(mockBuffer.createBufferFromString).mockReturnValue(testBuffer);
vi.mocked(mockHash.createHash).mockResolvedValue(testHash);
vi.mocked(mockSignature.createSignature).mockResolvedValue(testSignature);
const appScope = nock(DEFAULT_API_BASE_URL)
.get(`/v1/apps/${appId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: appId, name: 'Test App' });
const bundleScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: bundleId, appBuildId: 'build-789' });
const uploadScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles/${bundleId}/files`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(201, { id: 'file-123' });
const updateScope = nock(DEFAULT_API_BASE_URL)
.patch(`/v1/apps/${appId}/bundles/${bundleId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: bundleId });
await uploadCommand.action(options, undefined);
expect(appScope.isDone()).toBe(true);
expect(bundleScope.isDone()).toBe(true);
expect(uploadScope.isDone()).toBe(true);
expect(updateScope.isDone()).toBe(true);
expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
});
it('should handle private key file not found', async () => {
const appId = 'app-123';
const privateKeyPath = 'nonexistent-key.pem';
const options = {
appId,
path: './dist',
privateKey: privateKeyPath,
artifactType: 'zip',
rollout: 1,
};
mockIsReadable.mockImplementation((path) => {
if (path === privateKeyPath)
return Promise.resolve(false);
return Promise.resolve(true);
});
mockIsDirectory.mockResolvedValue(true);
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
]);
// Mock utility functions
const mockBuffer = await import('../../../utils/buffer.js');
vi.mocked(mockBuffer.isPrivateKeyContent).mockReturnValue(false);
await expect(uploadCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
expect(mockConsola.error).toHaveBeenCalledWith(`The private key file does not exist or is not accessible: ${privateKeyPath}`);
});
it('should handle invalid private key format', async () => {
const appId = 'app-123';
const invalidPrivateKey = 'not-a-valid-key';
const options = {
appId,
path: './dist',
privateKey: invalidPrivateKey,
artifactType: 'zip',
rollout: 1,
};
mockIsReadable.mockResolvedValue(true);
mockIsDirectory.mockResolvedValue(false);
// Mock zip utility to pass path validation
const mockZip = await import('../../../utils/zip.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
// Mock utility functions
const mockBuffer = await import('../../../utils/buffer.js');
vi.mocked(mockBuffer.isPrivateKeyContent).mockReturnValue(false);
await expect(uploadCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
expect(mockConsola.error).toHaveBeenCalledWith('Private key must be either a path to a .pem file or the private key content as plain text.');
});
it('should handle API error during creation', async () => {
const appId = 'app-123';
const bundlePath = './dist';
const testToken = 'test-token';
const testBuffer = Buffer.from('test');
const options = {
appId,
path: bundlePath,
artifactType: 'zip',
rollout: 1,
};
mockIsReadable.mockResolvedValue(true);
mockIsDirectory.mockResolvedValue(true);
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
]);
// Mock utility functions
const mockZip = await import('../../../utils/zip.js');
vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
const appScope = nock(DEFAULT_API_BASE_URL)
.get(`/v1/apps/${appId}`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(200, { id: appId, name: 'Test App' });
const bundleScope = nock(DEFAULT_API_BASE_URL)
.post(`/v1/apps/${appId}/bundles`)
.matchHeader('Authorization', `Bearer ${testToken}`)
.reply(400, { message: 'Invalid bundle data' });
await expect(uploadCommand.action(options, undefined)).rejects.toThrow();
expect(appScope.isDone()).toBe(true);
expect(bundleScope.isDone()).toBe(true);
});
});