From 2cf53533a308443f516b6fe8eb9509a25fde0983 Mon Sep 17 00:00:00 2001 From: m5r Date: Mon, 2 Aug 2021 21:43:27 +0800 Subject: [PATCH] replace early returns with NotFoundError test /api/webhook/incoming-message --- .../api/queue/notify-incoming-message.ts | 2 +- .../api/webhook/incoming-message.test.ts | 134 ++++++++++++++++++ app/messages/api/webhook/incoming-message.ts | 7 +- app/messages/queries/get-conversation.ts | 4 +- app/messages/queries/get-conversations.ts | 4 +- app/phone-calls/hooks/use-phone-calls.ts | 4 +- .../get-current-customer-phone-number.ts | 4 +- blitz.config.ts | 18 +-- package-lock.json | 54 ++++++- package.json | 1 + test/setup.ts | 19 ++- 11 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 app/messages/api/webhook/incoming-message.test.ts diff --git a/app/messages/api/queue/notify-incoming-message.ts b/app/messages/api/queue/notify-incoming-message.ts index b4b1fd5..a365c58 100644 --- a/app/messages/api/queue/notify-incoming-message.ts +++ b/app/messages/api/queue/notify-incoming-message.ts @@ -47,7 +47,7 @@ const notifyIncomingMessageQueue = Queue( } catch (error) { logger.error(error); if (error instanceof WebPushError) { - // subscription most likely expired + // subscription most likely expired or has been revoked await db.notificationSubscription.delete({ where: { id: subscription.id } }); } } diff --git a/app/messages/api/webhook/incoming-message.test.ts b/app/messages/api/webhook/incoming-message.test.ts new file mode 100644 index 0000000..9f6493b --- /dev/null +++ b/app/messages/api/webhook/incoming-message.test.ts @@ -0,0 +1,134 @@ +import { testApiHandler } from "next-test-api-route-handler"; +import twilio from "twilio"; + +import db from "db"; +import handler from "./incoming-message"; +import notifyIncomingMessageQueue from "../queue/notify-incoming-message"; +import insertIncomingMessageQueue from "../queue/insert-incoming-message"; + +describe("/api/webhook/incoming-message", () => { + const mockedFindFirstPhoneNumber = db.phoneNumber.findFirst as jest.Mock< + ReturnType + >; + const mockedFindFirstCustomer = db.customer.findFirst as jest.Mock>; + const mockedEnqueueNotifyIncomingMessage = notifyIncomingMessageQueue.enqueue as jest.Mock< + ReturnType + >; + const mockedEnqueueInsertIncomingMessage = insertIncomingMessageQueue.enqueue as jest.Mock< + ReturnType + >; + const mockedValidateRequest = twilio.validateRequest as jest.Mock>; + + beforeEach(() => { + mockedFindFirstPhoneNumber.mockResolvedValue({ phoneNumber: "+33757592025" } as any); + mockedFindFirstCustomer.mockResolvedValue({ id: "9292", authToken: "twi" } as any); + }); + + afterEach(() => { + mockedFindFirstPhoneNumber.mockReset(); + mockedFindFirstCustomer.mockReset(); + mockedEnqueueNotifyIncomingMessage.mockReset(); + mockedEnqueueInsertIncomingMessage.mockReset(); + mockedValidateRequest.mockReset(); + }); + + it("responds 200 and enqueue background jobs", async () => { + expect.hasAssertions(); + mockedValidateRequest.mockReturnValue(true); + + await testApiHandler({ + handler, + test: async ({ fetch }) => { + const res = await fetch({ + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "fgZnYDKvZvb8n/Mc18x5APtOuO4=", + }, + body: "ToCountry=FR&ToState=&SmsMessageSid=SM157246f02006b80953e8c753fb68fad6&NumMedia=0&ToCity=&FromZip=&SmsSid=SM157246f02006b80953e8c753fb68fad6&FromState=&SmsStatus=received&FromCity=&Body=cccccasdasd&FromCountry=FR&To=%2B33757592025&ToZip=&NumSegments=1&MessageSid=SM157246f02006b80953e8c753fb68fad6&AccountSid=ACa886d066be0832990d1cf43fb1d53362&From=%2B33757592722&ApiVersion=2010-04-01", + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html"); + [mockedEnqueueNotifyIncomingMessage, mockedEnqueueNotifyIncomingMessage].forEach((enqueue) => { + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue).toHaveBeenCalledWith( + { + messageSid: "SM157246f02006b80953e8c753fb68fad6", + customerId: "9292", + }, + { id: "notify-SM157246f02006b80953e8c753fb68fad6" }, + ); + }); + }, + }); + }); + + it("responds 400 when request is invalid", async () => { + expect.hasAssertions(); + mockedValidateRequest.mockReturnValue(false); + + await testApiHandler({ + handler, + test: async ({ fetch }) => { + const res = await fetch({ + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "fgZnYDKvZvb8n/Mc18x5APtOuO4=", + }, + body: "ToCountry=FR&ToState=&SmsMessageSid=SM157246f02006b80953e8c753fb68fad6&NumMedia=0&ToCity=&FromZip=&SmsSid=SM157246f02006b80953e8c753fb68fad6&FromState=&SmsStatus=received&FromCity=&Body=cccccasdasd&FromCountry=FR&To=%2B33757592025&ToZip=&NumSegments=1&MessageSid=SM157246f02006b80953e8c753fb68fad6&AccountSid=ACa886d066be0832990d1cf43fb1d53362&From=%2B33757592722&ApiVersion=2010-04-01", + }); + + expect(res.status).toBe(400); + }, + }); + }); + + it("responds 400 when twilio signature is invalid", async () => { + expect.hasAssertions(); + mockedValidateRequest.mockReturnValue(false); + + await testApiHandler({ + handler, + test: async ({ fetch }) => { + const res = await fetch({ + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: "ToCountry=FR&ToState=&SmsMessageSid=SM157246f02006b80953e8c753fb68fad6&NumMedia=0&ToCity=&FromZip=&SmsSid=SM157246f02006b80953e8c753fb68fad6&FromState=&SmsStatus=received&FromCity=&Body=cccccasdasd&FromCountry=FR&To=%2B33757592025&ToZip=&NumSegments=1&MessageSid=SM157246f02006b80953e8c753fb68fad6&AccountSid=ACa886d066be0832990d1cf43fb1d53362&From=%2B33757592722&ApiVersion=2010-04-01", + }); + + expect(res.status).toBe(400); + }, + }); + }); +}); + +jest.mock("db", () => ({ + phoneNumber: { findFirst: jest.fn() }, + customer: { findFirst: jest.fn() }, +})); +jest.mock("../queue/notify-incoming-message", () => ({ + enqueue: jest.fn(), +})); +jest.mock("../queue/insert-incoming-message", () => ({ + enqueue: jest.fn(), +})); +jest.mock("twilio", () => ({ validateRequest: jest.fn() })); + +// fetch({ +// method: "POST", +// headers: { +// host: serverRuntimeConfig.app.baseUrl, +// "x-amzn-trace-id": "Root=1-6107d34d-0bae421100dd7a8e015e6288", +// "content-length": "385", +// "content-type": "application/x-www-form-urlencoded", +// "x-twilio-signature": "fgZnYDKvZvb8n/Mc18x5APtOuO4=", +// "i-twilio-idempotency-token": "c77ed9e8-d13e-4e9a-b46c-a39c43956f06", +// accept: "*/*", +// "user-agent": "TwilioProxy/1.1", +// }, +// body: "ToCountry=FR&ToState=&SmsMessageSid=SM157246f02006b80953e8c753fb68fad6&NumMedia=0&ToCity=&FromZip=&SmsSid=SM157246f02006b80953e8c753fb68fad6&FromState=&SmsStatus=received&FromCity=&Body=cccccasdasd&FromCountry=FR&To=%2B33757592025&ToZip=&NumSegments=1&MessageSid=SM157246f02006b80953e8c753fb68fad6&AccountSid=ACa886d066be0832990d1cf43fb1d53362&From=%2B33757592722&ApiVersion=2010-04-01", +// }) diff --git a/app/messages/api/webhook/incoming-message.ts b/app/messages/api/webhook/incoming-message.ts index 84bb4ae..6f01e44 100644 --- a/app/messages/api/webhook/incoming-message.ts +++ b/app/messages/api/webhook/incoming-message.ts @@ -45,7 +45,7 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res: }); if (!customerPhoneNumber) { // phone number is not registered by any of our customer - res.status(200).end(); + res.status(500).end(); return; } @@ -53,7 +53,7 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res: where: { id: customerPhoneNumber.customerId }, }); if (!customer || !customer.authToken) { - res.status(200).end(); + res.status(500).end(); return; } @@ -90,7 +90,8 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res: ), ]); - res.status(200).end(); + res.setHeader("content-type", "text/html"); + res.status(200).send(""); } catch (error) { const statusCode = error.statusCode ?? 500; const apiError: ApiError = { diff --git a/app/messages/queries/get-conversation.ts b/app/messages/queries/get-conversation.ts index ad554ff..861c4fc 100644 --- a/app/messages/queries/get-conversation.ts +++ b/app/messages/queries/get-conversation.ts @@ -1,4 +1,4 @@ -import { resolver } from "blitz"; +import { NotFoundError, resolver } from "blitz"; import { z } from "zod"; import db, { Prisma } from "../../../db"; @@ -12,7 +12,7 @@ const GetConversations = z.object({ export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(), async ({ recipient }, context) => { const customer = await getCurrentCustomer(null, context); if (!customer) { - return; + throw new NotFoundError(); } const conversation = await db.message.findMany({ diff --git a/app/messages/queries/get-conversations.ts b/app/messages/queries/get-conversations.ts index 0eeee1e..996182f 100644 --- a/app/messages/queries/get-conversations.ts +++ b/app/messages/queries/get-conversations.ts @@ -1,4 +1,4 @@ -import { resolver } from "blitz"; +import { resolver, NotFoundError } from "blitz"; import db, { Direction, Message, Prisma } from "../../../db"; import getCurrentCustomer from "../../customers/queries/get-current-customer"; @@ -7,7 +7,7 @@ import { decrypt } from "../../../db/_encryption"; export default resolver.pipe(resolver.authorize(), async (_ = null, context) => { const customer = await getCurrentCustomer(null, context); if (!customer) { - return; + throw new NotFoundError(); } const messages = await db.message.findMany({ diff --git a/app/phone-calls/hooks/use-phone-calls.ts b/app/phone-calls/hooks/use-phone-calls.ts index 9b5f264..15886d9 100644 --- a/app/phone-calls/hooks/use-phone-calls.ts +++ b/app/phone-calls/hooks/use-phone-calls.ts @@ -1,4 +1,4 @@ -import { useQuery } from "blitz"; +import { NotFoundError, useQuery } from "blitz"; import useCurrentCustomer from "../../core/hooks/use-current-customer"; import getPhoneCalls from "../queries/get-phone-calls"; @@ -6,7 +6,7 @@ import getPhoneCalls from "../queries/get-phone-calls"; export default function usePhoneCalls() { const { customer } = useCurrentCustomer(); if (!customer) { - throw new Error("customer not found"); + throw new NotFoundError(); } const { phoneCalls } = useQuery(getPhoneCalls, { customerId: customer.id })[0]; diff --git a/app/phone-numbers/queries/get-current-customer-phone-number.ts b/app/phone-numbers/queries/get-current-customer-phone-number.ts index 48d11ba..d75d52b 100644 --- a/app/phone-numbers/queries/get-current-customer-phone-number.ts +++ b/app/phone-numbers/queries/get-current-customer-phone-number.ts @@ -1,4 +1,4 @@ -import { resolver } from "blitz"; +import { NotFoundError, resolver } from "blitz"; import db from "db"; import getCurrentCustomer from "../../customers/queries/get-current-customer"; @@ -6,7 +6,7 @@ import getCurrentCustomer from "../../customers/queries/get-current-customer"; export default resolver.pipe(resolver.authorize(), async (_ = null, context) => { const customer = await getCurrentCustomer(null, context); if (!customer) { - return; + throw new NotFoundError(); } return db.phoneNumber.findFirst({ diff --git a/blitz.config.ts b/blitz.config.ts index e82b7f8..5a0fa38 100644 --- a/blitz.config.ts +++ b/blitz.config.ts @@ -2,7 +2,7 @@ import { BlitzConfig, sessionMiddleware, simpleRolesIsAuthorized } from "blitz"; const withPWA = require("next-pwa"); -const config: BlitzConfig = { +export const config: BlitzConfig = { middleware: [ sessionMiddleware({ cookiePrefix: "virtual-phone-blitz", @@ -47,10 +47,12 @@ const config: BlitzConfig = { */ }; -module.exports = withPWA({ - ...config, - pwa: { - dest: "public", - disable: process.env.NODE_ENV !== "production", - }, -}); +export default process.env.NODE_ENV === "test" + ? config + : withPWA({ + ...config, + pwa: { + dest: "public", + disable: process.env.NODE_ENV !== "production", + }, + }); diff --git a/package-lock.json b/package-lock.json index ef38719..878a987 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1567,7 +1567,7 @@ "integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw==" }, "@fortawesome/fontawesome-pro": { - "version": "file:fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz", + "version": "file:../virtual-phone.blitz/fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz", "integrity": "sha512-zrIqXGUiKI/qyEbNJV2Zw084XF6npZR/wzYgqzbGhdRdOT3ZcdseiKUvmW5eUTEkoL9/mCdT8WIzHVvP8wfMsQ==" }, "@fortawesome/fontawesome-svg-core": { @@ -1603,28 +1603,28 @@ } }, "@fortawesome/pro-duotone-svg-icons": { - "version": "file:fontawesome/fortawesome-pro-duotone-svg-icons-5.15.3.tgz", + "version": "file:../virtual-phone.blitz/fontawesome/fortawesome-pro-duotone-svg-icons-5.15.3.tgz", "integrity": "sha512-5BAT6uLAcYnsM76HLrP8SRuQh+N0eMy6VriEK9l9+6Xmm966wgXR2G9NZvua+W9qVv5GbPo2pXDqY6cUa/MoyA==", "requires": { "@fortawesome/fontawesome-common-types": "^0.2.35" } }, "@fortawesome/pro-light-svg-icons": { - "version": "file:fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz", + "version": "file:../virtual-phone.blitz/fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz", "integrity": "sha512-HgQSTQIYsJku91yV/1txyr6IWfnQRnCNrqAo1UtPOkG53H7JPLO6l1GDsuhwjYJSIpjmqu7llgYAFOI/5cZWJA==", "requires": { "@fortawesome/fontawesome-common-types": "^0.2.35" } }, "@fortawesome/pro-regular-svg-icons": { - "version": "file:fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz", + "version": "file:../virtual-phone.blitz/fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz", "integrity": "sha512-4CUIJWj+6ABnzYoYDECfB8hWHS/0FNeovaLqWZZMkaPfMGqC9tNSwWKZQUfBR2nwhEUeyMxtWo6mPJCh4Zz8YA==", "requires": { "@fortawesome/fontawesome-common-types": "^0.2.35" } }, "@fortawesome/pro-solid-svg-icons": { - "version": "file:fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz", + "version": "file:../virtual-phone.blitz/fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz", "integrity": "sha512-stGmfbqLu54PghoxPjQ+BjblO/13EppJ8Fn9ceGZBz8K4lesvAhdMp2hZusXUz8VPuu/3pCHI84PbJ6wOKFYhQ==", "requires": { "@fortawesome/fontawesome-common-types": "^0.2.35" @@ -9480,6 +9480,16 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "isomorphic-unfetch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", + "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "dev": true, + "requires": { + "node-fetch": "^2.6.1", + "unfetch": "^4.2.0" + } + }, "istanbul-lib-coverage": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", @@ -11786,6 +11796,28 @@ } } }, + "next-test-api-route-handler": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/next-test-api-route-handler/-/next-test-api-route-handler-2.0.2.tgz", + "integrity": "sha512-K0+VvPCvf3U5BWrTWBN8B8MktNT/iMSiTnYHaESFcQQ1QvhZ/HMSWxSpOWOIW73DmY6+aIfSc29ztMQo9L5WDw==", + "dev": true, + "requires": { + "debug": "^4.3.2", + "isomorphic-unfetch": "^3.1.0", + "test-listen": "^1.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + } + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -15717,6 +15749,12 @@ "minimatch": "^3.0.4" } }, + "test-listen": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/test-listen/-/test-listen-1.1.0.tgz", + "integrity": "sha512-OyEVi981C1sb9NX1xayfgZls3p8QTDRwp06EcgxSgd1kktaENBW8dO15i8v/7Fi15j0IYQctJzk5J+hyEBId2w==", + "dev": true + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16177,6 +16215,12 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-3.3.3.tgz", "integrity": "sha512-JcC6p86DLPDne5vhm9nZ9N6hW/WPCtO8/NV+7YHS+x/mQ+NpWvtGxIt28ObBsySPec8FsabyiLPhmn7Htl9w3A==" }, + "unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/package.json b/package.json index 4289f76..861f189 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "eslint": "7.32.0", "husky": "6.0.0", "lint-staged": "10.5.4", + "next-test-api-route-handler": "2.0.2", "prettier": "2.3.2", "prettier-plugin-prisma": "0.14.0", "pretty-quick": "3.1.1", diff --git a/test/setup.ts b/test/setup.ts index b19f76f..8544f57 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,17 @@ -// This is the jest 'setupFilesAfterEnv' setup file -// It's a good place to set globals, add global before/after hooks, etc +import { setConfig } from "blitz"; -export {}; // so TS doesn't complain +import { config } from "../blitz.config"; + +setConfig({ + serverRuntimeConfig: config.serverRuntimeConfig, + publicRuntimeConfig: config.publicRuntimeConfig, +}); + +jest.mock("../integrations/logger", () => ({ + child: jest.fn().mockReturnValue({ + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }), +}));