/*! * @license * Copyright Squiz Australia Pty Ltd. All Rights Reserved. */ import { AttributeValue, DeleteItemCommandOutput, DynamoDBClient, DynamoDBClientConfig, GetItemCommandOutput, PutItemCommandOutput, QueryCommandInput, ScanCommandOutput, } from '@aws-sdk/client-dynamodb'; import { initIocContainer, iocContainer } from '../../../../mocks/mockIoc'; import { InjectTokens } from '../../constants/InjectTokens'; import { ENTITY_TYPES } from '../../constants/Repository.constants'; import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants'; import { createMockClientGetResponse, createMockClientPutResponse, createMockClientScanResponse, } from '../../core/dynamoClient.mock'; import { PaginationInfo } from '../../core/pagination/model/PaginationInfo'; import { PAGINATION_DIRECTION_TYPE_BEFORE } from '../../core/pagination/model/PaginationTypes'; import * as PaginationUtils from '../../core/pagination/utils/PaginationUtils'; import { createMockJob, createMockJobRecord } from '../../model/Job'; import { Job } from '../../model/Job/Job'; import { createMockJobDto, createMockJobDtos } from '../../model/Job/mocks/JobDto.mock'; import { createMockCreateJobRequest } from '../../model/Job/mocks/JobRequest.mock'; import { JobRepository } from './JobRepository'; initIocContainer(); // Mock DynamoDB Client const mockOptions = {} as DynamoDBClientConfig; const mockClient = new DynamoDBClient(mockOptions); const mockSend = jest.fn(); mockClient.send = mockSend; // Inject JobRepository with a mocked client iocContainer.rebind(DynamoDBClient).toConstantValue(mockClient); describe('JobRepository', (): void => { let jobRepository: JobRepository; let tenant: string; let tableName: string; beforeAll(() => { jobRepository = iocContainer.get(JobRepository); tenant = iocContainer.get(InjectTokens.Tenant); tableName = iocContainer.get(InjectTokens.DynamoTableName); }); describe('list', () => { it(`should return an empty list`, async (): Promise => { mockSend.mockImplementationOnce((): ScanCommandOutput => createMockClientScanResponse()); const response = await jobRepository.list(); expect(mockSend).toBeCalled(); expect(response).toStrictEqual([]); }); it(`should return a list of existing jobs`, async (): Promise => { const mockRecords = createMockJobDtos(); mockSend.mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: mockRecords, LastEvaluatedKey: undefined, }), ); const response = await jobRepository.list(); expect(mockSend).toBeCalledTimes(1); expect(response).toHaveLength(mockRecords.length); }); it(`should return a list of existing jobs by name`, async (): Promise => { const mockName = `mock`; const mockRecords = [createMockJobDto({ name: mockName }), createMockJobDto({ name: mockName })]; mockSend.mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: mockRecords, LastEvaluatedKey: undefined, }), ); const response = await jobRepository.list({ filters: { name: mockName }, }); expect(mockSend).toBeCalledTimes(1); expect(response).toHaveLength(mockRecords.length); }); it(`should return a list of existing jobs by version`, async (): Promise => { const mockVersion = `1.0.1`; const mockRecords = [createMockJobDto({ version: mockVersion }), createMockJobDto({ version: mockVersion })]; mockSend.mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: mockRecords, LastEvaluatedKey: undefined, }), ); const response = await jobRepository.list({ filters: { version: mockVersion }, }); expect(mockSend).toBeCalledTimes(1); expect(response).toHaveLength(mockRecords.length); }); it(`should paginate results to return a list of existing jobs`, async (): Promise => { const mockRecordsPage1 = createMockJobDtos(2); const mockRecordsPage2 = createMockJobDtos(2); mockSend .mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: mockRecordsPage1, LastEvaluatedKey: mockRecordsPage1.slice(-1)[0], }), ) .mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: mockRecordsPage2, LastEvaluatedKey: undefined, }), ); const response = await jobRepository.list({ limit: 3, }); expect(mockSend).toBeCalledTimes(2); expect(response).toHaveLength(3); }); it(`should stop paginating if unexpect result is returned`, async (): Promise => { mockSend.mockImplementationOnce(() => null); const response = await jobRepository.list(); expect(mockSend).toBeCalledTimes(1); expect(response).toHaveLength(0); }); }); describe('findOne', () => { it(`should return job by name`, async (): Promise => { const mockJobRecord = createMockJobDto(); mockSend.mockImplementationOnce( (): GetItemCommandOutput => createMockClientGetResponse({ Item: mockJobRecord, }), ); const response = await jobRepository.findOne({ name: mockJobRecord.name.S as string, version: mockJobRecord.version.S as string, }); expect(response).toStrictEqual({ name: mockJobRecord.name.S as string, type: `job`, version: mockJobRecord.version.S as string, description: mockJobRecord.description.S as string, image: mockJobRecord.image.S as string, } as Job); expect(mockSend).toBeCalledTimes(1); }); it(`should default missing image to legacy runtime`, async (): Promise => { const mockJobRecord = createMockJobDto(); delete (mockJobRecord as Partial).image; mockSend.mockImplementationOnce( (): GetItemCommandOutput => createMockClientGetResponse({ Item: mockJobRecord, }), ); const response = await jobRepository.findOne({ name: mockJobRecord.name.S as string, version: mockJobRecord.version.S as string, }); expect(response).toEqual( expect.objectContaining({ image: LEGACY_RUNTIME_IMAGE, }), ); }); it(`should return null if job does not exist`, async (): Promise => { mockSend.mockImplementationOnce( (): ScanCommandOutput => createMockClientScanResponse({ Items: [], }), ); const response = await jobRepository.findOne({ name: 'unknown', version: '0.0.1' }); expect(mockSend).toBeCalledTimes(1); expect(response).toBe(null); }); }); describe('create', () => { it(`should create a new job record and return job in the response`, async (): Promise => { const newJobToCreate = createMockCreateJobRequest(); const mockReponse = createMockClientPutResponse({ Attributes: { pk: { S: ENTITY_TYPES.job }, sk: { S: `${newJobToCreate.name}-${newJobToCreate.version}` }, name: { S: newJobToCreate.name }, version: { S: newJobToCreate.version }, }, }); mockSend.mockImplementationOnce((): PutItemCommandOutput => mockReponse); const response = await jobRepository.create(newJobToCreate); expect(mockSend).toBeCalledTimes(1); expect(response).toStrictEqual({ ...newJobToCreate, type: ENTITY_TYPES.job, }); }); }); describe('getJobs', () => { let mockExclusiveStartKey: Record; let mockPaginationInfo: PaginationInfo; let mockParams: QueryCommandInput; beforeEach(() => { mockExclusiveStartKey = { pk: { S: `${ENTITY_TYPES.job}~${tenant}` }, sk: { S: 'mockSk' }, }; mockPaginationInfo = { direction: PAGINATION_DIRECTION_TYPE_BEFORE, limit: 100, requestUrl: 'mockUrl', token: 'mockToken', }; mockParams = { ExclusiveStartKey: mockExclusiveStartKey, ExpressionAttributeValues: { ':pk': { S: `${ENTITY_TYPES.job}~${tenant}` }, }, KeyConditionExpression: 'pk = :pk', ScanIndexForward: false, TableName: tableName, }; jest.spyOn(PaginationUtils, 'decodeTokenString').mockReturnValue(mockExclusiveStartKey); }); it('should return an empty array if no jobs are found', async () => { mockSend.mockImplementationOnce(() => Promise.resolve({ Items: [], LastEvaluatedKey: null, }), ); const mockRequestData: PaginationInfo = mockPaginationInfo; const result = await jobRepository.getJobs(mockRequestData); expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ input: { ...mockParams }, }), ); expect(result.items).toEqual([]); expect(result.lastEvaluatedKey).toBe(null); }); it('should return an array of jobs if found', async () => { const mockJobRecords = createMockJobDtos(); const mockLastEvaluatedKey = { pk: { S: ENTITY_TYPES.job }, sk: { S: 'mockSk' }, }; mockSend.mockImplementationOnce(() => Promise.resolve({ Items: mockJobRecords, LastEvaluatedKey: mockLastEvaluatedKey, }), ); const mockRequestData: PaginationInfo = mockPaginationInfo; const result = await jobRepository.getJobs(mockRequestData); expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ input: { ...mockParams }, }), ); expect(result.items).toHaveLength(mockJobRecords.length); expect(result.lastEvaluatedKey).toStrictEqual(mockLastEvaluatedKey); }); }); describe('update', () => { it(`should update a job record`, async (): Promise => { const jobToUpdate = createMockJob(); const updatedJob: Job = { ...jobToUpdate, description: `updated description`, }; const updatedJobRecord = createMockJobRecord(updatedJob); const updatedJobRecordDto = createMockJobDto(updatedJobRecord); const putResponse = createMockClientPutResponse({ Attributes: { ...updatedJobRecordDto }, }); mockSend.mockImplementation((): PutItemCommandOutput => putResponse); const response = await jobRepository.update( { name: jobToUpdate.name, version: jobToUpdate.version }, updatedJobRecord, ); expect(response).toStrictEqual(updatedJob); expect(mockSend).toBeCalledTimes(1); }); }); describe('convertToDynamoDbItem', () => { it(`should persist image on the DynamoDB item`, () => { const job = createMockJob({ image: 'node:24' }); const dto = jobRepository.convertToDynamoDbItem(job); expect(dto.image).toEqual({ S: 'node:24' }); }); }); describe('delete', () => { it(`should return undefined when calling the delete operation`, async (): Promise => { mockSend.mockImplementationOnce((): DeleteItemCommandOutput => { return { $metadata: {}, }; }); const response = await jobRepository.delete({ name: `name`, version: '1.0.0' }); expect(response).toBe(undefined); expect(mockSend).toBeCalledTimes(1); }); }); });