diff --git a/package.json b/package.json index 0d3a806..6b87b9d 100755 --- a/package.json +++ b/package.json @@ -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" }, @@ -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", diff --git a/src/lambdas/foodHow/deleteShoppingListItems.test.ts b/src/lambdas/foodHow/deleteShoppingListItems.test.ts new file mode 100755 index 0000000..0267f96 --- /dev/null +++ b/src/lambdas/foodHow/deleteShoppingListItems.test.ts @@ -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, + }) + }) +}) diff --git a/src/lambdas/foodHow/deleteShoppingListItems.ts b/src/lambdas/foodHow/deleteShoppingListItems.ts new file mode 100755 index 0000000..1af9153 --- /dev/null +++ b/src/lambdas/foodHow/deleteShoppingListItems.ts @@ -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 => 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) + }) +}) diff --git a/src/lambdas/foodHow/getShoppingList.ts b/src/lambdas/foodHow/getShoppingList.ts index 5498000..07b2f66 100755 --- a/src/lambdas/foodHow/getShoppingList.ts +++ b/src/lambdas/foodHow/getShoppingList.ts @@ -7,7 +7,7 @@ export const handler = (): Promise => 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', diff --git a/src/lambdas/foodHow/utils/shoppingList.ts b/src/lambdas/foodHow/utils/shoppingList.ts index ea8970e..d3a5091 100755 --- a/src/lambdas/foodHow/utils/shoppingList.ts +++ b/src/lambdas/foodHow/utils/shoppingList.ts @@ -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' @@ -57,3 +58,23 @@ export const saveShoppingListItem = async (item: ShoppingListItem, userName: str } } } + +export const deleteShoppingListItems = async (itemIds: number[]): Promise => { + const { shoppingListTableName } = process.env + + try { + await deleteItems({ + tableName: shoppingListTableName!, + keys: itemIds, + }) + + return { + isError: false, + } + } catch (error: unknown) { + return { + isError: true, + errorMessage: getErrorMessage(error), + } + } +} \ No newline at end of file diff --git a/src/lambdas/foodHow/utils/shoppingListError.test.ts b/src/lambdas/foodHow/utils/shoppingListError.test.ts index c6a0661..ae79441 100755 --- a/src/lambdas/foodHow/utils/shoppingListError.test.ts +++ b/src/lambdas/foodHow/utils/shoppingListError.test.ts @@ -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 { @@ -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, + }) + }) }) diff --git a/src/lambdas/foodHow/utils/shoppingListSuccess.test.ts b/src/lambdas/foodHow/utils/shoppingListSuccess.test.ts index 4edef98..4667d57 100755 --- a/src/lambdas/foodHow/utils/shoppingListSuccess.test.ts +++ b/src/lambdas/foodHow/utils/shoppingListSuccess.test.ts @@ -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'), @@ -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() + }) +}) \ No newline at end of file diff --git a/src/stacks/foodHow.ts b/src/stacks/foodHow.ts index 5510b2a..dbf1269 100755 --- a/src/stacks/foodHow.ts +++ b/src/stacks/foodHow.ts @@ -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 // ---------------------------------------------------------------------------------------- @@ -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) } } diff --git a/src/utils/tables.test.ts b/src/utils/tables.test.ts index 288f34b..54523c2 100755 --- a/src/utils/tables.test.ts +++ b/src/utils/tables.test.ts @@ -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') @@ -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 }) + }) }) \ No newline at end of file diff --git a/src/utils/tables.ts b/src/utils/tables.ts index 0361459..95e55a7 100755 --- a/src/utils/tables.ts +++ b/src/utils/tables.ts @@ -1,6 +1,11 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb" +import { BatchWriteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb" import { BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb' import { CreateTableType } from '../types' +// This is a chunk size restriction from DynamoDB for batch operations +const DYNAMO_CHUNK_SIZE_LIMIT = 25 + export const createTable = ({ name, primaryKey, stack, type }: CreateTableType): Table => new Table(stack, name, { partitionKey: { @@ -9,3 +14,30 @@ export const createTable = ({ name, primaryKey, stack, type }: CreateTableType): }, billingMode: BillingMode.PAY_PER_REQUEST, }) + +export const deleteItems = async ({ + tableName, + keys, +}: { + tableName: string + keys: number[] +}) => { + const dynamoDbClient = new DynamoDBClient() + const dynamoDocumentClient = DynamoDBDocumentClient.from(dynamoDbClient) + const idKeys = keys.map((key) => ({ id: key })) + + const chunks = [] + for (let i = 0; i < idKeys.length; i += DYNAMO_CHUNK_SIZE_LIMIT) { + chunks.push(idKeys.slice(i, i + DYNAMO_CHUNK_SIZE_LIMIT)) + } + + for (const chunk of chunks) { + await dynamoDocumentClient.send(new BatchWriteCommand({ + RequestItems: { + [tableName]: chunk.map((key) => ({ + DeleteRequest: { Key: key }, + })), + }, + })) + } +} \ No newline at end of file