Skip to content

Commit

Permalink
Update facia-responder to notify SNS queue on publish succeed/fail
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathonherbert committed Jul 31, 2024
1 parent e550657 commit a52cb9f
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 152 deletions.
4 changes: 4 additions & 0 deletions lambda/facia-responder/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { mandatoryParameter } from 'lib/recipes-data/src/lib/config';

Check failure on line 1 in lambda/facia-responder/src/config.ts

View workflow job for this annotation

GitHub Actions / build

Module '"lib/recipes-data/src/lib/config"' declares 'mandatoryParameter' locally, but it is not exported.

export const faciaPublicationStatusTopicArn = mandatoryParameter("FACIA_PUBLISH_STATUS_TOPIC_ARN");
export const faciaPublicationStatusRoleArn = mandatoryParameter("FACIA_PUBLISH_STATUS_ROLE_ARN");
47 changes: 47 additions & 0 deletions lambda/facia-responder/src/facia-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { SNS } from '@aws-sdk/client-sns';
import { TemporaryCredentials } from 'aws-sdk';
import { faciaPublicationStatusRoleArn, faciaPublicationStatusTopicArn } from './config';

// The publication status event we send over SNS.
type PublicationStatusEventEnvelope = {
event: PublicationStatusEvent;
};

export type PublicationStatusEvent = {
edition: string;
issueDate: string;
version: string;
status:
| 'Started'
| 'Proofing'
| 'Proofed'
| 'Publishing'
| 'Published'
| 'Failed'
| 'PostProcessing';
message: string;
timestamp: number;
};

export async function notifyFaciaTool(
event: PublicationStatusEvent,
): Promise<void> {
const payload = JSON.stringify({ event } as PublicationStatusEventEnvelope);

console.log(`Publishing publish event to SNS: ${payload}`);

const sns = new SNS({
region: 'eu-west-1',
credentials: new TemporaryCredentials({
RoleArn: faciaPublicationStatusRoleArn,
RoleSessionName: 'front-assume-role-access-for-sns',
}),
});

const sendStatus = await sns.publish({
TopicArn: faciaPublicationStatusTopicArn,
Message: payload,
});

console.log(`SNS status publish response: ${JSON.stringify(sendStatus)}`);
}
48 changes: 48 additions & 0 deletions lambda/facia-responder/src/fixtures/sns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export const validMessageContent = {
id: 'D9AEEA41-F8DB-4FC8-A0DA-275571EA7331',
edition: 'feast-northern-hemisphere',
version: 'v1',
issueDate: '2024-01-02',
fronts: {
'all-recipes': [
{
id: 'd353e2de-1a65-45de-85ca-d229bc1fafad',
title: 'Dish of the day',
body: '',
items: [
{
recipe: {
id: '14129325',
},
},
],
},
],
'meat-free': [
{
id: 'fa6ccb35-926b-4eff-b3a9-5d0ca88387ff',
title: 'Dish of the day',
body: '',
items: [
{
recipe: {
id: '14132263',
},
},
],
},
],
},
};

export const validMessage = {
Message: JSON.stringify(validMessageContent),
};

export const brokenMessage = {
...validMessage,
Message: JSON.stringify({
...validMessageContent,
issueDate: 'dfsdfsjk',
}),
};
271 changes: 145 additions & 126 deletions lambda/facia-responder/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,131 +1,150 @@
import {deployCurationData} from "@recipes-api/lib/recipes-data";
import {ZodError} from "zod";
import {handler} from "./main";
import { deployCurationData } from '@recipes-api/lib/recipes-data';
import { ZodError } from 'zod';
import { notifyFaciaTool } from './facia-notifications';
import {
brokenMessage,
validMessage,
validMessageContent,
} from './fixtures/sns';
import { handler } from './main';

jest.mock("@recipes-api/lib/recipes-data", ()=>({
deployCurationData: jest.fn()
jest.mock('@recipes-api/lib/recipes-data', () => ({
deployCurationData: jest.fn(),
}));

const message = {
id: "D9AEEA41-F8DB-4FC8-A0DA-275571EA7331",
edition: "feast-northern-hemisphere",
version: "v1",
issueDate: "2024-01-02",
fronts: {
"all-recipes": [
{
"id": "d353e2de-1a65-45de-85ca-d229bc1fafad",
"title": "Dish of the day",
"body": "",
"items": [
{
"recipe": {
"id": "14129325"
}
}
]
}
],
"meat-free": [
{
"id": "fa6ccb35-926b-4eff-b3a9-5d0ca88387ff",
"title": "Dish of the day",
"body": "",
"items": [
{
"recipe": {
"id": "14132263"
}
}
]
}
]
}
};

const rawContent = {
Message: JSON.stringify(message)
}

describe("main", ()=>{
it("should publish the content it was given", async ()=>{
const rec = {
Records: [
{
eventSource: "sqs",
awsRegion: "xx-north-n",
messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5",
body: JSON.stringify(rawContent)
}
]
};

// @ts-ignore
await handler(rec, null, null);

const importCurationDataMock = deployCurationData as jest.Mock;
expect(importCurationDataMock.mock.calls.length).toEqual(2);
expect(importCurationDataMock.mock.calls[0][0]).toEqual(JSON.stringify(message.fronts["all-recipes"]));
expect(importCurationDataMock.mock.calls[0][1]).toEqual(message.edition);
expect(importCurationDataMock.mock.calls[0][2]).toEqual("all-recipes");
expect(importCurationDataMock.mock.calls[0][3]).toEqual(new Date(2024, 0, 2));

expect(importCurationDataMock.mock.calls[1][0]).toEqual(JSON.stringify(message.fronts["meat-free"]));
expect(importCurationDataMock.mock.calls[1][1]).toEqual(message.edition);
expect(importCurationDataMock.mock.calls[1][2]).toEqual("meat-free");
expect(importCurationDataMock.mock.calls[1][3]).toEqual(new Date(2024, 0, 2));
});


it("should not accept valid json that does not match schema", async ()=>{
const brokenContent = {
...rawContent,
Message: JSON.stringify({
...message,
issueDate: 'dfsdfsjk',
}),
jest.mock('./config', () => ({
faciaPublicationStatusTopicArn: 'config-param',
faciaPublicationStatusRoleArn: 'config-param',
}));

jest.mock('./facia-notifications', () => ({
notifyFaciaTool: jest.fn(),
}));

const importCurationDataMock = deployCurationData as jest.Mock;
const notifyFaciaToolMock = notifyFaciaTool as jest.Mock;

describe('main', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should publish the content it was given', async () => {
const rec = {
Records: [
{
eventSource: 'sqs',
awsRegion: 'xx-north-n',
messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5',
body: JSON.stringify(validMessage),
},
],
};

const rec = {
Records: [
{
eventSource: "sqs",
awsRegion: "xx-north-n",
messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5",
body: JSON.stringify(brokenContent)
}
]
};

const expectedError = new ZodError([
{
"code": "custom",
"fatal": true,
"path": [
"issueDate"
],
"message": "Invalid input"
}
])

// @ts-ignore
await expect(()=>handler(rec, null, null)).rejects.toEqual(expectedError);
});

it("should not accept invalid json", async ()=>{
const rec = {
Records: [
{
eventSource: "sqs",
awsRegion: "xx-north-n",
messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5",
body: "blahblahblahthisisnotjson"
}
]
};

const expectedError = new SyntaxError("Unexpected token b in JSON at position 0");
// @ts-ignore
await expect(()=>handler(rec, null, null)).rejects.toEqual(expectedError);
})
})
// @ts-ignore
await handler(rec, null, null);

expect(importCurationDataMock.mock.calls.length).toEqual(2);
expect(importCurationDataMock.mock.calls[0][0]).toEqual(
JSON.stringify(validMessageContent.fronts['all-recipes']),
);
expect(importCurationDataMock.mock.calls[0][1]).toEqual(
validMessageContent.edition,
);
expect(importCurationDataMock.mock.calls[0][2]).toEqual('all-recipes');
expect(importCurationDataMock.mock.calls[0][3]).toEqual(
new Date(2024, 0, 2),
);

expect(importCurationDataMock.mock.calls[1][0]).toEqual(
JSON.stringify(validMessageContent.fronts['meat-free']),
);
expect(importCurationDataMock.mock.calls[1][1]).toEqual(
validMessageContent.edition,
);
expect(importCurationDataMock.mock.calls[1][2]).toEqual('meat-free');
expect(importCurationDataMock.mock.calls[1][3]).toEqual(
new Date(2024, 0, 2),
);

const notifyFaciaToolMock = notifyFaciaTool as jest.Mock;
expect(notifyFaciaToolMock.mock.calls[0][0]).toMatchObject({
edition: 'feast-northern-hemisphere',
issueDate: '2024-01-02',
message: 'This issue has been published',
status: 'Published',
version: 'v1',
});
});

it('should not accept valid json that does not match schema', async () => {
const rec = {
Records: [
{
eventSource: 'sqs',
awsRegion: 'xx-north-n',
messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5',
body: JSON.stringify(brokenMessage),
},
],
};

const expectedError = new ZodError([
{
code: 'custom',
fatal: true,
path: ['issueDate'],
message: 'Invalid input',
},
]);

// @ts-ignore
await expect(() => handler(rec, null, null)).rejects.toEqual(expectedError);
});

it('should not accept invalid json', async () => {
const rec = {
Records: [
{
eventSource: 'sqs',
awsRegion: 'xx-north-n',
messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5',
body: 'blahblahblahthisisnotjson',
},
],
};

const expectedError = new SyntaxError(
'Unexpected token b in JSON at position 0',
);
// @ts-ignore
await expect(() => handler(rec, null, null)).rejects.toEqual(expectedError);
});

it('should notify when the deploy fails', async () => {
const rec = {
Records: [
{
eventSource: 'sqs',
awsRegion: 'xx-north-n',
messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5',
body: JSON.stringify(validMessage),
},
],
};

const expectedError = new Error('Error deploying content');
importCurationDataMock.mockRejectedValueOnce(expectedError);

// @ts-ignore
await handler(rec, null, null);

expect(notifyFaciaToolMock.mock.calls[0][0]).toMatchObject({
edition: 'feast-northern-hemisphere',
issueDate: '2024-01-02',
message: 'Failed to publish this issue. Error: Error deploying content',
status: 'Failed',
version: 'v1',
});
});
});
Loading

0 comments on commit a52cb9f

Please sign in to comment.