diff --git a/__tests__/table-streams/synonyms.ts b/__tests__/table-streams/synonyms.ts index 67ecbf6c0..16e110cf2 100644 --- a/__tests__/table-streams/synonyms.ts +++ b/__tests__/table-streams/synonyms.ts @@ -8,13 +8,18 @@ import { tables } from '@architect/functions' import { search } from '@nasa-gcn/architect-functions-search' import type { DynamoDBRecord } from 'aws-lambda' +import crypto from 'crypto' -import type { Synonym } from '~/routes/synonyms/synonyms.lib' import { handler } from '~/table-streams/synonyms/index' -const synonymId = 'abcde-abcde-abcde-abcde-abcde' +const synonymId = crypto.randomUUID() const eventId = 'GRB 123' const existingEventId = 'GRB 99999999' +const additionalEventId = 'GRB 424242A' +const previousSynonymId = crypto.randomUUID() +const existingEventSlug = existingEventId.replace(' ', '-').toLowerCase() +const eventSlug = eventId.replace(' ', '-').toLowerCase() +const additionalEventSlug = additionalEventId.replace(' ', '-').toLowerCase() const putData = { index: 'synonym-groups', @@ -26,27 +31,10 @@ const putData = { }, } -jest.mock('@nasa-gcn/architect-functions-search', () => ({ - search: jest.fn(), -})) - -// Github-slugger is mocked to prevent jest failing to properly load the package. If Jest attempts -// to load it, it will encounter a syntax error. Since these eventIds do not have any characters that -// would be changed by the slugger, ensuring they are all lowercase is enough to mock the behavior -// of github-slugger in this case. -jest.mock('github-slugger', () => ({ - slug: (eventId: string) => { - return eventId.toLowerCase() - }, -})) - -jest.mock('@architect/functions', () => ({ - tables: jest.fn(), -})) - -const mockIndex = jest.fn() -const mockDelete = jest.fn() -const mockQuery = jest.fn() +const deleteData = { + index: 'synonym-groups', + id: previousSynonymId, +} const mockStreamEvent = { Records: [ @@ -64,6 +52,50 @@ const mockStreamEvent = { eventId: { S: eventId, }, + slug: { + S: eventSlug, + }, + }, + NewImage: { + synonymId: { + S: synonymId, + }, + eventId: { + S: eventId, + }, + slug: { + S: eventSlug, + }, + }, + SequenceNumber: '111', + SizeBytes: 26, + StreamViewType: 'NEW_IMAGE', + }, + eventSourceARN: + 'arn:aws:dynamodb:us-west-2:123456789012:table/synonym-groups/stream/2020-01-01T00:00:00.000', + } as DynamoDBRecord, + ], +} + +const mockStreamEventWithOldRecord = { + Records: [ + { + eventID: '1', + eventName: 'INSERT', + eventVersion: '1.0', + eventSource: 'aws:dynamodb', + awsRegion: 'us-west-2', + dynamodb: { + Keys: { + synonymId: { + S: synonymId, + }, + eventId: { + S: eventId, + }, + slug: { + S: eventSlug, + }, }, NewImage: { synonymId: { @@ -72,6 +104,20 @@ const mockStreamEvent = { eventId: { S: eventId, }, + slug: { + S: eventSlug, + }, + }, + OldImage: { + synonymId: { + S: previousSynonymId, + }, + eventId: { + S: eventId, + }, + slug: { + S: eventSlug, + }, }, SequenceNumber: '111', SizeBytes: 26, @@ -83,21 +129,47 @@ const mockStreamEvent = { ], } +// Github-slugger is mocked to prevent jest failing to properly load the package. If Jest attempts +// to load it, it will encounter a syntax error. Since all places where a slug would be created have been mocked, +// it doesn't need to return anything. +jest.mock('github-slugger', () => ({ + slug: jest.fn(), +})) + +jest.mock('@nasa-gcn/architect-functions-search', () => ({ + search: jest.fn(), +})) + +jest.mock('@architect/functions', () => ({ + tables: jest.fn(), +})) + +const mockIndex = jest.fn() +const mockDelete = jest.fn() +const mockQuery = jest.fn() + afterEach(() => { jest.clearAllMocks() }) -describe('testing put synonymGroup table-stream', () => { - test('insert new synonym group', async () => { - const mockItems = [{ synonymId, eventId }] +describe('testing synonymGroup table-stream', () => { + test('insert initial synonym record where no previous opensearch record exists', async () => { + putData.id = synonymId + putData.body.synonymId = synonymId + putData.body.eventIds = [eventId] + putData.body.slugs = [eventSlug] + + mockQuery.mockResolvedValue({ + Items: [{ synonymId, eventId, slug: eventSlug }], + }) + const mockClient = { synonyms: { query: mockQuery, }, } - mockQuery.mockResolvedValue({ Items: mockItems }) + ;(tables as unknown as jest.Mock).mockResolvedValue(mockClient) - putData.body.eventIds = [eventId] ;(search as unknown as jest.Mock).mockReturnValue({ index: mockIndex, delete: mockDelete, @@ -106,98 +178,192 @@ describe('testing put synonymGroup table-stream', () => { await handler(mockStreamEvent) expect(mockIndex).toHaveBeenCalledWith(putData) + expect(mockIndex).toHaveBeenCalledTimes(1) + expect(mockDelete).not.toHaveBeenCalled() + expect(mockQuery).toHaveBeenCalledTimes(1) }) - test('insert into existing synonym group', async () => { + test('insert into existing synonym group with removal of previous now unused group', async () => { + putData.id = synonymId + putData.body.synonymId = synonymId + putData.body.eventIds = [existingEventId, eventId] + putData.body.slugs = [existingEventSlug, eventSlug] + const mockItems = [ - { synonymId, eventId: existingEventId }, - { synonymId, eventId }, + { synonymId, eventId: existingEventId, slug: existingEventSlug }, + { synonymId, eventId, slug: eventSlug }, ] + + const implementedMockQuery = mockQuery.mockImplementation((query) => { + if (query.ExpressionAttributeValues[':synonymId'] == previousSynonymId) { + return { Items: [] } + } else { + return { Items: mockItems } + } + }) + + ;(search as unknown as jest.Mock).mockReturnValue({ + index: mockIndex, + delete: mockDelete, + }) + const mockClient = { synonyms: { - query: mockQuery, + query: implementedMockQuery, }, } - mockQuery.mockResolvedValue({ Items: mockItems }) + ;(tables as unknown as jest.Mock).mockResolvedValue(mockClient) - putData.body.eventIds = [existingEventId, eventId] - ;(search as unknown as jest.Mock).mockReturnValue({ - index: mockIndex, - delete: mockDelete, - }) - await handler(mockStreamEvent) + await handler(mockStreamEventWithOldRecord) + expect(mockQuery).toHaveBeenCalledTimes(2) + // the new group opensearch record is updated expect(mockIndex).toHaveBeenCalledWith(putData) + expect(mockIndex).toHaveBeenCalledTimes(1) + // the old group opensearch record is deleted + expect(mockDelete).toHaveBeenCalledWith(deleteData) + expect(mockDelete).toHaveBeenCalledTimes(1) }) - test('insert only once', async () => { + test('insert into existing synonym group with removal from old group with remaining members', async () => { const mockItems = [ - { synonymId, eventId: existingEventId }, - { synonymId, eventId }, + { synonymId, eventId: existingEventId, slug: existingEventSlug }, + { synonymId, eventId, slug: eventSlug }, ] + + const mockPreviousItems = [ + { + synonymId: previousSynonymId, + eventId: additionalEventId, + slug: additionalEventSlug, + }, + ] + + const implementedMockQuery = mockQuery.mockImplementation((query) => { + if (query.ExpressionAttributeValues[':synonymId'] == previousSynonymId) { + return { Items: mockPreviousItems } + } else { + return { Items: mockItems } + } + }) + + ;(search as unknown as jest.Mock).mockReturnValue({ + index: mockIndex, + delete: mockDelete, + }) + const mockClient = { synonyms: { - query: mockQuery, + query: implementedMockQuery, }, } - mockQuery.mockResolvedValue({ Items: mockItems }) + ;(tables as unknown as jest.Mock).mockResolvedValue(mockClient) + + await handler(mockStreamEventWithOldRecord) + + expect(mockQuery).toHaveBeenCalledTimes(2) + putData.id = synonymId + putData.body.synonymId = synonymId putData.body.eventIds = [existingEventId, eventId] + putData.body.slugs = [existingEventSlug, eventSlug] + expect(mockIndex).toHaveBeenCalledWith(putData) + putData.id = previousSynonymId + putData.body.synonymId = previousSynonymId + putData.body.eventIds = [additionalEventId] + putData.body.slugs = [additionalEventSlug] + expect(mockIndex).toHaveBeenCalledWith(putData) + expect(mockIndex).toHaveBeenCalledTimes(2) + expect(mockDelete).not.toHaveBeenCalled() + }) + + test('insert into new synonym group with removal from old group with remaining members', async () => { + const mockItems = [{ synonymId, eventId, slug: eventSlug }] + + const mockPreviousItems = [ + { + synonymId: previousSynonymId, + eventId: additionalEventId, + slug: additionalEventSlug, + }, + ] + + const implementedMockQuery = mockQuery.mockImplementation((query) => { + if (query.ExpressionAttributeValues[':synonymId'] == previousSynonymId) { + return { Items: mockPreviousItems } + } else { + return { Items: mockItems } + } + }) + ;(search as unknown as jest.Mock).mockReturnValue({ index: mockIndex, delete: mockDelete, }) - await handler(mockStreamEvent) - - expect(mockIndex).toHaveBeenCalledWith(putData) - }) -}) - -describe('testing delete synonymGroup table-stream', () => { - test('remove one eventId while leaving others', async () => { - const mockItems = [{ synonymId, eventId: existingEventId }] const mockClient = { synonyms: { - query: mockQuery, + query: implementedMockQuery, }, } - mockQuery.mockResolvedValue({ Items: mockItems }) + ;(tables as unknown as jest.Mock).mockResolvedValue(mockClient) - mockStreamEvent.Records[0].eventName = 'REMOVE' - putData.body.eventIds = [existingEventId] - ;(search as unknown as jest.Mock).mockReturnValue({ - index: mockIndex, - delete: mockDelete, - }) - await handler(mockStreamEvent) + await handler(mockStreamEventWithOldRecord) + expect(mockQuery).toHaveBeenCalledTimes(2) + putData.id = synonymId + putData.body.synonymId = synonymId + putData.body.eventIds = [eventId] + putData.body.slugs = [eventSlug] expect(mockIndex).toHaveBeenCalledWith(putData) + putData.id = previousSynonymId + putData.body.synonymId = previousSynonymId + putData.body.eventIds = [additionalEventId] + putData.body.slugs = [additionalEventSlug] + expect(mockIndex).toHaveBeenCalledWith(putData) + expect(mockIndex).toHaveBeenCalledTimes(2) + expect(mockDelete).not.toHaveBeenCalled() }) - test('remove final synonym and delete synonym group', async () => { - const mockItems = [] as Synonym[] + test('insert into new synonym group with removal of previous now unused group', async () => { + putData.id = synonymId + putData.body.synonymId = synonymId + putData.body.eventIds = [eventId] + putData.body.slugs = [eventSlug] + + const mockItems = [{ synonymId, eventId, slug: eventSlug }] + + const implementedMockQuery = mockQuery.mockImplementation((query) => { + if (query.ExpressionAttributeValues[':synonymId'] == previousSynonymId) { + return { Items: [] } + } else { + return { Items: mockItems } + } + }) + + ;(search as unknown as jest.Mock).mockReturnValue({ + index: mockIndex, + delete: mockDelete, + }) + const mockClient = { synonyms: { - query: mockQuery, + query: implementedMockQuery, }, } - mockQuery.mockResolvedValue({ Items: mockItems }) + ;(tables as unknown as jest.Mock).mockResolvedValue(mockClient) - mockStreamEvent.Records[0].eventName = 'REMOVE' - const deleteData = { - index: 'synonym-groups', - id: synonymId, - } - ;(search as unknown as jest.Mock).mockReturnValue({ - index: mockIndex, - delete: mockDelete, - }) - await handler(mockStreamEvent) + await handler(mockStreamEventWithOldRecord) + expect(mockQuery).toHaveBeenCalledTimes(2) + // the new group opensearch record is updated + expect(mockIndex).toHaveBeenCalledWith(putData) + expect(mockIndex).toHaveBeenCalledTimes(1) + // the old group opensearch record is deleted expect(mockDelete).toHaveBeenCalledWith(deleteData) + expect(mockDelete).toHaveBeenCalledTimes(1) }) }) diff --git a/app/routes/synonyms/synonyms.server.ts b/app/routes/synonyms/synonyms.server.ts index 4fe83f9b1..598f2c354 100644 --- a/app/routes/synonyms/synonyms.server.ts +++ b/app/routes/synonyms/synonyms.server.ts @@ -106,7 +106,6 @@ export async function searchSynonymsByEventId({ fields: { eventIds: string[]; synonymId: string; slugs: string[] } }) => body ) - return { items: results, totalItems, diff --git a/app/table-streams/synonyms/index.ts b/app/table-streams/synonyms/index.ts index 8204bec58..9434465f7 100644 --- a/app/table-streams/synonyms/index.ts +++ b/app/table-streams/synonyms/index.ts @@ -19,6 +19,7 @@ const index = 'synonym-groups' async function removeIndex(id: string) { const client = await getSearchClient() + try { await client.delete({ index, id }) } catch (e) { @@ -40,16 +41,22 @@ async function putIndex(synonymGroup: SynonymGroup) { export const handler = createTriggerHandler( async ({ eventName, dynamodb }: DynamoDBRecord) => { if (!eventName || !dynamodb) return - const { synonymId } = unmarshallTrigger(dynamodb!.NewImage) as Synonym - const dynamoSynonyms = await getSynonymsByUuid(synonymId) - if (dynamoSynonyms.length > 0) { - await putIndex({ - synonymId, - eventIds: dynamoSynonyms.map((synonym) => synonym.eventId), - slugs: dynamoSynonyms.map((synonym) => synonym.slug), - }) - } else { - await removeIndex(synonymId) - } + await Promise.all( + [dynamodb.OldImage, dynamodb.NewImage] + .filter((image) => image !== undefined) + .map(async (image) => { + const { synonymId } = unmarshallTrigger(image) as Synonym + const synonyms = await getSynonymsByUuid(synonymId) + if (synonyms.length > 0) { + await putIndex({ + synonymId, + eventIds: synonyms.map((synonym) => synonym.eventId), + slugs: synonyms.map((synonym) => synonym.slug), + }) + } else { + await removeIndex(synonymId) + } + }) + ) } )