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
8 changes: 7 additions & 1 deletion apps/backend/src/datasources/mockups/SampleDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ export class SampleDataSourceMock implements SampleDataSource {
return this.samples.find((sample) => sample.id === sampleId) || null;
}

async getSamples(_args: SamplesArgs): Promise<Sample[]> {
async getSamples(args: SamplesArgs): Promise<Sample[]> {
if (args.filter?.proposalPk) {
return this.samples.filter(
(sample) => sample.proposalPk === args.filter?.proposalPk
);
}

return this.samples;
}

Expand Down
22 changes: 19 additions & 3 deletions apps/backend/src/datasources/mockups/UserDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,11 @@ export class UserDataSourceMock implements UserDataSource {
institution: Institution;
country: Country;
} | null> {
return null;
return {
user: dummyUser,
institution: { id: 1, name: 'Test Institution', country: 1 },
country: { countryId: 1, country: 'Test Country' },
};
}

async getUsers(
Expand Down Expand Up @@ -416,7 +420,13 @@ export class UserDataSourceMock implements UserDataSource {
country: Country;
}[]
> {
return [];
return [
{
user: dummyUser,
institution: { id: 1, name: 'Test Institution', country: 1 },
country: { countryId: 1, country: 'Test Country' },
},
];
}

async checkScientistToProposal(
Expand Down Expand Up @@ -568,6 +578,12 @@ export class UserDataSourceMock implements UserDataSource {
country: Country;
}[]
> {
return [];
return [
{
user: dummyUser,
institution: { id: 1, name: 'Test Institution', country: 1 },
country: { countryId: 1, country: 'Test Country' },
},
];
}
}
252 changes: 252 additions & 0 deletions apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { container } from 'tsyringe';

import { Tokens } from '../config/Tokens';
import { StatusDataSourceMock } from '../datasources/mockups/StatusDataSource';
import * as eventBusModule from '../events';
import { ApplicationEvent } from '../events/applicationEvents';
import { Event } from '../events/event.enum';
import * as workflowEngineModule from '../workflowEngine/experiment';
import { WorkflowEngineExperimentType } from '../workflowEngine/experiment';
import createExperimentSafetyWorkflowHandler, {
handleWorkflowEngineChange,
} from './experimentSafetyWorkflow';

const mockPublish = jest.fn();

let spyMarkEvent: jest.SpyInstance;
let spyResolveEventBus: jest.SpyInstance;
let mockStatusDataSource: StatusDataSourceMock;

beforeAll(() => {
spyMarkEvent = jest
.spyOn(
workflowEngineModule,
'markExperimentSafetyEventAsDoneAndCallWorkflowEngine'
)
.mockResolvedValue([]);

spyResolveEventBus = jest
.spyOn(eventBusModule, 'resolveApplicationEventBus')
.mockReturnValue({ publish: mockPublish } as any);
});

afterAll(() => {
spyMarkEvent.mockRestore();
spyResolveEventBus.mockRestore();
});

beforeEach(() => {
jest.clearAllMocks();

spyMarkEvent.mockResolvedValue([]);
spyResolveEventBus.mockReturnValue({ publish: mockPublish } as any);

mockStatusDataSource = container.resolve(Tokens.StatusDataSource);
jest.spyOn(mockStatusDataSource, 'getStatus');
});

const createMockEvent = (
overrides: Partial<ApplicationEvent> = {}
): ApplicationEvent =>
({
type: Event.EXPERIMENT_ESF_SUBMITTED,
isRejection: false,
key: 'experimentsafety',
loggedInUserId: null,
experimentsafety: { experimentPk: 42 },
...overrides,
}) as unknown as ApplicationEvent;

const createMockUpdatedExperiment = (
overrides: Partial<WorkflowEngineExperimentType> = {}
): WorkflowEngineExperimentType =>
({
experimentSafetyPk: 1,
experimentPk: 42,
statusId: 10,
prevStatusId: 5,
workflowId: 1,
callShortCode: 'CALL-1',
...overrides,
}) as WorkflowEngineExperimentType;

describe('experimentSafetyWorkflowHandler', () => {
describe('Early exit conditions', () => {
test('should return early if event.isRejection is true', async () => {
const handler = createExperimentSafetyWorkflowHandler();
const event = createMockEvent({ isRejection: true });

await handler(event);

expect(spyMarkEvent).not.toHaveBeenCalled();
});

test('should return early if event has no experimentPk anywhere', async () => {
const handler = createExperimentSafetyWorkflowHandler();
const event = {
type: Event.PROPOSAL_CREATED,
isRejection: false,
key: 'proposal',
loggedInUserId: null,
proposal: { proposalPk: 1, title: 'Test Proposal' },
} as unknown as ApplicationEvent;

await handler(event);

expect(spyMarkEvent).not.toHaveBeenCalled();
});
});

describe('Extracting experimentPk from events', () => {
test('should find experimentPk in a flat event object (e.g., experiment key)', async () => {
const handler = createExperimentSafetyWorkflowHandler();
const event = createMockEvent({
type: Event.EXPERIMENT_ESF_SUBMITTED,
experimentsafety: { experimentPk: 100 },
} as unknown as Partial<ApplicationEvent>);

await handler(event);

expect(spyMarkEvent).toHaveBeenCalledWith(
Event.EXPERIMENT_ESF_SUBMITTED,
[100]
);
});

test('should find experimentPk in a nested object (e.g., experimentsafety)', async () => {
const handler = createExperimentSafetyWorkflowHandler();
const event = {
type: Event.EXPERIMENT_ESF_SUBMITTED,
isRejection: false,
key: 'experimentsafety',
loggedInUserId: null,
experimentsafety: { experimentPk: 77, experimentSafetyPk: 1 },
} as unknown as ApplicationEvent;

await handler(event);

expect(spyMarkEvent).toHaveBeenCalledWith(
Event.EXPERIMENT_ESF_SUBMITTED,
[77]
);
});
});

describe('Non-experiment events are ignored', () => {
test('should not call workflow engine for events with no experiment info', async () => {
const handler = createExperimentSafetyWorkflowHandler();
const event = {
type: Event.PROPOSAL_UPDATED,
isRejection: false,
key: 'proposal',
loggedInUserId: null,
proposal: { proposalPk: 5, title: 'Another' },
} as unknown as ApplicationEvent;

await handler(event);

expect(spyMarkEvent).not.toHaveBeenCalled();
});
});
});

describe('handleWorkflowEngineChange', () => {
test('should wrap a single pk into an array and call workflow engine', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });

await handleWorkflowEngineChange(event, 42);

expect(spyMarkEvent).toHaveBeenCalledWith(Event.EXPERIMENT_ESF_SUBMITTED, [
42,
]);
});

test('should pass an array of pks directly to workflow engine', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });

await handleWorkflowEngineChange(event, [10, 20, 30]);

expect(spyMarkEvent).toHaveBeenCalledWith(
Event.EXPERIMENT_ESF_SUBMITTED,
[10, 20, 30]
);
});

describe('publishExperimentSafetyStatusChange', () => {
test('should publish status change events when workflow engine returns updated experiments', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });
const updatedExperiment = createMockUpdatedExperiment({
statusId: 10,
prevStatusId: 5,
});

spyMarkEvent.mockResolvedValue([updatedExperiment]);

jest
.spyOn(mockStatusDataSource, 'getStatus')
.mockResolvedValueOnce({ name: 'Approved' } as any) // new statusId (called first)
.mockResolvedValueOnce({ name: 'Draft' } as any); // prevStatusId (called second)

await handleWorkflowEngineChange(event, 42);

await new Promise(process.nextTick);

expect(mockPublish).toHaveBeenCalledWith(
expect.objectContaining({
type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW,
experimentsafety: updatedExperiment,
isRejection: false,
})
);
});

test('should NOT publish status change when event type is EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER', async () => {
const event = createMockEvent({
type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER,
experimentsafety: { experimentPk: 42 },
} as unknown as Partial<ApplicationEvent>);

const updatedExperiment = createMockUpdatedExperiment();
spyMarkEvent.mockResolvedValue([updatedExperiment]);

await handleWorkflowEngineChange(event, 42);
await new Promise(process.nextTick);

expect(mockPublish).not.toHaveBeenCalled();
});

test('should not publish if workflow engine returns an empty array', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });
spyMarkEvent.mockResolvedValue([]);

await handleWorkflowEngineChange(event, 42);
await new Promise(process.nextTick);

expect(mockPublish).not.toHaveBeenCalled();
});

test('should not publish if updated experiment has no statusId', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });
const updatedExperiment = createMockUpdatedExperiment({
statusId: undefined as unknown as number,
});

spyMarkEvent.mockResolvedValue([updatedExperiment]);

await handleWorkflowEngineChange(event, 42);
await new Promise(process.nextTick);

expect(mockPublish).not.toHaveBeenCalled();
});

test('should not publish if workflow engine returns null', async () => {
const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED });
spyMarkEvent.mockResolvedValue(null);

await handleWorkflowEngineChange(event, 42);
await new Promise(process.nextTick);

expect(mockPublish).not.toHaveBeenCalled();
});
});
});
Loading
Loading