Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda';
import { ConditionalCheckFailedException, TransactionCanceledException } from '@aws-sdk/client-dynamodb';

import { handler } from '../../../../../src/app/controllers/easy-genomics/laboratory/create-laboratory.lambda';

jest.mock('../../../../../src/app/services/easy-genomics/organization-service');
jest.mock('../../../../../src/app/services/easy-genomics/laboratory-service');
jest.mock('../../../../../src/app/services/ssm-service');
jest.mock('../../../../../src/app/utils/auth-utils');
jest.mock('../../../../../src/app/utils/rest-api-utils');

import { OrganizationService } from '../../../../../src/app/services/easy-genomics/organization-service';
import { LaboratoryService } from '../../../../../src/app/services/easy-genomics/laboratory-service';
import { SsmService } from '../../../../../src/app/services/ssm-service';
import { validateOrganizationAdminAccess } from '../../../../../src/app/utils/auth-utils';
import { httpRequest } from '../../../../../src/app/utils/rest-api-utils';

describe('create-laboratory.lambda', () => {
let mockOrgService: jest.MockedClass<typeof OrganizationService>;
let mockLabService: jest.MockedClass<typeof LaboratoryService>;
let mockSsmService: jest.MockedClass<typeof SsmService>;
let mockValidateOrgAdmin: jest.MockedFunction<typeof validateOrganizationAdminAccess>;

const createEvent = (body: any, overrides: Partial<APIGatewayProxyWithCognitoAuthorizerEvent> = {}) =>
({
body: JSON.stringify(body),
isBase64Encoded: false,
httpMethod: 'POST',
path: '/laboratory/create',
headers: {},
requestContext: {
authorizer: {
claims: {
email: 'admin@example.com',
'cognito:username': 'admin-user',
},
},
},
resource: '',
queryStringParameters: null,
multiValueQueryStringParameters: null,
pathParameters: null,
stageVariables: null,
multiValueHeaders: {},
...overrides,
}) as any;

const createContext = (): Context =>
({
functionName: 'create-laboratory',
functionVersion: '$LATEST',
invokedFunctionArn: 'arn:aws:lambda:region:acct:function:create-laboratory',
memoryLimitInMB: '128',
awsRequestId: 'req-id',
logGroupName: '/aws/lambda/create-laboratory',
logStreamName: '2026/03/11/[$LATEST]test',
identity: undefined,
clientContext: undefined,
callbackWaitsForEmptyEventLoop: true,
getRemainingTimeInMillis: () => 30000,
done: jest.fn(),
fail: jest.fn(),
succeed: jest.fn(),
}) as any;

const baseRequest = {
OrganizationId: 'org-1',
Name: 'Lab 1',
Description: 'desc',
S3Bucket: 'bucket',
AwsHealthOmicsEnabled: true,
NextFlowTowerEnabled: true,
NextFlowTowerApiBaseUrl: 'https://tower.example.com',
NextFlowTowerWorkspaceId: 'ws-1',
NextFlowTowerAccessToken: 'token',
};

beforeEach(() => {
jest.clearAllMocks();
mockOrgService = OrganizationService as jest.MockedClass<typeof OrganizationService>;
mockLabService = LaboratoryService as jest.MockedClass<typeof LaboratoryService>;
mockSsmService = SsmService as jest.MockedClass<typeof SsmService>;
mockValidateOrgAdmin = validateOrganizationAdminAccess as any;

mockValidateOrgAdmin.mockReturnValue(true);

(httpRequest as jest.Mock).mockResolvedValue({
items: [],
});
});

it('creates laboratory successfully and stores NF access token', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
AwsHealthOmicsEnabled: true,
NextFlowTowerEnabled: true,
});

(mockLabService.prototype.add as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
LaboratoryId: 'lab-1',
});

(mockSsmService.prototype.putParameter as jest.Mock).mockResolvedValue({});

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(200);
const body = JSON.parse(result.body);
expect(body.LaboratoryId).toBeDefined();
expect(mockOrgService.prototype.get).toHaveBeenCalledWith('org-1');
expect(mockLabService.prototype.add).toHaveBeenCalled();
expect(mockSsmService.prototype.putParameter).toHaveBeenCalled();
});

it('returns 400 for invalid request body', async () => {
const result = await handler(createEvent({}), createContext(), () => {});

expect(result.statusCode).toBe(400);
expect(mockLabService.prototype.add).not.toHaveBeenCalled();
});

it('returns 403 when caller is not organization admin', async () => {
mockValidateOrgAdmin.mockReturnValue(false);

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(403);
expect(mockLabService.prototype.add).not.toHaveBeenCalled();
});

it('returns 404 when organization is not found', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue(undefined);

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(404);
});

it('returns 400 when NF integration validation fails', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
});

(httpRequest as jest.Mock).mockRejectedValue(new Error('NF error'));

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(400);
});

it('maps ConditionalCheckFailedException to LaboratoryAlreadyExistsError', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
});

(mockLabService.prototype.add as jest.Mock).mockRejectedValue(new ConditionalCheckFailedException({}));

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(409);
});

it('maps TransactionCanceledException to LaboratoryNameTakenError', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
});

(mockLabService.prototype.add as jest.Mock).mockRejectedValue(new TransactionCanceledException({}));

const result = await handler(createEvent(baseRequest), createContext(), () => {});

expect(result.statusCode).toBe(409);
});

it('does not call NF validation or SSM when NextFlowTowerEnabled is false', async () => {
(mockOrgService.prototype.get as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
});

const requestWithoutNf = {
...baseRequest,
NextFlowTowerEnabled: false,
NextFlowTowerApiBaseUrl: undefined,
NextFlowTowerWorkspaceId: undefined,
NextFlowTowerAccessToken: undefined,
};

(mockLabService.prototype.add as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
LaboratoryId: 'lab-1',
});

const result = await handler(createEvent(requestWithoutNf), createContext(), () => {});

expect(result.statusCode).toBe(200);
expect(httpRequest as jest.Mock).not.toHaveBeenCalled();
expect(mockSsmService.prototype.putParameter).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda';

import { handler } from '../../../../../src/app/controllers/easy-genomics/laboratory/delete-laboratory.lambda';

jest.mock('../../../../../src/app/services/easy-genomics/laboratory-service');
jest.mock('../../../../../src/app/services/easy-genomics/laboratory-user-service');
jest.mock('../../../../../src/app/services/easy-genomics/laboratory-run-service');
jest.mock('../../../../../src/app/services/ssm-service');
jest.mock('../../../../../src/app/services/sns-service');
jest.mock('../../../../../src/app/utils/auth-utils');

import { LaboratoryService } from '../../../../../src/app/services/easy-genomics/laboratory-service';
import { LaboratoryUserService } from '../../../../../src/app/services/easy-genomics/laboratory-user-service';
import { LaboratoryRunService } from '../../../../../src/app/services/easy-genomics/laboratory-run-service';
import { SsmService } from '../../../../../src/app/services/ssm-service';
import { SnsService } from '../../../../../src/app/services/sns-service';
import { validateOrganizationAdminAccess } from '../../../../../src/app/utils/auth-utils';

describe('delete-laboratory.lambda', () => {
let mockLabService: jest.MockedClass<typeof LaboratoryService>;
let mockLabUserService: jest.MockedClass<typeof LaboratoryUserService>;
let mockLabRunService: jest.MockedClass<typeof LaboratoryRunService>;
let mockSsmService: jest.MockedClass<typeof SsmService>;
let mockSnsService: jest.MockedClass<typeof SnsService>;
let mockValidateOrgAdmin: jest.MockedFunction<typeof validateOrganizationAdminAccess>;

const createEvent = (id: string | undefined, overrides: Partial<APIGatewayProxyWithCognitoAuthorizerEvent> = {}) =>
({
body: null,
isBase64Encoded: false,
httpMethod: 'DELETE',
path: `/laboratory/${id ?? ''}`,
headers: {},
requestContext: {
authorizer: {
claims: {
email: 'admin@example.com',
},
},
},
resource: '',
queryStringParameters: null,
multiValueQueryStringParameters: null,
pathParameters: id ? { id } : null,
stageVariables: null,
multiValueHeaders: {},
...overrides,
}) as any;

const createContext = (): Context =>
({
functionName: 'delete-laboratory',
functionVersion: '$LATEST',
invokedFunctionArn: 'arn:aws:lambda:region:acct:function:delete-laboratory',
memoryLimitInMB: '128',
awsRequestId: 'req-id',
logGroupName: '/aws/lambda/delete-laboratory',
logStreamName: '2026/03/11/[$LATEST]test',
identity: undefined,
clientContext: undefined,
callbackWaitsForEmptyEventLoop: true,
getRemainingTimeInMillis: () => 30000,
done: jest.fn(),
fail: jest.fn(),
succeed: jest.fn(),
}) as any;

beforeEach(() => {
jest.clearAllMocks();
mockLabService = LaboratoryService as jest.MockedClass<typeof LaboratoryService>;
mockLabUserService = LaboratoryUserService as jest.MockedClass<typeof LaboratoryUserService>;
mockLabRunService = LaboratoryRunService as jest.MockedClass<typeof LaboratoryRunService>;
mockSsmService = SsmService as jest.MockedClass<typeof SsmService>;
mockSnsService = SnsService as jest.MockedClass<typeof SnsService>;
mockValidateOrgAdmin = validateOrganizationAdminAccess as any;

mockValidateOrgAdmin.mockReturnValue(true);
process.env.SNS_LABORATORY_DELETION_TOPIC = 'arn:aws:sns:region:acct:lab-deletion';
});

it('returns 400 when id path parameter is missing', async () => {
const result = await handler(createEvent(undefined), createContext(), () => {});

expect(result.statusCode).toBe(400);
});

it('returns 403 when caller is not organization admin', async () => {
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
LaboratoryId: 'lab-1',
});
mockValidateOrgAdmin.mockReturnValue(false);

const result = await handler(createEvent('lab-1'), createContext(), () => {});

expect(result.statusCode).toBe(403);
});

it('publishes delete events, deletes lab and SSM token, and returns success', async () => {
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({
OrganizationId: 'org-1',
LaboratoryId: 'lab-1',
});

(mockLabUserService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([
{ LaboratoryId: 'lab-1', UserId: 'user-1' },
]);
(mockLabRunService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([
{ LaboratoryId: 'lab-1', RunId: 'run-1' },
]);
(mockSnsService.prototype.publish as jest.Mock).mockResolvedValue({});
(mockLabService.prototype.delete as jest.Mock).mockResolvedValue(true);
(mockSsmService.prototype.deleteParameter as jest.Mock).mockResolvedValue({});

const result = await handler(createEvent('lab-1'), createContext(), () => {});

expect(result.statusCode).toBe(200);
const body = JSON.parse(result.body);
expect(body.Status).toBe('Success');
expect(mockSnsService.prototype.publish).toHaveBeenCalled();
expect(mockLabService.prototype.delete).toHaveBeenCalled();
expect(mockSsmService.prototype.deleteParameter).toHaveBeenCalled();
});
});
Loading
Loading