Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PIMS-1928: Cancel and resend notifications #2616

Merged
merged 10 commits into from
Aug 12, 2024
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import notificationServices from '@/services/notifications/notificationServices';
import notificationServices, {
NotificationStatus,
} from '@/services/notifications/notificationServices';
import userServices from '@/services/users/usersServices';
import { SSOUser } from '@bcgov/citz-imb-sso-express';
import { Request, Response } from 'express';
Expand Down Expand Up @@ -48,3 +50,39 @@ export const getNotificationsByProjectId = async (req: Request, res: Response) =
return res.status(500).send({ message: 'Error fetching notifications' });
}
};

export const resendNotificationById = async (req: Request, res: Response) => {
const id = Number(req.params.id);
const notification = await notificationServices.getNotificationById(id);
if (!notification) {
return res.status(404).send('Notification not found.');
}
const kcUser = req.user;
if (!isAdmin(kcUser)) {
return res.status(403).send({ message: 'User is not authorized to access this endpoint.' });
}
const resultantNotification = await notificationServices.sendNotification(notification, kcUser);
const user = await userServices.getUser(kcUser.preferred_username);
const updatedNotification = await notificationServices.updateNotificationStatus(
resultantNotification.Id,
user,
);
return res.status(200).send(updatedNotification);
};

export const cancelNotificationById = async (req: Request, res: Response) => {
const id = Number(req.params.id);
const notification = await notificationServices.getNotificationById(id);
if (!notification) {
return res.status(404).send('Notification not found.');
}
const kcUser = req.user;
if (!isAdmin(kcUser)) {
return res.status(403).send({ message: 'User is not authorized to access this endpoint.' });
}
const resultantNotification = await notificationServices.cancelNotificationById(notification.Id);
if (resultantNotification.Status !== NotificationStatus.Cancelled) {
return res.status(400).send(resultantNotification);
}
return res.status(200).send(resultantNotification);
};
12 changes: 10 additions & 2 deletions express-api/src/routes/notificationsRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ export const DISPOSAL_API_ROUTE = '/projects/disposal';
export const NOTIFICATION_QUEUE_ROUTE = '/queue';
export const NOTIFICATION_TEMPLATE_ROUTE = '/templates';

const { getNotificationsByProjectId } = controllers;
const { getNotificationsByProjectId, resendNotificationById, cancelNotificationById } = controllers;

//I believe that the IDs used in these routes are actually the project ID, even though the structure here sort of implies
//that it might be an individual "notification id".
router
.route(`${NOTIFICATION_QUEUE_ROUTE}`)
.route(NOTIFICATION_QUEUE_ROUTE)
.get(activeUserCheck, catchErrors(getNotificationsByProjectId));

router
.route(`${NOTIFICATION_QUEUE_ROUTE}/:id`)
.put(activeUserCheck, catchErrors(resendNotificationById));

router
.route(`${NOTIFICATION_QUEUE_ROUTE}/:id`)
.delete(activeUserCheck, catchErrors(cancelNotificationById));
dbarkowsky marked this conversation as resolved.
Show resolved Hide resolved

export default router;
2 changes: 1 addition & 1 deletion express-api/src/services/ches/chesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ const getStatusByIdAsync = async (messageId: string): Promise<IChesStatusRespons
if (!response) {
throw new Error(`Failed to fetch status for messageId ${messageId}`);
}
return await response;
return response;
} catch (error) {
logger.error(`Error fetching status for messageId ${messageId}:`, error);
return null;
Expand Down
21 changes: 21 additions & 0 deletions express-api/src/services/notifications/notificationServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,25 @@ const generateProjectWatchNotifications = async (
return notificationsInserted;
};

const cancelNotificationById = async (id: number) => {
const notification = await AppDataSource.getRepository(NotificationQueue).findOne({
where: { Id: id },
});
const chesResult = await chesServices.cancelEmailByIdAsync(notification.ChesMessageId);
if (chesResult.status === 'cancelled') {
return AppDataSource.getRepository(NotificationQueue).save({
Id: notification.Id,
Status: NotificationStatus.Cancelled,
});
} else {
return notification;
}
};

const getNotificationById = async (id: number) => {
return AppDataSource.getRepository(NotificationQueue).findOne({ where: { Id: id } });
};

const notificationServices = {
generateProjectNotifications,
generateAccessRequestNotification,
Expand All @@ -698,6 +717,8 @@ const notificationServices = {
updateNotificationStatus,
getProjectNotificationsInQueue,
convertChesStatusToNotificationStatus,
getNotificationById,
cancelNotificationById,
cancelProjectNotifications,
};

Expand Down
13 changes: 5 additions & 8 deletions express-api/tests/testUtils/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ import { Task } from '@/typeorm/Entities/Task';
import { ProjectTask } from '@/typeorm/Entities/ProjectTask';
import { ProjectStatusNotification } from '@/typeorm/Entities/ProjectStatusNotification';
import { NotificationQueue } from '@/typeorm/Entities/NotificationQueue';
import {
NotificationAudience,
NotificationStatus,
} from '@/services/notifications/notificationServices';
import { NotificationAudience } from '@/services/notifications/notificationServices';
import { NotificationTemplate } from '@/typeorm/Entities/NotificationTemplate';
import { BuildingFiscal } from '@/typeorm/Entities/BuildingFiscal';
import { BuildingEvaluation } from '@/typeorm/Entities/BuildingEvaluation';
Expand Down Expand Up @@ -916,13 +913,13 @@ export const produceNotificationQueue = (props?: Partial<NotificationQueue>) =>
const queue: NotificationQueue = {
Id: faker.number.int(),
Key: randomUUID(),
Status: NotificationStatus.Pending,
Priority: EmailPriority.Normal,
Encoding: EmailEncoding.Utf8,
Status: 1,
Priority: 'normal',
Encoding: 'utf-8',
SendOn: new Date(),
To: faker.internet.email(),
Subject: faker.lorem.word(),
BodyType: EmailBody.Html,
BodyType: 'html',
Body: faker.lorem.sentences(),
Bcc: '',
Cc: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import {
produceUser,
produceSSO,
produceProject,
produceNotificationQueue,
} from 'tests/testUtils/factories';
import projectServices from '@/services/projects/projectsServices';
import { randomUUID } from 'crypto';
import { NotificationQueue } from '@/typeorm/Entities/NotificationQueue';
import { Roles } from '@/constants/roles';
import { NotificationStatus } from '@/services/notifications/notificationServices';

const _getUser = jest
.fn()
Expand All @@ -19,14 +24,33 @@ const _getProjectById = jest.fn().mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (_id: number) => ({ AgencyId: 1 }),
);
const _getNotifById = jest
.fn()
.mockImplementation((id: number) => produceNotificationQueue({ Id: id }));
const _cancelNotifById = jest
.fn()
.mockImplementation((id: number) => produceNotificationQueue({ Id: id, Status: 2 }));
const _updateNotifStatus = jest
.fn()
.mockImplementation((id: number) => produceNotificationQueue({ Id: id }));

jest.mock('@/services/users/usersServices', () => ({
getAgencies: () => _getAgencies(),
getUser: () => _getUser(),
}));

jest.mock('@/services/notifications/notificationServices', () => ({
...jest.requireActual('@/services/notifications/notificationServices'),
getProjectNotificationsInQueue: () => _getProjectNotificationsInQueue(),
getNotificationById: (id: number) => _getNotifById(id),
cancelNotificationById: (id: number) => _cancelNotifById(id),
sendNotification: (notification: NotificationQueue) =>
produceNotificationQueue({
...notification,
ChesMessageId: randomUUID(),
ChesTransactionId: randomUUID(),
}),
updateNotificationStatus: (id: number) => _updateNotifStatus(id),
}));

jest.mock('@/services/projects/projectsServices', () => ({
Expand All @@ -42,58 +66,116 @@ describe('UNIT - Testing controllers for notifications routes.', () => {
mockResponse = mockRes;
});

it('should return 400 if filter parsing fails', async () => {
mockRequest.query = { invalidField: 'invalidValue' };
describe('getNotificationsByProjectId', () => {
it('should return 400 if filter parsing fails', async () => {
mockRequest.query = { invalidField: 'invalidValue' };

await controllers.getNotificationsByProjectId(mockRequest, mockResponse);
await controllers.getNotificationsByProjectId(mockRequest, mockResponse);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.send).toHaveBeenCalledWith({ message: 'Could not parse filter.' });
});
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.send).toHaveBeenCalledWith({ message: 'Could not parse filter.' });
});

it('should return 400 if no valid filter provided', async () => {
await controllers.getNotificationsByProjectId(mockRequest, mockResponse);
it('should return 400 if no valid filter provided', async () => {
await controllers.getNotificationsByProjectId(mockRequest, mockResponse);

expect(mockResponse.statusValue).toBe(400);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.send).toHaveBeenCalledWith({
message: 'Could not parse filter.',
expect(mockResponse.statusValue).toBe(400);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.send).toHaveBeenCalledWith({
message: 'Could not parse filter.',
});
});
});

it('should return 403 if user is not authorized', async () => {
const kcUser = produceSSO();
mockRequest.user = kcUser;
mockRequest.query = { projectId: '1' };
it('should return 403 if user is not authorized', async () => {
const kcUser = produceSSO();
mockRequest.user = kcUser;
mockRequest.query = { projectId: '1' };

await controllers.getNotificationsByProjectId(mockRequest, mockResponse);
await controllers.getNotificationsByProjectId(mockRequest, mockResponse);

expect(mockResponse.status).toHaveBeenCalledWith(403);
});
expect(mockResponse.status).toHaveBeenCalledWith(403);
});

it('should return 200 and notifications if user is authorized', async () => {
const mockRequest = {
query: { projectId: '123' },
user: { agencies: [1] },
} as unknown as Request;
it('should return 200 and notifications if user is authorized', async () => {
const mockRequest = {
query: { projectId: '123' },
user: { agencies: [1] },
} as unknown as Request;

const mockProject = produceProject({
Id: 123,
AgencyId: 1,
});
const mockProject = produceProject({
Id: 123,
AgencyId: 1,
});

const getProjectByIdSpy = jest
.spyOn(projectServices, 'getProjectById')
.mockResolvedValueOnce(mockProject);
const getProjectByIdSpy = jest
.spyOn(projectServices, 'getProjectById')
.mockResolvedValueOnce(mockProject);

const mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as Response;
const mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as Response;

await controllers.getNotificationsByProjectId(mockRequest, mockResponse);
await controllers.getNotificationsByProjectId(mockRequest, mockResponse);

expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(getProjectByIdSpy).toHaveBeenCalledWith(123);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(getProjectByIdSpy).toHaveBeenCalledWith(123);
});
});
describe('resendNotifcationById', () => {
it('should read a notification and try to send it through ches again, status 200', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [Roles.ADMIN] });
await controllers.resendNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(200);
expect(mockResponse.sendValue.Id).toBe(1);
});
it('should 404 if notif not found', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [Roles.ADMIN] });
_getNotifById.mockImplementationOnce(() => null);
await controllers.resendNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(404);
});
it('should 403 if user lacks permissions', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [] });
await controllers.resendNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(403);
});
});
describe('cancelNotificationById', () => {
it('should try to cancel a notification, status 200', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [Roles.ADMIN] });
await controllers.cancelNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(200);
expect(mockResponse.sendValue.Id).toBe(1);
expect(mockResponse.sendValue.Status).toBe(NotificationStatus.Cancelled);
});
it('should 404 if no notif found', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [Roles.ADMIN] });
_getNotifById.mockImplementationOnce(() => null);
await controllers.cancelNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(404);
});
it('should 400 if the notification came back with non-cancelled status', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [Roles.ADMIN] });
_cancelNotifById.mockImplementationOnce((id: number) =>
produceNotificationQueue({ Id: id, Status: NotificationStatus.Completed }),
);
await controllers.cancelNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(400);
expect(mockResponse.sendValue.Id).toBe(1);
expect(mockResponse.sendValue.Status).not.toBe(NotificationStatus.Cancelled);
});
it('should 403 if user lacks permissions', async () => {
mockRequest.params.id = '1';
mockRequest.user = produceSSO({ client_roles: [] });
await controllers.cancelNotificationById(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(403);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,41 @@ describe('generateProjectWatchNotifications', () => {
expect(result.length).toBe(1);
});
});
describe('cancelNotificationById', () => {
it('should cancel a notificaion', async () => {
_cancelEmailByIdAsync.mockImplementationOnce(() => ({
status: 'cancelled',
tag: 'sampleTag',
txId: randomUUID(),
updatedTS: Date.now(),
createdTS: Date.now(),
msgId: randomUUID(),
}));
const result = await notificationServices.cancelNotificationById(1);
expect(result.Status).toBe(NotificationStatus.Cancelled);
});
it('should return unmodified notification in the case of a non cancelled notification', async () => {
_cancelEmailByIdAsync.mockImplementationOnce(() => ({
status: 'completed',
tag: 'sampleTag',
txId: randomUUID(),
updatedTS: Date.now(),
createdTS: Date.now(),
msgId: randomUUID(),
}));
const notif = produceNotificationQueue({ ChesMessageId: randomUUID() });
_notifQueueFindOne.mockImplementationOnce(() => notif);
const result = await notificationServices.cancelNotificationById(1);
expect(result.ChesMessageId).toBe(notif.ChesMessageId);
expect(result.Status).toBe(notif.Status);
});
});
describe('getNotificationById', () => {
it('should return a single notification', async () => {
const result = await notificationServices.getNotificationById(1);
expect(result).toBeDefined();
expect(result.Status).toBeDefined();
expect(result.TemplateId).toBeDefined();
expect(result.Id).toBeDefined();
});
});
Loading
Loading