From 172d3f1206773d3229d68f9ff60e5ec0914a816c Mon Sep 17 00:00:00 2001 From: Angie Che Date: Sat, 10 Oct 2026 11:31:31 -0400 Subject: [PATCH 1/2] handler + handler test + enabled lint + enable jest and node types in tsconfig --- apps/backend/upload-lambda/project.json | 2 +- .../backend/upload-lambda/src/handler.test.ts | 110 ++++++++++++++++++ apps/backend/upload-lambda/src/handler.ts | 18 +++ apps/backend/upload-lambda/tsconfig.json | 3 +- 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 apps/backend/upload-lambda/src/handler.test.ts create mode 100644 apps/backend/upload-lambda/src/handler.ts diff --git a/apps/backend/upload-lambda/project.json b/apps/backend/upload-lambda/project.json index 0a342c52c..fb1f259eb 100644 --- a/apps/backend/upload-lambda/project.json +++ b/apps/backend/upload-lambda/project.json @@ -19,7 +19,7 @@ "passWithNoTests": true } }, - "_lint_TODO_reenable_once_real_ts_files_exist": { + "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { diff --git a/apps/backend/upload-lambda/src/handler.test.ts b/apps/backend/upload-lambda/src/handler.test.ts new file mode 100644 index 000000000..763bd7f6f --- /dev/null +++ b/apps/backend/upload-lambda/src/handler.test.ts @@ -0,0 +1,110 @@ +import type { S3Event, S3EventRecord } from 'aws-lambda'; +import { extractS3ObjectLocations, handler } from './handler'; + +const buildS3Record = (bucket: string, key: string): S3EventRecord => ({ + eventVersion: '2.1', + eventSource: 'aws:s3', + awsRegion: 'us-east-1', + eventTime: '2026-10-08T12:00:00.000Z', + eventName: 'ObjectCreated:Put', + userIdentity: { principalId: 'EXAMPLE' }, + requestParameters: { sourceIPAddress: '127.0.0.1' }, + responseElements: { + 'x-amz-request-id': 'EXAMPLE123456789', + 'x-amz-id-2': 'EXAMPLE123/abcdefghijklmnopqrstuvwxyz', + }, + s3: { + s3SchemaVersion: '1.0', + configurationId: 'testConfigRule', + bucket: { + name: bucket, + ownerIdentity: { principalId: 'EXAMPLE' }, + arn: `arn:aws:s3:::${bucket}`, + }, + object: { + key, + size: 1024, + eTag: '0123456789abcdef0123456789abcdef', + sequencer: '0A1B2C3D4E5F678901', + }, + }, +}); + +const buildS3Event = (...records: S3EventRecord[]): S3Event => ({ + Records: records, +}); + +describe('extractS3ObjectLocations', () => { + it('extracts the bucket and key from a single record', () => { + const event = buildS3Event(buildS3Record('bho-uploads', 'daily-obs.csv')); + + expect(extractS3ObjectLocations(event)).toEqual([ + { bucket: 'bho-uploads', key: 'daily-obs.csv' }, + ]); + }); + + it('extracts every record in order', () => { + const event = buildS3Event( + buildS3Record('bho-uploads', 'first.csv'), + buildS3Record('bho-uploads', 'second.xlsx'), + buildS3Record('bho-archive', 'third.csv'), + ); + + expect(extractS3ObjectLocations(event)).toEqual([ + { bucket: 'bho-uploads', key: 'first.csv' }, + { bucket: 'bho-uploads', key: 'second.xlsx' }, + { bucket: 'bho-archive', key: 'third.csv' }, + ]); + }); + + it('decodes URL-encoded keys', () => { + const event = buildS3Event( + buildS3Record('bho-uploads', 'uploads/BHO+Daily+Obs+%232.csv'), + ); + + expect(extractS3ObjectLocations(event)).toEqual([ + { bucket: 'bho-uploads', key: 'uploads/BHO Daily Obs #2.csv' }, + ]); + }); + + it('returns an empty array when the event has no records', () => { + expect(extractS3ObjectLocations(buildS3Event())).toEqual([]); + }); +}); + +describe('handler', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('logs the bucket and key for each record', async () => { + const event = buildS3Event( + buildS3Record('bho-uploads', 'first.csv'), + buildS3Record('bho-uploads', 'second.xlsx'), + ); + + await expect(handler(event)).resolves.toBeUndefined(); + + expect(logSpy).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenNthCalledWith( + 1, + 'Received upload: bucket=bho-uploads, key=first.csv', + ); + expect(logSpy).toHaveBeenNthCalledWith( + 2, + 'Received upload: bucket=bho-uploads, key=second.xlsx', + ); + }); + + it('logs nothing when the event has no records', async () => { + await handler(buildS3Event()); + + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/upload-lambda/src/handler.ts b/apps/backend/upload-lambda/src/handler.ts new file mode 100644 index 000000000..44c781560 --- /dev/null +++ b/apps/backend/upload-lambda/src/handler.ts @@ -0,0 +1,18 @@ +import type { S3Event } from 'aws-lambda'; + +export interface S3ObjectLocation { + bucket: string; + key: string; +} + +export const extractS3ObjectLocations = (event: S3Event): S3ObjectLocation[] => + event.Records.map((record) => ({ + bucket: record.s3.bucket.name, + key: decodeURIComponent(record.s3.object.key.replace(/\+/g, ' ')), + })); + +export const handler = async (event: S3Event): Promise => { + for (const { bucket, key } of extractS3ObjectLocations(event)) { + console.log(`Received upload: bucket=${bucket}, key=${key}`); + } +}; diff --git a/apps/backend/upload-lambda/tsconfig.json b/apps/backend/upload-lambda/tsconfig.json index 5275c6a4b..560da81e7 100644 --- a/apps/backend/upload-lambda/tsconfig.json +++ b/apps/backend/upload-lambda/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "./dist", "rootDir": "./src", "module": "commonjs", - "target": "es2020" + "target": "es2020", + "types": ["jest", "node"] }, "include": ["src/**/*.ts"] } From 8a79b572ea513d793f79ce18440ba40dabdc189c Mon Sep 17 00:00:00 2001 From: Angie Che Date: Sat, 10 Oct 2026 11:53:16 -0400 Subject: [PATCH 2/2] chore: add @types/aws-lambda dev dependency --- package.json | 3 ++- yarn.lock | 43 +++++++++---------------------------------- 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index c9dd38d2d..4beaecd78 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "dependencies": { "@aws-amplify/ui-react": "^6.15.4", "@aws-sdk/client-cognito-identity-provider": "^3.1058.0", - "@aws-sdk/client-sesv2": "^3.1063.0", "@aws-sdk/client-s3": "^3.1073.0", + "@aws-sdk/client-sesv2": "^3.1063.0", "@nestjs/cli": "^11.0.24", "@nestjs/common": "^10.0.2", "@nestjs/config": "^4.0.4", @@ -67,6 +67,7 @@ "@nx/vite": "22.7.5", "@nx/webpack": "23.0.0", "@testing-library/react": "^15.0.7", + "@types/aws-lambda": "^8.10.164", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.9.3", diff --git a/yarn.lock b/yarn.lock index d7ac9b68a..52d794875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4226,6 +4226,11 @@ resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.162.tgz#589cfecdda3a996e9bdf20a73a26a0811cafb549" integrity sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw== +"@types/aws-lambda@^8.10.164": + version "8.10.164" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.164.tgz#baac93d5bd8aa395411aaf8791acda075d2703e6" + integrity sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -11509,7 +11514,7 @@ react-hook-form@7.53.2: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.2.tgz#6fa37ae27330af81089baadd7f322cc987b8e2ac" integrity sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw== -"react-is-18@npm:react-is@^18.3.1": +"react-is-18@npm:react-is@^18.3.1", react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -11529,11 +11534,6 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - react-refresh@^0.17.0: version "0.17.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" @@ -12432,16 +12432,7 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@4.2.3, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12542,14 +12533,7 @@ string_decoder@1.3.0, string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13634,16 +13618,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@7.0.0, wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==