From 5c5ad84162f2d61b4f7748735954464dc2d0b48e Mon Sep 17 00:00:00 2001 From: Troy Blank Date: Fri, 19 Sep 2025 17:47:33 -0500 Subject: [PATCH] Adds foodhow shopping list creation. --- jest.config.js | 1 + src/app.ts | 89 ++++++++++-- src/constants/foodHow/shoppingListItem.ts | 16 ++ src/constants/index.ts | 1 - .../foodHow/createShoppingListItem.test.ts | 137 ++++++++++++++++++ src/lambdas/foodHow/createShoppingListItem.ts | 45 ++++++ src/lambdas/foodHow/utils/shoppingList.ts | 32 ++++ .../foodHow/utils/shoppingListError.test.ts | 40 +++++ .../foodHow/utils/shoppingListSuccess.test.ts | 31 ++++ src/lambdas/halfsies/createHalfsie.test.ts | 7 +- src/lambdas/halfsies/createHalfsie.ts | 7 +- src/lambdas/halfsies/getBalance.test.ts | 2 +- src/lambdas/halfsies/getBalance.ts | 2 +- src/lambdas/halfsies/getLog.test.ts | 2 +- src/lambdas/halfsies/getLog.ts | 2 +- src/lambdas/halfsies/utils/index.ts | 2 +- src/lambdas/halfsies/utils/logError.test.ts | 10 +- src/lambdas/halfsies/utils/logSuccess.test.ts | 9 +- src/lambdas/utils/index.ts | 1 + src/lambdas/{halfsies => }/utils/user.test.ts | 2 +- src/lambdas/{halfsies => }/utils/user.ts | 2 +- src/mocks/index.ts | 1 + src/mocks/shoppingListItem.ts | 22 +++ src/stacks/apiDomain.ts | 38 +++++ src/stacks/foodHow.ts | 86 +++++++++++ src/stacks/halfsies.ts | 38 ++--- src/stacks/index.ts | 2 + src/types/lambdas/foodHow.ts | 11 ++ src/types/lambdas/index.ts | 1 + src/types/stacks/apiDomain.ts | 6 + src/types/stacks/foodHow.ts | 10 ++ src/types/stacks/halfsies.ts | 4 +- src/types/stacks/index.ts | 1 + src/utils/foodHow/shoppingListItem.test.ts | 47 ++++++ src/utils/foodHow/shoppingListItem.ts | 23 +++ 35 files changed, 659 insertions(+), 71 deletions(-) create mode 100755 src/constants/foodHow/shoppingListItem.ts delete mode 100755 src/constants/index.ts create mode 100755 src/lambdas/foodHow/createShoppingListItem.test.ts create mode 100755 src/lambdas/foodHow/createShoppingListItem.ts create mode 100755 src/lambdas/foodHow/utils/shoppingList.ts create mode 100755 src/lambdas/foodHow/utils/shoppingListError.test.ts create mode 100755 src/lambdas/foodHow/utils/shoppingListSuccess.test.ts create mode 100755 src/lambdas/utils/index.ts rename src/lambdas/{halfsies => }/utils/user.test.ts (87%) rename src/lambdas/{halfsies => }/utils/user.ts (80%) create mode 100755 src/mocks/shoppingListItem.ts create mode 100755 src/stacks/apiDomain.ts create mode 100755 src/stacks/foodHow.ts create mode 100755 src/types/lambdas/foodHow.ts create mode 100755 src/types/stacks/apiDomain.ts create mode 100755 src/types/stacks/foodHow.ts create mode 100755 src/utils/foodHow/shoppingListItem.test.ts create mode 100755 src/utils/foodHow/shoppingListItem.ts diff --git a/jest.config.js b/jest.config.js index 4ae49c5..2f23515 100755 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,7 @@ module.exports = { }, collectCoverageFrom: [ "/src/**/*.ts", + "!/src/mocks/**", "!/src/stacks/*.ts", ], testEnvironment: 'node', diff --git a/src/app.ts b/src/app.ts index f52af97..dc63e61 100755 --- a/src/app.ts +++ b/src/app.ts @@ -2,28 +2,90 @@ /* istanbul ignore file */ import 'source-map-support/register' import { App } from 'aws-cdk-lib' -import { AuthStack, HalfsiesStack } from './stacks' +import { + APIDomainStack, + AuthStack, + FoodHowStack, + HalfsiesStack, +} from './stacks' const app = new App() +const ENV_REGION = 'us-east-2' const { blankFamilyUserPool } = new AuthStack(app, 'Auth', { stackName: 'Auth', env: { - region: 'us-east-2', + region: ENV_REGION, }, }) + +// PROD +const PROD_API_DOMAIN_CERTIFICATE_ARN = 'arn:aws:acm:us-east-1:382713793519:certificate/700ed0de-e320-4c84-b377-9984263f610d' +const PROD_API_DOMAIN_NAME = 'api.troyblank.com' +const PROD_RESOURCE_POSTFIX = 'Prod' + +const { domainName: prodDomainName } = new APIDomainStack( + app, + 'ApiDomain', + { + stackName: 'ApiDomain', + domainName: PROD_API_DOMAIN_NAME, + domainCertificateARN: PROD_API_DOMAIN_CERTIFICATE_ARN, + env: { region: ENV_REGION }, + }, +) + +new FoodHowStack( + app, + 'FoodHow', + { + stackName: 'FoodHow', + apiDomainName: prodDomainName, + resourcePostFix: PROD_RESOURCE_POSTFIX, + accessControlAllowOrigin: 'https://foodhow.troyblank.com', + userPool: blankFamilyUserPool, + env: { region: ENV_REGION }, + }, +) new HalfsiesStack( app, 'Halfsies', { stackName: 'Halfsies', - customDomainCertificateARN: 'arn:aws:acm:us-east-1:382713793519:certificate/700ed0de-e320-4c84-b377-9984263f610d', - customDomainName: 'api.troyblank.com', - resourcePostFix: 'Prod', + apiDomainName: prodDomainName, + resourcePostFix: PROD_RESOURCE_POSTFIX, accessControlAllowOrigin: 'https://halfsies.troyblank.com', userPool: blankFamilyUserPool, - env: { - region: 'us-east-2', - }, + env: { region: ENV_REGION }, + }, +) + +// STAGE +const STAGE_API_DOMAIN_CERTIFICATE_ARN = 'arn:aws:acm:us-east-1:382713793519:certificate/74c434a7-2ad0-4477-8a50-e3b41218f85a' +const STAGE_API_DOMAIN_NAME = 'stage-api.troyblank.com' +const STAGE_RESOURCE_POSTFIX = 'Stage' +const STAGE_ACCESS_CONTROL_ALLOW_ORIGIN = '*' + +const { domainName: stageDomainName } = new APIDomainStack( + app, + 'ApiDomain-Stage', + { + stackName: 'ApiDomain-Stage', + domainName: STAGE_API_DOMAIN_NAME, + domainCertificateARN: STAGE_API_DOMAIN_CERTIFICATE_ARN, + env: { region: ENV_REGION }, + }, +) + +new FoodHowStack( + app, + 'FoodHow-Stage', + { + stackName: 'FoodHow-Stage', + apiDomainName: stageDomainName, + resourcePostFix: STAGE_RESOURCE_POSTFIX, + accessControlAllowOrigin: STAGE_ACCESS_CONTROL_ALLOW_ORIGIN, + userPool: blankFamilyUserPool, + env: { region: ENV_REGION }, }, ) new HalfsiesStack( @@ -31,13 +93,10 @@ new HalfsiesStack( 'Halfsies-Stage', { stackName: 'Halfsies-Stage', - customDomainCertificateARN: 'arn:aws:acm:us-east-1:382713793519:certificate/74c434a7-2ad0-4477-8a50-e3b41218f85a', - customDomainName: 'stage-api.troyblank.com', - resourcePostFix: 'Stage', - accessControlAllowOrigin: '*', + apiDomainName: stageDomainName, + resourcePostFix: STAGE_RESOURCE_POSTFIX, + accessControlAllowOrigin: STAGE_ACCESS_CONTROL_ALLOW_ORIGIN, userPool: blankFamilyUserPool, - env: { - region: 'us-east-2', - }, + env: { region: ENV_REGION }, }, ) diff --git a/src/constants/foodHow/shoppingListItem.ts b/src/constants/foodHow/shoppingListItem.ts new file mode 100755 index 0000000..2af4de6 --- /dev/null +++ b/src/constants/foodHow/shoppingListItem.ts @@ -0,0 +1,16 @@ +export const SHOPPING_ITEM_TYPE = [ + 'meat', + 'perishable', + 'produce', + 'frozen', + 'imperishable', + 'spice', + 'uncommon', +] as const + +export const SHOPPING_ITEM_STORE = [ + 'Jewel-Osco', + 'Pete\'s', + 'Wild Fork', + 'Unspecified', +] as const diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100755 index 6ad03a4..0000000 --- a/src/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './responseCodes' \ No newline at end of file diff --git a/src/lambdas/foodHow/createShoppingListItem.test.ts b/src/lambdas/foodHow/createShoppingListItem.test.ts new file mode 100755 index 0000000..2609b5c --- /dev/null +++ b/src/lambdas/foodHow/createShoppingListItem.test.ts @@ -0,0 +1,137 @@ +import { Chance } from 'chance' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' +import { mockApiGatewayProxyEvent } from '../../mocks/apiGatewayProxyEvent' +import { mockShoppingListItem } from '../../mocks/shoppingListItem' +// import { sortLogs } from '../../utils/halfsies/log' +import { saveShoppingListItem } from './utils/shoppingList' +import { handler } from './createShoppingListItem' + +jest.mock('./utils/shoppingList') + +describe('Lambda - Create Shopping List Item', () => { + const chance = new Chance() + + beforeEach(() => { + process.env.shoppingListTableName = chance.word({ syllables: 4 }) + }) + + it('Should create a shopping list item.', async () => { + jest.mocked(saveShoppingListItem).mockResolvedValue({ + isError: false, + }) + + const mockedShoppingListItem = mockShoppingListItem() + const expectedBody = { newShoppingListItem: mockedShoppingListItem } + + const result = await handler(mockApiGatewayProxyEvent( + mockedShoppingListItem, + ) as any) + + 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 critical problem when saving a shopping list item.', async () => { + const errorMessage = chance.sentence() + jest.mocked(saveShoppingListItem).mockRejectedValue(errorMessage) + + const mockedShoppingListItem = mockShoppingListItem() + const expectedBody = { + errorMessage, + } + + const result = await handler(mockApiGatewayProxyEvent( + mockedShoppingListItem, + ) as any) + + 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 problem when saving a shopping list item.', async () => { + const errorMessage = chance.sentence() + jest.mocked(saveShoppingListItem).mockResolvedValue({ + isError: true, + errorMessage, + }) + + const mockedShoppingListItem = mockShoppingListItem() + const expectedBody = { + errorMessage, + } + + const result = await handler(mockApiGatewayProxyEvent( + mockedShoppingListItem, + ) as any) + + 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 when saving a shopping list item if the shopping list item is invalid.', async () => { + jest.mocked(saveShoppingListItem).mockResolvedValue({ + isError: false, + }) + + const mockedShoppingListItem = mockShoppingListItem({ + name: undefined, + }) + const expectedBody = { + message: 'No valid shopping list item given to create.', + } + + const result = await handler(mockApiGatewayProxyEvent( + mockedShoppingListItem, + ) as any) + + 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 when saving without a body.', async () => { + jest.mocked(saveShoppingListItem).mockResolvedValue({ + isError: false, + }) + + const expectedBody = { + message: 'No valid shopping list item given to create.', + } + + const result = await handler(mockApiGatewayProxyEvent({}, { + body: undefined, + }) as any) + + 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/createShoppingListItem.ts b/src/lambdas/foodHow/createShoppingListItem.ts new file mode 100755 index 0000000..397ddef --- /dev/null +++ b/src/lambdas/foodHow/createShoppingListItem.ts @@ -0,0 +1,45 @@ +import { type APIGatewayProxyEvent, type APIGatewayProxyResult } from 'aws-lambda' +import { type DatabaseResponse, type ShoppingListItem } from '../../types' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' +import { isAShoppingListItem } from '../../utils/foodHow/shoppingListItem' +import { getUserName } from '../utils/user' +import { saveShoppingListItem } from './utils/shoppingList' + +export const handler = ({ body, headers }: APIGatewayProxyEvent): Promise => new Promise((resolve) => { + const { accessControlAllowOrigin = '' } = process.env + const newShoppingListItem: ShoppingListItem = JSON.parse(body || '{}') + const user: string = getUserName(headers) + let message: string | undefined = 'Successfully created a shopping list item.' + + const result: APIGatewayProxyResult = { + body: JSON.stringify({ message }), + headers: { + 'Access-Control-Allow-Origin': accessControlAllowOrigin, + 'Content-Type': 'application/json', + }, + statusCode: RESPONSE_CODE_OK, + } + + if (!isAShoppingListItem(newShoppingListItem)) { + result.statusCode = RESPONSE_CODE_SERVER_ERROR + result.body = JSON.stringify({ message: 'No valid shopping list item given to create.' }) + + return resolve(result) + } + + saveShoppingListItem(newShoppingListItem, user).then(({ isError, errorMessage }: DatabaseResponse) => { + if (isError) { + result.statusCode = RESPONSE_CODE_SERVER_ERROR + result.body = JSON.stringify({ errorMessage: errorMessage }) + } else { + result.body = JSON.stringify({ newShoppingListItem }) + } + + resolve(result) + }).catch((error) => { + result.statusCode = RESPONSE_CODE_SERVER_ERROR + result.body = JSON.stringify({ errorMessage: error }) + + resolve(result) + }) +}) diff --git a/src/lambdas/foodHow/utils/shoppingList.ts b/src/lambdas/foodHow/utils/shoppingList.ts new file mode 100755 index 0000000..7533e07 --- /dev/null +++ b/src/lambdas/foodHow/utils/shoppingList.ts @@ -0,0 +1,32 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb' +import { getErrorMessage } from '../../../utils/error' +import { type DatabaseResponse } from '../../../types' +import { type ShoppingListItem } from '../../../types/lambdas/foodHow' + +export const saveShoppingListItem = async (item: ShoppingListItem, userName: string): Promise => { + const dynamoDbClient = new DynamoDBClient() + const dynamoDocumentClient = DynamoDBDocumentClient.from(dynamoDbClient) + const { shoppingListTableName } = process.env + const now: Date = new Date() + + try { + await dynamoDocumentClient.send(new PutCommand({ + TableName: shoppingListTableName, + Item: { + ...item, + id: now.getTime(), + user: userName, + }, + })) + + return { + isError: false, + } + } catch (error: unknown) { + return { + isError: true, + errorMessage: getErrorMessage(error), + } + } +} diff --git a/src/lambdas/foodHow/utils/shoppingListError.test.ts b/src/lambdas/foodHow/utils/shoppingListError.test.ts new file mode 100755 index 0000000..38ff123 --- /dev/null +++ b/src/lambdas/foodHow/utils/shoppingListError.test.ts @@ -0,0 +1,40 @@ +import { Chance } from 'chance' +import { mockShoppingListItem } from '../../../mocks' +import { saveShoppingListItem } from './shoppingList' + +jest.mock('@aws-sdk/client-dynamodb', () => { + return { + ...jest.requireActual('@aws-sdk/client-dynamodb'), + DynamoDBClient: jest.fn(() => ({ + send: jest.fn().mockRejectedValue(new Error('Something bad happened.')), + })), + } +}) + +jest.mock('@aws-sdk/lib-dynamodb', () => { + return { + ...jest.requireActual('@aws-sdk/lib-dynamodb'), + DynamoDBDocumentClient: { + from: jest.fn(() => ({ + send: jest.fn().mockRejectedValue(new Error('Something bad happened.')), + })), + }, + } +}) + +describe('Shopping List Util - failure', () => { + const chance = new Chance() + + beforeEach(() => { + process.env.shoppingListTableName = chance.word({ syllables: 4 }) + }) + + it('Should allow fail when saving a shopping list item.', async () => { + const result = await saveShoppingListItem(mockShoppingListItem(), chance.name()) + + 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 new file mode 100755 index 0000000..e4e0b1e --- /dev/null +++ b/src/lambdas/foodHow/utils/shoppingListSuccess.test.ts @@ -0,0 +1,31 @@ +import { Chance } from 'chance' +import { mockShoppingListItem } from '../../../mocks' +import { saveShoppingListItem } from './shoppingList' + +jest.mock('@aws-sdk/client-dynamodb', () => ({ + ...jest.requireActual('@aws-sdk/client-dynamodb'), + DynamoDBClient: jest.fn(() => ({})), +})) + +jest.mock('@aws-sdk/lib-dynamodb', () => { + return { + ...jest.requireActual('@aws-sdk/lib-dynamodb'), + DynamoDBDocumentClient: { + from: jest.fn(() => ({ + send: jest.fn().mockResolvedValue({ Items: [] }), + })), + }, + } +}) + +describe('Shopping List Util - success', () => { + const chance = new Chance() + + beforeEach(() => { + process.env.shoppingListTableName = chance.word({ syllables: 4 }) + }) + + it('Should save a shopping list item.', async () => { + expect(async () => await saveShoppingListItem(mockShoppingListItem(), chance.name())).not.toThrow() + }) +}) diff --git a/src/lambdas/halfsies/createHalfsie.test.ts b/src/lambdas/halfsies/createHalfsie.test.ts index 009c1f6..67858f9 100755 --- a/src/lambdas/halfsies/createHalfsie.test.ts +++ b/src/lambdas/halfsies/createHalfsie.test.ts @@ -2,11 +2,11 @@ import { Chance } from 'chance' import { MAIN_USER_NAME } from '../../../config' import { HalfsieLog } from '../../types' import { mockApiGatewayProxyEvent, mockHalfsieLogs, mockNewLog } from '../../mocks' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { pruneLogs } from '../../utils/halfsies/log' +import { getUserName } from '../utils/user' import { getBalance, - getUserName, saveLog, updateBalance, } from './utils' @@ -16,6 +16,7 @@ jest.mock('../../utils/halfsies/log', () => ({ ...jest.requireActual('../../utils/halfsies/log'), pruneLogs: jest.fn(), })) +jest.mock('../utils/user') jest.mock('./utils') describe('Lambda - Create Halfsie', () => { @@ -261,7 +262,7 @@ describe('Lambda - Create Halfsie', () => { }) }) - it('should return an error if there is a problem pruning the logs', async () => { + it('Should return an error if there is a problem pruning the logs.', async () => { const errorMessage = chance.sentence() jest.mocked(pruneLogs).mockResolvedValue({ diff --git a/src/lambdas/halfsies/createHalfsie.ts b/src/lambdas/halfsies/createHalfsie.ts index 5dcf762..c730b88 100755 --- a/src/lambdas/halfsies/createHalfsie.ts +++ b/src/lambdas/halfsies/createHalfsie.ts @@ -1,11 +1,11 @@ import { type APIGatewayProxyEvent, type APIGatewayProxyResult } from 'aws-lambda' import { type DatabaseResponse, type NewLog } from '../../types' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { isUserNameTheMainUserName } from '../../utils/halfsies/auth' import { isALog, pruneLogs } from '../../utils/halfsies/log' +import { getUserName } from '../utils/user' import { getBalance, - getUserName, saveLog, updateBalance, } from './utils' @@ -28,7 +28,8 @@ export const handler = ({ body, headers }: APIGatewayProxyEvent): Promise { diff --git a/src/lambdas/halfsies/getBalance.test.ts b/src/lambdas/halfsies/getBalance.test.ts index 2a367dc..787de59 100755 --- a/src/lambdas/halfsies/getBalance.test.ts +++ b/src/lambdas/halfsies/getBalance.test.ts @@ -1,5 +1,5 @@ import { Chance } from 'chance' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { getBalance } from './utils' import { handler } from './getBalance' diff --git a/src/lambdas/halfsies/getBalance.ts b/src/lambdas/halfsies/getBalance.ts index 531c669..afff6aa 100755 --- a/src/lambdas/halfsies/getBalance.ts +++ b/src/lambdas/halfsies/getBalance.ts @@ -1,6 +1,6 @@ import { type APIGatewayProxyResult } from 'aws-lambda' import { type DatabaseResponse } from '../../types' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { getBalance } from './utils' export const handler = (): Promise => new Promise((resolve) => { diff --git a/src/lambdas/halfsies/getLog.test.ts b/src/lambdas/halfsies/getLog.test.ts index 67a813a..d37ec71 100755 --- a/src/lambdas/halfsies/getLog.test.ts +++ b/src/lambdas/halfsies/getLog.test.ts @@ -1,5 +1,5 @@ import { Chance } from 'chance' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { mockHalfsieLog } from '../../mocks' import { sortLogs } from '../../utils/halfsies/log' import { getLog } from './utils/log' diff --git a/src/lambdas/halfsies/getLog.ts b/src/lambdas/halfsies/getLog.ts index a765c13..02ff2e0 100755 --- a/src/lambdas/halfsies/getLog.ts +++ b/src/lambdas/halfsies/getLog.ts @@ -1,6 +1,6 @@ import { type APIGatewayProxyResult } from 'aws-lambda' import { type DatabaseResponse } from '../../types' -import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants' +import { RESPONSE_CODE_OK, RESPONSE_CODE_SERVER_ERROR } from '../../constants/responseCodes' import { sortLogs } from '../../utils/halfsies/log' import { getLog } from './utils/log' diff --git a/src/lambdas/halfsies/utils/index.ts b/src/lambdas/halfsies/utils/index.ts index cbaa1b6..26d2f02 100755 --- a/src/lambdas/halfsies/utils/index.ts +++ b/src/lambdas/halfsies/utils/index.ts @@ -1,3 +1,3 @@ export * from './balance' export * from './log' -export * from './user' +export * from '../../utils/user' diff --git a/src/lambdas/halfsies/utils/logError.test.ts b/src/lambdas/halfsies/utils/logError.test.ts index 92074d0..19cb564 100755 --- a/src/lambdas/halfsies/utils/logError.test.ts +++ b/src/lambdas/halfsies/utils/logError.test.ts @@ -3,9 +3,8 @@ import { mockNewLog } from '../../../mocks' import { deleteLog, getLog, saveLog } from './log' jest.mock('@aws-sdk/client-dynamodb', () => { - const actual = jest.requireActual('@aws-sdk/client-dynamodb') return { - ...actual, // keeps DeleteItemCommand, DeleteItemCommandInput, etc. + ...jest.requireActual('@aws-sdk/client-dynamodb'), DynamoDBClient: jest.fn(() => ({ send: jest.fn().mockRejectedValue(new Error('Something bad happened.')), })), @@ -13,9 +12,8 @@ jest.mock('@aws-sdk/client-dynamodb', () => { }) jest.mock('@aws-sdk/lib-dynamodb', () => { - const originalModule = jest.requireActual('@aws-sdk/lib-dynamodb') return { - ...originalModule, + ...jest.requireActual('@aws-sdk/lib-dynamodb'), DynamoDBDocumentClient: { from: jest.fn(() => ({ send: jest.fn().mockRejectedValue(new Error('Something bad happened.')), @@ -40,7 +38,7 @@ describe('Log util - failure', () => { }) }) - it('should allow fail when saving a log', async () => { + it('Should allow fail when saving a log.', async () => { const result = await saveLog(mockNewLog(), chance.name()) expect(result).toStrictEqual({ @@ -49,7 +47,7 @@ describe('Log util - failure', () => { }) }) - it('should allow fail when deleting a log', async () => { + it('Should allow fail when deleting a log.', async () => { const result = await deleteLog(chance.natural()) expect(result).toStrictEqual({ diff --git a/src/lambdas/halfsies/utils/logSuccess.test.ts b/src/lambdas/halfsies/utils/logSuccess.test.ts index 3ac5175..411f0df 100755 --- a/src/lambdas/halfsies/utils/logSuccess.test.ts +++ b/src/lambdas/halfsies/utils/logSuccess.test.ts @@ -8,9 +8,8 @@ jest.mock('@aws-sdk/client-dynamodb', () => ({ })) jest.mock('@aws-sdk/lib-dynamodb', () => { - const originalModule = jest.requireActual('@aws-sdk/lib-dynamodb') return { - ...originalModule, + ...jest.requireActual('@aws-sdk/lib-dynamodb'), DynamoDBDocumentClient: { from: jest.fn(() => ({ send: jest.fn().mockResolvedValue({ Items: [] }), @@ -26,15 +25,15 @@ describe('Log util - success', () => { process.env.halfsiesLogTableName = chance.word({ syllables: 4 }) }) - it('should get logs', async () => { + it('Should get logs.', async () => { expect(async () => await getLog()).not.toThrow() }) - it('should save a log', async () => { + it('Should save a log.', async () => { expect(async () => await saveLog(mockNewLog(), chance.name())).not.toThrow() }) - it('should delete a log', async () => { + it('Should delete a log.', async () => { expect(async () => await deleteLog(chance.natural())).not.toThrow() }) }) diff --git a/src/lambdas/utils/index.ts b/src/lambdas/utils/index.ts new file mode 100755 index 0000000..ed10cd5 --- /dev/null +++ b/src/lambdas/utils/index.ts @@ -0,0 +1 @@ +export * from './user' diff --git a/src/lambdas/halfsies/utils/user.test.ts b/src/lambdas/utils/user.test.ts similarity index 87% rename from src/lambdas/halfsies/utils/user.test.ts rename to src/lambdas/utils/user.test.ts index bdb62e6..142b576 100755 --- a/src/lambdas/halfsies/utils/user.test.ts +++ b/src/lambdas/utils/user.test.ts @@ -1,4 +1,4 @@ -import { getUserName } from './user' +import { getUserName } from './' describe('User util', () => { diff --git a/src/lambdas/halfsies/utils/user.ts b/src/lambdas/utils/user.ts similarity index 80% rename from src/lambdas/halfsies/utils/user.ts rename to src/lambdas/utils/user.ts index 7a22f87..6a6e6b6 100755 --- a/src/lambdas/halfsies/utils/user.ts +++ b/src/lambdas/utils/user.ts @@ -1,4 +1,4 @@ import { type APIGatewayProxyEventHeaders } from 'aws-lambda' -import { parseJwt } from '../../../utils/jwt' +import { parseJwt } from '../../utils/jwt' export const getUserName = (headers: APIGatewayProxyEventHeaders): string => parseJwt(headers?.Authorization?.split(' ')[1])['cognito:username'] diff --git a/src/mocks/index.ts b/src/mocks/index.ts index e41079b..4965cfb 100755 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,3 +1,4 @@ export * from './apiGatewayProxyEvent' export * from './createHalfsie' export * from './halfsieLog' +export * from './shoppingListItem' diff --git a/src/mocks/shoppingListItem.ts b/src/mocks/shoppingListItem.ts new file mode 100755 index 0000000..a93dc05 --- /dev/null +++ b/src/mocks/shoppingListItem.ts @@ -0,0 +1,22 @@ +import { Chance } from 'chance' +import { type ShoppingListItem } from '../types/lambdas/foodHow' +import { SHOPPING_ITEM_TYPE, SHOPPING_ITEM_STORE } from '../constants/foodHow/shoppingListItem' + +const chance = new Chance() + +export const mockShoppingListItem = (overrides: any = {}): ShoppingListItem => ({ + name: chance.word(), + amount: chance.natural(), + type: chance.pickone(SHOPPING_ITEM_TYPE), + store: chance.pickone(SHOPPING_ITEM_STORE), + ...overrides, +}) + +export const mockShoppingListItems = (minAmount: number = 1): ShoppingListItem[] => { + const amountOfItems: number = chance.natural({ min: minAmount, max: minAmount + 100}) + const uniqueIDs = chance.unique(chance.natural, amountOfItems) + + return Array.from(Array(amountOfItems)).map(() => mockShoppingListItem({ + id: uniqueIDs.pop(), + })) +} diff --git a/src/stacks/apiDomain.ts b/src/stacks/apiDomain.ts new file mode 100755 index 0000000..ca0fa0a --- /dev/null +++ b/src/stacks/apiDomain.ts @@ -0,0 +1,38 @@ +/* istanbul ignore file */ +import { Construct } from 'constructs' +import { Stack } from 'aws-cdk-lib' +import { DomainName, EndpointType } from 'aws-cdk-lib/aws-apigateway' +import { Certificate } from 'aws-cdk-lib/aws-certificatemanager' +import { APIDomainStackProps } from '../types/stacks/apiDomain' + +// ---------------------------------------------------------------------------------------- +// CUSTOM DOMAIN NAME +// ---------------------------------------------------------------------------------------- +// This stack allows sharing an api for multiple services by using BasePathMapping (subDomain.domain.com/{app}/{endpoint}) +// To get a custom domain working you must +// 1. Get a Certificate using AWS Certificate Manager (on us-east-1 ONLY) and add a DNS CNAME record from your domain provider. +// 2. Setup your domain provider's DNS Records CNAME record to add the "API Gateway domain name" with a period at the end (found in API Gateway > Custom domain names) + +// ---------------------------------------------------------------------------------------- +// WHEN DELETING THIS IN CLOUD FORMATION +// ---------------------------------------------------------------------------------------- +// Be sure to remove the api DNS records made under your custom domain name on your your domain provider's. (You will have to re-add this if re-deploy - your cert is on us-east-1). + +export class APIDomainStack extends Stack { + public domainName: DomainName + + constructor(scope: Construct, id: string, props: APIDomainStackProps) { + super(scope, id, props) + + const { + domainName, + domainCertificateARN, + } = props + + this.domainName = new DomainName(this, 'ApiDomain', { + domainName, + certificate: Certificate.fromCertificateArn(this, 'ApiCert', domainCertificateARN), + endpointType: EndpointType.EDGE, + }) + } +} diff --git a/src/stacks/foodHow.ts b/src/stacks/foodHow.ts new file mode 100755 index 0000000..8ba2049 --- /dev/null +++ b/src/stacks/foodHow.ts @@ -0,0 +1,86 @@ +/* istanbul ignore file */ +import { join } from 'path' +import { Construct } from 'constructs' +import { Stack } from 'aws-cdk-lib' +import { + BasePathMapping, + CognitoUserPoolsAuthorizer, + LambdaIntegration, + Resource, + RestApi, + type Authorizer, +} from 'aws-cdk-lib/aws-apigateway' +import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb' +import { Runtime } from 'aws-cdk-lib/aws-lambda' +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs' +import { FoodHowStackProps } from '../types' +import { requiresAuthorization } from '../utils/auth' +import { createTable } from '../utils/tables' +import { addCorsOptions } from '../utils/apiGateway' + +const NODE_VERSION = Runtime.NODEJS_22_X + +// ---------------------------------------------------------------------------------------- +// WHEN DELETING THIS IN CLOUD FORMATION +// ---------------------------------------------------------------------------------------- +// Be sure to remove all DB tables from DynamoDB associated with this stack. + +export class FoodHowStack extends Stack { + constructor(scope: Construct, id: string, props: FoodHowStackProps) { + super(scope, id, props) + + const { + accessControlAllowOrigin, + apiDomainName, + resourcePostFix = '', + userPool, + } = props + // ---------------------------------------------------------------------------------------- + // DYNAMO DB + // ---------------------------------------------------------------------------------------- + const shoppingListDb: Table = createTable({ + name: `foodHowShoppingList${resourcePostFix}`, + primaryKey: 'id', + stack: this, + type: AttributeType.NUMBER, + }) + // ---------------------------------------------------------------------------------------- + // LAMBDAS + // ---------------------------------------------------------------------------------------- + const createShoppingListItem: NodejsFunction = new NodejsFunction(this, 'createShoppingListItem', { + functionName: `foodHowCreateShoppingListItem${resourcePostFix}`, + entry: join(__dirname, '../lambdas', 'foodHow', 'createShoppingListItem.ts'), + handler: 'handler', + runtime: NODE_VERSION, + environment: { + accessControlAllowOrigin, + shoppingListTableName: shoppingListDb.tableName, + }, + }) + + // ---------------------------------------------------------------------------------------- + // AUTHORIZATION + // ---------------------------------------------------------------------------------------- + const authorizer: Authorizer = new CognitoUserPoolsAuthorizer(this, `foodHowApiAuthorizer${resourcePostFix}`, { + cognitoUserPools: [ userPool ], + }) + + // ---------------------------------------------------------------------------------------- + // API GATEWAY + // ---------------------------------------------------------------------------------------- + const api: RestApi = new RestApi(this, `foodHowApi${resourcePostFix}`) + new BasePathMapping(this, `FoodHowBasePathMapping${resourcePostFix}`, { + domainName: apiDomainName, + restApi: api, + basePath: 'foodhow', + }) + + // create shopping list item + const createShoppingListItemLambdaIntegration: LambdaIntegration = new LambdaIntegration(createShoppingListItem) + const createShoppingListItemLambdaResource: Resource = api.root.addResource('createShoppingListItem') + + createShoppingListItemLambdaResource.addMethod('POST', createShoppingListItemLambdaIntegration, requiresAuthorization(authorizer)) + shoppingListDb.grantReadWriteData(createShoppingListItem) + addCorsOptions(createShoppingListItemLambdaResource, accessControlAllowOrigin) + } +} diff --git a/src/stacks/halfsies.ts b/src/stacks/halfsies.ts index 6d2f600..c511af4 100755 --- a/src/stacks/halfsies.ts +++ b/src/stacks/halfsies.ts @@ -3,19 +3,17 @@ import { join } from 'path' import { Construct } from 'constructs' import { CustomResource, Stack } from 'aws-cdk-lib' import { + BasePathMapping, CognitoUserPoolsAuthorizer, LambdaIntegration, Resource, RestApi, - EndpointType, - DomainNameOptions, type Authorizer, } from 'aws-cdk-lib/aws-apigateway' -import { Certificate, ICertificate } from 'aws-cdk-lib/aws-certificatemanager' import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb' import { Runtime } from 'aws-cdk-lib/aws-lambda' import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs' -import { HalfsiesStackProps } from '../types' +import { HalfsiesStackProps } from '../types/stacks/halfsies' import { requiresAuthorization } from '../utils/auth' import { createTable } from '../utils/tables' import { addCorsOptions } from '../utils/apiGateway' @@ -25,8 +23,7 @@ const NODE_VERSION = Runtime.NODEJS_22_X // ---------------------------------------------------------------------------------------- // WHEN DELETING THIS IN CLOUD FORMATION // ---------------------------------------------------------------------------------------- -// 1. Be sure to remove the api DNS records made under your custom domain name on your your domain provider's. (You will have to re-add this if re-deploy - your cert is on us-east-1). -// 2. Be sure to remove all DB tables from DynamoDB associated with this stack. +// Be sure to remove all DB tables from DynamoDB associated with this stack. export class HalfsiesStack extends Stack { constructor(scope: Construct, id: string, props: HalfsiesStackProps) { @@ -34,26 +31,10 @@ export class HalfsiesStack extends Stack { const { accessControlAllowOrigin, - customDomainCertificateARN, - customDomainName, + apiDomainName, resourcePostFix = '', userPool, } = props - - // ---------------------------------------------------------------------------------------- - // CUSTOM DOMAIN NAME - // ---------------------------------------------------------------------------------------- - // To get a custom domain working you must - // 1. Get a Certificate using AWS Certificate Manager (on us-east-1 ONLY) and add a DNS CNAME record from your domain provider. - // 2. Setup your domain provider's DNS Records CNAME record to add the "API Gateway domain name" with a period at the end (found in API Gateway > Custom domain names) - const customDomainCertificate: ICertificate = Certificate.fromCertificateArn(this, 'domainCert', customDomainCertificateARN) - const customApiDomain: DomainNameOptions = { - domainName: customDomainName, - basePath: 'halfsies', - certificate: customDomainCertificate, - endpointType: EndpointType.EDGE, - } - // ---------------------------------------------------------------------------------------- // DYNAMO DB // ---------------------------------------------------------------------------------------- @@ -122,10 +103,6 @@ export class HalfsiesStack extends Stack { const initializeHalfsiesDatabase: NodejsFunction = new NodejsFunction(this, 'initializeHalfsiesDatabase', { functionName: `halfsiesInitializeHalfsiesDatabase${resourcePostFix}`, entry: join(__dirname, '../lambdas', 'halfsies', 'initializeHalfsiesDatabase.ts'), - // bundling: { - // minify: true, - // sourceMap: false, - // }, handler: 'handler', runtime: NODE_VERSION, environment: { @@ -143,8 +120,11 @@ export class HalfsiesStack extends Stack { // API GATEWAY // ---------------------------------------------------------------------------------------- const api: RestApi = new RestApi(this, `halfsiesApi${resourcePostFix}`) - - api.addDomainName('ApiGatewayDomain', customApiDomain) + new BasePathMapping(this, `HalfsiesBasePathMapping${resourcePostFix}`, { + domainName: apiDomainName, + restApi: api, + basePath: 'halfsies', + }) // create halfsie const createHalfsieLambdaIntegration: LambdaIntegration = new LambdaIntegration(createHalfsie) diff --git a/src/stacks/index.ts b/src/stacks/index.ts index 9e129fa..9da1326 100755 --- a/src/stacks/index.ts +++ b/src/stacks/index.ts @@ -1,3 +1,5 @@ /* istanbul ignore file */ +export { APIDomainStack } from './apiDomain' export { AuthStack } from './auth' +export { FoodHowStack } from './foodHow' export { HalfsiesStack } from './halfsies' diff --git a/src/types/lambdas/foodHow.ts b/src/types/lambdas/foodHow.ts new file mode 100755 index 0000000..8da3ca3 --- /dev/null +++ b/src/types/lambdas/foodHow.ts @@ -0,0 +1,11 @@ +import { SHOPPING_ITEM_TYPE, SHOPPING_ITEM_STORE } from '../../constants/foodHow/shoppingListItem' + +export type ShoppingItemType = typeof SHOPPING_ITEM_TYPE[number]; +export type ShoppingItemStore = typeof SHOPPING_ITEM_STORE[number]; + +export type ShoppingListItem = { + amount: number, + name: string, + type: ShoppingItemType, + store: ShoppingItemStore, +} diff --git a/src/types/lambdas/index.ts b/src/types/lambdas/index.ts index 5b44672..28aa745 100755 --- a/src/types/lambdas/index.ts +++ b/src/types/lambdas/index.ts @@ -1,3 +1,4 @@ /* istanbul ignore file */ export * from './createHalfsie' +export * from './foodHow' export * from './utils' diff --git a/src/types/stacks/apiDomain.ts b/src/types/stacks/apiDomain.ts new file mode 100755 index 0000000..7b456ff --- /dev/null +++ b/src/types/stacks/apiDomain.ts @@ -0,0 +1,6 @@ +import { StackProps } from 'aws-cdk-lib' + +export interface APIDomainStackProps extends StackProps { + domainCertificateARN: string, + domainName: string, +} diff --git a/src/types/stacks/foodHow.ts b/src/types/stacks/foodHow.ts new file mode 100755 index 0000000..ec98ab4 --- /dev/null +++ b/src/types/stacks/foodHow.ts @@ -0,0 +1,10 @@ +import { StackProps } from 'aws-cdk-lib' +import { DomainName } from 'aws-cdk-lib/aws-apigateway' +import { UserPool } from 'aws-cdk-lib/aws-cognito' + +export interface FoodHowStackProps extends StackProps { + apiDomainName: DomainName, + accessControlAllowOrigin: string, + resourcePostFix: string, + userPool: UserPool +} diff --git a/src/types/stacks/halfsies.ts b/src/types/stacks/halfsies.ts index 69282bc..3298bbb 100755 --- a/src/types/stacks/halfsies.ts +++ b/src/types/stacks/halfsies.ts @@ -1,4 +1,5 @@ import { StackProps } from 'aws-cdk-lib' +import { DomainName } from 'aws-cdk-lib/aws-apigateway' import { UserPool } from 'aws-cdk-lib/aws-cognito' export type HalfsieLog = { @@ -10,9 +11,8 @@ export type HalfsieLog = { } export interface HalfsiesStackProps extends StackProps { + apiDomainName: DomainName, accessControlAllowOrigin: string, - customDomainCertificateARN: string, - customDomainName: string, resourcePostFix: string, userPool: UserPool } diff --git a/src/types/stacks/index.ts b/src/types/stacks/index.ts index 8fecbeb..bc99f9e 100755 --- a/src/types/stacks/index.ts +++ b/src/types/stacks/index.ts @@ -1,2 +1,3 @@ /* istanbul ignore file */ +export * from './foodHow' export * from './halfsies' diff --git a/src/utils/foodHow/shoppingListItem.test.ts b/src/utils/foodHow/shoppingListItem.test.ts new file mode 100755 index 0000000..5980662 --- /dev/null +++ b/src/utils/foodHow/shoppingListItem.test.ts @@ -0,0 +1,47 @@ +import { Chance } from 'chance' +import { mockShoppingListItem } from '../../mocks' +import { SHOPPING_ITEM_TYPE, SHOPPING_ITEM_STORE } from '../../constants/foodHow/shoppingListItem' +import { + isAShoppingListItem, + isAShoppingItemType, + isAShoppingItemStore, +} from './shoppingListItem' + +describe('Shopping List util', () => { + const chance = new Chance() + + it('Should determine what is a shopping list item type.', async () => { + expect(isAShoppingItemType(chance.pickone(SHOPPING_ITEM_TYPE))).toBe(true) + expect(isAShoppingItemType(chance.word({ syllables: 5 }))).toBe(false) + }) + + it('Should determine what is a shopping list item store.', async () => { + expect(isAShoppingItemStore(chance.pickone(SHOPPING_ITEM_STORE))).toBe(true) + expect(isAShoppingItemStore(chance.word({ syllables: 5 }))).toBe(false) + }) + + it('Should determine what is a shopping list item.', async () => { + expect(isAShoppingListItem(mockShoppingListItem())).toBe(true) + expect(isAShoppingListItem(undefined)).toBe(false) + expect(isAShoppingListItem({ + ...mockShoppingListItem(), + amount: chance.word({ syllables: 5 }), + })).toBe(false) + expect(isAShoppingListItem({ + ...mockShoppingListItem(), + name: chance.integer(), + })).toBe(false) + expect(isAShoppingListItem({ + ...mockShoppingListItem(), + name: '', + })).toBe(false) + expect(isAShoppingListItem({ + ...mockShoppingListItem(), + type: chance.word({ syllables: 5 }), + })).toBe(false) + expect(isAShoppingListItem({ + ...mockShoppingListItem(), + store: chance.word({ syllables: 5 }), + })).toBe(false) + }) +}) diff --git a/src/utils/foodHow/shoppingListItem.ts b/src/utils/foodHow/shoppingListItem.ts new file mode 100755 index 0000000..c49f613 --- /dev/null +++ b/src/utils/foodHow/shoppingListItem.ts @@ -0,0 +1,23 @@ +import { SHOPPING_ITEM_TYPE, SHOPPING_ITEM_STORE } from '../../constants/foodHow/shoppingListItem' +import { type ShoppingItemType, type ShoppingItemStore } from '../../types' + +export const isAShoppingItemType = (shoppingItemType: any): boolean => { + return typeof shoppingItemType === 'string' && + SHOPPING_ITEM_TYPE.includes(shoppingItemType as ShoppingItemType) +} + +export const isAShoppingItemStore = (shoppingItemStore: any): boolean => { + return typeof shoppingItemStore === 'string' && + SHOPPING_ITEM_STORE.includes(shoppingItemStore as ShoppingItemStore) +} + +export const isAShoppingListItem = (shoppingListItem: any): boolean => { + const { amount, name, type, store } = shoppingListItem || {} + + return ( + typeof amount === 'number' && + typeof name === 'string' && name.length > 0 && + isAShoppingItemType(type) && + isAShoppingItemStore(store) + ) +}