import fetch, { Response } from 'node-fetch'; import * as FormData from 'form-data'; import { red, yellow } from 'colorette'; import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client'; jest.mock('node-fetch', () => ({ default: jest.fn(), })); function mockFetchResponse(response: any) { (fetch as jest.MockedFunction).mockResolvedValue(response as unknown as Response); } describe('ApiClient', () => { const testToken = 'test-token'; const testDomain = 'test-domain.com'; const testOrg = 'test-org'; const testProject = 'test-project'; const version = '1.0.0'; const command = 'push'; const expectedUserAgent = `redocly-cli/${version} ${command}`; describe('getDefaultBranch()', () => { let apiClient: ReuniteApi; beforeEach(() => { apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should get default project branch', async () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue({ branchName: 'test-branch', }), }); const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject); expect(fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${testToken}`, 'user-agent': expectedUserAgent, }, signal: expect.any(Object), } ); expect(result).toEqual('test-branch'); }); it('should throw parsed error if response is not ok', async () => { mockFetchResponse({ ok: false, json: jest.fn().mockResolvedValue({ type: 'about:blank', title: 'Project source not found', status: 404, detail: 'Not Found', object: 'problem', }), }); await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( new ReuniteApiError('Failed to fetch default branch. Project source not found.', 404) ); }); it('should throw statusText error if response is not ok', async () => { mockFetchResponse({ ok: false, statusText: 'Not found', json: jest.fn().mockResolvedValue({ unknownField: 'unknown-error', }), }); await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( new ReuniteApiError('Failed to fetch default branch. Not found.', 404) ); }); }); describe('upsert()', () => { const remotePayload = { mountBranchName: 'remote-mount-branch-name', mountPath: 'remote-mount-path', }; const responseMock = { id: 'remote-id', type: 'CICD', mountPath: 'remote-mount-path', mountBranchName: 'remote-mount-branch-name', organizationId: testOrg, projectId: testProject, }; let apiClient: ReuniteApi; beforeEach(() => { apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should upsert remote', async () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseMock), }); const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload); expect(fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${testToken}`, 'user-agent': expectedUserAgent, }, body: JSON.stringify({ mountPath: remotePayload.mountPath, mountBranchName: remotePayload.mountBranchName, type: 'CICD', autoMerge: true, }), signal: expect.any(Object), agent: undefined, } ); expect(result).toEqual(responseMock); }); it('should throw parsed error if response is not ok', async () => { mockFetchResponse({ ok: false, json: jest.fn().mockResolvedValue({ type: 'about:blank', title: 'Not allowed to mount remote outside of project content path: /docs', status: 403, detail: 'Forbidden', object: 'problem', }), }); await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( new ReuniteApiError( 'Failed to upsert remote. Not allowed to mount remote outside of project content path: /docs.', 403 ) ); }); it('should throw statusText error if response is not ok', async () => { mockFetchResponse({ ok: false, status: 404, statusText: 'Not found', json: jest.fn().mockResolvedValue({ unknownField: 'unknown-error', }), }); await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( new ReuniteApiError('Failed to upsert remote. Not found.', 404) ); }); }); describe('push()', () => { const testRemoteId = 'test-remote-id'; const pushPayload = { remoteId: testRemoteId, commit: { message: 'test-message', author: { name: 'test-name', email: 'test-email', }, branchName: 'test-branch-name', }, } as unknown as PushPayload; const filesMock = [{ path: 'some-file.yaml', stream: Buffer.from('fefef') }]; const responseMock = { branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main', hasChanges: true, files: [ { type: 'file', name: 'some-file.yaml', path: 'docs/remotes/some-file.yaml', lastModified: 1698925132394.2993, mimeType: 'text/yaml', }, ], commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43', outdated: false, }; let apiClient: ReuniteApi; beforeEach(() => { apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it('should push to remote', async () => { let passedFormData = new FormData(); (fetch as jest.MockedFunction).mockImplementationOnce( async (_: any, options: any): Promise => { passedFormData = options.body as FormData; return { ok: true, json: jest.fn().mockResolvedValue(responseMock), } as unknown as Response; } ); const formData = new FormData(); formData.append('remoteId', testRemoteId); formData.append('commit[message]', pushPayload.commit.message); formData.append('commit[author][name]', pushPayload.commit.author.name); formData.append('commit[author][email]', pushPayload.commit.author.email); formData.append('commit[branchName]', pushPayload.commit.branchName); formData.append('files[some-file.yaml]', filesMock[0].stream); const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock); expect(fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`, expect.objectContaining({ method: 'POST', headers: { Authorization: `Bearer ${testToken}`, 'user-agent': expectedUserAgent, }, }) ); expect( JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '') ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), '')); expect(result).toEqual(responseMock); }); it('should throw parsed error if response is not ok', async () => { mockFetchResponse({ ok: false, json: jest.fn().mockResolvedValue({ type: 'about:blank', title: 'Cannot push to remote', status: 403, detail: 'Forbidden', object: 'problem', }), }); await expect( apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) ).rejects.toThrow(new ReuniteApiError('Failed to push. Cannot push to remote.', 403)); }); it('should throw statusText error if response is not ok', async () => { mockFetchResponse({ ok: false, status: 404, statusText: 'Not found', json: jest.fn().mockResolvedValue({ unknownField: 'unknown-error', }), }); await expect( apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404)); }); }); describe('Sunset header', () => { const upsertRemoteMock = { requestFn: () => apiClient.remotes.upsert(testOrg, testProject, { mountBranchName: 'remote-mount-branch-name', mountPath: 'remote-mount-path', }), responseBody: { id: 'remote-id', type: 'CICD', mountPath: 'remote-mount-path', mountBranchName: 'remote-mount-branch-name', organizationId: testOrg, projectId: testProject, }, }; const getDefaultBranchMock = { requestFn: () => apiClient.remotes.getDefaultBranch(testOrg, testProject), responseBody: { branchName: 'test-branch', }, }; const pushMock = { requestFn: () => apiClient.remotes.push( testOrg, testProject, { remoteId: 'test-remote-id', commit: { message: 'test-message', author: { name: 'test-name', email: 'test-email', }, branchName: 'test-branch-name', }, }, [{ path: 'some-file.yaml', stream: Buffer.from('text content') }] ), responseBody: { branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main', hasChanges: true, files: [ { type: 'file', name: 'some-file.yaml', path: 'docs/remotes/some-file.yaml', lastModified: 1698925132394.2993, mimeType: 'text/yaml', }, ], commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43', outdated: false, }, }; const endpointMocks = [upsertRemoteMock, getDefaultBranchMock, pushMock]; let apiClient: ReuniteApi; beforeEach(() => { apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command }); }); it.each(endpointMocks)( 'should report endpoint sunset in the past', async ({ responseBody, requestFn }) => { jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); const sunsetDate = new Date('2024-09-06T12:30:32.456Z'); mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseBody), headers: new Headers({ Sunset: sunsetDate.toISOString(), }), }); await requestFn(); apiClient.reportSunsetWarnings(); expect(process.stdout.write).toHaveBeenCalledWith( red( `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` ) ); } ); it.each(endpointMocks)( 'should report endpoint sunset in the future', async ({ responseBody, requestFn }) => { jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); const sunsetDate = new Date(Date.now() + 1000 * 60 * 60 * 24); mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseBody), headers: new Headers({ Sunset: sunsetDate.toISOString(), }), }); await requestFn(); apiClient.reportSunsetWarnings(); expect(process.stdout.write).toHaveBeenCalledWith( yellow( `The "push" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` ) ); } ); it('should report only expired resource', async () => { jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true); mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody), headers: new Headers({ Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), }), }); await upsertRemoteMock.requestFn(); mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody), headers: new Headers({ Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), }), }); await getDefaultBranchMock.requestFn(); mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(pushMock.responseBody), headers: new Headers({ Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), }), }); await pushMock.requestFn(); apiClient.reportSunsetWarnings(); expect(process.stdout.write).toHaveBeenCalledTimes(1); expect(process.stdout.write).toHaveBeenCalledWith( red( `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n` ) ); }); }); });