diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 2176f6854c..c834ec2839 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -184,7 +184,7 @@ export async function createRouter( response.json({ status: 'ok' }); }); - router.get('/notifications', async (req, res) => { + router.get('/', async (req, res) => { const user = await getUser(req); const opts: NotificationGetOptions = { user: user, @@ -274,7 +274,7 @@ export async function createRouter( // Add new notification // Allowed only for service-to-service authentication, uses `getUsersForEntityRef` to retrieve recipients for // specific entity reference - router.post('/notifications', async (req, res) => { + router.post('/', async (req, res) => { const { recipients, origin, payload } = req.body; const notifications = []; let users = []; diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index fd27d3c63c..b7393c1123 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -22,7 +22,9 @@ "postpack": "backstage-cli package postpack" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "msw": "^1.0.0" }, "files": [ "dist" diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.test.ts b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts new file mode 100644 index 0000000000..a9b204ca7f --- /dev/null +++ b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; +import { + DefaultNotificationService, + NotificationSendOptions, +} from './DefaultNotificationService'; + +const server = setupServer(); + +const testNotification: NotificationPayload = { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', +}; + +describe('DefaultNotificationService', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/notifications'; + const discoveryApi = { + getBaseUrl: async () => mockBaseUrl, + getExternalBaseUrl: async () => mockBaseUrl, + }; + const tokenManager = { + getToken: async () => ({ token: '1234' }), + authenticate: jest.fn(), + }; + + let service: DefaultNotificationService; + beforeEach(() => { + service = DefaultNotificationService.create({ + discovery: discoveryApi, + tokenManager, + pluginId: 'test', + }); + }); + + describe('getNotifications', () => { + it('should create notification', async () => { + const body: NotificationSendOptions = { + recipients: { type: 'entity', entityRef: ['user:default/john.doe'] }, + payload: testNotification, + }; + + server.use( + rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => { + const json = await req.json(); + expect(json).toEqual({ ...body, origin: 'plugin-test' }); + expect(req.headers.get('Authorization')).toEqual('Bearer 1234'); + return res(ctx.status(200)); + }), + ); + await expect(service.send(body)).resolves.not.toThrow(); + }); + + it('should throw error if failing', async () => { + const body: NotificationSendOptions = { + recipients: { type: 'entity', entityRef: ['user:default/john.doe'] }, + payload: testNotification, + }; + + server.use( + rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => { + const json = await req.json(); + expect(json).toEqual({ ...body, origin: 'plugin-test' }); + expect(req.headers.get('Authorization')).toEqual('Bearer 1234'); + return res(ctx.status(400)); + }), + ); + await expect(service.send(body)).rejects.toThrow(); + }); + }); +}); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 0f28f3bc18..7e5cf58f53 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -60,7 +60,7 @@ export class DefaultNotificationService implements NotificationService { try { const baseUrl = await this.discovery.getBaseUrl('notifications'); const { token } = await this.tokenManager.getToken(); - await fetch(`${baseUrl}/notifications`, { + const response = await fetch(`${baseUrl}/`, { method: 'POST', body: JSON.stringify({ ...notification, @@ -69,9 +69,14 @@ export class DefaultNotificationService implements NotificationService { }), headers: { 'Content-Type': 'application/json', + Accept: 'application/json', Authorization: `Bearer ${token}`, }, }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } } catch (error) { // TODO: Should not throw in optimal case, see BEP throw new Error(`Failed to send notifications: ${error}`); diff --git a/plugins/notifications/src/api/NotificationsClient.test.ts b/plugins/notifications/src/api/NotificationsClient.test.ts new file mode 100644 index 0000000000..a7a2d1e5a7 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsClient.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { NotificationsClient } from './NotificationsClient'; +import { Notification } from '@backstage/plugin-notifications-common'; + +const server = setupServer(); + +const testNotification: Partial = { + user: 'user:default/john.doe', + origin: 'plugin-test', + payload: { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', + }, +}; + +describe('NotificationsClient', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/notifications'; + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; + const fetchApi = new MockFetchApi(); + + let client: NotificationsClient; + beforeEach(() => { + client = new NotificationsClient({ discoveryApi, fetchApi }); + }); + + describe('getNotifications', () => { + const expectedResp = [testNotification]; + + it('should fetch notifications from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (_, res, ctx) => + res(ctx.json(expectedResp)), + ), + ); + const response = await client.getNotifications(); + expect(response).toEqual(expectedResp); + }); + + it('should fetch notifications with options', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?type=undone&limit=10&offset=0&search=find+me', + ); + return res(ctx.json(expectedResp)); + }), + ); + const response = await client.getNotifications({ + type: 'undone', + limit: 10, + offset: 0, + search: 'find me', + }); + expect(response).toEqual(expectedResp); + }); + + it('should fetch status from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/status`, (_, res, ctx) => + res(ctx.json({ read: 1, unread: 1 })), + ), + ); + const response = await client.getStatus(); + expect(response).toEqual({ read: 1, unread: 1 }); + }); + + it('should update notifications', async () => { + server.use( + rest.post(`${mockBaseUrl}/update`, async (req, res, ctx) => { + expect(await req.json()).toEqual({ + ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'], + done: true, + }); + return res(ctx.json(expectedResp)); + }), + ); + const response = await client.updateNotifications({ + ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'], + done: true, + }); + expect(response).toEqual(expectedResp); + }); + }); +}); diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 3dd5651ec5..fe7a90d3e8 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -45,17 +45,17 @@ export class NotificationsClient implements NotificationsApi { if (options?.type) { queryString.append('type', options.type); } - if (options?.limit) { + if (options?.limit !== undefined) { queryString.append('limit', options.limit.toString(10)); } - if (options?.offset) { + if (options?.offset !== undefined) { queryString.append('offset', options.offset.toString(10)); } if (options?.search) { queryString.append('search', options.search); } - const urlSegment = `notifications?${queryString}`; + const urlSegment = `?${queryString}`; return await this.request(urlSegment); } diff --git a/yarn.lock b/yarn.lock index 06d186eaab..e4224b56d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7830,7 +7830,9 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-signals-node": "workspace:^" + "@backstage/test-utils": "workspace:^" knex: ^3.0.0 + msw: ^1.0.0 uuid: ^8.0.0 languageName: unknown linkType: soft