Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
18 changes: 18 additions & 0 deletions cdk/app/lib/helper/KmsLambdaFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,15 @@ export class LambdaFunctions {
'GET'
)

this.createApiLambda(
scope,
'getHistoricalConceptsInScheme/handler.js',
'get-historical-concepts-in-scheme',
'getHistoricalConceptsInScheme',
'/concepts/historical/concept_scheme/{conceptScheme}',
'GET'
)

this.createApiLambda(
scope,
'getConcepts/handler.js',
Expand Down Expand Up @@ -279,6 +288,15 @@ export class LambdaFunctions {
'GET'
)

this.createApiLambda(
scope,
'getHistoricalConceptVersions/handler.js',
'get-historical-concept-versions',
'getHistoricalConceptVersions',
'/concept_versions/historical',
'GET'
)

this.createApiLambda(
scope,
'getFullPath/handler.js',
Expand Down
136 changes: 136 additions & 0 deletions serverless/src/getHistoricalConceptVersions/__tests__/handler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
beforeEach,
describe,
expect,
vi
} from 'vitest'

import { getApplicationConfig } from '@/shared/getConfig'
import { logAnalyticsData } from '@/shared/logAnalyticsData'

import { getHistoricalConceptVersions } from '../handler'

// `getS3Client()` is called once at module-load time in handler.js, so the
// mocked client needs to exist before the handler module is imported. Using
// vi.hoisted + a mock factory ensures `mockSend` is available when the
// `@/shared/awsClients` mock is set up, and lets us control its behavior
// per test via mockSend.mockResolvedValueOnce(...).
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn()
}))

vi.mock('@/shared/awsClients', () => ({
getS3Client: () => ({ send: mockSend })
}))

vi.mock('@/shared/getConfig')
vi.mock('@/shared/logAnalyticsData')

describe('getHistoricalConceptVersions', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})

// Mock getApplicationConfig
getApplicationConfig.mockReturnValue({
defaultResponseHeaders: { 'X-Test': 'test-header' }
})
})

describe('when successful', () => {
test('should return 200 with the list of version directories', async () => {
mockSend.mockResolvedValueOnce({
CommonPrefixes: [
{ Prefix: 'A/' },
{ Prefix: 'B/' }
]
})

const event = {}
const context = {}
const response = await getHistoricalConceptVersions(event, context)

expect(response.statusCode).toBe(200)
expect(response.headers['X-Test']).toBe('test-header')
expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A', 'B'] })
})

test('should exclude the draft directory', async () => {
mockSend.mockResolvedValueOnce({
CommonPrefixes: [
{ Prefix: 'draft/' },
{ Prefix: 'A/' }
]
})

const response = await getHistoricalConceptVersions({}, {})

expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A'] })
})

test('should return an empty array when there are no common prefixes', async () => {
mockSend.mockResolvedValueOnce({})

const response = await getHistoricalConceptVersions({}, {})

expect(response.statusCode).toBe(200)
expect(JSON.parse(response.body)).toEqual({ historicalVersions: [] })
})

test('should paginate through multiple pages of results', async () => {
mockSend
.mockResolvedValueOnce({
CommonPrefixes: [{ Prefix: 'A/' }],
NextContinuationToken: 'page-2-token'
})
.mockResolvedValueOnce({
CommonPrefixes: [{ Prefix: 'B/' }]
})

const response = await getHistoricalConceptVersions({}, {})

expect(mockSend).toHaveBeenCalledTimes(2)
expect(JSON.parse(response.body)).toEqual({ historicalVersions: ['A', 'B'] })
})

test('should call logAnalyticsData with the event and context', async () => {
mockSend.mockResolvedValueOnce({ CommonPrefixes: [] })

const event = { some: 'event' }
const context = { some: 'context' }
await getHistoricalConceptVersions(event, context)

expect(logAnalyticsData).toHaveBeenCalledWith({
event,
context
})
})
})

describe('when unsuccessful', () => {
test('should return 500 when the S3 request fails', async () => {
mockSend.mockRejectedValueOnce(new Error('The specified bucket does not exist'))

const response = await getHistoricalConceptVersions({}, {})

expect(response.statusCode).toBe(500)
expect(response.headers['X-Test']).toBe('test-header')
expect(JSON.parse(response.body)).toEqual({
message: 'Failed to fetch version directories'
})
})

test('should log the error to the console', async () => {
const error = new Error('The specified bucket does not exist')
mockSend.mockRejectedValueOnce(error)

await getHistoricalConceptVersions({}, {})

// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
'Failed to list S3 version directories:',
error.message
)
})
})
})
84 changes: 84 additions & 0 deletions serverless/src/getHistoricalConceptVersions/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ListObjectsV2Command } from '@aws-sdk/client-s3'

import { getS3Client } from '@/shared/awsClients'
import { getApplicationConfig } from '@/shared/getConfig'
import { logAnalyticsData } from '@/shared/logAnalyticsData'

/**
* S3 bucket name to list version directories from
* @type {string}
*/
const bucketName = process.env.S3_BUCKET_NAME || 'kms-rdf-backup-sit'
Comment thread
htranho marked this conversation as resolved.
Outdated

/**
* S3 Client from shared configuration
* @type {S3Client}
*/
const s3Client = getS3Client()

/**
* Lists top-level "directory" names in the S3 bucket by using a delimiter
* so S3 groups keys into CommonPrefixes (its equivalent of folders),
* excluding the "draft/" prefix.
*
* @returns {Promise<Array<string>>} Array of version/directory names
*/
const listVersionDirectories = async () => {
Comment thread
htranho marked this conversation as resolved.
const historicalVersions = []
let continuationToken

/* eslint-disable no-await-in-loop */
do {
const command = new ListObjectsV2Command({
Bucket: bucketName,
Delimiter: '/',
ContinuationToken: continuationToken
})

const response = await s3Client.send(command)

if (response.CommonPrefixes) {
Comment thread
htranho marked this conversation as resolved.
Outdated
const prefixes = response.CommonPrefixes
.map((p) => p.Prefix)
.filter(Boolean)
.map((prefix) => prefix.replace(/\/$/, '')) // Strip trailing slash
.filter((name) => name !== 'draft') // Exclude the draft "directory"

historicalVersions.push(...prefixes)
}

continuationToken = response.NextContinuationToken
} while (continuationToken)
/* eslint-enable no-await-in-loop */

return historicalVersions
}

export const getHistoricalConceptVersions = async (event, context) => {
const { defaultResponseHeaders } = getApplicationConfig()

logAnalyticsData({
event,
context
})

try {
const historicalVersions = await listVersionDirectories()

return {
statusCode: 200,
headers: defaultResponseHeaders,
body: JSON.stringify({ historicalVersions })
}
} catch (error) {
console.error('Failed to list S3 version directories:', error.message)

return {
statusCode: 500,
headers: defaultResponseHeaders,
body: JSON.stringify({ message: 'Failed to fetch version directories' })
}
}
}

export default getHistoricalConceptVersions
Loading