diff --git a/apps/api/bunfig.toml b/apps/api/bunfig.toml new file mode 100644 index 00000000000..a22eda2e883 --- /dev/null +++ b/apps/api/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/setup-env.ts"] diff --git a/apps/api/src/app/api/account/jobs/purge-expired-deletions/route.test.ts b/apps/api/src/app/api/account/jobs/purge-expired-deletions/route.test.ts index 7f3461811b4..5fb369c770e 100644 --- a/apps/api/src/app/api/account/jobs/purge-expired-deletions/route.test.ts +++ b/apps/api/src/app/api/account/jobs/purge-expired-deletions/route.test.ts @@ -1,14 +1,11 @@ -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { db } from "@superset/db/client"; +import * as accountPurge from "@superset/trpc/account-purge"; import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; +import * as singleFlight from "@/lib/singleFlight"; +import * as verifyQstash from "@/lib/verifyQstash"; +import { stub } from "../../../../../../test/stub"; interface FakeUser { id: string; @@ -29,39 +26,35 @@ const dialect = new PgDialect(); const params = (condition: SQL) => dialect.sqlToQuery(condition).params; const userById = (id: string) => fakeUsers.find((user) => user.id === id); -mock.module("@/lib/verifyQstash", () => ({ +stub(verifyQstash, { verifyQstashRequest: async () => null, -})); +}); // Evaluates the route's own predicate: the cutoff is read back out of the // query so the test exercises the window arithmetic, not a copy of it. -mock.module("@superset/db/client", () => ({ - db: { - select: () => ({ - from: () => ({ - where: (condition: SQL) => { - const cutoff = new Date(params(condition)[0] as string); - const matching = fakeUsers - .filter( - (user) => - user.deletedAt === null && user.deletionRequestedAt < cutoff, - ) - .sort( - (a, b) => - a.deletionRequestedAt.getTime() - - b.deletionRequestedAt.getTime(), - ); - return { - orderBy: async () => matching.map(({ id }) => ({ id })), - }; - }, - }), +stub(db, { + select: () => ({ + from: () => ({ + where: (condition: SQL) => { + const cutoff = new Date(params(condition)[0] as string); + const matching = fakeUsers + .filter( + (user) => + user.deletedAt === null && user.deletionRequestedAt < cutoff, + ) + .sort( + (a, b) => + a.deletionRequestedAt.getTime() - b.deletionRequestedAt.getTime(), + ); + return { + orderBy: async () => matching.map(({ id }) => ({ id })), + }; + }, }), - }, - dbWs: {}, -})); + }), +}); -mock.module("@/lib/singleFlight", () => ({ +stub(singleFlight, { singleFlight: async (_job: string, fn: (tx: unknown) => Promise) => { if (lockHeld) return { ran: false }; const tx = { @@ -77,9 +70,9 @@ mock.module("@/lib/singleFlight", () => ({ }; return { ran: true, result: await fn(tx) }; }, -})); +}); -mock.module("@superset/trpc/account-purge", () => ({ +stub(accountPurge, { findOrganizationSolelyOwnedBy: async (userId: string) => soleOwnerOf.get(userId) ?? null, purgeAccount: async (userId: string) => { @@ -88,7 +81,7 @@ mock.module("@superset/trpc/account-purge", () => ({ const user = userById(userId); if (user) user.deletedAt = NOW; }, -})); +}); const { POST } = await import("./route"); diff --git a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts index 34fa6164fad..3a69098731a 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/processDelivery.test.ts @@ -1,4 +1,11 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { db } from "@superset/db/client"; +import * as connectors from "@superset/trpc/connectors"; +import * as linear from "@superset/trpc/integrations/linear"; +import * as syncPolicy from "@superset/trpc/sync-policy"; +import * as automationEvent from "@/lib/automations/ingestAutomationEvent"; +import * as webhookDelivery from "@/lib/ingest/recordWebhookDelivery"; +import { stub } from "../../../../../../test/stub"; const ORG = "org-1"; const WORKSPACE = "linear-workspace-1"; @@ -16,48 +23,43 @@ function connection(id: string, userId: string) { } let subscribers: ReturnType[] = []; -// `mock.module` is process-wide, so every export the real module has must be -// here: another file's import of `connectionBotToken` resolves against this -// stub too. -mock.module("@superset/trpc/connectors", () => ({ +stub(connectors, { accountConnection: mock(async () => subscribers[0] ?? null), accountConnections: mock(async () => subscribers), connectionBotToken: mock(async () => "bot-token"), -})); +}); // The plan gate is exercised on the route, not here: these cases are about // how one delivery fans out across connections. -mock.module("@superset/trpc/sync-policy", () => ({ +stub(syncPolicy, { organizationSyncs: mock(() => undefined), organizationSyncsNow: mock(async () => true), syncingOrganizationIds: mock( async (organizationIds: string[]) => new Set(organizationIds), ), -})); +}); -mock.module("@superset/trpc/integrations/linear", () => ({ +stub(linear, { getLinearClient: mock(async () => null), linearClientFor: mock(async () => null), - isLinearAuthError: () => false, - mapPriorityFromLinear: () => null, -})); +}); let eventSeq = 0; -mock.module("@/lib/ingest/recordWebhookDelivery", () => ({ +stub(webhookDelivery, { recordWebhookDelivery: mock(async () => ({ id: `webhook-event-${++eventSeq}`, status: "pending", retryCount: 0, receivedAt: new Date(), })), -})); +}); const ingestCalls: Array<{ connectionId: string | null; externalEventId: string; ownerUserId: string | null; }> = []; -mock.module("@/lib/automations/ingestAutomationEvent", () => ({ +stub(automationEvent, { ingestAutomationEvent: mock(async (_db: unknown, delivery: never) => { const d = delivery as { skip?: string; @@ -75,11 +77,11 @@ mock.module("@/lib/automations/ingestAutomationEvent", () => ({ }); return { status: "dispatched", eventId: "e" }; }), -})); +}); -mock.module("@superset/db/client", () => ({ - db: { update: () => ({ set: () => ({ where: async () => undefined }) }) }, -})); +stub(db, { + update: () => ({ set: () => ({ where: async () => undefined }) }), +}); const { processDelivery } = await import("./processDelivery"); diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.test.ts b/apps/api/src/app/api/integrations/linear/webhook/route.test.ts index ec3772e5c1f..e516e930577 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/route.test.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/route.test.ts @@ -1,17 +1,13 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; - -mock.module("@/env", () => ({ - env: { LINEAR_WEBHOOK_SECRET: "test-secret" }, -})); - -mock.module("@linear/sdk/webhooks", () => ({ - LINEAR_WEBHOOK_SIGNATURE_HEADER: "linear-signature", - LinearWebhookClient: class { - parseData(body: Buffer) { - return JSON.parse(body.toString()); - } - }, -})); +import { LinearWebhookClient } from "@linear/sdk/webhooks"; +import * as webhookDelivery from "@/lib/ingest/recordWebhookDelivery"; +import { stub } from "../../../../../../test/stub"; +import * as processDelivery from "./processDelivery"; +import * as queue from "./queue"; + +stub(LinearWebhookClient.prototype, { + parseData: (body: Buffer) => JSON.parse(body.toString()), +}); let recorded: { id: string; @@ -21,27 +17,27 @@ let recorded: { } | null = null; let recordCalls = 0; let recordThrows: Error | null = null; -mock.module("@/lib/ingest/recordWebhookDelivery", () => ({ +stub(webhookDelivery, { recordWebhookDelivery: mock(async () => { recordCalls += 1; if (recordThrows) throw recordThrows; return recorded; }), -})); +}); let subscribed = true; -mock.module("./processDelivery", () => ({ +stub(processDelivery, { hasActiveSubscriber: mock(async () => subscribed), -})); +}); let enqueued: unknown[] = []; let enqueueThrows: Error | null = null; -mock.module("./queue", () => ({ +stub(queue, { enqueueLinearDelivery: mock(async (work: unknown) => { if (enqueueThrows) throw enqueueThrows; enqueued.push(work); }), -})); +}); const { POST } = await import("./route"); diff --git a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts index 9abf0769060..fa482621132 100644 --- a/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/process-agent-message/process-agent-message.test.ts @@ -1,4 +1,16 @@ import { beforeEach, expect, mock, test } from "bun:test"; +import { db } from "@superset/db/client"; +import * as connectors from "@superset/trpc/connectors"; +import { Client } from "@upstash/qstash"; +import { posthog } from "@/lib/analytics"; +import { stub } from "../../../../../../../test/stub"; +import * as slackUserLink from "../../lib/find-slack-user-link"; +import * as agentDelivery from "../utils/agent-delivery"; +import * as connectUrl from "../utils/generate-connect-url"; +import * as agent from "../utils/run-agent"; +import * as slackClient from "../utils/slack-client"; +import * as slackImageAssets from "../utils/slack-image-assets"; +import * as threadSessions from "../utils/thread-sessions"; let postCount = 0; const postMessage = mock(async (_args: Record) => ({ @@ -28,57 +40,39 @@ const claim = mock( const finish = mock(async (_id: string, _succeeded: boolean) => {}); const release = mock(async (_id: string) => {}); const publishJSON = mock(async (_options: unknown) => ({})); -mock.module("@upstash/qstash", () => ({ - Client: class { - publishJSON = publishJSON; - }, -})); +stub(Client.prototype, { publishJSON }); const findLink = mock( async (_args: unknown): Promise<{ userId: string } | undefined> => ({ userId: "linked-user", }), ); -mock.module("@superset/db/client", () => ({ - db: { - query: { - subscriptions: { findFirst: async () => ({ id: "subscription" }) }, - }, - }, -})); -// `mock.module` is process-wide, so every export the real module has must be -// here: another file's import of one of these resolves against this stub too. -mock.module("@superset/trpc/connectors", () => ({ +stub(db.query.subscriptions, { + findFirst: async () => ({ id: "subscription" }), +}); +stub(connectors, { accountConnection: async () => ({ organizationId: "org" }), accountConnections: async () => [{ organizationId: "org" }], connectionBotToken: async () => "token", -})); -mock.module("@/env", () => ({ - env: { - NEXT_PUBLIC_WEB_URL: "https://app.superset.sh", - NEXT_PUBLIC_API_URL: "https://api.test", - QSTASH_TOKEN: "qstash-token", - }, -})); -mock.module("@/lib/analytics", () => ({ posthog: { capture: () => {} } })); -mock.module("../../lib/find-slack-user-link", () => ({ +}); +stub(posthog, { capture: () => {} }); +stub(slackUserLink, { findSlackUserLink: findLink, -})); -mock.module("../utils/generate-connect-url", () => ({ +}); +stub(connectUrl, { generateConnectUrl: () => "https://app.superset.sh/connect", -})); -mock.module("../utils/run-agent", () => ({ +}); +stub(agent, { runSlackAgent: runAgent, resolveUserMentions: async () => (text: string) => text, formatErrorForSlack: async () => "Unable to finish", mentionsPlugin: (text: string, plugin: { displayName: string }) => text.toLowerCase().includes(plugin.displayName.toLowerCase()), - SlackAgentError: class extends Error {}, -})); -mock.module("../utils/agent-delivery", () => ({ +}); +stub(agentDelivery, { claimAgentDelivery: claim, finishAgentDelivery: finish, releaseAgentDelivery: release, -})); +}); const session = { id: "thread-session", quiet: false, @@ -109,7 +103,7 @@ const takeQueued = mock( ); const completeHandoff = mock(async (_id: string, _handoff: string) => {}); const abandonHandoff = mock(async (_id: string, _handoff: string) => {}); -mock.module("../utils/thread-sessions", () => ({ +stub(threadSessions, { beginThreadRun: beginThread, finishThreadRun: finishThread, setThreadQuiet: setQuiet, @@ -131,11 +125,8 @@ mock.module("../utils/thread-sessions", () => ({ }, renderThreadMemory: (entities: { label: string }[]) => entities.map((e) => e.label).join(", "), -})); -const { slackRateLimitRetryAfterMs } = await import( - "../utils/slack-client/request-bounds" -); -mock.module("../utils/slack-client", () => ({ +}); +stub(slackClient, { createSlackClient: (_token: string, options: { deadline?: number } = {}) => { const bounded = (call: (args: A) => Promise) => @@ -156,14 +147,11 @@ mock.module("../utils/slack-client", () => ({ }; }, isUnpostableChannelError: () => false, - slackRateLimitRetryAfterMs, -})); -// Mock the barrel only; the image utility's own tests import its implementation. -mock.module("../utils/slack-image-assets", () => ({ +}); +stub(slackImageAssets, { extractSlackImageAssets: async () => [], formatSlackImageAssetError: () => "Invalid image", - SlackImageAssetError: class extends Error {}, -})); +}); const { processAgentMessage } = await import("./process-agent-message"); const params = { teamId: "T1", diff --git a/apps/api/src/app/api/integrations/slack/events/route.test.ts b/apps/api/src/app/api/integrations/slack/events/route.test.ts index c9aff3d808a..1e32cea6684 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.test.ts @@ -1,68 +1,55 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; - -mock.module("@/env", () => ({ - env: { - QSTASH_TOKEN: "test-token", - NEXT_PUBLIC_API_URL: "http://localhost", - SLACK_SIGNING_SECRET: "test-secret", - }, -})); +import { Client } from "@upstash/qstash"; +import { stub } from "../../../../../../test/stub"; +import * as verifySignature from "../verify-signature"; +import * as appHomeOpened from "./process-app-home-opened"; +import * as automationEvent from "./process-automation-event"; +import * as slackDelivery from "./process-automation-event/normalizeSlackDelivery"; +import * as entityDetails from "./process-entity-details"; +import * as linkShared from "./process-link-shared"; +import * as threadSessions from "./utils/thread-sessions"; const publishJSON = mock(async (_options: unknown) => ({})); -beforeEach(() => publishJSON.mockClear()); -mock.module("@upstash/qstash", () => ({ - Client: class { - publishJSON = publishJSON; - }, -})); +stub(Client.prototype, { publishJSON }); -mock.module("../verify-signature", () => ({ +stub(verifySignature, { verifySlackSignature: () => true, -})); +}); -mock.module("./process-app-home-opened", () => ({ +stub(appHomeOpened, { processAppHomeOpened: mock(async () => ({})), -})); +}); -mock.module("./process-automation-event", () => ({ +stub(automationEvent, { isAutomationEvent: () => false, processAutomationEvent: mock(async () => [ { status: "skipped", reason: "unknown workspace" }, ]), -})); +}); -mock.module("./process-entity-details", () => ({ +stub(entityDetails, { processEntityDetails: mock(async () => ({})), -})); +}); -mock.module("./process-link-shared", () => ({ +stub(linkShared, { processLinkShared: mock(async () => ({})), -})); +}); const followUpTarget = mock( async (_key: unknown): Promise<{ id: string } | null> => null, ); -// Complete on purpose: bun keeps the first registration of a module across -// test files, so a partial mock here would break the handler test's imports. -mock.module("./utils/thread-sessions", () => ({ - threadFollowUpTarget: followUpTarget, - beginThreadRun: async () => ({ id: "session", entityLog: [], quiet: false }), - finishThreadRun: async () => {}, - setThreadQuiet: async () => {}, - threadFollowUpsEnabled: async () => true, - parseThreadCommand: () => null, - requestThreadStop: async () => false, - threadStopRequested: async () => false, - takeQueuedEvents: async () => [], - completeHandBack: async () => {}, - abandonHandBack: async () => {}, - renderThreadMemory: () => "", -})); -mock.module("./process-automation-event/normalizeSlackDelivery", () => ({ +stub(threadSessions, { threadFollowUpTarget: followUpTarget }); +beforeEach(() => { + publishJSON.mockReset(); + publishJSON.mockImplementation(async () => ({})); + followUpTarget.mockReset(); + followUpTarget.mockImplementation(async () => null); +}); +stub(slackDelivery, { ownBotUserIds: (envelope: { authorizations?: { user_id?: string }[] }) => (envelope.authorizations ?? []).map((a) => a.user_id), -})); +}); const { POST } = await import("./route"); @@ -306,7 +293,6 @@ describe("Slack agent delivery", () => { }); test("a reply in a thread without a session, or a top-level channel post, is ignored", async () => { - followUpTarget.mockClear(); await POST(channelReply("random chatter")); await POST(channelReply("top level", { thread_ts: undefined })); expect(publishJSON).not.toHaveBeenCalled(); @@ -314,7 +300,6 @@ describe("Slack agent delivery", () => { }); test("a reply also sent to the channel still counts as a thread reply", async () => { - followUpTarget.mockClear(); followUpTarget.mockImplementationOnce(async () => ({ id: "session" })); await POST( channelReply("broadcast reply", { subtype: "thread_broadcast" }), @@ -323,7 +308,6 @@ describe("Slack agent delivery", () => { }); test("a thread reply that mentions the bot is left to the app_mention path", async () => { - followUpTarget.mockClear(); followUpTarget.mockImplementationOnce(async () => ({ id: "session" })); await POST(channelReply("<@UBOT> and this")); expect(followUpTarget).not.toHaveBeenCalled(); diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts index 28956dfe6ab..6f71c2b3e72 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type Anthropic from "@anthropic-ai/sdk"; +import Anthropic from "@anthropic-ai/sdk"; +import * as pluginsProxy from "@superset/trpc/plugins-proxy"; +import { stub } from "../../../../../../../../test/stub"; +import * as slackClient from "../slack-client"; +import * as mcpClients from "./mcp-clients"; const create = mock( async ( @@ -43,38 +47,10 @@ const pluginListTools = mock( const pluginCallTool = mock( async (..._args: unknown[]): Promise> => ({}), ); -mock.module("@/env", () => ({ env: { ANTHROPIC_API_KEY: "test" } })); -class FakeAPIError extends Error { - status?: number; - headers?: Headers; -} -class FakeConnectionError extends FakeAPIError {} -class FakeTimeoutError extends FakeConnectionError {} -class FakePluginTargetError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } -} -class FakeAmbiguousPluginError extends Error {} // The real in-memory client/server pair runs; only target resolution and the // two request handlers behind it are faked. -mock.module("@superset/trpc/plugins-proxy", () => ({ - AmbiguousPluginError: FakeAmbiguousPluginError, - PluginTargetError: FakePluginTargetError, +stub(pluginsProxy, { resolveTarget, - targetKey: (target: { - connectionId?: string; - accounts?: { connectionId: string }[]; - }) => - target.accounts - ? target.accounts - .map((account) => account.connectionId) - .sort() - .join("+") - : (target.connectionId ?? ""), buildPluginServer: async (target: { plugin: string }) => { const { Server } = await import( "@modelcontextprotocol/sdk/server/index.js" @@ -101,27 +77,14 @@ mock.module("@superset/trpc/plugins-proxy", () => ({ })); return server; }, -})); -mock.module("@anthropic-ai/sdk", () => ({ - default: class { - static APIError = FakeAPIError; - static APIConnectionError = FakeConnectionError; - static APIConnectionTimeoutError = FakeTimeoutError; - messages = { create }; - }, -})); +}); +stub(Anthropic.Messages.prototype, { create }); class FakeWebClient { conversations = { replies }; users = { info: async () => ({}) }; } -mock.module("@slack/web-api", () => ({ WebClient: FakeWebClient })); -// bun shares mock.module registrations across test files in one process, so -// pin the factory here rather than inheriting whichever file mocked it last. -mock.module("../slack-client", () => ({ - createSlackClient: () => new FakeWebClient(), - isUnpostableChannelError: () => false, -})); -mock.module("./mcp-clients", () => ({ +stub(slackClient, { createSlackClient: () => new FakeWebClient() }); +stub(mcpClients, { createSupersetMcpClient: async () => ({ client: { callTool, listTools }, cleanup, @@ -134,7 +97,7 @@ mock.module("./mcp-clients", () => ({ prefix: name.split("_")[0], toolName: name.slice(name.indexOf("_") + 1), }), -})); +}); const { fetchThreadContext, formatErrorForSlack, @@ -329,7 +292,7 @@ describe("agent loop", () => { test("a model request timeout is not retried; a 5xx is retried once", async () => { create.mockImplementationOnce(async () => { - throw new FakeTimeoutError("Request timed out."); + throw new Anthropic.APIConnectionTimeoutError(); }); await runSlackAgent(params).catch(() => {}); // The error rewrite also calls create; count only agent-loop requests. @@ -339,7 +302,7 @@ describe("agent loop", () => { create.mockReset(); create.mockImplementationOnce(async () => { - throw Object.assign(new FakeAPIError("overloaded"), { status: 500 }); + throw new Anthropic.APIError(500, undefined, "overloaded", undefined); }); create.mockImplementationOnce(async () => ({ stop_reason: "end_turn", @@ -351,7 +314,7 @@ describe("agent loop", () => { create.mockReset(); create.mockImplementationOnce(async () => { - throw new FakeConnectionError("socket hang up"); + throw new Anthropic.APIConnectionError({ message: "socket hang up" }); }); create.mockImplementationOnce(async () => ({ stop_reason: "end_turn", diff --git a/apps/api/src/app/api/integrations/slack/events/utils/thread-sessions/thread-sessions.test.ts b/apps/api/src/app/api/integrations/slack/events/utils/thread-sessions/thread-sessions.test.ts index 72e531469d7..8702299afe9 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/thread-sessions/thread-sessions.test.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/thread-sessions/thread-sessions.test.ts @@ -1,15 +1,17 @@ import { describe, expect, mock, test } from "bun:test"; +import * as connectors from "@superset/trpc/connectors"; +import { posthog } from "@/lib/analytics"; +import { stub } from "../../../../../../../../test/stub"; const isFeatureEnabled = mock( async (..._args: unknown[]): Promise => true, ); -mock.module("@superset/db/client", () => ({ db: {} })); -mock.module("@/lib/analytics", () => ({ posthog: { isFeatureEnabled } })); -mock.module("@superset/trpc/connectors", () => ({ +stub(posthog, { isFeatureEnabled }); +stub(connectors, { accountConnection: async () => ({ organizationId: "org" }), accountConnections: async () => [{ organizationId: "org" }], connectionBotToken: async () => "token", -})); +}); const { parseThreadCommand, renderThreadMemory, diff --git a/apps/api/src/app/api/integrations/slack/interactions/route.test.ts b/apps/api/src/app/api/integrations/slack/interactions/route.test.ts index b7fbd8bce1d..3af3c85d489 100644 --- a/apps/api/src/app/api/integrations/slack/interactions/route.test.ts +++ b/apps/api/src/app/api/integrations/slack/interactions/route.test.ts @@ -1,46 +1,36 @@ import { describe, expect, mock, test } from "bun:test"; +import { db } from "@superset/db/client"; +import * as connectors from "@superset/trpc/connectors"; +import { posthog } from "@/lib/analytics"; +import { stub } from "../../../../../../test/stub"; +import * as appHomeOpened from "../events/process-app-home-opened"; +import * as verifySignature from "../verify-signature"; const findSlackUser = mock(async () => undefined); -mock.module("@/env", () => ({ - env: { - SLACK_SIGNING_SECRET: "test-secret", - }, -})); - -mock.module("@/lib/analytics", () => ({ - posthog: { capture: () => undefined }, -})); - -mock.module("@superset/db/client", () => ({ - db: { - query: { - userIdentities: { findFirst: findSlackUser }, - }, - update: () => ({ set: () => ({ where: async () => undefined }) }), - delete: () => ({ where: async () => undefined }), - }, -})); +stub(posthog, { capture: () => undefined }); +stub(db.query.userIdentities, { findFirst: findSlackUser }); +stub(db, { + update: () => ({ set: () => ({ where: async () => undefined }) }), + delete: () => ({ where: async () => undefined }), +}); // The route resolves the Slack workspace to an organization before it looks at // any action. Returning a connection is what lets the tests below reach the // action handling at all. -// `mock.module` is process-wide, so every export the real module has must be -// here: another file's import of `connectionBotToken` resolves against this -// stub too. -mock.module("@superset/trpc/connectors", () => ({ +stub(connectors, { accountConnection: async () => ({ organizationId: "org-1" }), accountConnections: async () => [{ organizationId: "org-1" }], connectionBotToken: async () => "bot-token", -})); +}); -mock.module("../verify-signature", () => ({ +stub(verifySignature, { verifySlackSignature: () => true, -})); +}); -mock.module("../events/process-app-home-opened", () => ({ +stub(appHomeOpened, { processAppHomeOpened: mock(async () => ({})), -})); +}); const { POST } = await import("./route"); diff --git a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.test.ts b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.test.ts index 6810f5764da..364e96dda49 100644 --- a/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.test.ts +++ b/apps/api/src/app/api/integrations/slack/jobs/process-mention/route.test.ts @@ -1,4 +1,8 @@ import { beforeEach, expect, mock, test } from "bun:test"; +import * as verifyQstash from "@/lib/verifyQstash"; +import { stub } from "../../../../../../../test/stub"; +import * as assistantMessageEvents from "../../events/process-assistant-message"; +import * as mentionEvents from "../../events/process-mention"; const verify = mock( async ( @@ -9,13 +13,9 @@ const verify = mock( ); const processMention = mock(async (_args: unknown) => {}); const processMessage = mock(async (_args: unknown) => {}); -mock.module("@/lib/verifyQstash", () => ({ verifyQstashRequest: verify })); -mock.module("../../events/process-mention", () => ({ - processSlackMention: processMention, -})); -mock.module("../../events/process-assistant-message", () => ({ - processAssistantMessage: processMessage, -})); +stub(verifyQstash, { verifyQstashRequest: verify }); +stub(mentionEvents, { processSlackMention: processMention }); +stub(assistantMessageEvents, { processAssistantMessage: processMessage }); const mention = await import("./route"); const message = await import("../process-assistant-message/route"); beforeEach(() => { diff --git a/apps/api/src/lib/ingest/recordWebhookDelivery.test.ts b/apps/api/src/lib/ingest/recordWebhookDelivery.test.ts index 1833527b9f4..7e0e27b261c 100644 --- a/apps/api/src/lib/ingest/recordWebhookDelivery.test.ts +++ b/apps/api/src/lib/ingest/recordWebhookDelivery.test.ts @@ -1,5 +1,7 @@ import { describe, expect, mock, test } from "bun:test"; +import { db } from "@superset/db/client"; import { DrizzleQueryError } from "drizzle-orm"; +import { stub } from "../../../test/stub"; const execute = mock( async (_query: unknown): Promise<{ rows: unknown[] }> => ({ @@ -7,10 +9,7 @@ const execute = mock( }), ); -mock.module("@superset/db/client", () => ({ - db: { execute }, - dbWs: { execute }, -})); +stub(db, { execute }); const { recordWebhookDelivery } = await import("./recordWebhookDelivery"); diff --git a/apps/api/src/lib/integrations/oauthFlow.test.ts b/apps/api/src/lib/integrations/oauthFlow.test.ts index 76fe87311df..e67fc27b129 100644 --- a/apps/api/src/lib/integrations/oauthFlow.test.ts +++ b/apps/api/src/lib/integrations/oauthFlow.test.ts @@ -1,11 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; - -mock.module("@/env", () => ({ - env: { - BETTER_AUTH_SECRET: "test-secret", - NEXT_PUBLIC_API_URL: "https://api.test", - }, -})); +import { describe, expect, test } from "bun:test"; const { beginOAuthFlow, diff --git a/apps/api/src/lib/integrations/resolveCallback.test.ts b/apps/api/src/lib/integrations/resolveCallback.test.ts index 55863c421fe..acc8c21152d 100644 --- a/apps/api/src/lib/integrations/resolveCallback.test.ts +++ b/apps/api/src/lib/integrations/resolveCallback.test.ts @@ -1,14 +1,9 @@ -import { describe, expect, mock, test } from "bun:test"; - -mock.module("@/env", () => ({ - env: { - BETTER_AUTH_SECRET: "test-secret", - NEXT_PUBLIC_API_URL: "https://api.test", - }, -})); +import { describe, expect, test } from "bun:test"; +import * as dbUtils from "@superset/db/utils"; +import { stub } from "../../../test/stub"; let members: Array<{ userId: string; organizationId: string }> = []; -mock.module("@superset/db/utils", () => ({ +stub(dbUtils, { findOrgMembership: async ({ userId, organizationId, @@ -19,7 +14,7 @@ mock.module("@superset/db/utils", () => ({ members.find( (m) => m.userId === userId && m.organizationId === organizationId, ) ?? null, -})); +}); const { resolveCallback } = await import("./resolveCallback"); const { setStateCookie, STATE_COOKIES } = await import("./oauthFlow"); diff --git a/apps/api/src/lib/oauth-state.test.ts b/apps/api/src/lib/oauth-state.test.ts index e036b1f7848..ca200413723 100644 --- a/apps/api/src/lib/oauth-state.test.ts +++ b/apps/api/src/lib/oauth-state.test.ts @@ -1,10 +1,6 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createHmac } from "node:crypto"; -mock.module("@/env", () => ({ - env: { BETTER_AUTH_SECRET: "test-secret" }, -})); - const { createSignedState, pluginStateSchema, verifySignedState } = await import("./oauth-state"); diff --git a/apps/api/test/setup-env.ts b/apps/api/test/setup-env.ts new file mode 100644 index 00000000000..e4efa93eb94 --- /dev/null +++ b/apps/api/test/setup-env.ts @@ -0,0 +1,24 @@ +import "../../../scripts/test-preload.ts"; + +process.env.SKIP_ENV_VALIDATION = "1"; +// Importing a module that constructs a client throws without these. None +// connects until it is used. +process.env.STRIPE_SECRET_KEY ??= "sk_test_placeholder"; +process.env.RESEND_API_KEY ??= "re_placeholder"; +process.env.NEXT_PUBLIC_POSTHOG_KEY ??= "phc_placeholder"; +// @superset/email validates this at import and has no opt-out. +process.env.NEXT_PUBLIC_MARKETING_URL ??= "https://marketing.test"; +process.env.DATABASE_URL ??= "postgresql://test:test@localhost:5432/test"; +process.env.DATABASE_URL_UNPOOLED ??= process.env.DATABASE_URL; + +// apps/api reads `env` once at import, so the values tests assert on are fixed +// here and not left to a developer's shell or .env. +Object.assign(process.env, { + BETTER_AUTH_SECRET: "test-secret", + LINEAR_WEBHOOK_SECRET: "test-secret", + NEXT_PUBLIC_API_URL: "https://api.test", + NEXT_PUBLIC_WEB_URL: "https://app.superset.sh", + QSTASH_TOKEN: "qstash-token", + SERVER_ANTHROPIC_API_KEY: "test", + SLACK_SIGNING_SECRET: "test-secret", +}); diff --git a/apps/api/test/stub.ts b/apps/api/test/stub.ts new file mode 100644 index 00000000000..2bc4dd85ac8 --- /dev/null +++ b/apps/api/test/stub.ts @@ -0,0 +1,30 @@ +import { afterAll, beforeAll, spyOn } from "bun:test"; + +// `mock.module` has no undo and reaches every test file that loads later; this +// swaps members of a live object or module namespace for one file only. +export function stub( + target: T, + fakes: Partial>, +): void { + const restores: Array<() => void> = []; + beforeAll(() => { + for (const [key, fake] of Object.entries(fakes)) { + if (typeof fake === "function") { + const spy = spyOn(target, key as never).mockImplementation( + fake as never, + ); + restores.push(() => spy.mockRestore()); + continue; + } + const previous = Object.getOwnPropertyDescriptor(target, key); + Reflect.set(target, key, fake); + restores.push(() => { + if (previous) Object.defineProperty(target, key, previous); + else Reflect.deleteProperty(target, key); + }); + } + }); + afterAll(() => { + for (const restore of restores.reverse()) restore(); + }); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index c86de0e680d..672c10e3fca 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -6,5 +6,5 @@ } }, "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules", "**/*.test.ts", "test"] } diff --git a/packages/trpc/src/lib/account-purge/findOrganizationSolelyOwnedBy.test.ts b/packages/trpc/src/lib/account-purge/findOrganizationSolelyOwnedBy.test.ts index c63d59aa784..781ece93154 100644 --- a/packages/trpc/src/lib/account-purge/findOrganizationSolelyOwnedBy.test.ts +++ b/packages/trpc/src/lib/account-purge/findOrganizationSolelyOwnedBy.test.ts @@ -1,6 +1,8 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; +import { stub } from "../../../test/stub"; interface FakeMember { organizationId: string; @@ -17,37 +19,32 @@ const params = (condition: SQL) => // The two count queries differ only by the `role = 'owner'` term, so the // parameter list tells them apart: [org, user] or [org, "owner", user]. -mock.module("@superset/db/client", () => ({ - db: { - query: { - members: { - findMany: async () => - memberships.filter( - (member) => member.userId === USER_ID && member.role === "owner", - ), +stub(db.query.members, { + findMany: async () => + memberships.filter( + (member) => member.userId === USER_ID && member.role === "owner", + ), +}); +stub(db, { + select: () => ({ + from: () => ({ + where: async (condition: SQL) => { + const [organizationId, ...rest] = params(condition); + const ownersOnly = rest.includes("owner"); + return [ + { + value: memberships.filter( + (member) => + member.organizationId === organizationId && + member.userId !== USER_ID && + (!ownersOnly || member.role === "owner"), + ).length, + }, + ]; }, - }, - select: () => ({ - from: () => ({ - where: async (condition: SQL) => { - const [organizationId, ...rest] = params(condition); - const ownersOnly = rest.includes("owner"); - return [ - { - value: memberships.filter( - (member) => - member.organizationId === organizationId && - member.userId !== USER_ID && - (!ownersOnly || member.role === "owner"), - ).length, - }, - ]; - }, - }), }), - }, - dbWs: {}, -})); + }), +}); const { findOrganizationSolelyOwnedBy } = await import( "./findOrganizationSolelyOwnedBy" diff --git a/packages/trpc/src/lib/account-purge/purgeAccount.test.ts b/packages/trpc/src/lib/account-purge/purgeAccount.test.ts index 13454663970..8a3b64bc53a 100644 --- a/packages/trpc/src/lib/account-purge/purgeAccount.test.ts +++ b/packages/trpc/src/lib/account-purge/purgeAccount.test.ts @@ -1,7 +1,11 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { stripeClient } from "@superset/auth/stripe"; +import { db, dbWs } from "@superset/db/client"; import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; import { setTestEnv } from "../../../test/env"; +import { stub } from "../../../test/stub"; +import * as pageStore from "../page-store"; interface FakeOrganization { id: string; @@ -46,117 +50,102 @@ setTestEnv({ POSTHOG_API_KEY: "phx_test", }); -mock.module("@superset/db/client", () => { - const tx = { - delete: () => ({ where: async () => {} }), - update: () => ({ set: () => ({ where: async () => {} }) }), - }; - return { - db: { - query: { - members: { - findMany: async () => - organizations - .filter((org) => org.memberIds.includes(USER_ID)) - .map((org) => ({ organizationId: org.id, userId: USER_ID })), - }, - subscriptions: { - findFirst: async ({ where }: { where: SQL }) => { - const subscription = organizationById( - firstParam(where), - ).subscription; - return subscription && { ...subscription, status: "active" }; - }, - }, - organizations: { - findFirst: async ({ where }: { where: SQL }) => { - const organization = organizationById(firstParam(where)); - return { - id: organization.id, - stripeCustomerId: organization.stripeCustomerId, - }; - }, - }, - }, - select: () => ({ - from: () => ({ - where: async (condition: SQL) => [ - { - value: - organizationById(firstParam(condition)).memberIds.length - 1, - }, - ], - }), - }), - delete: () => ({ - where: async (condition: SQL) => { - log.push(`organization.delete ${firstParam(condition)}`); +const tx = { + delete: () => ({ where: async () => {} }), + update: () => ({ set: () => ({ where: async () => {} }) }), +}; + +stub(db.query.members, { + findMany: async () => + organizations + .filter((org) => org.memberIds.includes(USER_ID)) + .map((org) => ({ organizationId: org.id, userId: USER_ID })), +}); +stub(db.query.subscriptions, { + findFirst: async ({ where }: { where: SQL }) => { + const subscription = organizationById(firstParam(where)).subscription; + return subscription && { ...subscription, status: "active" }; + }, +}); +stub(db.query.organizations, { + findFirst: async ({ where }: { where: SQL }) => { + const organization = organizationById(firstParam(where)); + return { + id: organization.id, + stripeCustomerId: organization.stripeCustomerId, + }; + }, +}); +stub(db, { + select: () => ({ + from: () => ({ + where: async (condition: SQL) => [ + { + value: organizationById(firstParam(condition)).memberIds.length - 1, }, - }), + ], + }), + }), + delete: () => ({ + where: async (condition: SQL) => { + log.push(`organization.delete ${firstParam(condition)}`); }, - dbWs: { - transaction: async (run: (transaction: typeof tx) => Promise) => { - await run(tx); - log.push("tombstone"); - }, - }, - }; + }), +}); +stub(dbWs, { + transaction: async (run: (transaction: typeof tx) => Promise) => { + await run(tx); + log.push("tombstone"); + }, }); -mock.module("../page-store", () => ({ +stub(pageStore, { purgePageStorageForUser: async (userId: string) => { log.push(`page-storage.purge ${userId}`); if (failPageStoragePurge) throw failPageStoragePurge; return { pages: 0, cleared: 0 }; }, - callPageStore: async () => ({ ok: true }), - deletePageStorage: async () => {}, - notifyPageHub: () => {}, -})); +}); -mock.module("@superset/auth/stripe", () => ({ - stripeClient: { - subscriptions: { - retrieve: async () => ({ items: { data: [{ id: "si_1" }] } }), - update: async ( - id: string, - params: { items: Array<{ quantity: number }> }, - ) => { - log.push(`subscription.update ${id} ${params.items[0]?.quantity}`); - }, - list: async ({ customer }: { customer: string }) => ({ - data: customerById(customer).activeSubscriptionIds.map((id) => ({ - id, - })), - }), - cancel: async (id: string) => { - log.push(`subscription.cancel ${id}`); - }, - }, - charges: { - list: ({ customer }: { customer: string }) => { - log.push(`charges.list ${customer}`); - const { charges } = customerById(customer); - return (async function* () { - yield* charges; - })(); - }, - }, - customers: { - del: async (id: string) => { - if (failCustomerDelete) throw failCustomerDelete; - const customer = customerById(id); - if (customer.deleted) { - throw Object.assign(new Error(`No such customer: '${id}'`), { - code: "resource_missing", - }); - } - customer.deleted = true; - log.push(`customer.delete ${id}`); - }, - }, +stub(stripeClient.subscriptions, { + retrieve: async () => ({ items: { data: [{ id: "si_1" }] } }), + update: async ( + id: string, + params: { items: Array<{ quantity: number }> }, + ) => { + log.push(`subscription.update ${id} ${params.items[0]?.quantity}`); + }, + list: async ({ customer }: { customer: string }) => ({ + data: customerById(customer).activeSubscriptionIds.map((id) => ({ + id, + })), + }), + cancel: async (id: string) => { + log.push(`subscription.cancel ${id}`); + }, +}); +stub(stripeClient.charges, { + list: ({ customer }: { customer: string }) => { + log.push(`charges.list ${customer}`); + const { charges } = customerById(customer); + return (async function* () { + yield* charges; + })(); }, -})); +}); +stub(stripeClient.customers, { + del: async (id: string) => { + if (failCustomerDelete) throw failCustomerDelete; + const customer = customerById(id); + if (customer.deleted) { + throw Object.assign(new Error(`No such customer: '${id}'`), { + code: "resource_missing", + }); + } + customer.deleted = true; + log.push(`customer.delete ${id}`); + }, +}); const { purgeAccount } = await import("./purgeAccount"); const purge = () => purgeAccount(USER_ID); diff --git a/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts b/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts index 992d24ade23..4b0ddb9ba8d 100644 --- a/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts +++ b/packages/trpc/src/lib/cloud-guards/cloud-guards.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; +import { stub } from "../../../test/stub"; +import { posthog } from "../analytics"; let flagResult: boolean | undefined; let storedEmail: string | null; @@ -9,39 +12,24 @@ let calls: Array<{ options?: { personProperties?: Record }; }> = []; -// Both stubbed so the real clients — and the validated env and database -// connection they open at import — stay out of this test's module graph. -// `mock.module` is process-wide, so every export the real module has must be -// here: another file's import of `dbWs` resolves against this stub too. -mock.module("@superset/db/client", () => ({ - db: { - query: { - users: { - findFirst: () => { - lookups += 1; - return Promise.resolve( - storedEmail === null ? undefined : { email: storedEmail }, - ); - }, - }, - }, +stub(db.query.users, { + findFirst: () => { + lookups += 1; + return Promise.resolve( + storedEmail === null ? undefined : { email: storedEmail }, + ); }, - dbWs: { - transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), - }, -})); -mock.module("../analytics", () => ({ - posthog: { - isFeatureEnabled: ( - key: string, - distinctId: string, - options?: { personProperties?: Record }, - ) => { - calls.push({ key, distinctId, options }); - return Promise.resolve(flagResult); - }, +}); +stub(posthog, { + isFeatureEnabled: ( + key: string, + distinctId: string, + options?: { personProperties?: Record }, + ) => { + calls.push({ key, distinctId, options }); + return Promise.resolve(flagResult); }, -})); +}); const { assertCloudAccess } = await import("./cloud-guards"); diff --git a/packages/trpc/src/lib/connectors/refresh.test.ts b/packages/trpc/src/lib/connectors/refresh.test.ts index a77645c5451..358c8fc22cf 100644 --- a/packages/trpc/src/lib/connectors/refresh.test.ts +++ b/packages/trpc/src/lib/connectors/refresh.test.ts @@ -1,20 +1,12 @@ -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; - -// A refresh is a token-endpoint call plus what gets written back, so the -// endpoint and the update are the two things stubbed here. `mock.module` is -// process-wide, so each stub lists every export the real module has. -// -// The lookup rule lives here too rather than in its own file: these stubs are -// stateful, and a second file stubbing the same modules would both lose the -// race for whichever registration bun pins and leave its own `rows` binding -// unread by the pinned closure. -// -// `./index`, `./client-identity` and the manifest module are deliberately NOT -// stubbed: a partial stub of any of them pins process-wide and breaks every -// other file that imports the rest. A real registry connector with a static -// client is used instead, so `resolveEndpoints` runs for real and reaches no -// network, and the token endpoint is intercepted at `globalThis.fetch` — the -// same seam `connectors.test.ts` uses. +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; +import { stub } from "../../../test/stub"; +import * as crypto from "../../router/plugins/crypto"; + +// A refresh is a token-endpoint call plus what gets written back. A real +// registry connector with a static client is used, so `resolveEndpoints` runs +// for real and reaches no network, and the token endpoint is intercepted at +// `globalThis.fetch`. let rows: Array> = []; let stored: Record | null = null; @@ -51,23 +43,14 @@ const updateChain = { where: () => updateResult, }; -mock.module("@superset/db/client", () => ({ - db: { - select: () => selectChain, - update: () => updateChain, - query: {}, - }, - dbWs: { - transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), - }, -})); +stub(db, { select: () => selectChain, update: () => updateChain }); -mock.module("../../router/plugins/crypto", () => ({ +stub(crypto, { decryptSecret: (value: string) => Promise.resolve(value), decryptOptional: (value: string | null) => Promise.resolve(value), encryptSecret: (value: string) => Promise.resolve(value), encryptOptional: (value: string | null) => Promise.resolve(value), -})); +}); const { ConnectorUnavailableError, diff --git a/packages/trpc/src/lib/sandbox/credentials.test.ts b/packages/trpc/src/lib/sandbox/credentials.test.ts index 3ae61b02a2b..035478c71bc 100644 --- a/packages/trpc/src/lib/sandbox/credentials.test.ts +++ b/packages/trpc/src/lib/sandbox/credentials.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { SANDBOX_CREDENTIAL_PLACEHOLDER } from "@superset/shared/constants"; +import { setTestEnv } from "../../../test/env"; import { deriveSandboxCredentials, gitAuthorFor } from "./credentials"; +setTestEnv({ NEXT_PUBLIC_API_URL: undefined }); + type Policy = { allow: Record< string, diff --git a/packages/trpc/src/router/cloud-workspace/reap.test.ts b/packages/trpc/src/router/cloud-workspace/reap.test.ts index 9cb41aaa59c..ec678673b98 100644 --- a/packages/trpc/src/router/cloud-workspace/reap.test.ts +++ b/packages/trpc/src/router/cloud-workspace/reap.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; +import { stub } from "../../../test/stub"; +import * as sandbox from "../../lib/sandbox"; +import * as jobs from "./jobs"; type Row = { status: string; @@ -13,16 +17,8 @@ let sandboxCalls: string[] = []; let queued: Array<{ path: string; body: unknown; delaySeconds?: number }> = []; let failingStage: Stage | null = null; -// `mock.module` is process-wide, so every export the real module has must be here. -mock.module("@superset/db/client", () => ({ - db: { - query: { cloudWorkspaces: { findFirst: () => Promise.resolve(row) } }, - }, - dbWs: { - transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), - }, -})); -mock.module("../../lib/sandbox", () => ({ +stub(db.query.cloudWorkspaces, { findFirst: () => Promise.resolve(row) }); +stub(sandbox, { stopSandbox: (id: string) => { sandboxCalls.push(`stop:${id}`); return Promise.resolve(); @@ -31,8 +27,8 @@ mock.module("../../lib/sandbox", () => ({ sandboxCalls.push(`delete:${id}`); return Promise.resolve(); }, -})); -mock.module("./jobs", () => ({ +}); +stub(jobs, { publishCloudWorkspaceJob: (job: { path: string; body: { stage: Stage }; @@ -48,7 +44,7 @@ mock.module("./jobs", () => ({ }); return Promise.resolve(); }, -})); +}); const { queueReap, reapArchivedCloudWorkspace } = await import("./reap"); diff --git a/packages/trpc/src/router/page-comment/public-access.test.ts b/packages/trpc/src/router/page-comment/public-access.test.ts index 1133713713a..0ea2aa8f42c 100644 --- a/packages/trpc/src/router/page-comment/public-access.test.ts +++ b/packages/trpc/src/router/page-comment/public-access.test.ts @@ -1,20 +1,28 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { db, dbWs } from "@superset/db/client"; import type { TRPCError } from "@trpc/server"; +import { stub } from "../../../test/stub"; +import { posthog } from "../../lib/analytics"; let dbTouched = false; -const forbidDb = new Proxy( - {}, - { - get: () => { - dbTouched = true; - throw new Error("reached the database"); - }, - }, -); +const reached = () => { + dbTouched = true; + throw new Error("reached the database"); +}; +const forbidden = { + query: new Proxy({}, { get: reached }), + select: reached, + insert: reached, + update: reached, + delete: reached, + execute: reached, + transaction: reached, +}; -mock.module("@superset/db/client", () => ({ db: forbidDb, dbWs: forbidDb })); -mock.module("../../lib/analytics", () => ({ posthog: { capture: () => {} } })); +stub(db, forbidden); +stub(dbWs, forbidden); +stub(posthog, { capture: () => {} }); const { pageCommentRouter } = await import("./page-comment"); const { createCallerFactory, createTRPCContext, createTRPCRouter } = diff --git a/packages/trpc/src/router/plugins/plugins.test.ts b/packages/trpc/src/router/plugins/plugins.test.ts index ea20a699bb5..bf7af80cdd2 100644 --- a/packages/trpc/src/router/plugins/plugins.test.ts +++ b/packages/trpc/src/router/plugins/plugins.test.ts @@ -1,7 +1,12 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; import { connections, pluginInstalls } from "@superset/db/schema"; import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; +import { stub } from "../../../test/stub"; +import { posthog } from "../../lib/analytics"; +import * as pluginConnections from "./connections"; +import * as proxy from "./proxy"; interface InstallRow { id: string; @@ -32,85 +37,73 @@ const rows = (value: T[]) => orderBy: () => Promise.resolve(value), }); -mock.module("@superset/auth/server", () => ({ auth: {} })); -mock.module("../../lib/analytics", () => ({ posthog: { capture: () => {} } })); -mock.module("./proxy", () => ({ - forgetUpstreamTools: (id: string) => forgotten.push(id), -})); +stub(posthog, { capture: () => {} }); +stub(proxy, { forgetUpstreamTools: (id: string) => forgotten.push(id) }); -mock.module("./connections", () => ({ +stub(pluginConnections, { installRecord: (_userId: string, pluginName: string) => { const row = installs.find((entry) => entry.pluginName === pluginName); return Promise.resolve( row && { id: row.id, marketplace: row.marketplace, siblings: 1 }, ); }, - installedPlugin: () => Promise.resolve(null), - installById: () => Promise.resolve(null), - installedManifest: () => Promise.resolve(null), - AmbiguousPluginError: class extends Error {}, -})); - -mock.module("@superset/db/client", () => ({ - db: { - select: () => ({ - from: (table: unknown) => ({ - where: (condition: SQL) => { - if (table === connections) { - return rows( - live - .filter( - (row) => - row.connectedByUserId === USER_ID && - row.disconnectedAt === null, - ) - .map((row) => ({ - id: row.id, - connector: row.connector, - account: null, - user: "me@superset.sh", - })), - ); - } - if (table !== pluginInstalls) return rows([]); - const values = bound(condition); +}); + +stub(db, { + select: () => ({ + from: (table: unknown) => ({ + where: (condition: SQL) => { + if (table === connections) { return rows( - installs.filter( - (row) => values.includes(row.id) || values.includes(row.userId), - ), + live + .filter( + (row) => + row.connectedByUserId === USER_ID && + row.disconnectedAt === null, + ) + .map((row) => ({ + id: row.id, + connector: row.connector, + account: null, + user: "me@superset.sh", + })), ); - }, - }), - }), - delete: () => ({ - where: (condition: SQL) => { + } + if (table !== pluginInstalls) return rows([]); const values = bound(condition); - installs = installs.filter((row) => !values.includes(row.id)); - return Promise.resolve(); + return rows( + installs.filter( + (row) => values.includes(row.id) || values.includes(row.userId), + ), + ); }, }), - update: () => ({ - set: () => ({ - where: (condition: SQL) => ({ - returning: () => { - const values = bound(condition); - const hit = live.filter( - (row) => - row.disconnectedAt === null && - values.includes(row.connector) && - values.includes(row.connectedByUserId), - ); - for (const row of hit) row.disconnectedAt = new Date(); - return Promise.resolve(hit.map((row) => ({ id: row.id }))); - }, - }), + }), + delete: () => ({ + where: (condition: SQL) => { + const values = bound(condition); + installs = installs.filter((row) => !values.includes(row.id)); + return Promise.resolve(); + }, + }), + update: () => ({ + set: () => ({ + where: (condition: SQL) => ({ + returning: () => { + const values = bound(condition); + const hit = live.filter( + (row) => + row.disconnectedAt === null && + values.includes(row.connector) && + values.includes(row.connectedByUserId), + ); + for (const row of hit) row.disconnectedAt = new Date(); + return Promise.resolve(hit.map((row) => ({ id: row.id }))); + }, }), }), - }, - dbWs: { - transaction: () => Promise.reject(new Error("dbWs is stubbed in tests")), - }, -})); + }), +}); const { pluginsRouter } = await import("./plugins"); const { createCallerFactory, createTRPCContext, createTRPCRouter } = diff --git a/packages/trpc/src/router/plugins/proxy/plugin-server.test.ts b/packages/trpc/src/router/plugins/proxy/plugin-server.test.ts index 7ebdc932b75..7cc5b373c57 100644 --- a/packages/trpc/src/router/plugins/proxy/plugin-server.test.ts +++ b/packages/trpc/src/router/plugins/proxy/plugin-server.test.ts @@ -79,6 +79,10 @@ async function connect(target: PluginTarget, rejected: string[] = []) { }; } +let layoutsBuilt = 0; + +// The server caches each account layout by plugin version, so every target +// gets its own version to keep one test's layout out of the next. function multiTarget( resolve: (connectionId: string) => Promise, hosted?: PluginTarget extends { hosted?: infer H } ? H : never, @@ -86,7 +90,7 @@ function multiTarget( return { kind: "multi", plugin: "gmail", - version: "1.0.0", + version: `1.0.${++layoutsBuilt}`, connector: "google", connectorLabel: "Google", accounts: ACCOUNTS, diff --git a/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts b/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts index 3ad9eb831c2..ad5bef45441 100644 --- a/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts +++ b/packages/trpc/src/router/plugins/proxy/resolve-target.test.ts @@ -1,12 +1,12 @@ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${config.*} is the manifest placeholder syntax, not a template literal -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; import { setTestEnv } from "../../../../test/env"; +import { stub } from "../../../../test/stub"; +import * as lookup from "../../../lib/connectors/lookup"; +import * as refresh from "../../../lib/connectors/refresh"; +import * as upsert from "../../../lib/connectors/upsert"; import type { InstalledPlugin } from "../connections"; - -// Every dependency that reaches the database is stubbed so the validated env -// and the connection it opens at import stay out of this test's module graph. -// `mock.module` is process-wide, so each stub lists every export the real -// module has: another file's import resolves against the stub too. +import * as connections from "../connections"; let install: InstalledPlugin | null = null; let installedCalls: Array<[string, string, string | undefined]> = []; @@ -16,52 +16,24 @@ let pinned: Record | null = null; let pinnedCalls: Array<[string, unknown]> = []; let refreshError: Error | null = null; -class StubUnrefreshable extends Error { - constructor(connector: string) { - super(`The ${connector} connection expired and carries no refresh token.`); - this.name = "UnrefreshableConnectionError"; - } -} - -class StubUnavailable extends Error { - constructor( - readonly connector: string, - detail: string, - ) { - super(`The ${connector} token endpoint is unavailable: ${detail}`); - this.name = "ConnectorUnavailableError"; - } -} - setTestEnv({ NEXT_PUBLIC_API_URL: "https://api.superset.test" }); -mock.module("../connections", () => ({ +stub(connections, { installedPlugin: (userId: string, plugin: string, marketplace?: string) => { installedCalls.push([userId, plugin, marketplace]); return Promise.resolve(install); }, - installRecord: () => Promise.resolve(null), - installById: () => Promise.resolve(null), - installedManifest: () => Promise.resolve(null), - AmbiguousPluginError: class extends Error {}, -})); +}); -mock.module("../../../lib/connectors/lookup", () => ({ +stub(lookup, { connectionById: (id: string, options: unknown) => { pinnedCalls.push([id, options]); return Promise.resolve(pinned); }, - orgConnection: () => Promise.resolve(null), - userConnection: () => Promise.resolve(null), userConnections: () => Promise.resolve(accounts ?? (active ? [active] : [])), - accountConnection: () => Promise.resolve(null), - accountConnections: () => Promise.resolve([]), - connectorConnections: () => Promise.resolve([]), - connectionBotToken: () => Promise.resolve(null), - AmbiguousConnectionError: class extends Error {}, -})); - -mock.module("../../../lib/connectors/upsert", () => ({ +}); + +stub(upsert, { activeConnection: () => Promise.resolve(active), connectionSecrets: (row: { id: string }) => Promise.resolve({ @@ -69,19 +41,12 @@ mock.module("../../../lib/connectors/upsert", () => ({ refreshToken: null, config: { bot_token: null }, }), - connectionConflict: () => Promise.resolve(null), - upsertConnection: () => Promise.resolve(null), -})); +}); -mock.module("../../../lib/connectors/refresh", () => ({ +stub(refresh, { ensureFreshConnection: (row: Record) => refreshError ? Promise.reject(refreshError) : Promise.resolve(row), - connectionAccessToken: () => Promise.resolve("token"), - markNeedsReauth: () => Promise.resolve(), - NEEDS_REAUTH: "needs_reauth", - ConnectorUnavailableError: StubUnavailable, - UnrefreshableConnectionError: StubUnrefreshable, -})); +}); const { PluginTargetError, resolveTarget, targetKey } = await import( "./resolve-target" @@ -197,7 +162,7 @@ describe("resolveTarget", () => { test("asks for auth, with the reason, when the token cannot be refreshed", async () => { install = installed("superset", { connector: "acme-crm" }); active = { id: "conn-1", authMethod: "oauth2" }; - refreshError = new StubUnrefreshable("acme-crm"); + refreshError = new refresh.UnrefreshableConnectionError("acme-crm"); const target = await resolveTarget(request); @@ -221,7 +186,10 @@ describe("resolveTarget", () => { test("an unreachable token endpoint is a bad gateway, not a reconnect prompt", async () => { install = installed("superset", { connector: "acme-crm" }); active = { id: "conn-1", authMethod: "oauth2" }; - refreshError = new StubUnavailable("acme-crm", "503 Service Unavailable"); + refreshError = new refresh.ConnectorUnavailableError( + "acme-crm", + "503 Service Unavailable", + ); // needs-auth would tell the user to reconnect a connection that is fine. const error = await resolveTarget(request).catch((e) => e); diff --git a/packages/trpc/src/router/user/user.test.ts b/packages/trpc/src/router/user/user.test.ts index 73584e34b6a..8d667a9489f 100644 --- a/packages/trpc/src/router/user/user.test.ts +++ b/packages/trpc/src/router/user/user.test.ts @@ -1,26 +1,19 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; +import { stub } from "../../../test/stub"; +import * as activationEvents from "../../lib/activation-events"; +import { posthog } from "../../lib/analytics"; const membership = (organizationId: string) => ({ organizationId, organization: { id: organizationId }, }); -mock.module("@superset/db/client", () => ({ - db: { - query: { - members: { - findMany: async () => [membership("box-org"), membership("other-org")], - }, - }, - }, - dbWs: {}, -})); -mock.module("../../lib/analytics", () => ({ - posthog: { capture: () => {}, isFeatureEnabled: async () => false }, -})); -mock.module("../../lib/activation-events", () => ({ - emitAppFirstOpened: async () => {}, -})); +stub(db.query.members, { + findMany: async () => [membership("box-org"), membership("other-org")], +}); +stub(posthog, { capture: () => {}, isFeatureEnabled: async () => false }); +stub(activationEvents, { emitAppFirstOpened: async () => {} }); const { userRouter } = await import("./user"); const { createCallerFactory, createTRPCContext, createTRPCRouter } = diff --git a/packages/trpc/src/trpc.test.ts b/packages/trpc/src/trpc.test.ts index 62dac213abc..488c022e66c 100644 --- a/packages/trpc/src/trpc.test.ts +++ b/packages/trpc/src/trpc.test.ts @@ -1,23 +1,17 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { db } from "@superset/db/client"; import type { TRPCError } from "@trpc/server"; +import { stub } from "../test/stub"; +import { posthog } from "./lib/analytics"; const CREATOR_ORGS = ["box-org", "other-org"]; -mock.module("@superset/db/client", () => ({ - db: { - query: { - members: { - findMany: async () => - CREATOR_ORGS.map((organizationId) => ({ organizationId })), - findFirst: async () => ({ id: "membership" }), - }, - }, - }, - dbWs: {}, -})); -mock.module("./lib/analytics", () => ({ - posthog: { capture: () => {}, isFeatureEnabled: async () => false }, -})); +stub(db.query.members, { + findMany: async () => + CREATOR_ORGS.map((organizationId) => ({ organizationId })), + findFirst: async () => ({ id: "membership" }), +}); +stub(posthog, { capture: () => {}, isFeatureEnabled: async () => false }); const { createCallerFactory, diff --git a/packages/trpc/test/env.ts b/packages/trpc/test/env.ts index 5c7a7ad2d73..df1b0d94ffb 100644 --- a/packages/trpc/test/env.ts +++ b/packages/trpc/test/env.ts @@ -1,15 +1,17 @@ import { afterAll } from "bun:test"; // Every test file shares one process, so a value set here must be put back. -export function setTestEnv(values: Record): void { +export function setTestEnv(values: Record): void { const previous = Object.fromEntries( Object.keys(values).map((key) => [key, process.env[key]]), ); - Object.assign(process.env, values); - afterAll(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); + assign(values); + afterAll(() => assign(previous)); +} + +function assign(values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } diff --git a/packages/trpc/test/setup-env.ts b/packages/trpc/test/setup-env.ts index 4de37137629..98adf95f9de 100644 --- a/packages/trpc/test/setup-env.ts +++ b/packages/trpc/test/setup-env.ts @@ -1,8 +1,10 @@ import "../../../scripts/test-preload.ts"; process.env.SKIP_ENV_VALIDATION = "1"; -// Importing @superset/auth/stripe or @superset/db/client constructs a client, -// which throws without these. Neither connects until it is used. +// Importing a module that constructs a client throws without these. None +// connects until it is used. process.env.STRIPE_SECRET_KEY ??= "sk_test_placeholder"; +process.env.RESEND_API_KEY ??= "re_placeholder"; +process.env.NEXT_PUBLIC_POSTHOG_KEY ??= "phc_placeholder"; process.env.DATABASE_URL ??= "postgresql://test:test@localhost:5432/test"; process.env.DATABASE_URL_UNPOOLED ??= process.env.DATABASE_URL; diff --git a/packages/trpc/test/stub.ts b/packages/trpc/test/stub.ts new file mode 100644 index 00000000000..2bc4dd85ac8 --- /dev/null +++ b/packages/trpc/test/stub.ts @@ -0,0 +1,30 @@ +import { afterAll, beforeAll, spyOn } from "bun:test"; + +// `mock.module` has no undo and reaches every test file that loads later; this +// swaps members of a live object or module namespace for one file only. +export function stub( + target: T, + fakes: Partial>, +): void { + const restores: Array<() => void> = []; + beforeAll(() => { + for (const [key, fake] of Object.entries(fakes)) { + if (typeof fake === "function") { + const spy = spyOn(target, key as never).mockImplementation( + fake as never, + ); + restores.push(() => spy.mockRestore()); + continue; + } + const previous = Object.getOwnPropertyDescriptor(target, key); + Reflect.set(target, key, fake); + restores.push(() => { + if (previous) Object.defineProperty(target, key, previous); + else Reflect.deleteProperty(target, key); + }); + } + }); + afterAll(() => { + for (const restore of restores.reverse()) restore(); + }); +}