Skip to content

Commit de17299

Browse files
feat: added unit tests for laboratory runs related lambdas (#664)
1 parent c8dee3e commit de17299

8 files changed

Lines changed: 1269 additions & 3 deletions

packages/back-end/src/app/controllers/easy-genomics/laboratory/run/process-update-laboratory-run.lambda.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const handler: Handler = async (event: SQSEvent): Promise<APIGatewayProxy
4848
}
4949
};
5050

51-
async function processStatusCheckEvent(operation: SnsProcessingOperation, laboratoryRun: LaboratoryRun) {
51+
export async function processStatusCheckEvent(operation: SnsProcessingOperation, laboratoryRun: LaboratoryRun) {
5252
if (operation === 'UPDATE') {
5353
console.log('Processing LaboratoryRun Status Update: ', laboratoryRun);
5454
const existingRun: LaboratoryRun = await laboratoryRunService.queryByRunId(laboratoryRun.RunId);
@@ -83,7 +83,7 @@ async function processStatusCheckEvent(operation: SnsProcessingOperation, labora
8383
return true;
8484
}
8585

86-
async function getAWSHealthOmicsStatus(laboratoryRun: LaboratoryRun): Promise<string> {
86+
export async function getAWSHealthOmicsStatus(laboratoryRun: LaboratoryRun): Promise<string> {
8787
console.log('Fetching AWS Health Omics status for run: ', laboratoryRun.RunId);
8888

8989
const response = await omicsService.getRun(<GetRunCommandInput>{
@@ -93,7 +93,7 @@ async function getAWSHealthOmicsStatus(laboratoryRun: LaboratoryRun): Promise<st
9393
return response.status || 'UNKNOWN';
9494
}
9595

96-
async function getSeqeraCloudStatus(laboratoryRun: LaboratoryRun): Promise<string> {
96+
export async function getSeqeraCloudStatus(laboratoryRun: LaboratoryRun): Promise<string> {
9797
console.log('Fetching NF Tower status for run: ', laboratoryRun.RunId);
9898
const laboratory: Laboratory = await laboratoryService.queryByLaboratoryId(laboratoryRun.LaboratoryId);
9999

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda';
2+
import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/run/create-laboratory-run.lambda';
3+
4+
jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-run-service');
5+
jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-service');
6+
jest.mock('../../../../../../src/app/services/sns-service');
7+
jest.mock('../../../../../../src/app/utils/auth-utils');
8+
9+
import { LaboratoryRunService } from '../../../../../../src/app/services/easy-genomics/laboratory-run-service';
10+
import { LaboratoryService } from '../../../../../../src/app/services/easy-genomics/laboratory-service';
11+
import { SnsService } from '../../../../../../src/app/services/sns-service';
12+
import {
13+
validateOrganizationAdminAccess,
14+
validateLaboratoryManagerAccess,
15+
validateLaboratoryTechnicianAccess,
16+
} from '../../../../../../src/app/utils/auth-utils';
17+
18+
describe('create-laboratory-run.lambda', () => {
19+
let mockRunService: jest.MockedClass<typeof LaboratoryRunService>;
20+
let mockLabService: jest.MockedClass<typeof LaboratoryService>;
21+
let mockSnsService: jest.MockedClass<typeof SnsService>;
22+
let mockValidateOrgAdmin: jest.MockedFunction<typeof validateOrganizationAdminAccess>;
23+
let mockValidateLabManager: jest.MockedFunction<typeof validateLaboratoryManagerAccess>;
24+
let mockValidateLabTechnician: jest.MockedFunction<typeof validateLaboratoryTechnicianAccess>;
25+
26+
const createEvent = (body: any, overrides: Partial<APIGatewayProxyWithCognitoAuthorizerEvent> = {}) =>
27+
({
28+
body: JSON.stringify(body),
29+
isBase64Encoded: false,
30+
httpMethod: 'POST',
31+
path: '/laboratory/run/create',
32+
headers: {},
33+
requestContext: {
34+
authorizer: {
35+
claims: {
36+
email: 'user@example.com',
37+
'cognito:username': 'user-1',
38+
},
39+
},
40+
},
41+
resource: '',
42+
queryStringParameters: null,
43+
multiValueQueryStringParameters: null,
44+
pathParameters: null,
45+
stageVariables: null,
46+
multiValueHeaders: {},
47+
...overrides,
48+
}) as any;
49+
50+
const createContext = (): Context =>
51+
({
52+
functionName: 'create-laboratory-run',
53+
functionVersion: '$LATEST',
54+
invokedFunctionArn: 'arn:aws:lambda:region:acct:function:create-laboratory-run',
55+
memoryLimitInMB: '128',
56+
awsRequestId: 'req-id',
57+
logGroupName: '/aws/lambda/create-laboratory-run',
58+
logStreamName: '2026/03/11/[$LATEST]test',
59+
identity: undefined,
60+
clientContext: undefined,
61+
callbackWaitsForEmptyEventLoop: true,
62+
getRemainingTimeInMillis: () => 30000,
63+
done: jest.fn(),
64+
fail: jest.fn(),
65+
succeed: jest.fn(),
66+
}) as any;
67+
68+
const baseRequest = {
69+
LaboratoryId: 'lab-1',
70+
RunId: 'run-1',
71+
RunName: 'Test Run',
72+
Platform: 'Seqera Cloud',
73+
PlatformApiBaseUrl: 'https://tower.example.com',
74+
Status: 'RUNNING',
75+
WorkflowName: 'wf',
76+
ExternalRunId: 'ext-1',
77+
InputS3Url: 's3://bucket/input',
78+
OutputS3Url: 's3://bucket/output',
79+
SampleSheetS3Url: 's3://bucket/sample.csv',
80+
Settings: { param: 'value' },
81+
};
82+
83+
beforeEach(() => {
84+
jest.clearAllMocks();
85+
mockRunService = LaboratoryRunService as jest.MockedClass<typeof LaboratoryRunService>;
86+
mockLabService = LaboratoryService as jest.MockedClass<typeof LaboratoryService>;
87+
mockSnsService = SnsService as jest.MockedClass<typeof SnsService>;
88+
mockValidateOrgAdmin = validateOrganizationAdminAccess as any;
89+
mockValidateLabManager = validateLaboratoryManagerAccess as any;
90+
mockValidateLabTechnician = validateLaboratoryTechnicianAccess as any;
91+
92+
mockValidateOrgAdmin.mockReturnValue(true);
93+
mockValidateLabManager.mockReturnValue(false);
94+
mockValidateLabTechnician.mockReturnValue(false);
95+
96+
process.env.SNS_LABORATORY_RUN_UPDATE_TOPIC = 'arn:aws:sns:region:acct:lab-run-update';
97+
});
98+
99+
it('creates a laboratory run for an existing lab and queues status check when ExternalRunId is present', async () => {
100+
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({
101+
OrganizationId: 'org-1',
102+
LaboratoryId: 'lab-1',
103+
});
104+
105+
(mockRunService.prototype.add as jest.Mock).mockResolvedValue({
106+
...baseRequest,
107+
OrganizationId: 'org-1',
108+
Owner: 'user@example.com',
109+
Settings: JSON.stringify({ param: 'value' }),
110+
});
111+
112+
const result = await handler(createEvent(baseRequest), createContext(), () => {});
113+
114+
expect(result.statusCode).toBe(200);
115+
const body = JSON.parse(result.body);
116+
expect(body.LaboratoryId).toBe('lab-1');
117+
expect(body.RunId).toBe('run-1');
118+
expect(mockLabService.prototype.queryByLaboratoryId).toHaveBeenCalledWith('lab-1');
119+
expect(mockRunService.prototype.add).toHaveBeenCalled();
120+
expect(mockSnsService.prototype.publish).toHaveBeenCalled();
121+
});
122+
123+
it('does not queue status check when ExternalRunId is missing', async () => {
124+
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({
125+
OrganizationId: 'org-1',
126+
LaboratoryId: 'lab-1',
127+
});
128+
129+
(mockRunService.prototype.add as jest.Mock).mockResolvedValue({
130+
...baseRequest,
131+
ExternalRunId: undefined,
132+
OrganizationId: 'org-1',
133+
Settings: JSON.stringify({}),
134+
});
135+
136+
const body = { ...baseRequest, ExternalRunId: undefined };
137+
const result = await handler(createEvent(body), createContext(), () => {});
138+
139+
expect(result.statusCode).toBe(200);
140+
expect(mockSnsService.prototype.publish).not.toHaveBeenCalled();
141+
});
142+
143+
it('rejects invalid request body', async () => {
144+
const result = await handler(createEvent({}), createContext(), () => {});
145+
146+
expect(result.statusCode).toBe(400);
147+
expect(mockRunService.prototype.add).not.toHaveBeenCalled();
148+
});
149+
150+
it('returns 404 when laboratory is not found', async () => {
151+
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue(undefined);
152+
153+
const result = await handler(createEvent(baseRequest), createContext(), () => {});
154+
155+
expect(result.statusCode).toBe(404);
156+
});
157+
158+
it('denies access when user does not have org or lab role', async () => {
159+
(mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({
160+
OrganizationId: 'org-1',
161+
LaboratoryId: 'lab-1',
162+
});
163+
164+
mockValidateOrgAdmin.mockReturnValue(false);
165+
mockValidateLabManager.mockReturnValue(false);
166+
mockValidateLabTechnician.mockReturnValue(false);
167+
168+
const result = await handler(createEvent(baseRequest), createContext(), () => {});
169+
170+
expect(result.statusCode).toBe(403);
171+
expect(mockRunService.prototype.add).not.toHaveBeenCalled();
172+
});
173+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda';
2+
import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/run/delete-laboratory-run.lambda';
3+
4+
jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-run-service');
5+
jest.mock('../../../../../../src/app/utils/auth-utils');
6+
7+
import { LaboratoryRunService } from '../../../../../../src/app/services/easy-genomics/laboratory-run-service';
8+
import {
9+
validateLaboratoryManagerAccess,
10+
validateLaboratoryTechnicianAccess,
11+
validateOrganizationAdminAccess,
12+
} from '../../../../../../src/app/utils/auth-utils';
13+
14+
describe('delete-laboratory-run.lambda', () => {
15+
let mockRunService: jest.MockedClass<typeof LaboratoryRunService>;
16+
let mockValidateOrgAdmin: jest.MockedFunction<typeof validateOrganizationAdminAccess>;
17+
let mockValidateLabManager: jest.MockedFunction<typeof validateLaboratoryManagerAccess>;
18+
let mockValidateLabTechnician: jest.MockedFunction<typeof validateLaboratoryTechnicianAccess>;
19+
20+
const createEvent = (id: string | undefined, overrides: Partial<APIGatewayProxyWithCognitoAuthorizerEvent> = {}) =>
21+
({
22+
body: null,
23+
isBase64Encoded: false,
24+
httpMethod: 'DELETE',
25+
path: `/laboratory/run/${id ?? ''}`,
26+
headers: {},
27+
requestContext: {
28+
authorizer: {
29+
claims: {
30+
email: 'user@example.com',
31+
},
32+
},
33+
},
34+
resource: '',
35+
queryStringParameters: null,
36+
multiValueQueryStringParameters: null,
37+
pathParameters: id ? { id } : null,
38+
stageVariables: null,
39+
multiValueHeaders: {},
40+
...overrides,
41+
}) as any;
42+
43+
const createContext = (): Context =>
44+
({
45+
functionName: 'delete-laboratory-run',
46+
functionVersion: '$LATEST',
47+
invokedFunctionArn: 'arn:aws:lambda:region:acct:function:delete-laboratory-run',
48+
memoryLimitInMB: '128',
49+
awsRequestId: 'req-id',
50+
logGroupName: '/aws/lambda/delete-laboratory-run',
51+
logStreamName: '2026/03/11/[$LATEST]test',
52+
identity: undefined,
53+
clientContext: undefined,
54+
callbackWaitsForEmptyEventLoop: true,
55+
getRemainingTimeInMillis: () => 30000,
56+
done: jest.fn(),
57+
fail: jest.fn(),
58+
succeed: jest.fn(),
59+
}) as any;
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
mockRunService = LaboratoryRunService as jest.MockedClass<typeof LaboratoryRunService>;
64+
mockValidateOrgAdmin = validateOrganizationAdminAccess as any;
65+
mockValidateLabManager = validateLaboratoryManagerAccess as any;
66+
mockValidateLabTechnician = validateLaboratoryTechnicianAccess as any;
67+
68+
mockValidateOrgAdmin.mockReturnValue(true);
69+
mockValidateLabManager.mockReturnValue(false);
70+
mockValidateLabTechnician.mockReturnValue(false);
71+
});
72+
73+
it('returns 400 when id path parameter is missing', async () => {
74+
const result = await handler(createEvent(undefined), createContext(), () => {});
75+
76+
expect(result.statusCode).toBe(400);
77+
});
78+
79+
it('returns 404 when run is not found', async () => {
80+
(mockRunService.prototype.queryByRunId as jest.Mock).mockResolvedValue(undefined);
81+
82+
const result = await handler(createEvent('run-1'), createContext(), () => {});
83+
84+
expect(result.statusCode).toBe(404);
85+
});
86+
87+
it('denies access when user does not have org or lab role', async () => {
88+
(mockRunService.prototype.queryByRunId as jest.Mock).mockResolvedValue({
89+
OrganizationId: 'org-1',
90+
LaboratoryId: 'lab-1',
91+
RunId: 'run-1',
92+
});
93+
mockValidateOrgAdmin.mockReturnValue(false);
94+
mockValidateLabManager.mockReturnValue(false);
95+
mockValidateLabTechnician.mockReturnValue(false);
96+
97+
const result = await handler(createEvent('run-1'), createContext(), () => {});
98+
99+
expect(result.statusCode).toBe(403);
100+
});
101+
102+
it('deletes run successfully when user is authorized', async () => {
103+
(mockRunService.prototype.queryByRunId as jest.Mock).mockResolvedValue({
104+
OrganizationId: 'org-1',
105+
LaboratoryId: 'lab-1',
106+
RunId: 'run-1',
107+
});
108+
(mockRunService.prototype.delete as jest.Mock).mockResolvedValue(true);
109+
110+
const result = await handler(createEvent('run-1'), createContext(), () => {});
111+
112+
expect(result.statusCode).toBe(200);
113+
const body = JSON.parse(result.body);
114+
expect(body.Status).toBe('Success');
115+
expect(mockRunService.prototype.delete).toHaveBeenCalled();
116+
});
117+
118+
it('returns 500-style error when delete fails (LaboratoryRunDeleteFailedError)', async () => {
119+
(mockRunService.prototype.queryByRunId as jest.Mock).mockResolvedValue({
120+
OrganizationId: 'org-1',
121+
LaboratoryId: 'lab-1',
122+
RunId: 'run-1',
123+
});
124+
(mockRunService.prototype.delete as jest.Mock).mockResolvedValue(false);
125+
126+
const result = await handler(createEvent('run-1'), createContext(), () => {});
127+
128+
expect(result.statusCode).toBe(500);
129+
});
130+
});

0 commit comments

Comments
 (0)