diff --git a/.changeset/brown-ties-create.md b/.changeset/brown-ties-create.md new file mode 100644 index 0000000000..cae0a78eb4 --- /dev/null +++ b/.changeset/brown-ties-create.md @@ -0,0 +1,5 @@ +--- +"@exactly/infra": minor +--- + +🧱 setup services for api and hooks diff --git a/.changeset/yellow-corners-bet.md b/.changeset/yellow-corners-bet.md new file mode 100644 index 0000000000..f4cb9d32fa --- /dev/null +++ b/.changeset/yellow-corners-bet.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add runtime entrypoints for api and hooks diff --git a/infra/utils/modules.ts b/infra/utils/modules.ts index 5eb230d5ec..4142ba6d7d 100644 --- a/infra/utils/modules.ts +++ b/infra/utils/modules.ts @@ -2,10 +2,61 @@ export default define({ common: ["redis-url", "sentry-dsn"], crema: ["redis-address", "redis-password", "redis-username"], services: { + api: { + secrets: [ + "auth-secret", + "bridge-api-key", + "intercom-identity-key", + "manteca-api-key", + "panda-api-key", + "pax-associate-id-key", + "pax-api-key", + "persona-api-key", + "postgres-url", + "sardine-api-key", + "segment-write-key", + "wallet-extension-secret", + ], + shared: [ + "bridge-api-url", + "manteca-api-url", + "panda-api-url", + "pax-api-url", + "persona-api-url", + "sardine-api-url", + ], + }, + activity: { secrets: ["alchemy-webhooks-key", "onesignal-api-key", "postgres-url"] }, + block: { secrets: ["alchemy-webhooks-key", "onesignal-api-key"], signers: ["executor"] }, + bridge: { + secrets: ["bridge-api-key", "onesignal-api-key", "persona-api-key", "postgres-url", "segment-write-key"], + shared: ["bridge-api-url", "persona-api-url"], + }, chat: { env: { WHATSAPP_PHONE_NUMBER_ID: "whatsappPhoneNumberId" }, secrets: ["whatsapp-app-secret", "whatsapp-verify-token"], }, + manteca: { + secrets: ["manteca-api-key", "onesignal-api-key", "postgres-url", "segment-write-key", "webhooks-key"], + shared: ["manteca-api-url"], + }, + panda: { + secrets: ["onesignal-api-key", "panda-api-key", "postgres-url", "sardine-api-key", "segment-write-key"], + shared: ["panda-api-url", "sardine-api-url"], + signers: ["settler", "issuer"], + }, + persona: { + secrets: [ + "panda-api-key", + "pax-associate-id-key", + "pax-api-key", + "persona-api-key", + "persona-webhook-secret", + "postgres-url", + "sardine-api-key", + ], + shared: ["panda-api-url", "pax-api-url", "persona-api-url", "sardine-api-url"], + }, }, workers: { allow: { signers: ["allower"] }, diff --git a/server/api/bin.ts b/server/api/bin.ts new file mode 100644 index 0000000000..bcc476bc1f --- /dev/null +++ b/server/api/bin.ts @@ -0,0 +1,95 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Redis } from "ioredis"; + +import api from "."; +import * as schema from "../database/schema"; +import supervise, { own } from "../supervise"; +import createIntercom from "../utils/intercom"; +import createPanda from "../utils/panda"; +import createPax from "../utils/pax"; +import createPersona from "../utils/persona"; +import createBridge from "../utils/ramps/bridge"; +import createManteca from "../utils/ramps/manteca"; +import createSardine from "../utils/sardine"; +import secret from "../utils/secret"; +import createSegment from "../utils/segment"; +import createWalletExtension from "../utils/walletExtension"; +import createCredit from "../workers/credit/queue"; +import createSubscribe from "../workers/subscribe/queue"; +import { connect } from "../workers/worker"; + +const secrets = new SecretManagerServiceClient(); + +supervise( + "api", + Promise.all([ + secret("redis-url", secrets).then((url) => { + const bullmq = connect(url); + return [new Redis(url), bullmq, createCredit(bullmq), createSubscribe(bullmq)] as const; + }), + secret("api-auth-secret", secrets), + Promise.all([secret("api-bridge-api-key", secrets), secret("bridge-api-url", secrets)]).then(([key, url]) => + createBridge(key, url), + ), + secret("api-postgres-url", secrets).then((url) => drizzle(url, { schema })), + secret("api-intercom-identity-key", secrets).then((key) => createIntercom(key)), + Promise.all([secret("api-manteca-api-key", secrets), secret("manteca-api-url", secrets)]).then(([key, url]) => + createManteca(key, url), + ), + Promise.all([secret("api-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) => + createPanda({ key, url }), + ), + Promise.all([ + secret("api-pax-associate-id-key", secrets), + secret("api-pax-api-key", secrets), + secret("pax-api-url", secrets), + ]).then(([associateKey, key, url]) => createPax({ associateKey, key, url })), + Promise.all([secret("api-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) => + createPersona(key, url), + ), + Promise.all([secret("api-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) => + createSardine(key, url), + ), + secret("api-segment-write-key", secrets).then((key) => createSegment(key)), + secret("api-wallet-extension-secret", secrets).then((value) => createWalletExtension(value)), + ]).then( + ([ + [redis, bullmq, credit, subscribe], + authSecret, + bridge, + database, + intercom, + manteca, + panda, + pax, + persona, + sardine, + segment, + walletExtension, + ]) => + own( + api({ + authSecret, + bridge, + credit, + database, + intercom, + manteca, + panda, + pax, + persona, + redis, + sardine, + segment, + subscribe, + walletExtension, + }), + () => database.$client.end(), + () => redis.quit(), + () => secrets.close(), + () => segment.close(), + () => Promise.all([credit.close(), subscribe.close()]).finally(() => bullmq.quit()), + ), + ), +); diff --git a/server/hooks/bin/activity.ts b/server/hooks/bin/activity.ts new file mode 100644 index 0000000000..e01e495886 --- /dev/null +++ b/server/hooks/bin/activity.ts @@ -0,0 +1,34 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Redis } from "ioredis"; + +import * as schema from "../../database/schema"; +import supervise, { own } from "../../supervise"; +import createAlchemy from "../../utils/alchemy"; +import createOnesignal from "../../utils/onesignal"; +import secret from "../../utils/secret"; +import createPoke from "../../workers/poke/queue"; +import { connect } from "../../workers/worker"; +import activity from "../activity"; + +const secrets = new SecretManagerServiceClient(); + +supervise( + "activity", + Promise.all([ + secret("activity-alchemy-webhooks-key", secrets).then((key) => createAlchemy(key)), + secret("activity-postgres-url", secrets).then((url) => drizzle(url, { schema })), + secret("activity-onesignal-api-key", secrets).then((key) => createOnesignal(key)), + secret("redis-url", secrets) + .then((url) => [new Redis(url), connect(url)] as const) + .then(([redis, bullmq]) => [redis, bullmq, createPoke(bullmq)] as const), + ]).then(([alchemy, database, onesignal, [redis, bullmq, poke]]) => + own( + activity({ alchemy, database, onesignal, poke, redis }), + () => database.$client.end(), + () => poke.close().finally(() => bullmq.quit()), + () => redis.quit(), + () => secrets.close(), + ), + ), +); diff --git a/server/hooks/bin/block.ts b/server/hooks/bin/block.ts new file mode 100644 index 0000000000..e7928fcec9 --- /dev/null +++ b/server/hooks/bin/block.ts @@ -0,0 +1,30 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { Redis } from "ioredis"; + +import supervise, { own } from "../../supervise"; +import createAlchemy from "../../utils/alchemy"; +import createOnesignal from "../../utils/onesignal"; +import secret from "../../utils/secret"; +import { signer } from "../../utils/wallet"; +import block from "../block"; + +const kms = new KeyManagementServiceClient(); +const secrets = new SecretManagerServiceClient(); + +supervise( + "block", + Promise.all([ + secret("block-alchemy-webhooks-key", secrets).then((key) => createAlchemy(key)), + signer("executor", kms), + secret("block-onesignal-api-key", secrets).then((key) => createOnesignal(key)), + secret("redis-url", secrets).then((url) => new Redis(url)), + ]).then(([alchemy, executor, onesignal, redis]) => + own( + block({ alchemy, executor, onesignal, redis }), + () => kms.close(), + () => redis.quit(), + () => secrets.close(), + ), + ), +); diff --git a/server/hooks/bin/bridge.ts b/server/hooks/bin/bridge.ts new file mode 100644 index 0000000000..8cfa9e6e3b --- /dev/null +++ b/server/hooks/bin/bridge.ts @@ -0,0 +1,35 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; + +import * as schema from "../../database/schema"; +import supervise, { own } from "../../supervise"; +import createOnesignal from "../../utils/onesignal"; +import createPersona from "../../utils/persona"; +import createBridge from "../../utils/ramps/bridge"; +import secret from "../../utils/secret"; +import createSegment from "../../utils/segment"; +import bridge from "../bridge"; + +const secrets = new SecretManagerServiceClient(); + +supervise( + "bridge", + Promise.all([ + Promise.all([secret("bridge-bridge-api-key", secrets), secret("bridge-api-url", secrets)]).then(([key, url]) => + createBridge(key, url), + ), + secret("bridge-postgres-url", secrets).then((url) => drizzle(url, { schema })), + secret("bridge-onesignal-api-key", secrets).then((key) => createOnesignal(key)), + Promise.all([secret("bridge-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) => + createPersona(key, url), + ), + secret("bridge-segment-write-key", secrets).then((key) => createSegment(key)), + ]).then(([provider, database, onesignal, persona, segment]) => + own( + bridge({ bridge: provider, database, onesignal, persona, segment }), + () => database.$client.end(), + () => secrets.close(), + () => segment.close(), + ), + ), +); diff --git a/server/hooks/bin/manteca.ts b/server/hooks/bin/manteca.ts new file mode 100644 index 0000000000..6fd71e95ba --- /dev/null +++ b/server/hooks/bin/manteca.ts @@ -0,0 +1,32 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; + +import * as schema from "../../database/schema"; +import supervise, { own } from "../../supervise"; +import createOnesignal from "../../utils/onesignal"; +import createManteca from "../../utils/ramps/manteca"; +import secret from "../../utils/secret"; +import createSegment from "../../utils/segment"; +import manteca from "../manteca"; + +const secrets = new SecretManagerServiceClient(); + +supervise( + "manteca", + Promise.all([ + secret("manteca-postgres-url", secrets).then((url) => drizzle(url, { schema })), + Promise.all([secret("manteca-manteca-api-key", secrets), secret("manteca-api-url", secrets)]).then(([key, url]) => + createManteca(key, url), + ), + secret("manteca-webhooks-key", secrets), + secret("manteca-onesignal-api-key", secrets).then((key) => createOnesignal(key)), + secret("manteca-segment-write-key", secrets).then((key) => createSegment(key)), + ]).then(([database, provider, mantecaWebhookKey, onesignal, segment]) => + own( + manteca({ database, manteca: provider, mantecaWebhookKey, onesignal, segment }), + () => database.$client.end(), + () => secrets.close(), + () => segment.close(), + ), + ), +); diff --git a/server/hooks/bin/panda.ts b/server/hooks/bin/panda.ts new file mode 100644 index 0000000000..639cd0c75d --- /dev/null +++ b/server/hooks/bin/panda.ts @@ -0,0 +1,48 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; + +import * as schema from "../../database/schema"; +import supervise, { own } from "../../supervise"; +import createOnesignal from "../../utils/onesignal"; +import createPanda from "../../utils/panda"; +import createSardine from "../../utils/sardine"; +import secret from "../../utils/secret"; +import createSegment from "../../utils/segment"; +import { signer } from "../../utils/wallet"; +import createHook from "../../workers/hook/queue"; +import createRefund from "../../workers/refund/queue"; +import { connect } from "../../workers/worker"; +import panda from "../panda"; + +const kms = new KeyManagementServiceClient(); +const secrets = new SecretManagerServiceClient(); + +supervise( + "panda", + Promise.all([ + secret("panda-postgres-url", secrets).then((url) => drizzle(url, { schema })), + signer("issuer", kms), + secret("panda-onesignal-api-key", secrets).then((key) => createOnesignal(key)), + Promise.all([secret("panda-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) => + createPanda({ key, url }), + ), + secret("redis-url", secrets) + .then((url) => connect(url)) + .then((bullmq) => [bullmq, createRefund(bullmq), createHook(bullmq)] as const), + Promise.all([secret("panda-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) => + createSardine(key, url), + ), + secret("panda-segment-write-key", secrets).then((key) => createSegment(key)), + signer("settler", kms), + ]).then(([database, issuer, onesignal, provider, [bullmq, refund, webhook], sardine, segment, settler]) => + own( + panda({ database, issuer, onesignal, panda: provider, refund, sardine, segment, settler, webhook }), + () => database.$client.end(), + () => kms.close(), + () => secrets.close(), + () => segment.close(), + () => Promise.all([refund.close(), webhook.close()]).finally(() => bullmq.quit()), + ), + ), +); diff --git a/server/hooks/bin/persona.ts b/server/hooks/bin/persona.ts new file mode 100644 index 0000000000..771470a136 --- /dev/null +++ b/server/hooks/bin/persona.ts @@ -0,0 +1,47 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { drizzle } from "drizzle-orm/node-postgres"; + +import * as schema from "../../database/schema"; +import supervise, { own } from "../../supervise"; +import createPanda from "../../utils/panda"; +import createPax from "../../utils/pax"; +import createPersona from "../../utils/persona"; +import createSardine from "../../utils/sardine"; +import secret from "../../utils/secret"; +import createAllow from "../../workers/allow/queue"; +import { connect } from "../../workers/worker"; +import persona from "../persona"; + +const secrets = new SecretManagerServiceClient(); + +supervise( + "persona", + Promise.all([ + secret("persona-postgres-url", secrets).then((url) => drizzle(url, { schema })), + Promise.all([secret("persona-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) => + createPanda({ key, url }), + ), + Promise.all([ + secret("persona-pax-associate-id-key", secrets), + secret("persona-pax-api-key", secrets), + secret("pax-api-url", secrets), + ]).then(([associateKey, key, url]) => createPax({ associateKey, key, url })), + Promise.all([secret("persona-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) => + createPersona(key, url), + ), + secret("persona-persona-webhook-secret", secrets), + secret("redis-url", secrets) + .then((url) => connect(url)) + .then((bullmq) => [bullmq, createAllow(bullmq)] as const), + Promise.all([secret("persona-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) => + createSardine(key, url), + ), + ]).then(([database, panda, pax, provider, personaWebhookSecret, [bullmq, allow], sardine]) => + own( + persona({ allow, database, panda, pax, persona: provider, personaWebhookSecret, sardine }), + () => database.$client.end(), + () => allow.close().finally(() => bullmq.quit()), + () => secrets.close(), + ), + ), +); diff --git a/server/test/api/bin.test.ts b/server/test/api/bin.test.ts new file mode 100644 index 0000000000..63a2d1b8e2 --- /dev/null +++ b/server/test/api/bin.test.ts @@ -0,0 +1,147 @@ +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type * as Supervise from "../../supervise"; + +const bridge = {}; +const credit = { close: vi.fn<() => Promise>() }; +const database = { $client: { end: vi.fn<() => Promise>() } }; +const intercom = vi.fn(); +const manteca = {}; +const panda = {}; +const pax = {}; +const persona = {}; +const sardine = {}; +const segment = { close: vi.fn<() => Promise>() }; +const subscribe = { close: vi.fn<() => Promise>() }; +const walletExtension = {}; +const mocks = { + api: vi.fn<(config: Record) => Hook>(), + bridge: vi.fn<(key: string, url: string) => object>(), + credit: vi.fn<(bullmq: object) => typeof credit>(), + drizzle: vi.fn<() => typeof database>(), + intercom: vi.fn<(key: string) => typeof intercom>(), + manteca: vi.fn<(key: string, url: string) => object>(), + panda: vi.fn<(config: { key: string; url: string }) => object>(), + pax: vi.fn<(config: { associateKey: string; key: string; url: string }) => object>(), + persona: vi.fn<(key: string, url: string) => object>(), + sardine: vi.fn<(key: string, url: string) => object>(), + secret: vi.fn<(name: string, secrets: object) => Promise>(), + segment: vi.fn<(key: string) => typeof segment>(), + subscribe: vi.fn<(bullmq: object) => typeof subscribe>(), + supervise: vi.fn<(name: string, created: Promise) => void>(), + walletExtension: vi.fn<(secret: string) => object>(), +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.resetModules(); + mocks.api.mockReset().mockReturnValue({ + app: new Hono().get("/", (c) => c.json({ status: "ok" })), + ready: Promise.resolve(), + }); + mocks.bridge.mockReset().mockReturnValue(bridge); + mocks.credit.mockReset().mockReturnValue(credit); + mocks.drizzle.mockReset().mockReturnValue(database); + mocks.intercom.mockReset().mockReturnValue(intercom); + mocks.manteca.mockReset().mockReturnValue(manteca); + mocks.panda.mockReset().mockReturnValue(panda); + mocks.pax.mockReset().mockReturnValue(pax); + mocks.persona.mockReset().mockReturnValue(persona); + mocks.sardine.mockReset().mockReturnValue(sardine); + mocks.secret.mockReset().mockImplementation((name) => Promise.resolve(name)); + mocks.segment.mockReset().mockReturnValue(segment); + mocks.subscribe.mockReset().mockReturnValue(subscribe); + mocks.supervise.mockReset(); + mocks.walletExtension.mockReset().mockReturnValue(walletExtension); + vi.doMock("drizzle-orm/node-postgres", () => ({ drizzle: mocks.drizzle })); + vi.doMock("ioredis", () => ({ + Redis: class { + constructor( + readonly redisUrl: string, + readonly options?: { maxRetriesPerRequest: null }, + ) {} + }, + })); + vi.doMock("../../api", () => ({ default: mocks.api })); + vi.doMock("../../supervise", async (importOriginal) => ({ + ...(await importOriginal()), + default: mocks.supervise, + })); + vi.doMock("../../utils/intercom", () => ({ default: mocks.intercom })); + vi.doMock("../../utils/panda", () => ({ default: mocks.panda })); + vi.doMock("../../utils/pax", () => ({ default: mocks.pax })); + vi.doMock("../../utils/persona", () => ({ default: mocks.persona })); + vi.doMock("../../utils/ramps/bridge", () => ({ default: mocks.bridge })); + vi.doMock("../../utils/ramps/manteca", () => ({ default: mocks.manteca })); + vi.doMock("../../utils/sardine", () => ({ default: mocks.sardine })); + vi.doMock("../../utils/secret", () => ({ default: mocks.secret })); + vi.doMock("../../utils/segment", () => ({ default: mocks.segment })); + vi.doMock("../../utils/walletExtension", () => ({ default: mocks.walletExtension })); + vi.doMock("../../workers/credit/queue", () => ({ default: mocks.credit })); + vi.doMock("../../workers/subscribe/queue", () => ({ default: mocks.subscribe })); +}); + +describe("api bin", () => { + it("resolves private config before constructing and supervising the api", async () => { + await import("../../api/bin"); + const created = mocks.supervise.mock.calls[0]?.[1]; + if (!created) throw new Error("missing api"); + const handle = await created; + + expect(mocks.secret.mock.calls.map(([secret]) => secret)).toStrictEqual([ + "redis-url", + "api-auth-secret", + "api-bridge-api-key", + "bridge-api-url", + "api-postgres-url", + "api-intercom-identity-key", + "api-manteca-api-key", + "manteca-api-url", + "api-panda-api-key", + "panda-api-url", + "api-pax-associate-id-key", + "api-pax-api-key", + "pax-api-url", + "api-persona-api-key", + "persona-api-url", + "api-sardine-api-key", + "sardine-api-url", + "api-segment-write-key", + "api-wallet-extension-secret", + ]); + expect(new Set(mocks.secret.mock.calls.map(([, secrets]) => secrets)).size).toBe(1); + expect(mocks.api).toHaveBeenCalledExactlyOnceWith({ + authSecret: "api-auth-secret", + bridge, + credit, + database, + intercom, + manteca, + panda, + pax, + persona, + redis: expect.objectContaining({ redisUrl: "redis-url", options: undefined }) as object, + sardine, + segment, + subscribe, + walletExtension, + }); + const bullmq = mocks.credit.mock.calls[0]?.[0]; + expect(bullmq).toEqual(expect.objectContaining({ redisUrl: "redis-url", options: { maxRetriesPerRequest: null } })); + expect(mocks.subscribe).toHaveBeenCalledExactlyOnceWith(bullmq); + expect(mocks.supervise).toHaveBeenCalledExactlyOnceWith("api", created); + const response = await handle.app.request("/"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ status: "ok" }); + }); +}); + +type Hook = { + app: Hono; + ready: Promise; +}; +type Handle = Hook & { close(): Promise }; diff --git a/server/test/hooks/bin.test.ts b/server/test/hooks/bin.test.ts index 590005aa3b..8c25c0ad99 100644 --- a/server/test/hooks/bin.test.ts +++ b/server/test/hooks/bin.test.ts @@ -1,13 +1,45 @@ import { Hono } from "hono"; +import { padHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type * as Supervise from "../../supervise"; +const account = privateKeyToAccount(padHex("0x69")); +const issuer = privateKeyToAccount(padHex("0x420")); +const alchemy = {}; +const bridge = {}; +const database = { $client: { end: vi.fn<() => Promise>() } }; +const manteca = {}; +const onesignal = {}; +const panda = {}; +const pax = {}; +const persona = {}; +const sardine = {}; +const segment = { close: vi.fn<() => Promise>() }; +const allow = { close: vi.fn<() => Promise>() }; +const poke = { close: vi.fn<() => Promise>() }; +const refund = { close: vi.fn<() => Promise>() }; +const webhook = { close: vi.fn<() => Promise>() }; const mocks = { - close: vi.fn<() => Promise>(), - hook: vi.fn<(config: Record) => Hook>(), + alchemy: vi.fn<(key: string) => object>(), + allow: vi.fn<(bullmq: object) => typeof allow>(), + bridge: vi.fn<(key: string, url: string) => object>(), + drizzle: vi.fn<() => typeof database>(), + hook: vi.fn<(config: Record) => Handle>(), + manteca: vi.fn<(key: string, url: string) => object>(), + onesignal: vi.fn<(key: string) => object>(), + panda: vi.fn<(config: { key: string; url: string }) => object>(), + pax: vi.fn<(config: { associateKey: string; key: string; url: string }) => object>(), + persona: vi.fn<(key: string, url: string) => object>(), + poke: vi.fn<(bullmq: object) => typeof poke>(), + refund: vi.fn<(bullmq: object) => typeof refund>(), + sardine: vi.fn<(key: string, url: string) => object>(), secret: vi.fn<(name: string, secrets: object) => Promise>(), - supervise: vi.fn<(name: string, created: Promise) => void>(), + segment: vi.fn<(key: string) => typeof segment>(), + signer: vi.fn<(name: string, kms: object) => Promise>(), + supervise: vi.fn<(name: string, created: Promise) => void>(), + webhook: vi.fn<(bullmq: object) => typeof webhook>(), }; afterEach(() => { @@ -16,33 +48,167 @@ afterEach(() => { beforeEach(() => { vi.resetModules(); - mocks.close.mockReset().mockResolvedValue(); + mocks.alchemy.mockReset().mockReturnValue(alchemy); + mocks.allow.mockReset().mockReturnValue(allow); + mocks.bridge.mockReset().mockReturnValue(bridge); + mocks.drizzle.mockReset().mockReturnValue(database); mocks.hook.mockReset().mockReturnValue({ app: new Hono().get("/", (c) => c.json({ status: "ok" })), - close: mocks.close, + close: vi.fn<() => Promise>().mockResolvedValue(), ready: Promise.resolve(), }); + mocks.manteca.mockReset().mockReturnValue(manteca); + mocks.onesignal.mockReset().mockReturnValue(onesignal); + mocks.panda.mockReset().mockReturnValue(panda); + mocks.pax.mockReset().mockReturnValue(pax); + mocks.persona.mockReset().mockReturnValue(persona); + mocks.poke.mockReset().mockReturnValue(poke); + mocks.refund.mockReset().mockReturnValue(refund); + mocks.sardine.mockReset().mockReturnValue(sardine); mocks.secret.mockReset().mockImplementation((name) => Promise.resolve(name)); + mocks.segment.mockReset().mockReturnValue(segment); + mocks.signer.mockReset().mockImplementation((name) => Promise.resolve(name === "issuer" ? issuer : account)); mocks.supervise.mockReset(); + mocks.webhook.mockReset().mockReturnValue(webhook); + vi.doMock("drizzle-orm/node-postgres", () => ({ drizzle: mocks.drizzle })); vi.doMock("ioredis", () => ({ Redis: class { constructor( readonly redisUrl: string, - readonly options: { maxRetriesPerRequest: number }, + readonly options?: { maxRetriesPerRequest: number }, ) {} }, })); + vi.doMock("../../hooks/activity", () => ({ default: mocks.hook })); + vi.doMock("../../hooks/block", () => ({ default: mocks.hook })); + vi.doMock("../../hooks/bridge", () => ({ default: mocks.hook })); vi.doMock("../../hooks/chat", () => ({ default: mocks.hook })); + vi.doMock("../../hooks/manteca", () => ({ default: mocks.hook })); + vi.doMock("../../hooks/panda", () => ({ default: mocks.hook })); + vi.doMock("../../hooks/persona", () => ({ default: mocks.hook })); vi.doMock("../../supervise", async (importOriginal) => ({ ...(await importOriginal()), default: mocks.supervise, })); + vi.doMock("../../utils/alchemy", () => ({ default: mocks.alchemy })); + vi.doMock("../../utils/onesignal", () => ({ default: mocks.onesignal })); + vi.doMock("../../utils/panda", () => ({ default: mocks.panda })); + vi.doMock("../../utils/pax", () => ({ default: mocks.pax })); + vi.doMock("../../utils/persona", () => ({ default: mocks.persona })); + vi.doMock("../../utils/ramps/bridge", () => ({ default: mocks.bridge })); + vi.doMock("../../utils/ramps/manteca", () => ({ default: mocks.manteca })); + vi.doMock("../../utils/sardine", () => ({ default: mocks.sardine })); vi.doMock("../../utils/secret", () => ({ default: mocks.secret })); + vi.doMock("../../utils/segment", () => ({ default: mocks.segment })); + vi.doMock("../../utils/wallet", () => ({ signer: mocks.signer })); + vi.doMock("../../workers/allow/queue", () => ({ default: mocks.allow })); + vi.doMock("../../workers/hook/queue", () => ({ default: mocks.webhook })); + vi.doMock("../../workers/poke/queue", () => ({ default: mocks.poke })); + vi.doMock("../../workers/refund/queue", () => ({ default: mocks.refund })); }); describe("hook bin", () => { it.each([ { + accounts: [], + config: { + alchemy, + database, + onesignal, + poke, + redis: expect.objectContaining({ redisUrl: "redis-url", options: undefined }) as object, + }, + load: () => import("../../hooks/bin/activity"), + name: "activity", + secrets: ["activity-alchemy-webhooks-key", "activity-postgres-url", "activity-onesignal-api-key", "redis-url"], + }, + { + accounts: ["executor"], + config: { + alchemy, + executor: account, + onesignal, + redis: expect.objectContaining({ redisUrl: "redis-url", options: undefined }) as object, + }, + load: () => import("../../hooks/bin/block"), + name: "block", + secrets: ["block-alchemy-webhooks-key", "block-onesignal-api-key", "redis-url"], + }, + { + accounts: [], + config: { bridge, database, onesignal, persona, segment }, + load: () => import("../../hooks/bin/bridge"), + name: "bridge", + secrets: [ + "bridge-bridge-api-key", + "bridge-api-url", + "bridge-postgres-url", + "bridge-onesignal-api-key", + "bridge-persona-api-key", + "persona-api-url", + "bridge-segment-write-key", + ], + }, + { + accounts: [], + config: { database, manteca, mantecaWebhookKey: "manteca-webhooks-key", onesignal, segment }, + load: () => import("../../hooks/bin/manteca"), + name: "manteca", + secrets: [ + "manteca-postgres-url", + "manteca-manteca-api-key", + "manteca-api-url", + "manteca-webhooks-key", + "manteca-onesignal-api-key", + "manteca-segment-write-key", + ], + }, + { + accounts: ["issuer", "settler"], + config: { database, issuer, onesignal, panda, refund, sardine, segment, settler: account, webhook }, + load: () => import("../../hooks/bin/panda"), + name: "panda", + secrets: [ + "panda-postgres-url", + "panda-onesignal-api-key", + "panda-panda-api-key", + "panda-api-url", + "redis-url", + "panda-sardine-api-key", + "sardine-api-url", + "panda-segment-write-key", + ], + }, + { + accounts: [], + config: { + allow, + database, + panda, + pax, + persona, + personaWebhookSecret: "persona-persona-webhook-secret", + sardine, + }, + load: () => import("../../hooks/bin/persona"), + name: "persona", + secrets: [ + "persona-postgres-url", + "persona-panda-api-key", + "panda-api-url", + "persona-pax-associate-id-key", + "persona-pax-api-key", + "pax-api-url", + "persona-persona-api-key", + "persona-api-url", + "persona-persona-webhook-secret", + "redis-url", + "persona-sardine-api-key", + "sardine-api-url", + ], + }, + { + accounts: [], config: { redis: expect.objectContaining({ redisUrl: "redis-url", options: { maxRetriesPerRequest: 1 } }) as object, whatsappFrom: "whatsapp", @@ -55,25 +221,48 @@ describe("hook bin", () => { }, ])( "resolves config before constructing and supervising the $name hook", - async ({ config, load, name, secrets: names }) => { + async ({ accounts, config, load, name, secrets }) => { await load(); const created = mocks.supervise.mock.calls[0]?.[1]; if (!created) throw new Error(`missing ${name} hook`); - const createdHook = await created; + const handle = await created; - expect(mocks.secret.mock.calls.map(([secret]) => secret)).toStrictEqual(names); - expect(new Set(mocks.secret.mock.calls.map(([, secrets]) => secrets)).size).toBe(1); - const response = await createdHook.app.request("/"); + expect(mocks.secret.mock.calls.map(([secret]) => secret)).toStrictEqual(secrets); + expect(new Set(mocks.secret.mock.calls.map(([, client]) => client)).size).toBe(1); + expect(mocks.signer.mock.calls.map(([role]) => role)).toStrictEqual(accounts); + const response = await handle.app.request("/"); expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ status: "ok" }); expect(mocks.hook).toHaveBeenCalledExactlyOnceWith(config); expect(mocks.supervise).toHaveBeenCalledExactlyOnceWith(name, created); }, ); + + it.each([ + { accounts: ["executor"], load: () => import("../../hooks/bin/block"), name: "block", role: "executor" }, + { accounts: ["issuer", "settler"], load: () => import("../../hooks/bin/panda"), name: "panda", role: "issuer" }, + { accounts: ["issuer", "settler"], load: () => import("../../hooks/bin/panda"), name: "panda", role: "settler" }, + ])("fails before constructing the $name hook without its $role account", async ({ accounts, load, name, role }) => { + const error = new Error(`missing ${role}`); + mocks.signer.mockImplementation((accountName) => + accountName === role ? Promise.reject(error) : Promise.resolve(accountName === "issuer" ? issuer : account), + ); + mocks.supervise.mockImplementation((_, created) => { + created.catch(() => undefined); + }); + + await load(); + const created = mocks.supervise.mock.calls[0]?.[1]; + if (!created) throw new Error(`missing ${name} hook`); + + await expect(created).rejects.toBe(error); + expect(mocks.signer.mock.calls.map(([accountName]) => accountName)).toStrictEqual(accounts); + expect(mocks.hook).not.toHaveBeenCalled(); + }); }); -type Hook = { +type Handle = { app: Hono; - close(): Promise; + close(): Promise; ready: Promise; };