Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions serverless/src/getCapabilities/__tests__/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ describe('getCapabilities', () => {
expect(result.body).toContain('<a name="get_historical_concept_by_full_path"')
expect(result.body).toContain('<a name="get_historical_concept_by_short_name"')
expect(result.body).not.toContain('/cache/rebuild')
expect(result.body).toContain('<a name="get_historical_concept_versions"')
expect(result.body).toContain('<a name="get_historical_concepts_in_scheme"')
})
})

Expand Down
16 changes: 16 additions & 0 deletions serverless/src/getCapabilities/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ export const getCapabilities = async () => {
params: 'scheme=',
action: 'GET'
}
},
{
':@': {
name: 'get_historical_concept_versions',
href: '/concept_versions/historical',
params: 'None',
action: 'GET'
}
},
{
':@': {
name: 'get_historical_concepts_in_scheme',
href: '/concepts/historical/concept_scheme/{conceptScheme}',
params: 'version=',
action: 'GET'
}
}

]
Expand Down
244 changes: 244 additions & 0 deletions serverless/src/getHistoricalConceptVersions/__tests__/handler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { S3Client } from '@aws-sdk/client-s3'
import {
beforeEach,
describe,
expect,
vi
} from 'vitest'

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

import { getHistoricalConceptVersions } from '../handler'

const { mockSend } = vi.hoisted(() => {
process.env.RDF_BUCKET_NAME = 'test-bucket'

return { mockSend: vi.fn() }
})

vi.mock('@/shared/awsClients', () => ({
getS3Client: () => {
const client = new S3Client({ region: 'us-east-1' })
client.send = mockSend

return client
}
}))

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 that contain a CSV', async () => {
mockSend
// Top-level listing: two candidate version prefixes
.mockResolvedValueOnce({
CommonPrefixes: [
{ Prefix: 'A/' },
{ Prefix: 'B/' }
]
})
// Per-version CSV check for "A"
.mockResolvedValueOnce({
Contents: [{ Key: 'A/ScienceKeywords.csv' }]
})
// Per-version CSV check for "B"
.mockResolvedValueOnce({
Contents: [{ Key: 'B/ScienceKeywords.csv' }]
})

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/' }
]
})
.mockResolvedValueOnce({
Contents: [{ Key: 'A/ScienceKeywords.csv' }]
})

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

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

test('should exclude versions that have no CSV files, e.g. an rdf.xml-only or incomplete export', async () => {
mockSend
.mockResolvedValueOnce({
CommonPrefixes: [
{ Prefix: 'A/' },
{ Prefix: 'B/' }
]
})
// "A" only has an rdf.xml export, no CSV
.mockResolvedValueOnce({
Contents: [{ Key: 'A/rdf.xml' }]
})
// "B" has a CSV
.mockResolvedValueOnce({
Contents: [{ Key: 'B/ScienceKeywords.csv' }]
})

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

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

test('should exclude a version whose prefix has no objects at all', async () => {
mockSend
.mockResolvedValueOnce({
CommonPrefixes: [{ Prefix: 'A/' }]
})
.mockResolvedValueOnce({})

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

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

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: [] })
// No candidate versions, so no per-version CSV checks should happen
expect(mockSend).toHaveBeenCalledTimes(1)
})

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

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

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

test('should paginate through multiple pages when checking a single version for CSVs', async () => {
mockSend
.mockResolvedValueOnce({
CommonPrefixes: [{ Prefix: 'A/' }]
})
.mockResolvedValueOnce({
Contents: [{ Key: 'A/rdf.xml' }],
NextContinuationToken: 'version-page-2-token'
})
.mockResolvedValueOnce({
Contents: [{ Key: 'A/ScienceKeywords.csv' }]
})

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

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

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
)
})

test('should return 500 when checking a version for CSVs fails', async () => {
mockSend
.mockResolvedValueOnce({
CommonPrefixes: [{ Prefix: 'A/' }]
})
.mockRejectedValueOnce(new Error('Access denied'))

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

expect(response.statusCode).toBe(500)
expect(JSON.parse(response.body)).toEqual({
message: 'Failed to fetch version directories'
})
})
})

describe('when RDF_BUCKET_NAME is not set', () => {
test('should throw a clear error at module load instead of silently falling back to a default bucket', async () => {
const originalValue = process.env.RDF_BUCKET_NAME
delete process.env.RDF_BUCKET_NAME

vi.resetModules()

await expect(import('../handler')).rejects.toThrow(
'Missing required environment variable: RDF_BUCKET_NAME'
)

process.env.RDF_BUCKET_NAME = originalValue
})
})
})
Loading