diff --git a/etc/scripts/common.mjs b/etc/scripts/common.mjs index d0d1cc8..9c07068 100644 --- a/etc/scripts/common.mjs +++ b/etc/scripts/common.mjs @@ -31,14 +31,6 @@ export const esbuildConfig = { authToken: process.env.SENTRY_AUTH_TOKEN, disable: !isSentryEnabled, }), - copy({ - watch: true, - resolveFrom: "cwd", - assets: { - from: ["./src/api/graphql/schema.graphql"], - to: ["./build"], - }, - }), copy({ resolveFrom: "cwd", assets: { diff --git a/package.json b/package.json index 8166042..b9935d5 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "husky": "^9.1.6", "jose": "^5.9.4", "jsonwebtoken": "^9.0.2", + "klona": "^2.0.6", "nodemailer": "^6.9.15", "path-to-regexp": "^8.2.0", "pg": "^8.13.0", @@ -73,6 +74,7 @@ "react-email": "^3.0.1", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", + "traverse": "^0.6.10", "ts-invariant": "^0.10.3", "tsconfig-paths": "^4.2.0", "typescript": "^5.6.3", @@ -97,6 +99,7 @@ "@types/node": "^20.16.11", "@types/nodemailer": "^6.4.16", "@types/pg": "^8.11.10", + "@types/traverse": "^0.6.37", "@typescript-eslint/parser": "^7.18.0", "@vitest/coverage-v8": "^2.1.3", "commitizen": "^4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e4ee43..94ce184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + klona: + specifier: ^2.0.6 + version: 2.0.6 nodemailer: specifier: ^6.9.15 version: 6.9.15 @@ -113,6 +116,9 @@ importers: tailwindcss: specifier: ^3.4.14 version: 3.4.14 + traverse: + specifier: ^0.6.10 + version: 0.6.10 ts-invariant: specifier: ^0.10.3 version: 0.10.3 @@ -184,6 +190,9 @@ importers: '@types/pg': specifier: ^8.11.10 version: 8.11.10 + '@types/traverse': + specifier: ^0.6.37 + version: 0.6.37 '@typescript-eslint/parser': specifier: ^7.18.0 version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) @@ -2615,6 +2624,9 @@ packages: '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/traverse@0.6.37': + resolution: {integrity: sha512-c90MVeDiUI1FhOZ6rLQ3kDWr50YE8+paDpM+5zbHjbmsqEp2DlMYkqnZnwbK9oI+NvDe8yRajup4jFwnVX6xsA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -4508,6 +4520,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} @@ -5730,6 +5746,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + traverse@0.6.10: + resolution: {integrity: sha512-hN4uFRxbK+PX56DxYiGHsTn2dME3TVr9vbNqlQGcGcPhJAn+tdP126iA+TArMpI4YSgnTkMWyoLl5bf81Hi5TA==} + engines: {node: '>= 0.4'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5825,6 +5845,10 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} + typedarray.prototype.slice@1.0.3: + resolution: {integrity: sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==} + engines: {node: '>= 0.4'} + typescript-eslint@7.18.0: resolution: {integrity: sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==} engines: {node: ^18.18.0 || >=20.0.0} @@ -9074,6 +9098,8 @@ snapshots: '@types/shimmer@1.2.0': {} + '@types/traverse@0.6.37': {} + '@types/triple-beam@1.3.5': {} '@types/ws@8.5.12': @@ -11329,6 +11355,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + klona@2.0.6: {} + kuler@2.0.0: {} leac@0.6.0: {} @@ -12594,6 +12622,12 @@ snapshots: tr46@0.0.3: {} + traverse@0.6.10: + dependencies: + gopd: 1.0.1 + typedarray.prototype.slice: 1.0.3 + which-typed-array: 1.1.15 + tree-kill@1.2.2: {} triple-beam@1.4.1: {} @@ -12684,6 +12718,15 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 + typedarray.prototype.slice@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + typed-array-buffer: 1.0.2 + typed-array-byte-offset: 1.0.2 + typescript-eslint@7.18.0(eslint@8.57.1)(typescript@5.6.3): dependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) diff --git a/src/api/rest/routes.test.ts b/src/api/rest/routes.test.ts index 7498e60..d9dab2c 100644 --- a/src/api/rest/routes.test.ts +++ b/src/api/rest/routes.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import * as validate from "@/lib/graphql/validate"; import * as auth from "@/lib/saleor/auth"; @@ -8,7 +8,7 @@ import { EVENT_HANDLERS } from "./saleor/webhooks"; describe("apiRoutes", () => { describe("/api/healthcheck", () => { - test("200", async () => { + it("200", async () => { const app = await createServer(); const response = await app.inject({ method: "GET", @@ -31,7 +31,7 @@ describe("apiRoutes", () => { app.sqs.send = sendSpy; }); - test("Should return 401 with invalid JWT.", async () => { + it("Should return 401 with invalid JWT.", async () => { // given const expectedStatusCode = 401; const expectedJson = { @@ -56,7 +56,7 @@ describe("apiRoutes", () => { expect(response.statusCode).toStrictEqual(expectedStatusCode); }); - test("Should return 400 passed when event is not supported.", async () => { + it("Should return 400 passed when event is not supported.", async () => { // given const expectedStatusCode = 400; const expectedJson = { @@ -87,7 +87,7 @@ describe("apiRoutes", () => { expect(response.statusCode).toStrictEqual(expectedStatusCode); }); - test("Should return proper response.", async () => { + it("Should return proper response.", async () => { const jwtVerifySpy = vi.spyOn(auth, "verifyJWTSignature"); const validateSpy = vi.spyOn(validate, "validateDocumentAgainstData"); const expectedJson = { status: "ok" }; diff --git a/src/api/rest/routes.ts b/src/api/rest/routes.ts index ed277f5..2424ed7 100644 --- a/src/api/rest/routes.ts +++ b/src/api/rest/routes.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { CONFIG } from "@/config"; import { OrderCreatedSubscriptionDocument } from "@/graphql/operations/subscriptions/generated"; -import { serializePayload } from "@/lib/emails/events/helpers"; import { validateDocumentAgainstData } from "@/lib/graphql/validate"; +import { serializePayload } from "@/lib/payload"; import { verifyJWTSignature } from "@/lib/saleor/auth"; import { saleorBearerHeader } from "@/lib/saleor/schema"; import { getJWKSProvider } from "@/providers/jwks"; diff --git a/src/api/rest/saleor/webhooks.test.ts b/src/api/rest/saleor/webhooks.test.ts index 30e9ee2..4b3acc4 100644 --- a/src/api/rest/saleor/webhooks.test.ts +++ b/src/api/rest/saleor/webhooks.test.ts @@ -1,8 +1,8 @@ import { JWTInvalid } from "jose/errors"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { CONFIG } from "@/config"; -import { serializePayload } from "@/lib/emails/events/helpers"; +import { serializePayload } from "@/lib/payload"; import * as auth from "@/lib/saleor/auth"; import { createServer } from "@/server"; @@ -21,7 +21,7 @@ describe("saleorWebhooksRoutes", () => { }); describe("/api/saleor/webhooks/email/*", () => { - test.each(EVENT_HANDLERS)( + it.each(EVENT_HANDLERS)( "should register route for $event event", ({ event }) => { // Given @@ -37,7 +37,7 @@ describe("saleorWebhooksRoutes", () => { } ); - test("require saleor headers", async () => { + it("require saleor headers", async () => { // Given const url = `${baseUrl}account-confirmed`; const expectedStatusCode = 400; @@ -82,7 +82,7 @@ describe("saleorWebhooksRoutes", () => { expect(response.statusCode).toStrictEqual(expectedStatusCode); }); - test("is protected by JWT", async () => { + it("is protected by JWT", async () => { // Given const url = `${baseUrl}account-confirmed`; const expectedStatusCode = 401; @@ -116,7 +116,7 @@ describe("saleorWebhooksRoutes", () => { expect(response.statusCode).toStrictEqual(expectedStatusCode); }); - test("it should send SQS message with proper payload", async () => { + it("it should send SQS message with proper payload", async () => { // Given const url = `${baseUrl}account-confirmed`; const event = "account_confirmed"; diff --git a/src/api/rest/saleor/webhooks.ts b/src/api/rest/saleor/webhooks.ts index aa900db..be14e09 100644 --- a/src/api/rest/saleor/webhooks.ts +++ b/src/api/rest/saleor/webhooks.ts @@ -20,7 +20,7 @@ import { OrderRefundedSubscriptionDocument, } from "@/graphql/operations/subscriptions/generated"; import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; -import { serializePayload } from "@/lib/emails/events/helpers"; +import { serializePayload } from "@/lib/payload"; import { verifyWebhookSignature } from "@/lib/saleor/auth"; import { saleorWebhookHeaders } from "@/lib/saleor/schema"; import { getJWKSProvider } from "@/providers/jwks"; diff --git a/src/config.ts b/src/config.ts index 7966f01..a88c826 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,7 +31,11 @@ export const configSchema = z }) .and(commonConfigSchema) .and(appConfigSchema) - .and(saleorAppConfigSchema); + .and(saleorAppConfigSchema) + .refine((data) => { + data.STOREFRONT_URL = new URL(data.STOREFRONT_URL).origin; + return data; + }); export type ConfigSchema = z.infer; diff --git a/src/emails-sender-proxy.ts b/src/emails-sender-proxy.ts index 94cb38d..42a21e7 100644 --- a/src/emails-sender-proxy.ts +++ b/src/emails-sender-proxy.ts @@ -1,4 +1,3 @@ -import { SQSClient } from "@aws-sdk/client-sqs"; import { type Context, type SQSEvent, type SQSRecord } from "aws-lambda"; import { Consumer } from "sqs-consumer"; @@ -7,10 +6,6 @@ import { handler, logger } from "@/emails-sender"; const app = Consumer.create({ queueUrl: CONFIG.SQS_QUEUE_URL, - sqs: new SQSClient({ - useQueueUrlAsEndpoint: false, - endpoint: CONFIG.SQS_QUEUE_URL, - }), handleMessageBatch: async (messages) => { const event: SQSEvent = { Records: messages as SQSRecord[], @@ -18,7 +13,7 @@ const app = Consumer.create({ const context = {} as Context; const callback = () => null; - await handler(event, context, () => callback); + return await handler(event, context, () => callback); }, }); diff --git a/src/emails-sender.ts b/src/emails-sender.ts index 4453b26..cef5edf 100644 --- a/src/emails-sender.ts +++ b/src/emails-sender.ts @@ -1,20 +1,33 @@ import "./instrument.emails-sender"; import * as Sentry from "@sentry/aws-serverless"; -import { type Context, type SQSBatchResponse, type SQSEvent } from "aws-lambda"; +import { + type Callback, + type Context, + type SQSBatchItemFailure, + type SQSBatchResponse, + type SQSEvent, +} from "aws-lambda"; import { CONFIG } from "@/config"; -import { parseRecord } from "@/lib/aws/serverless/utils"; import { TEMPLATES_MAP } from "@/lib/emails/const"; -import { getJSONFormatHeader } from "@/lib/saleor/apps/utils"; +import { + EventNotSupportedError, + FormatNotSupportedError, +} from "@/lib/emails/errors"; +import { getJSONFormatHeader, parseRecord } from "@/lib/payload"; import { getEmailProvider } from "@/providers/email"; import { getLogger } from "@/providers/logger"; export const logger = getLogger(); export const handler = Sentry.wrapHandler( - async (event: SQSEvent, context: Context) => { - const failures: string[] = []; + async ( + event: SQSEvent, + context: Context, + callback: Callback + ): Promise => { + const batchItemFailures: SQSBatchItemFailure[] = []; logger.info(`Received event with ${event.Records.length} records.`); @@ -25,16 +38,24 @@ export const handler = Sentry.wrapHandler( format, payload: { data, event }, } = parseRecord(record); + const supportedJSONFormat = getJSONFormatHeader({ + version: 1, + name: CONFIG.NAME, + }); - if (format === getJSONFormatHeader({ version: 1, name: CONFIG.NAME })) { + if (format === supportedJSONFormat) { const match = TEMPLATES_MAP[event]; if (!match) { - return logger.warn("Received payload with unhandled template.", { + logger.warn("Received payload with unhandled template.", { format, data, event, }); + throw new EventNotSupportedError( + `No template matched for event: ${event}.`, + { cause: { format, data, event } } + ); } const { extractFn, template } = match; @@ -53,8 +74,6 @@ export const handler = Sentry.wrapHandler( props: { data }, template, }); - // TODO: Handle properly - // Will throw TypeError if failed to render / non transient await sender.send({ html, @@ -63,21 +82,32 @@ export const handler = Sentry.wrapHandler( logger.info("Email sent successfully.", { toEmail, event }); } else { - return logger.warn("Received payload with unsupported format.", { + logger.warn("Received payload with unsupported format.", { format, data, event, }); + throw new FormatNotSupportedError( + `Unsupported payload format header: ${format}`, + { + cause: { + supportedFormat: supportedJSONFormat, + incomingFormat: format, + data, + event, + }, + } + ); } } - if (failures.length) { - const batchFailure: SQSBatchResponse = { - batchItemFailures: failures.map((id) => ({ itemIdentifier: id })), - }; - logger.error(`FAILING messages: ${JSON.stringify(batchFailure)}`); - - return batchFailure; + if (batchItemFailures.length) { + const failedMessagesId = batchItemFailures.map( + ({ itemIdentifier }) => itemIdentifier + ); + logger.error(`Failed messages: ${failedMessagesId.join(", ")}.`); } + + return { batchItemFailures }; } ); diff --git a/src/events-receiver.ts b/src/events-receiver.ts index 60119f0..ed7bc70 100644 --- a/src/events-receiver.ts +++ b/src/events-receiver.ts @@ -1,5 +1,3 @@ -// TODO: Saleor events receiver & queue dispatcher - import AWSLambdaFastify from "@fastify/aws-lambda"; import { createServer } from "./server"; diff --git a/src/lib/api/errorHandler.test.ts b/src/lib/api/errorHandler.test.ts index a749df4..8246d82 100644 --- a/src/lib/api/errorHandler.test.ts +++ b/src/lib/api/errorHandler.test.ts @@ -1,13 +1,17 @@ import type { ZodTypeProvider } from "fastify-type-provider-zod"; import { decodeJwt } from "jose"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { HttpError, UnauthorizedError, ValidationError } from "@/lib/errors"; +import { + HttpError, + UnauthorizedError, + ValidationError, +} from "@/lib/errors/api"; import { createServer } from "@/server"; describe("errorHandler", () => { - test("Default error handling", async () => { + it("Default error handling", async () => { // Given const expectedMessage = "Oh snap"; const expectedStatusCode = 500; @@ -33,7 +37,7 @@ describe("errorHandler", () => { expect(response.statusCode).toBe(expectedStatusCode); }); - test("ZodError errors handling", async () => { + it("ZodError errors handling", async () => { // Given const expectedStatusCode = 400; const expectedJson = { @@ -70,7 +74,7 @@ describe("errorHandler", () => { expect(response.statusCode).toBe(expectedStatusCode); }); - test("Jose errors handling", async () => { + it("Jose errors handling", async () => { // Given const expectedStatusCode = 401; const expectedJson = { @@ -93,7 +97,7 @@ describe("errorHandler", () => { expect(response.statusCode).toBe(expectedStatusCode); }); - test.each([ + it.each([ { error: new UnauthorizedError({ message: "You have no power here!" }), json: { diff --git a/src/lib/aws/serverless/utils.test.ts b/src/lib/aws/serverless/utils.test.ts deleted file mode 100644 index 1a9dcb3..0000000 --- a/src/lib/aws/serverless/utils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { type SQSRecord } from "aws-lambda"; -import { describe, expect, test, vi } from "vitest"; - -import { EmailParsePayloadError } from "@/lib/emails/errors"; -import { SUPPORTED_EVENTS } from "@/lib/emails/events/helpers"; -import { getLogger } from "@/providers/logger"; - -import { parseRecord } from "./utils"; - -describe("utils", () => { - describe("parseRecord", () => { - vi.mock("@/providers/logger", () => { - const mockLogger = { - error: vi.fn(), - }; - - return { - getLogger: () => mockLogger, - }; - }); - - test("should parse valid record and return data", () => { - // given - const data = { - payload: { - event: SUPPORTED_EVENTS[0], - data: { key: "value" }, - }, - format: "any", - }; - const validRecord = { - Body: JSON.stringify(data), - } as any as SQSRecord; - - // when - const result = parseRecord(validRecord); - - // when - expect(result).toEqual(data); - }); - - test("should log and throw error when parsing fails", () => { - // given - const invalidRecord = { - format: "any", - data: "How about not valid", - } as any as SQSRecord; - const logger = getLogger(); - - // when & then - expect(() => parseRecord(invalidRecord)).toThrow(EmailParsePayloadError); - expect(logger.error).toHaveBeenCalledWith( - "Failed to parse record payload.", - expect.objectContaining({ - record: invalidRecord, - error: expect.any(Error), - }) - ); - }); - }); -}); diff --git a/src/lib/aws/serverless/utils.ts b/src/lib/aws/serverless/utils.ts deleted file mode 100644 index af75cea..0000000 --- a/src/lib/aws/serverless/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type SQSRecord } from "aws-lambda"; - -import { EmailParsePayloadError } from "@/lib/emails/errors"; -import { parsePayload } from "@/lib/emails/events/helpers"; -import { getLogger } from "@/providers/logger"; - -const logger = getLogger(); - -export const parseRecord = (record: SQSRecord) => { - try { - // Proxy events have invalid types. - const data = JSON.parse((record as any).Body); - - return parsePayload(data); - } catch (error) { - logger.error("Failed to parse record payload.", { record, error }); - - // TODO: Should be non transient error - throw new EmailParsePayloadError("Failed to parse record payload.", { - cause: { source: error as Error }, - }); - } -}; diff --git a/src/lib/emails/const.ts b/src/lib/emails/const.ts index e509a62..ff84c4c 100644 --- a/src/lib/emails/const.ts +++ b/src/lib/emails/const.ts @@ -12,7 +12,7 @@ import GiftCardSentEmail from "@/emails/templates/GiftCardSentEmail"; import OrderCancelledEmail from "@/emails/templates/OrderCancelledEmail"; import OrderCreatedEmail from "@/emails/templates/OrderCreatedEmail"; import OrderRefundedEmail from "@/emails/templates/OrderRefundedEmail"; -import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; +import { type Event } from "@/lib/payload"; const extractEmailFromOrder = (data: { order: { userEmail: string } }) => data.order.userEmail; @@ -24,7 +24,7 @@ const extractEmailFromUser = (data: { user: { email: string } }) => data.user.email; export const TEMPLATES_MAP: { - [key in Lowercase]?: { + [key in Event]?: { extractFn: (data: any) => string; template: ComponentType & { Subject: string }; }; diff --git a/src/lib/emails/errors.ts b/src/lib/emails/errors.ts index 20d55f9..3644b0f 100644 --- a/src/lib/emails/errors.ts +++ b/src/lib/emails/errors.ts @@ -1,5 +1,9 @@ -import { BaseError } from "@/lib/errors"; +import { NonTransientError, TransientError } from "@/lib/errors/base"; -export class EmailSendError extends BaseError {} +export class EmailSendError extends TransientError {} -export class EmailParsePayloadError extends BaseError {} +export class EventNotSupportedError extends NonTransientError {} + +export class EmailRenderError extends NonTransientError {} + +export class FormatNotSupportedError extends NonTransientError {} diff --git a/src/lib/emails/helpers.tsx b/src/lib/emails/helpers.tsx index 23970bd..501b1d3 100644 --- a/src/lib/emails/helpers.tsx +++ b/src/lib/emails/helpers.tsx @@ -2,6 +2,8 @@ import { render } from "@react-email/components"; import type { Component, ComponentType, FC } from "react"; import { type ClassNameValue, twJoin, twMerge } from "tailwind-merge"; +import { EmailRenderError } from "./errors"; + type PropsFrom = C extends FC ? Props @@ -15,6 +17,14 @@ export const renderEmail = async >({ }: { props: PropsFrom; template: C; -}) => render(); +}) => { + try { + return render(); + } catch (err) { + throw new EmailRenderError("Failed to render email template.", { + cause: { source: err as Error }, + }); + } +}; export const cn = (...input: ClassNameValue[]) => twMerge(twJoin(input)); diff --git a/src/lib/errors.ts b/src/lib/errors/api.ts similarity index 73% rename from src/lib/errors.ts rename to src/lib/errors/api.ts index 6d2a4c1..adfb732 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors/api.ts @@ -1,28 +1,7 @@ -import { type FastifyError } from "@fastify/error"; +import { type FastifyError } from "fastify"; -import { type RequireAtLeastOne } from "@/lib/types"; - -interface BaseErrorOptions { - cause?: { source?: Error } & Record; -} - -export class BaseError extends Error { - constructor(message?: string, options?: BaseErrorOptions) { - super(message, options); - this.name = this.constructor.name; - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, BaseError); - } - } -} - -export class BaseAggregateError extends AggregateError { - constructor(errors: Iterable, message?: string) { - super(errors, message); - this.name = this.constructor.name; - } -} +import { type RequireAtLeastOne } from "../types"; +import { NonTransientError } from "./base"; type WithSource = { source: FastifyError }; type WithError = RequireAtLeastOne<{ @@ -31,7 +10,7 @@ type WithError = RequireAtLeastOne<{ statusCode: number; }>; -export class HttpError extends BaseError { +export class HttpError extends NonTransientError { /** * https://github.com/Microsoft/TypeScript/issues/3841@/issuecomment-1488919713 */ diff --git a/src/lib/errors/base.ts b/src/lib/errors/base.ts new file mode 100644 index 0000000..fca043c --- /dev/null +++ b/src/lib/errors/base.ts @@ -0,0 +1,25 @@ +interface BaseErrorOptions { + cause?: { source?: Error } & Record; +} + +export class BaseError extends Error { + constructor(message?: string, options?: BaseErrorOptions) { + super(message, options); + this.name = this.constructor.name; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BaseError); + } + } +} + +export class BaseAggregateError extends AggregateError { + constructor(errors: Iterable, message?: string) { + super(errors, message); + this.name = this.constructor.name; + } +} + +export class TransientError extends BaseError {} + +export class NonTransientError extends BaseError {} diff --git a/src/lib/errors/serverless.ts b/src/lib/errors/serverless.ts new file mode 100644 index 0000000..e26fd05 --- /dev/null +++ b/src/lib/errors/serverless.ts @@ -0,0 +1,3 @@ +import { NonTransientError } from "./base"; + +export class ParsePayloadError extends NonTransientError {} diff --git a/src/lib/graphql/errors.ts b/src/lib/graphql/errors.ts index 094963b..af35d52 100644 --- a/src/lib/graphql/errors.ts +++ b/src/lib/graphql/errors.ts @@ -1,4 +1,4 @@ -import { HttpError } from "@/lib/errors"; +import { HttpError } from "@/lib/errors/api"; import { type GraphqlError } from "@/lib/graphql/types"; export class GraphQLClientError extends HttpError { diff --git a/src/lib/graphql/helpers.test.ts b/src/lib/graphql/helpers.test.ts index e10deff..7272e0b 100644 --- a/src/lib/graphql/helpers.test.ts +++ b/src/lib/graphql/helpers.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { getOperationName } from "./helpers"; describe("helpers", () => { describe("getOperationName", () => { - test("should return the operation name for a query", () => { + it("should return the operation name for a query", () => { const document = ` query GetUser { user(id: "1") { @@ -16,7 +16,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe("GetUser"); }); - test("should return the operation name for a mutation", () => { + it("should return the operation name for a mutation", () => { const document = ` mutation CreateUser { createUser(input: { name: "John" }) { @@ -28,7 +28,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe("CreateUser"); }); - test("should return the operation name for a subscription", () => { + it("should return the operation name for a subscription", () => { const document = ` subscription OnUserCreated { userCreated { @@ -40,7 +40,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe("OnUserCreated"); }); - test("should return an empty string if the document has no operation name", () => { + it("should return an empty string if the document has no operation name", () => { const document = ` query { user(id: "1") { @@ -52,7 +52,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe(""); }); - test("should return an empty string for invalid document format", () => { + it("should return an empty string for invalid document format", () => { const document = ` { user(id: "1") { @@ -64,7 +64,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe(""); }); - test("should return an empty string if the operation type is missing", () => { + it("should return an empty string if the operation type is missing", () => { const document = ` GetUser { user(id: "1") { @@ -76,7 +76,7 @@ describe("helpers", () => { expect(getOperationName(document)).toBe(""); }); - test("should return the correct operation names when there are multiple operations", () => { + it("should return the correct operation names when there are multiple operations", () => { const document = ` query GetUser { user(id: "1") { diff --git a/src/lib/graphql/validate.ts b/src/lib/graphql/validate.ts index 81429a2..ad88faa 100644 --- a/src/lib/graphql/validate.ts +++ b/src/lib/graphql/validate.ts @@ -6,10 +6,11 @@ import { type SelectionSetNode, } from "graphql"; -import { BaseError } from "../errors"; +import { NonTransientError } from "@/lib/errors/base"; + import { type TypedDocumentTypeDecoration } from "./types"; -export class ValidationError extends BaseError {} +export class ValidationError extends NonTransientError {} type FieldInfo = { selectionSet?: FieldMap; diff --git a/src/lib/invariant.test.ts b/src/lib/invariant.test.ts deleted file mode 100644 index 8f39c91..0000000 --- a/src/lib/invariant.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { invariant } from "./invariant"; - -describe("invariant", () => { - test("It should raise an error if invariant fails.", () => { - expect(() => invariant(false, "Ops!")).toThrowError("Ops!"); - }); - - test("It should raise with default message if invariant fails with no message passed.", () => { - expect(() => invariant(false)).toThrowError("Invariant error."); - }); - - test("It should not throw an error if invariant succeed.", () => { - expect(() => invariant(true)).not.toThrowError("Ops!"); - }); -}); diff --git a/src/lib/invariant.ts b/src/lib/invariant.ts deleted file mode 100644 index f41afda..0000000 --- a/src/lib/invariant.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class InvariantError extends Error { - constructor(message = "Invariant error.") { - super(message); - } -} - -export function invariant(condition: any, message?: string): asserts condition { - if (!condition) { - throw new InvariantError(message); - } -} diff --git a/src/lib/emails/events/helpers.test.ts b/src/lib/payload.test.ts similarity index 67% rename from src/lib/emails/events/helpers.test.ts rename to src/lib/payload.test.ts index a736035..8d4ca4f 100644 --- a/src/lib/emails/events/helpers.test.ts +++ b/src/lib/payload.test.ts @@ -1,9 +1,16 @@ +import { type SQSRecord } from "aws-lambda"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { parsePayload, serializePayload, SUPPORTED_EVENTS } from "./helpers"; +import { ParsePayloadError } from "./errors/serverless"; +import { + parsePayload, + parseRecord, + serializePayload, + SUPPORTED_EVENTS, +} from "./payload"; -describe("helpers", () => { +describe("payload", () => { describe("serializePayload", () => { it("should serialize payload correctly when valid data is provided", () => { // given @@ -89,4 +96,37 @@ describe("helpers", () => { }).toThrow(z.ZodError); }); }); + + describe("parseRecord", () => { + it("should parse valid record and return data", () => { + // given + const data = { + payload: { + event: SUPPORTED_EVENTS[0], + data: { key: "value" }, + }, + format: "any", + }; + const validRecord = { + Body: JSON.stringify(data), + } as any as SQSRecord; + + // when + const result = parseRecord(validRecord); + + // when + expect(result).toEqual(data); + }); + + it("should throw an error when parsing fails", () => { + // given + const invalidRecord = { + format: "any", + data: "How about not valid", + } as any as SQSRecord; + + // when & then + expect(() => parseRecord(invalidRecord)).toThrow(ParsePayloadError); + }); + }); }); diff --git a/src/lib/emails/events/helpers.ts b/src/lib/payload.ts similarity index 62% rename from src/lib/emails/events/helpers.ts rename to src/lib/payload.ts index d6a4048..57dd411 100644 --- a/src/lib/emails/events/helpers.ts +++ b/src/lib/payload.ts @@ -1,10 +1,11 @@ +import { type SQSRecord } from "aws-lambda"; import { type FastifyRequest } from "fastify"; import { z } from "zod"; import { EVENT_HANDLERS } from "@/api/rest/saleor/webhooks"; import { CONFIG } from "@/config"; import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; -import { getJSONFormatHeader } from "@/lib/saleor/apps/utils"; +import { ParsePayloadError } from "@/lib/errors/serverless"; export const SUPPORTED_EVENTS = EVENT_HANDLERS.map(({ event }) => event.toLowerCase() @@ -37,4 +38,22 @@ export const serializePayload = ({ export const parsePayload = (data: unknown) => payloadSchema.parse(data); -export type SerializedPayload = ReturnType; +export const getJSONFormatHeader = ({ + name, + version = 1, +}: { + name: string; + version?: number; +}) => `application/vnd.mirumee.nimara.${name}.v${version}+json`; + +export const parseRecord = (record: SQSRecord) => { + try { + const data = JSON.parse(record.Body); + + return parsePayload(data); + } catch (error) { + throw new ParsePayloadError("Failed to parse record payload.", { + cause: { source: error as Error }, + }); + } +}; diff --git a/src/lib/plugins/urlForPlugin.test.ts b/src/lib/plugins/urlForPlugin.test.ts index 1628111..18aa072 100644 --- a/src/lib/plugins/urlForPlugin.test.ts +++ b/src/lib/plugins/urlForPlugin.test.ts @@ -1,6 +1,6 @@ import Fastify, { type FastifyInstance } from "fastify"; import { type compile } from "path-to-regexp"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import UrlForPlugin, { addRoute, urlFor, urlPathFor } from "./urlForPlugin"; @@ -19,7 +19,7 @@ afterEach(async () => { describe("urlForPlugin", () => { describe("Plugin", () => { - test("Adds urlFor & urlPathFor methods to the request object.", async () => { + it("Adds urlFor & urlPathFor methods to the request object.", async () => { // Given await fastify.register(UrlForPlugin); @@ -31,7 +31,7 @@ describe("urlForPlugin", () => { expect(fastify.hasRequestDecorator("urlFor")).toBeTruthy(); }); - test("Throws an error for duplicated routes.", async () => { + it("Throws an error for duplicated routes.", async () => { // Given const routeName = "test-route"; await fastify.register(UrlForPlugin); @@ -47,7 +47,7 @@ describe("urlForPlugin", () => { }); describe("urlPathFor", () => { - test("Throws an error for non existing route.", async () => { + it("Throws an error for non existing route.", async () => { // Given const routeName = "not-there"; @@ -61,7 +61,7 @@ describe("urlForPlugin", () => { ); }); - test("Generates path for name.", async () => { + it("Generates path for name.", async () => { // Given const routeName = "not-there"; const expectedPath = "/super/cool/path"; @@ -81,7 +81,7 @@ describe("urlForPlugin", () => { }); describe("urlFor", () => { - test("Throws an error when appUrl is not defined.", async () => { + it("Throws an error when appUrl is not defined.", async () => { // Given-When-Then // @ts-expect-error intended to use within FastifyRequest context. expect(() => urlFor(routesMap)).toThrowError( @@ -89,7 +89,7 @@ describe("urlForPlugin", () => { ); }); - test("Generates url for name.", async () => { + it("Generates url for name.", async () => { const routeName = "cool-route"; const expectedPath = "/super/cool/another-path"; const appUrl = "http://cool.app.com"; @@ -114,7 +114,7 @@ describe("urlForPlugin", () => { }); describe("addRoute", () => { - test("Adds route to the routes map", () => { + it("Adds route to the routes map", () => { // Given const routeName = "another-route"; diff --git a/src/lib/plugins/urlPlugin.test.ts b/src/lib/plugins/urlPlugin.test.ts index 40ebec4..dacf894 100644 --- a/src/lib/plugins/urlPlugin.test.ts +++ b/src/lib/plugins/urlPlugin.test.ts @@ -1,5 +1,5 @@ import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MagicMock } from "@/lib/test/mock"; @@ -16,7 +16,7 @@ afterEach(async () => { }); describe("urlPlugin", () => { - test("Adds appUrl & urlFull properties to the request object.", async () => { + it("Adds appUrl & urlFull properties to the request object.", async () => { // Given await fastify.register(UrlPlugin); @@ -28,7 +28,7 @@ describe("urlPlugin", () => { expect(fastify.hasRequestDecorator("urlFull")).toBeTruthy(); }); - test("Populates proper appUrl & urlFull and adds it to the request.", async () => { + it("Populates proper appUrl & urlFull and adds it to the request.", async () => { // Given const mockedRequest = MagicMock({ headers: { diff --git a/src/lib/plugins/winstonLoggingPlugin/logger.test.ts b/src/lib/plugins/winstonLoggingPlugin/logger.test.ts new file mode 100644 index 0000000..0108442 --- /dev/null +++ b/src/lib/plugins/winstonLoggingPlugin/logger.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { redact, type TransformableInfo } from "./logger"; + +describe("logger", () => { + describe("redact function", () => { + it("should redact sensitive keys in the object", () => { + // given + const mockObject = { + name: "John Doe", + password: "mysecretpassword", + secretQuestion: "Your pet's name?", + email: "john@example.com", + } as any as TransformableInfo; + const expectedRedacted = { + name: "John Doe", + password: "*********", + secretQuestion: "*********", + email: "*********", + }; + + // when + const redactedObj = redact(mockObject); + + // then + expect(redactedObj).toEqual(expectedRedacted); + }); + + it("should not modify non-sensitive keys", () => { + // given + const mockObject = { + name: "John", + surname: "Doe", + password: "mysecretpassword", + secretQuestion: "Your pet's name?", + email: "john@example.com", + } as any as TransformableInfo; + + // when + const redactedObj = redact(mockObject); + + // then + expect((redactedObj as any).name).toBe(mockObject.name); + expect((redactedObj as any).surname).toBe(mockObject.surname); + }); + + it("should handle empty objects", () => { + // given + const emptyObject = {} as any as TransformableInfo; + // when + const redactedObj = redact(emptyObject); + + // then + expect(redactedObj).toEqual({}); + }); + + it("should handle nested objects and arrays", () => { + // given + const nestedObject = { + name: "Jane Doe", + credentials: { + password: "mypassword", + secretCode: "mysecretcode", + }, + transactions: [{ phone: "987-65-4321" }, { amount: 100 }], + } as any as TransformableInfo; + const expectedRedacted = { + name: "Jane Doe", + credentials: { + password: "*********", + secretCode: "*********", + }, + transactions: [{ phone: "*********" }, { amount: 100 }], + }; + + // when + const redactedObj = redact(nestedObject); + + // then + expect(redactedObj).toEqual(expectedRedacted); + }); + }); +}); diff --git a/src/lib/plugins/winstonLoggingPlugin/logger.ts b/src/lib/plugins/winstonLoggingPlugin/logger.ts index c86be18..a1238f9 100644 --- a/src/lib/plugins/winstonLoggingPlugin/logger.ts +++ b/src/lib/plugins/winstonLoggingPlugin/logger.ts @@ -1,4 +1,6 @@ import { type FastifyBaseLogger } from "fastify"; +import { klona } from "klona/full"; +import traverse from "traverse"; import { createLogger as createWinstonLogger, format, @@ -6,8 +8,38 @@ import { } from "winston"; import { consoleFormat } from "winston-console-format"; +import { CONFIG } from "@/config"; + import { PLUGIN_CONFIG } from "./config"; +const REDACT_KEYS = [ + /^pw$/, + /^password$/i, + /^phone/i, + /^secret/i, + /email/i, + /userEmail/i, +]; + +export type TransformFunction = Parameters[0]; + +export type TransformableInfo = Parameters[0]; + +export const redact: TransformFunction = (obj) => { + const copy = klona(obj); // Making a deep copy to prevent side effects + + traverse(copy).forEach(function redactor() { + const isSensitiveKey = + this.key && REDACT_KEYS.some((regex) => regex.test(this.key!)); + + if (isSensitiveKey) { + this.update("*********"); + } + }); + + return copy; +}; + export const createLogger = ({ environment, service, @@ -25,7 +57,7 @@ export const createLogger = ({ colors: true, maxArrayLength: Infinity, breakLength: 120, - compact: Infinity, + compact: false, sorted: true, }, }), @@ -33,21 +65,22 @@ export const createLogger = ({ : [format.json()]; return createWinstonLogger({ - defaultMeta: { - environment, - nodeEvn: PLUGIN_CONFIG.NODE_ENV, - service, - }, + defaultMeta: CONFIG.IS_DEVELOPMENT + ? {} + : { + environment, + nodeEvn: PLUGIN_CONFIG.NODE_ENV, + service, + }, format: format.combine( format((info) => { info.level = `[${info.level.toUpperCase()}]`; return info; })(), + format(redact)(), format.errors({ stack: true }), - format.timestamp({ - format: "DD/MM/YYYY HH:mm:ss", - }), + format.timestamp({ format: "DD/MM/YYYY HH:mm:ss" }), ...formatters ), diff --git a/src/lib/regions.test.ts b/src/lib/regions.test.ts index 8dcfdf7..0568757 100644 --- a/src/lib/regions.test.ts +++ b/src/lib/regions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CONFIG } from "@/config"; @@ -6,7 +6,7 @@ import { DEFAULT_REGION, getRegion, REGIONS } from "./regions"; describe("regions", () => { describe("getRegion", () => { - test("should return the correct region for a valid slug", () => { + it("should return the correct region for a valid slug", () => { // given const validSlug = "channel-us"; @@ -17,7 +17,7 @@ describe("regions", () => { expect(result).toEqual(REGIONS.US); }); - test("should return the default region when slug is not found", () => { + it("should return the default region when slug is not found", () => { // given const invalidSlug = "channel-invalid"; @@ -28,7 +28,7 @@ describe("regions", () => { expect(result).toEqual(DEFAULT_REGION); }); - test("should throw an error if no default region exists in CONFIG", () => { + it("should throw an error if no default region exists in CONFIG", () => { // given const invalidSlug = "channel-invalid"; const originalDefaultRegion = CONFIG.DEFAULT_REGION; @@ -47,7 +47,7 @@ describe("regions", () => { ); }); - test("should return region even if slug case is different", () => { + it("should return region even if slug case is different", () => { // given const slugWithDifferentCase = "CHANNEL-UK"; diff --git a/src/lib/saleor/apps/config/schema.ts b/src/lib/saleor/apps/config/schema.ts index 7a09dbf..1b9aae8 100644 --- a/src/lib/saleor/apps/config/schema.ts +++ b/src/lib/saleor/apps/config/schema.ts @@ -12,8 +12,11 @@ export const saleorAppConfigSchema = ( }> ).refine((data) => { if (data.SALEOR_URL) { - data.SALEOR_GRAPHQL_URL = `${data.SALEOR_URL}/graphql/`; - data.SALEOR_DOMAIN = new URL(data.SALEOR_URL ?? "").hostname; + const url = new URL(data.SALEOR_URL); + + data.SALEOR_URL = url.origin; + data.SALEOR_GRAPHQL_URL = `${url.origin}/graphql/`; + data.SALEOR_DOMAIN = url.hostname; } return data; }); diff --git a/src/lib/saleor/apps/install.ts b/src/lib/saleor/apps/install.ts index e4ab6e1..4b3771e 100644 --- a/src/lib/saleor/apps/install.ts +++ b/src/lib/saleor/apps/install.ts @@ -1,4 +1,4 @@ -import { SaleorAppInstallationError } from "@/lib/errors"; +import { SaleorAppInstallationError } from "@/lib/errors/api"; import { type SaleorClient } from "@/lib/saleor/client/types"; import { type SaleorConfigProvider } from "@/lib/saleor/config/types"; import { type JWSProvider } from "@/lib/saleor/jwks/types"; diff --git a/src/lib/saleor/apps/utils.ts b/src/lib/saleor/apps/utils.ts deleted file mode 100644 index a20633b..0000000 --- a/src/lib/saleor/apps/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const getJSONFormatHeader = ({ - name, - version = 1, -}: { - name: string; - version?: number; -}) => `application/vnd.mirumee.nimara.${name}.v${version}+json`; diff --git a/src/lib/saleor/jwks/memory.ts b/src/lib/saleor/jwks/memory.ts index 173c3d8..e1f7850 100644 --- a/src/lib/saleor/jwks/memory.ts +++ b/src/lib/saleor/jwks/memory.ts @@ -1,6 +1,5 @@ import { createRemoteJWKSet } from "jose"; - -import { invariant } from "@/lib/invariant"; +import invariant from "ts-invariant"; import { type JWKSProviderFactory, type JWSProvider } from "./types"; diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 5ca9d98..4e3c165 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { isURL } from "./utils"; describe("utils", () => { describe("isURL", () => { - test("should return true for a valid URL", () => { + it("should return true for a valid URL", () => { // given const validUrl = "http://example.com"; @@ -15,7 +15,7 @@ describe("utils", () => { expect(result).toBe(true); }); - test("should return false for an invalid URL", () => { + it("should return false for an invalid URL", () => { // given const invalidUrl = "not a valid url"; @@ -26,7 +26,7 @@ describe("utils", () => { expect(result).toBe(false); }); - test("should return false for an empty string", () => { + it("should return false for an empty string", () => { // given const emptyString = ""; @@ -37,7 +37,7 @@ describe("utils", () => { expect(result).toBe(false); }); - test("should return true for a valid URL with query parameters", () => { + it("should return true for a valid URL with query parameters", () => { // given const validUrlWithParams = "https://example.com?query=test"; @@ -48,7 +48,7 @@ describe("utils", () => { expect(result).toBe(true); }); - test("should return false for a malformed URL", () => { + it("should return false for a malformed URL", () => { // given const malformedUrl = "http://example.com:port"; diff --git a/src/lib/zod/env.test.ts b/src/lib/zod/env.test.ts index be9aaba..4297462 100644 --- a/src/lib/zod/env.test.ts +++ b/src/lib/zod/env.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { z } from "zod"; import { envBool, envToStrList } from "./env"; describe("env", () => { describe("envBool", () => { - test('should return true for "true"', () => { + it('should return true for "true"', () => { // given const input = "true"; @@ -16,7 +16,7 @@ describe("env", () => { expect(result).toBe(true); }); - test('should return false for "false"', () => { + it('should return false for "false"', () => { // given const input = "false"; @@ -27,7 +27,7 @@ describe("env", () => { expect(result).toBe(false); }); - test("should return false for an empty string", () => { + it("should return false for an empty string", () => { // given const input = ""; @@ -38,7 +38,7 @@ describe("env", () => { expect(result).toBe(false); }); - test("should throw an error for invalid values", () => { + it("should throw an error for invalid values", () => { // given const input = "invalid"; @@ -48,7 +48,7 @@ describe("env", () => { }); describe("envToStrList", () => { - test("should return an array of strings for a valid comma-separated string", () => { + it("should return an array of strings for a valid comma-separated string", () => { // given const input = "value1,value2,value3"; @@ -59,7 +59,7 @@ describe("env", () => { expect(result).toEqual(["value1", "value2", "value3"]); }); - test("should return an empty array when env is undefined and defaultEmpty is false", () => { + it("should return an empty array when env is undefined and defaultEmpty is false", () => { // given const input = undefined; const defaultEmpty = false; @@ -71,7 +71,7 @@ describe("env", () => { expect(result).toEqual([]); }); - test("should return undefined when env is undefined and defaultEmpty is true", () => { + it("should return undefined when env is undefined and defaultEmpty is true", () => { // given const input = undefined; const defaultEmpty = true; @@ -83,7 +83,7 @@ describe("env", () => { expect(result).toBeUndefined(); }); - test("should filter out empty values in a comma-separated string", () => { + it("should filter out empty values in a comma-separated string", () => { // given const input = "value1,,value3"; @@ -94,7 +94,7 @@ describe("env", () => { expect(result).toEqual(["value1", "value3"]); }); - test("should return an empty array when env is an empty string", () => { + it("should return an empty array when env is an empty string", () => { // given const input = ""; diff --git a/src/lib/zod/util.test.ts b/src/lib/zod/util.test.ts index 1cda6c6..84eb495 100644 --- a/src/lib/zod/util.test.ts +++ b/src/lib/zod/util.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { z } from "zod"; import { prepareConfig } from "./util"; describe("utils", () => { describe("prepareConfig", () => { - test("should return parsed config for valid input", () => { + it("should return parsed config for valid input", () => { // given const schema = z.object({ key: z.string(), @@ -19,7 +19,7 @@ describe("utils", () => { expect(result).toEqual({ key: "value" }); }); - test("should return parsed config from process.env", () => { + it("should return parsed config from process.env", () => { // given const schema = z.object({ ENV_KEY: z.string(), @@ -33,7 +33,7 @@ describe("utils", () => { expect(result).toEqual({ ENV_KEY: "env_value" }); }); - test("should throw an error for invalid input", () => { + it("should throw an error for invalid input", () => { // given const schema = z.object({ key: z.string(), @@ -48,7 +48,7 @@ describe("utils", () => { ); }); - test("should return empty object when serverOnly is true and window is defined", () => { + it("should return empty object when serverOnly is true and window is defined", () => { // given const schema = z.object({ key: z.string(), @@ -66,7 +66,7 @@ describe("utils", () => { delete global.window; // Clean up global window after test }); - test("should throw an error with multiple validation issues", () => { + it("should throw an error with multiple validation issues", () => { // given const schema = z.object({ key1: z.string(), @@ -82,7 +82,7 @@ describe("utils", () => { ); }); - test("should merge process.env and input values", () => { + it("should merge process.env and input values", () => { // given process.env.ENV_KEY = "env_value"; const schema = z.object({ diff --git a/types/aws-lambda.d.ts b/types/aws-lambda.d.ts new file mode 100644 index 0000000..1aebacc --- /dev/null +++ b/types/aws-lambda.d.ts @@ -0,0 +1,22 @@ +import "aws-lambda"; + +declare module "aws-lambda" { + /** + * `aws-lambda` has wrong types. Every key should be staring in capital letters. + * Beware, lowercase properties are still available, due to TS interface merging! + * Please do not use them! + * https://github.com/aws/aws-lambda-go/issues/368 + */ + export interface SQSRecord { + MessageId: string; + ReceiptHandle: string; + Body: string; + Attributes: SQSRecordAttributes; + MessageAttributes: SQSMessageAttributes; + Md5OfBody: string; + Md5OfMessageAttributes?: string; + EventSource: string; + EventSourceARN: string; + AwsRegion: string; + } +}