Skip to content
Merged
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
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"deploy": "cdk deploy --all"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "3.913.0",
"@aws-sdk/lib-dynamodb": "3.913.0",
"@aws-sdk/client-dynamodb": "3.917.0",
"@aws-sdk/lib-dynamodb": "3.917.0",
"constructs": "10.4.2",
"source-map-support": "0.5.21"
},
Expand All @@ -25,9 +25,9 @@
"@types/chance": "1.1.7",
"@types/aws-lambda": "8.10.156",
"@types/jest": "30.0.0",
"@types/node": "24.8.1",
"aws-cdk": "2.1030.0",
"aws-cdk-lib": "2.220.0",
"@types/node": "24.9.1",
"aws-cdk": "2.1031.0",
"aws-cdk-lib": "2.221.0",
"aws-lambda": "1.0.7",
"chance": "1.1.13",
"esbuild": "0.25.11",
Expand Down
140 changes: 140 additions & 0 deletions src/lambdas/foodHow/deleteShoppingListItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Chance } from 'chance'
import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes'
import { mockApiGatewayProxyEvent } from '../../mocks/apiGatewayProxyEvent'
import { deleteShoppingListItems } from './utils/shoppingList'
import { handler } from './deleteShoppingListItems'

jest.mock('./utils/shoppingList')

describe('Lambda - Delete Shopping List Items', () => {
const chance = new Chance()

beforeEach(() => {
process.env.shoppingListTableName = chance.word({ syllables: 4 })
})

it('Should successfully delete shopping list items.', async () => {
jest.mocked(deleteShoppingListItems).mockResolvedValue({
isError: false,
})

const itemIdsToDelete = [123, 456, 789]
const expectedBody = { message: 'Items were deleted successfully.' }

const result = await handler(mockApiGatewayProxyEvent(itemIdsToDelete) as any)

expect(deleteShoppingListItems).toHaveBeenCalledWith(itemIdsToDelete)
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_OK,
})
})

it('Should handle deletion of empty array.', async () => {
jest.mocked(deleteShoppingListItems).mockResolvedValue({
isError: false,
})

const itemIdsToDelete: number[] = []
const expectedBody = { message: 'Items were deleted successfully.' }

const result = await handler(mockApiGatewayProxyEvent(itemIdsToDelete) as any)

expect(deleteShoppingListItems).toHaveBeenCalledWith(itemIdsToDelete)
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_OK,
})
})

it('Should return an error if there is a critical problem when deleting shopping list items.', async () => {
const errorMessage = chance.sentence()
jest.mocked(deleteShoppingListItems).mockRejectedValue(errorMessage)

const itemIdsToDelete = [123, 456]
const expectedBody = { message: errorMessage }

const result = await handler(mockApiGatewayProxyEvent(itemIdsToDelete) as any)

expect(deleteShoppingListItems).toHaveBeenCalledWith(itemIdsToDelete)
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_SERVER_ERROR,
})
})

it('Should return an error if there is a problem when deleting shopping list items.', async () => {
const errorMessage = chance.sentence()
jest.mocked(deleteShoppingListItems).mockResolvedValue({
isError: true,
errorMessage,
})

const itemIdsToDelete = [123, 456]
const expectedBody = { message: errorMessage }

const result = await handler(mockApiGatewayProxyEvent(itemIdsToDelete) as any)

expect(deleteShoppingListItems).toHaveBeenCalledWith(itemIdsToDelete)
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_SERVER_ERROR,
})
})

it('Should handle missing body by defaulting to empty array.', async () => {
jest.mocked(deleteShoppingListItems).mockResolvedValue({
isError: false,
})

const expectedBody = { message: 'Items were deleted successfully.' }

const result = await handler(mockApiGatewayProxyEvent({}, {
body: undefined,
}) as any)

expect(deleteShoppingListItems).toHaveBeenCalledWith([])
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_OK,
})
})

it('Should return an error for invalid JSON body.', async () => {
const expectedBody = { message: 'Invalid JSON in request body.' }

const result = await handler(mockApiGatewayProxyEvent({}, {
body: 'invalid json',
}) as any)

expect(deleteShoppingListItems).not.toHaveBeenCalled()
expect(result).toStrictEqual({
body: JSON.stringify(expectedBody),
headers: {
'Access-Control-Allow-Origin': '',
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_SERVER_ERROR,
})
})
})
47 changes: 47 additions & 0 deletions src/lambdas/foodHow/deleteShoppingListItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type APIGatewayProxyEvent, type APIGatewayProxyResult } from 'aws-lambda'
import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes'
import { deleteShoppingListItems } from './utils/shoppingList'

export const handler = ({ body }: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => new Promise((resolve) => {
const { accessControlAllowOrigin = '' } = process.env

let itemIdsToDelete: number[] = []
try {
itemIdsToDelete = JSON.parse(body || '[]')
} catch (error) {
const result: APIGatewayProxyResult = {
body: JSON.stringify({ message: 'Invalid JSON in request body.' }),
headers: {
'Access-Control-Allow-Origin': accessControlAllowOrigin,
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_SERVER_ERROR,
}
resolve(result)
return
}

const result: APIGatewayProxyResult = {
body: JSON.stringify({ message: 'Invalid state.' }),
headers: {
'Access-Control-Allow-Origin': accessControlAllowOrigin,
'Content-Type': 'application/json',
},
statusCode: RESPONSE_CODE_OK,
}

deleteShoppingListItems(itemIdsToDelete).then((response) => {
if (response.isError) {
result.statusCode = RESPONSE_CODE_SERVER_ERROR
result.body = JSON.stringify({ message: response.errorMessage })
} else {
result.statusCode = RESPONSE_CODE_OK
result.body = JSON.stringify({ message: 'Items were deleted successfully.' })
}
}).catch((error) => {
result.statusCode = RESPONSE_CODE_SERVER_ERROR
result.body = JSON.stringify({ message: error.toString() })
}).finally(() => {
resolve(result)
})
})
2 changes: 1 addition & 1 deletion src/lambdas/foodHow/getShoppingList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const handler = (): Promise<APIGatewayProxyResult> => new Promise((resolv
const { accessControlAllowOrigin = '' } = process.env

const result: APIGatewayProxyResult = {
body: JSON.stringify({ message: 'Invalid state.', log: [] }),
body: JSON.stringify({ message: 'Invalid state.', shoppingList: [] }),
headers: {
'Access-Control-Allow-Origin': accessControlAllowOrigin,
'Content-Type': 'application/json',
Expand Down
21 changes: 21 additions & 0 deletions src/lambdas/foodHow/utils/shoppingList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ScanCommandOutput,
} from "@aws-sdk/lib-dynamodb"
import { getErrorMessage } from '../../../utils/error'
import { deleteItems } from '../../../utils/tables'
import { type DatabaseResponse } from '../../../types'
import { type ShoppingListItem } from '../../../types/lambdas/foodHow'

Expand Down Expand Up @@ -57,3 +58,23 @@ export const saveShoppingListItem = async (item: ShoppingListItem, userName: str
}
}
}

export const deleteShoppingListItems = async (itemIds: number[]): Promise<DatabaseResponse> => {
const { shoppingListTableName } = process.env

try {
await deleteItems({
tableName: shoppingListTableName!,
keys: itemIds,
})

return {
isError: false,
}
} catch (error: unknown) {
return {
isError: true,
errorMessage: getErrorMessage(error),
}
}
}
12 changes: 11 additions & 1 deletion src/lambdas/foodHow/utils/shoppingListError.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Chance } from 'chance'
import { mockShoppingListItem } from '../../../mocks'
import { getShoppingList, saveShoppingListItem } from './shoppingList'
import { deleteShoppingListItems, getShoppingList, saveShoppingListItem } from './shoppingList'

jest.mock('@aws-sdk/client-dynamodb', () => {
return {
Expand Down Expand Up @@ -46,4 +46,14 @@ describe('Shopping List Util - failure', () => {
isError: true,
})
})

it('Should allow fail when deleting a shopping list item.', async () => {
const keys = chance.unique(chance.integer, chance.integer({ min: 1, max: 100 }))
const result = await deleteShoppingListItems(keys)

expect(result).toStrictEqual({
errorMessage: 'Something bad happened.',
isError: true,
})
})
})
10 changes: 8 additions & 2 deletions src/lambdas/foodHow/utils/shoppingListSuccess.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Chance } from 'chance'
import { mockShoppingListItem } from '../../../mocks'
import { getShoppingList, saveShoppingListItem } from './shoppingList'
import { deleteShoppingListItems, getShoppingList, saveShoppingListItem } from './shoppingList'

jest.mock('@aws-sdk/client-dynamodb', () => ({
...jest.requireActual('@aws-sdk/client-dynamodb'),
Expand Down Expand Up @@ -37,4 +37,10 @@ describe('Shopping List Util - success', () => {
it('Should save a shopping list item.', async () => {
expect(async () => await saveShoppingListItem(mockShoppingListItem(), chance.name())).not.toThrow()
})
})

it('Should delete a list of shopping list items.', async () => {
const keys = chance.unique(chance.integer, chance.integer({ min: 1, max: 100 }))

expect(async () => await deleteShoppingListItems(keys)).not.toThrow()
})
})
18 changes: 18 additions & 0 deletions src/stacks/foodHow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ export class FoodHowStack extends Stack {
},
})

const deleteShoppingListItems: NodejsFunction = new NodejsFunction(this, 'deleteShoppingListItems', {
functionName: `foodHowDeleteShoppingListItems${resourcePostFix}`,
entry: join(__dirname, '../lambdas', 'foodHow', 'deleteShoppingListItems.ts'),
handler: 'handler',
runtime: NODE_VERSION,
environment: {
accessControlAllowOrigin,
shoppingListTableName: shoppingListDb.tableName,
},
})
// ----------------------------------------------------------------------------------------
// AUTHORIZATION
// ----------------------------------------------------------------------------------------
Expand Down Expand Up @@ -101,5 +111,13 @@ export class FoodHowStack extends Stack {
createShoppingListItemLambdaResource.addMethod('POST', createShoppingListItemLambdaIntegration, requiresAuthorization(authorizer))
shoppingListDb.grantReadWriteData(createShoppingListItem)
addCorsOptions(createShoppingListItemLambdaResource, accessControlAllowOrigin)

// delete shopping list items
const deleteShoppingListItemsLambdaIntegration: LambdaIntegration = new LambdaIntegration(deleteShoppingListItems)
const deleteShoppingListItemsLambdaResource: Resource = api.root.addResource('deleteShoppingListItems')

deleteShoppingListItemsLambdaResource.addMethod('DELETE', deleteShoppingListItemsLambdaIntegration, requiresAuthorization(authorizer))
shoppingListDb.grantReadWriteData(deleteShoppingListItems)
addCorsOptions(deleteShoppingListItemsLambdaResource, accessControlAllowOrigin)
}
}
30 changes: 30 additions & 0 deletions src/utils/tables.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { Chance } from 'chance'
import { BatchWriteCommand } from "@aws-sdk/lib-dynamodb"
import { Stack } from 'aws-cdk-lib'
import { AttributeType } from 'aws-cdk-lib/aws-dynamodb'
import { CreateTableType } from '../types'
import * as tables from './tables'

jest.mock('@aws-sdk/client-dynamodb', () => ({
DynamoDBClient: jest.fn(() => ({})),
}))

jest.mock("@aws-sdk/lib-dynamodb", () => {
return {
DynamoDBDocumentClient: {
from: jest.fn(() => ({
send: jest.fn().mockResolvedValue({}),
})),
},
BatchWriteCommand: jest.fn(),
}
})
describe('Table Utils', () => {
const chance = new Chance()
const SomeStack = new Stack()

beforeEach(() => {
jest.mocked(BatchWriteCommand)
})

it('should be able to create a table', async () => {
jest.spyOn(tables, 'createTable')

Expand All @@ -22,4 +41,15 @@ describe('Table Utils', () => {

expect(tables.createTable).toHaveBeenCalledWith(createTableProps)
})

it('should be able to delete all items', async () => {
jest.spyOn(tables, 'deleteItems')

const tableName = chance.word()
const keys = chance.unique(chance.integer, chance.integer({ min: 1, max: 100 }))

await tables.deleteItems({ tableName, keys })

expect(tables.deleteItems).toHaveBeenCalledWith({ tableName, keys })
})
})
Loading