From fd0d08a3b8fa56852b8e328a9ead1d74f426d76c Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 10:19:59 -0700 Subject: [PATCH 01/24] improvement(mcp): push-driven settings freshness + lifecycle-audit fixes (#5842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(mcp): subscribe settings page to the live push, lean on it over re-probing The list_changed → SSE push pipeline was already wired end-to-end but only mounted in the workflow-editor tool picker. Subscribe the settings MCP page to the same shared, reference-counted EventSource so tool changes reflect in real time there too — the reference-client model (discover once, refresh on push). With push now active on the settings page, raise MCP_SERVER_TOOLS_STALE_TIME from 30s to 5min (matching the server-side cache TTL) so revisiting the page leans on push + stored state instead of re-probing every connected server. There is no background poll (no refetchInterval) — this only affects refetch-on-visit-if-stale; real changes still arrive instantly via push. * fix(mcp): re-sync tools on SSE reconnect so a missed list_changed can't strand stale tools A dropped/reconnected EventSource may miss a tools_changed event during the gap, which — with a longer stale time — could leave the page showing old tools until a remount or manual refresh. Invalidate the workspace tools on reconnect (never on the first open), the standard resync-on-reconnect pattern for push clients. * fix(mcp): resync on re-subscribe so events missed while the tab was closed reconcile Leaving the settings tab tears down the shared EventSource; remounting created a new connection whose first open was skipped, so a tools_changed fired while unsubscribed wasn't reconciled (the 5min stale time also won't refetch on remount). Track a per-workspace 'ever subscribed' flag: skip resync only on the session's first subscription (queries fetch fresh then); resync on the first open of any re-subscription and on every reconnect. * fix(mcp): reconcile a recovered server's status on successful discovery + fix stale comment Lifecycle-audit findings: - The per-server tools queryFn invalidated the server list only on error. A server that recovered via the stale re-probe returned fresh tools while the cached status still showed failed → 'tools present but row red' until the list independently refetched (a window the 5min stale-time widened). Now a successful probe against a server the cache still shows non-connected refreshes the list so the row clears. - Correct the keep/drop comment: tools drop once the stored status leaves 'connected' (disconnected/error), not at a 3-failure threshold. * fix(mcp): correct stale SSRF pin docstring and bound the remaining OAuth callback steps Lifecycle-audit follow-ups (merged-code): - validateMcpServerSsrf's return docstring described 'pin subsequent connections', which no longer holds for the public path — the returned IP is a policy signal selecting the validate-at-connect guarded fetch; redirect/rebind safety comes from per-connect validation + followRedirectsGuarded, not pinning (only the self-hosted private carve-out still literally pins). Corrected so a future reader can't reintroduce a real pin from the misleading text. - The callback wrapped only the five post-auth steps in timedStep; the earlier loadOauthRowByState, getSession, and server SELECT (plus the provider_error clearState) were unbounded, contradicting the 'every awaited step bounded' invariant. Wrapped them so a wedged DB read surfaces as a labeled timeout. * fix(mcp): resync on a first SSE open that recovered from an earlier connection error If the initial EventSource connection errors before opening (and the initial tools query fails with retry:false), the first successful onopen was skipped, leaving the query stale. Track erroredBeforeOpen so a first open that followed a connection error also resyncs — only a clean first subscription still skips. * fix(mcp): make the SSE push subscription intrinsic to the tools query The 5min stale time assumed push, but useMcpToolsEvents was only mounted in the settings page and the tool picker — other consumers of the tools query (dynamic args, tool selector, canvas block via useMcpToolsQuery/useMcpTools) had no subscription, so list_changed updates could stay invisible for the full window. Mount useMcpToolsEvents inside useMcpToolsQuery so every tools consumer gets real-time push from the shared, reference-counted connection — no consumer is left re-probing. Removed the now-redundant explicit mounts from the settings page and tool-input. * test(mcp): clear the shared SSE collections instead of reassigning globalThis mcp.ts captures the connections Map and subscribed Set in module consts at import, so setting the globalThis property to undefined didn't reset what the module uses — subscription state leaked across tests. Clear the shared instances instead. * fix(mcp): drop the success-path status reconciliation heuristic The client-side serversList invalidation on a successful probe assumed the probe updated the stored status, but a server-side cache hit returns tools without touching status — so in the failed-cache-delete edge it fired a pointless refetch. The benefit (instant vs the 60s serversList stale-time for status recovery) doesn't justify the incorrect assumption; rely on the existing 60s stale-time + SSE push for status recovery instead. Keeps the corrected keep/drop comment. --- apps/sim/app/api/mcp/oauth/callback/route.ts | 25 +++++++---- .../components/tool-input/tool-input.tsx | 2 - apps/sim/hooks/queries/mcp.test.tsx | 22 +++++++++- apps/sim/hooks/queries/mcp.ts | 43 ++++++++++++++++--- apps/sim/lib/mcp/domain-check.ts | 19 ++++---- 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 7694f23b010..79b7058ddea 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -125,12 +125,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { serverId?: string ) => htmlClose(message, ok, reason, serverId, state) - const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null + const initialRow = state + ? await timedStep('loadOauthRowByState', 15_000, () => loadOauthRowByState(state)).catch( + () => null + ) + : null const stateRowServerId = initialRow?.mcpServerId if (errorParam) { logger.warn(`MCP OAuth callback received error: ${errorParam}`) - if (initialRow) await clearState(initialRow.id, 'callback:provider_error').catch(() => {}) + if (initialRow) + await timedStep('clearState(provider_error)', 10_000, () => + clearState(initialRow.id, 'callback:provider_error') + ).catch(() => {}) return respond(`Authorization failed: ${errorParam}`, false, 'provider_error', stateRowServerId) } if (!state || !code) { @@ -144,7 +151,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let serverId: string | undefined try { - const session = await getSession() + const session = await timedStep('getSession', 15_000, () => getSession()) if (!session?.user?.id) { return respond( 'You must be signed in to complete authorization.', @@ -169,11 +176,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const [server] = await db - .select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId }) - .from(mcpServers) - .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) - .limit(1) + const [server] = await timedStep('loadServer', 15_000, () => + db + .select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId }) + .from(mcpServers) + .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) + .limit(1) + ) if (!server || !server.url) { return respond('Server no longer exists.', false, 'server_gone', serverId) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index a4a937a5e27..3cfcb3fe3e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -79,7 +79,6 @@ import { useCreateMcpServer, useForceRefreshMcpTools, useMcpServers, - useMcpToolsEvents, useStoredMcpTools, } from '@/hooks/queries/mcp' import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' @@ -570,7 +569,6 @@ export const ToolInput = memo(function ToolInput({ const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId) const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId) const forceRefreshMcpTools = useForceRefreshMcpTools().mutate - useMcpToolsEvents(workspaceId) const { navigateToSettings } = useSettingsNavigation() const createMcpServer = useCreateMcpServer() const { startOauthForServer } = useMcpOauthPopup({ workspaceId }) diff --git a/apps/sim/hooks/queries/mcp.test.tsx b/apps/sim/hooks/queries/mcp.test.tsx index ad6c120f516..18e3f2d5e34 100644 --- a/apps/sim/hooks/queries/mcp.test.tsx +++ b/apps/sim/hooks/queries/mcp.test.tsx @@ -103,13 +103,29 @@ function mockServers(servers: McpServer[]) { }) } +// jsdom has no EventSource; useMcpToolsQuery mounts the shared SSE subscription. +class FakeEventSource { + onopen: (() => void) | null = null + onerror: (() => void) | null = null + constructor(public url: string) {} + addEventListener(): void {} + close(): void {} +} + describe('useMcpToolsQuery', () => { beforeEach(() => { vi.clearAllMocks() + ;(globalThis as unknown as { EventSource: unknown }).EventSource = FakeEventSource }) afterEach(() => { vi.restoreAllMocks() + // mcp.ts captured these Map/Set instances in module consts at import, so reassigning the + // globalThis property wouldn't reset what the module uses — clear the shared instances. + ;( + globalThis as unknown as { __mcp_sse_connections?: Map } + ).__mcp_sse_connections?.clear() + ;(globalThis as unknown as { __mcp_sse_subscribed?: Set }).__mcp_sse_subscribed?.clear() }) it('does not auto-discover disconnected or errored OAuth servers', async () => { @@ -139,7 +155,11 @@ describe('useMcpToolsQuery', () => { const { unmount } = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) await flush() - expect(mockRequestJson).toHaveBeenCalledTimes(3) + // Both eligible servers get discovered. + const discoveryCalls = mockRequestJson.mock.calls.filter( + ([contract]) => contract === discoverMcpToolsContract + ) + expect(discoveryCalls).toHaveLength(2) expect(mockRequestJson).toHaveBeenCalledWith( discoverMcpToolsContract, expect.objectContaining({ diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 8daab301235..f7fbffe9176 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -42,7 +42,13 @@ const logger = createLogger('McpQueries') export type { McpServerStatusConfig, McpTool, StoredMcpTool } export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 -export const MCP_SERVER_TOOLS_STALE_TIME = 30 * 1000 +/** + * Tool discovery is kept fresh by the `list_changed` → SSE push (see `useMcpToolsEvents`), + * so the query only needs a re-probe-on-visit fallback for servers without push. Matches the + * server-side cache TTL (`MCP_CONSTANTS.CACHE_TIMEOUT`) — no reference MCP client re-probes + * more often than its cache; real changes arrive via push regardless of this value. + */ +export const MCP_SERVER_TOOLS_STALE_TIME = 5 * 60 * 1000 export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000 export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000 @@ -140,6 +146,11 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b export function useMcpToolsQuery(workspaceId: string) { const queryClient = useQueryClient() const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId) + // Push is intrinsic to consuming the tools query: every surface that reads tools (settings, + // tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed` + // refresh via the shared, reference-counted subscription — so the 5-min stale time is always + // push-backed and no consumer is left re-probing. + useMcpToolsEvents(workspaceId) /** * Skip disabled rows, rows retained from a previous workspace, and OAuth rows @@ -192,10 +203,10 @@ export function useMcpToolsQuery(workspaceId: string) { const serverId = serverIds[index] const status = serverId ? statusById.get(serverId) : undefined const persistentlyFailed = status === 'error' || status === 'disconnected' - // Keep last-known-good tools through a transient failure (React Query retains `data`, the - // stored status is still healthy) so a populated server doesn't blank — but drop them once - // the stored status crosses its failure threshold, so the workflow editor stops offering a - // dead server's stale tools. Matches how reference MCP clients treat transient vs. closed. + // Keep last-known-good tools while the stored status is still `connected` (React Query + // retains `data` across a failed refetch, so a populated server doesn't blank on a + // transient probe error) — but drop them once the stored status leaves `connected` + // (disconnected/error), so the workflow editor stops offering a dead server's stale tools. if (result.data && (!result.isError || !persistentlyFailed)) { tools.push(...result.data) hasData = true @@ -527,6 +538,12 @@ const sseConnections: Map = ((globalThis as Record)[SSE_KEY] as Map) ?? ((globalThis as Record)[SSE_KEY] = new Map()) +/** Per-workspace flag: has this session ever held a live SSE subscription for it? */ +const SSE_SUBSCRIBED_KEY = '__mcp_sse_subscribed' as const +const sseEverSubscribed: Set = + ((globalThis as Record)[SSE_SUBSCRIBED_KEY] as Set) ?? + ((globalThis as Record)[SSE_SUBSCRIBED_KEY] = new Set()) + /** Subscribes to `tools_changed` SSE events and invalidates the affected query keys. */ export function useMcpToolsEvents(workspaceId: string) { const queryClient = useQueryClient() @@ -563,7 +580,23 @@ export function useMcpToolsEvents(workspaceId: string) { invalidate(serverId) }) + // EventSource fires `onopen` on the initial connect and on every auto-reconnect. Re-sync + // the workspace whenever we could have missed a `tools_changed` event: on any reconnect, + // on the first open of a RE-subscription (leaving the tab tears the connection down), and + // on a first open that only succeeded after an earlier connection error (the initial tools + // query may have failed during that gap and won't retry itself). Skip only a clean first + // subscription — the queries fetch fresh on their own initial mount. + const isResubscribe = sseEverSubscribed.has(workspaceId) + sseEverSubscribed.add(workspaceId) + let opened = false + let erroredBeforeOpen = false + source.onopen = () => { + if (opened || isResubscribe || erroredBeforeOpen) invalidate() + opened = true + } + source.onerror = () => { + if (!opened) erroredBeforeOpen = true logger.warn(`SSE connection error for workspace ${workspaceId}`) } diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index e04b6f04cc3..1c4c40e3ac1 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -139,14 +139,17 @@ function isLocalhostHostname(hostname: string): boolean { * URLs with env var references in the hostname are skipped — they will be * validated after resolution at execution time. * - * Returns the IP address to pin subsequent connections to (the resolved IP for - * hostnames, or the literal itself for public IP-literal URLs) so the caller can - * prevent DNS-rebinding TOCTOU attacks and stop redirects from escaping to - * internal hosts. Pinning matters for IP literals too: without it the transport - * uses the default fetch, which follows an attacker-controlled 3xx redirect to a - * private/metadata address. Returns null only when pinning is unnecessary or - * impossible: no URL, allowlist-only mode, env-var hostnames (validated later), - * and localhost on self-hosted (no rebinding risk against a fixed loopback). + * Returns the resolved IP (or the literal itself for IP-literal URLs) as a + * non-null **policy signal**: the SSRF guard is active for this server. A public + * resolution selects the validate-at-connect guarded fetch — DNS-rebinding TOCTOU + * and redirect escapes are prevented by re-validating every socket connect and + * following redirects under per-hop validation (see `createSsrfGuardedMcpFetch` / + * `followRedirectsGuarded`), NOT by pinning to this address. The value is literally + * pinned only for the self-hosted private/loopback carve-out (a policy-permitted + * DNS alias the guarded lookup would otherwise filter). Returns null when the guard + * is unnecessary or impossible: no URL, allowlist-only mode, env-var hostnames + * (validated later), and localhost on self-hosted (no rebinding risk against a + * fixed loopback). * * @throws McpSsrfError if the URL resolves to a blocked IP address */ From a0a437dc99434dbc587285c14c52302112d55819 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 10:26:10 -0700 Subject: [PATCH 02/24] fix(deps): bump sharp to 0.35.3 and js-yaml to 4.3.0 for security advisories (#5848) * fix(deps): bump sharp to 0.35.3 and js-yaml to 4.3.0 for security advisories * chore: fix import order in file-reader --- apps/sim/lib/copilot/vfs/file-reader.ts | 3 +- apps/sim/package.json | 4 +- bun.lock | 103 +++++++++++++----------- 3 files changed, 61 insertions(+), 49 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index b8b4ba4768d..30ad2848c0d 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -1,6 +1,7 @@ import { type Span, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import type { SharpConstructor } from 'sharp' import { CopilotVfsOutcome, CopilotVfsReadOutcome, @@ -106,7 +107,7 @@ async function prepareImageForVision( const mediaType = detectImageMime(buffer, claimedType) span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, mediaType) - let sharpModule: typeof import('sharp') + let sharpModule: SharpConstructor try { sharpModule = (await import('sharp')).default } catch (err) { diff --git a/apps/sim/package.json b/apps/sim/package.json index 1a41e053ea3..71134235527 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -164,7 +164,7 @@ "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "json5": "2.2.3", "jszip": "3.10.1", "lru-cache": "11.3.6", @@ -204,7 +204,7 @@ "resend": "^4.1.2", "rss-parser": "3.13.0", "safe-regex2": "5.1.0", - "sharp": "0.34.3", + "sharp": "0.35.3", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", diff --git a/bun.lock b/bun.lock index 39e51d99b97..33a2bf8d972 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -231,7 +232,7 @@ "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "json5": "2.2.3", "jszip": "3.10.1", "lru-cache": "11.3.6", @@ -271,7 +272,7 @@ "resend": "^4.1.2", "rss-parser": "3.13.0", "safe-regex2": "5.1.0", - "sharp": "0.34.3", + "sharp": "0.35.3", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", @@ -1066,53 +1067,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -2290,14 +2295,10 @@ "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], - "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -2896,8 +2897,6 @@ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], @@ -2962,7 +2961,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -3088,7 +3087,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -3756,7 +3755,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="], + "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -3782,8 +3781,6 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], - "simstudio": ["simstudio@workspace:packages/cli"], "simstudio-ts-sdk": ["simstudio-ts-sdk@workspace:packages/ts-sdk"], @@ -4166,6 +4163,8 @@ "@a2a-js/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], @@ -4232,6 +4231,10 @@ "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4464,10 +4467,6 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], - "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - - "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4658,18 +4657,28 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-mdx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-mdx/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4706,6 +4715,8 @@ "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "json-schema-to-typescript/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "jsonwebtoken/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -4822,9 +4833,7 @@ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - - "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], @@ -5202,6 +5211,8 @@ "next/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "next/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], "next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], @@ -5216,6 +5227,8 @@ "next/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "next/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], "next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], @@ -5224,8 +5237,6 @@ "next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - "next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], "next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], From e841e4e731a3a53797ef0a1dc5470c56036a7c98 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 10:48:10 -0700 Subject: [PATCH 03/24] fix(mcp): cap transport response bodies to bound a hostile tools/call payload (#5850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): cap transport response bodies to bound a hostile tools/call payload A full-lifecycle comparison against LibreChat found the one place a reference client was stricter: the live transport had no response-body byte cap, so a hostile server could stream an unbounded tools/call result and OOM the process (discovery and OAuth bodies were already capped). Cap non-GET response bodies at 16 MiB — counted as they stream, not buffered — while leaving the standalone GET SSE notification stream uncapped (a cumulative cap would break its long-lived nature). Mirrors LibreChat's transport response-size cap. * fix(mcp): preserve url and redirected when capping a response body new Response() resets url/redirected; the SDK resolves relative auth-metadata URLs (resource_metadata) against response.url, so carry the originals over via defineProperty on the wrapped response. * fix(mcp): drop stale framing headers on a capped response body The wrapped body is the already-decoded stream, so content-encoding/content-length would misdescribe it — strip them, matching bufferUnderDeadline. --- apps/sim/lib/mcp/pinned-fetch.test.ts | 41 +++++++++++++++-- apps/sim/lib/mcp/pinned-fetch.ts | 64 ++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/mcp/pinned-fetch.test.ts b/apps/sim/lib/mcp/pinned-fetch.test.ts index 608499f17b1..32f15a96351 100644 --- a/apps/sim/lib/mcp/pinned-fetch.test.ts +++ b/apps/sim/lib/mcp/pinned-fetch.test.ts @@ -43,17 +43,50 @@ describe('createGuardedMcpFetch', () => { }) }) - it('builds the transport on the guarded connector with no response cap (streaming)', () => { + it('builds the transport on the guarded connector with no dispatcher-level response cap', () => { const { close } = createGuardedMcpFetch() - // No options: no `allowH2` opt-in (h1.1 default) and no maxResponseSize — - // the long-lived transport must stream unbounded SSE. + // No dispatcher options: no `allowH2` opt-in (h1.1 default) and no Agent-level + // maxResponseSize — the standalone GET SSE stream must stream unbounded (the body cap + // is applied per-response to non-GET exchanges instead). expect(mockCreateGuardedFetchWithDispatcher).toHaveBeenCalledWith() - // close() tears down the pooled sockets (incl. the long-lived SSE) on disconnect. void close() expect(mockDestroy).toHaveBeenCalledTimes(1) }) + + it('caps an oversized non-GET response body but leaves the GET SSE stream unbounded', async () => { + const big = new Uint8Array(20 * 1024 * 1024) // 20 MiB > 16 MiB cap + const makeBody = () => + new ReadableStream({ + start(c) { + c.enqueue(big) + c.close() + }, + }) + sentinelFetch.mockImplementation(async () => new Response(makeBody())) + const { fetch: guarded } = createGuardedMcpFetch() + + // A POST (tools/call) body over the cap errors when read. + const post = await guarded('https://mcp.example/mcp', { method: 'POST' }) + await expect(new Response(post.body).arrayBuffer()).rejects.toThrow(/exceeded \d+ bytes/) + + // The standalone GET SSE stream is not capped — its body streams through. + const get = await guarded('https://mcp.example/mcp', { method: 'GET' }) + await expect(new Response(get.body).arrayBuffer()).resolves.toBeInstanceOf(ArrayBuffer) + }) + + it('preserves url and redirected on a capped response (SDK auth-metadata resolution)', async () => { + const small = new Response('{"ok":true}', { status: 200 }) + Object.defineProperty(small, 'url', { value: 'https://mcp.example/mcp' }) + Object.defineProperty(small, 'redirected', { value: true }) + sentinelFetch.mockImplementation(async () => small) + const { fetch: guarded } = createGuardedMcpFetch() + + const res = await guarded('https://mcp.example/mcp', { method: 'POST' }) + expect(res.url).toBe('https://mcp.example/mcp') + expect(res.redirected).toBe(true) + }) }) describe('createSsrfGuardedMcpFetch', () => { diff --git a/apps/sim/lib/mcp/pinned-fetch.ts b/apps/sim/lib/mcp/pinned-fetch.ts index 3a83b783cc1..e739b2055f6 100644 --- a/apps/sim/lib/mcp/pinned-fetch.ts +++ b/apps/sim/lib/mcp/pinned-fetch.ts @@ -36,6 +36,57 @@ export interface GuardedMcpFetch { * the transport has no concurrency to gain from h2. `close` tears down pooled sockets * (incl. the SSE connection) on disconnect. */ +/** + * Byte ceiling for a single request/response exchange on the transport (JSON-RPC + * results, `initialize`). A hostile server could otherwise stream an unbounded + * `tools/call` body and OOM the process. Applied ONLY to non-GET responses — the + * standalone GET SSE notification stream is deliberately long-lived and would be + * broken by a cumulative cap. Mirrors LibreChat's transport response-size cap. + */ +const MAX_TRANSPORT_RESPONSE_BYTES = 16 * 1024 * 1024 + +/** True for the standalone server→client SSE stream (GET), which must stay uncapped. */ +function isStandaloneStream(method: string): boolean { + return method.toUpperCase() === 'GET' +} + +/** + * Wraps a response so its body errors once it exceeds `maxBytes`, without buffering — + * bytes are counted as they stream, so an oversized body aborts the SDK's read instead + * of accumulating in memory. Passthrough for normal-sized responses. + */ +function capResponseBody(response: Response, maxBytes: number): Response { + if (!response.body) return response + let seen = 0 + const limited = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + seen += chunk.byteLength + if (seen > maxBytes) { + controller.error(new McpError(`MCP response body exceeded ${maxBytes} bytes`)) + return + } + controller.enqueue(chunk) + }, + }) + ) + const headers = new Headers(response.headers) + // The wrapped body is the already-decoded stream; drop framing headers that would + // misdescribe it (consistent with `bufferUnderDeadline`). + headers.delete('content-encoding') + headers.delete('content-length') + const wrapped = new Response(limited, { + status: response.status, + statusText: response.statusText, + headers, + }) + // `new Response()` resets `url`/`redirected` (empty/false); the SDK resolves relative + // auth-metadata URLs (e.g. `resource_metadata`) against `response.url`, so carry them over. + Object.defineProperty(wrapped, 'url', { value: response.url, configurable: true }) + Object.defineProperty(wrapped, 'redirected', { value: response.redirected, configurable: true }) + return wrapped +} + /** * Legacy single-IP pin, kept ONLY for self-hosted private/loopback resolutions * (a DNS alias the policy explicitly permits): the guarded lookup would filter @@ -45,7 +96,14 @@ export interface GuardedMcpFetch { */ export function createPinnedPrivateMcpFetch(resolvedIP: string): GuardedMcpFetch { const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP) - return { fetch: pinnedFetch, close: () => dispatcher.destroy() } + const capped: typeof fetch = async (input, init) => { + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + const response = await pinnedFetch(input, init) + return isStandaloneStream(method) + ? response + : capResponseBody(response, MAX_TRANSPORT_RESPONSE_BYTES) + } + return { fetch: capped, close: () => dispatcher.destroy() } } export function createGuardedMcpFetch(): GuardedMcpFetch { @@ -75,7 +133,9 @@ export function createGuardedMcpFetch(): GuardedMcpFetch { status: response.status, ttfbMs: Date.now() - startedAt, }) - return response + return isStandaloneStream(method) + ? response + : capResponseBody(response, MAX_TRANSPORT_RESPONSE_BYTES) } catch (error) { const e = error as { name?: string; code?: string; cause?: { name?: string; code?: string } } transportLogger.warn('MCP transport request failed', { From 70814bc4a248fab3e81e3cf68fe14c66dc48553e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:35:42 -0700 Subject: [PATCH 04/24] feat(db): role-keyed dbFor clients for cleanup and exec workloads (#5583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(db): role-keyed dbFor clients for cleanup and exec workloads Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy * fix(db): keep cleanup-invoked helpers and snapshot reads on their role pools Route markLargeValuesDeleted / pruneLargeValueMetadata (optional dbClient) and chat-cleanup's file collection through the cleanup pool, and getSnapshot through the exec pool, so the cleanup and inline-execution workloads stop borrowing the process-wide pool for these queries. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy * fix(db): dbFor falls back to the process-role URL, not the base URL With DATABASE_URL_WEB/TRIGGER set (as in prod) and the sub-pool URLs unset, falling back to the base URL would silently shift execution-log and cleanup traffic to a different PgBouncer endpoint on deploy. Chain the fallback through the URL the process itself resolved so the rollout stays inert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy * feat(db): log which connection each dbFor sub-pool resolved to One line per role at first use: the dedicated DATABASE_URL_ when set, otherwise an explicit fallback message naming the process connection it shares — so a missing/typo'd env var is visible at rollout instead of silent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy * feat(db): route pause/resume and large-value metadata persistence to the exec pool Coverage scan follow-up: the paused_executions / resume_queue / workflow_execution_logs transactions in human-in-the-loop-manager.ts and the large-value owner/reference registration writes on the execution path now use dbFor('exec'), matching the completion writes in the execution logger. All are self-contained; billing calls remain outside the moved transactions on the default client. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy --------- Co-authored-by: Claude Fable 5 --- apps/sim/background/cleanup-logs.test.ts | 15 ++-- apps/sim/background/cleanup-logs.ts | 21 ++++-- .../background/cleanup-soft-deletes.test.ts | 13 +++- apps/sim/background/cleanup-soft-deletes.ts | 37 ++++++---- apps/sim/background/cleanup-tasks.ts | 17 +++-- apps/sim/lib/cleanup/batch-delete.ts | 21 +++++- apps/sim/lib/cleanup/chat-cleanup.ts | 11 ++- apps/sim/lib/core/config/env.ts | 2 + .../payloads/large-value-metadata.test.ts | 13 +++- .../payloads/large-value-metadata.ts | 50 ++++++++----- apps/sim/lib/logs/execution/logger.test.ts | 17 +++-- apps/sim/lib/logs/execution/logger.ts | 19 +++-- .../logs/execution/logging-session.test.ts | 13 +++- .../sim/lib/logs/execution/logging-session.ts | 17 +++-- .../lib/logs/execution/snapshot/service.ts | 16 ++-- .../executor/human-in-the-loop-manager.ts | 51 +++++++------ packages/db/db.ts | 73 ++++++++++++++++++- packages/testing/src/mocks/database.mock.ts | 4 + 18 files changed, 291 insertions(+), 119 deletions(-) diff --git a/apps/sim/background/cleanup-logs.test.ts b/apps/sim/background/cleanup-logs.test.ts index 6999171e630..9df5ec1ab77 100644 --- a/apps/sim/background/cleanup-logs.test.ts +++ b/apps/sim/background/cleanup-logs.test.ts @@ -80,12 +80,17 @@ const { } }) -vi.mock('@sim/db', () => ({ - db: { +vi.mock('@sim/db', () => { + const db = { execute: mockExecute, select: mockSelect, - }, -})) + } + return { + db, + // Cleanup-pool client shares the instance so the seeded chains still apply. + dbFor: () => db, + } +}) vi.mock('@sim/db/schema', () => ({ executionLargeValueDependencies: { @@ -255,7 +260,7 @@ describe('cleanup logs worker', () => { workspaceIds: ['workspace-1'], }) - expect(mockMarkLargeValuesDeleted).toHaveBeenCalledWith([largeValueKey]) + expect(mockMarkLargeValuesDeleted).toHaveBeenCalledWith([largeValueKey], expect.anything()) expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2) }) diff --git a/apps/sim/background/cleanup-logs.ts b/apps/sim/background/cleanup-logs.ts index bd451aa1e09..343e388ed2b 100644 --- a/apps/sim/background/cleanup-logs.ts +++ b/apps/sim/background/cleanup-logs.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { executionLargeValueDependencies, executionLargeValueReferences, @@ -30,6 +30,9 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata' const logger = createLogger('CleanupLogs') +/** All cleanup queries run on the dedicated cleanup pool. */ +const cleanupDb = dbFor('cleanup') + interface FileDeleteStats { filesTotal: number filesDeleted: number @@ -107,7 +110,7 @@ async function deleteLargeValueKeys(keys: string[]): Promise<{ deleted: number; if (deletedKeys.length > 0) { try { - await markLargeValuesDeleted(deletedKeys) + await markLargeValuesDeleted(deletedKeys, cleanupDb) } catch (error) { logger.error('Failed to mark large execution values as deleted:', { error }) return { deleted: 0, failed: result.failed.length + deletedKeys.length } @@ -153,7 +156,7 @@ async function cleanupLargeExecutionValues( LARGE_VALUE_CLEANUP_BATCH_SIZE, LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: executionLargeValues.key }) .from(executionLargeValues) .where( @@ -219,7 +222,7 @@ async function cleanupLegacyLargeExecutionValues( LARGE_VALUE_CLEANUP_BATCH_SIZE, LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: workspaceFiles.key }) .from(workspaceFiles) .where( @@ -353,7 +356,11 @@ async function cleanupLargeValueMetadata(workspaceIds: string[], label: string): const tombstonesDeletedBefore = new Date( Date.now() - LARGE_VALUE_TOMBSTONE_RETENTION_HOURS * 60 * 60 * 1000 ) - const result = await pruneLargeValueMetadata({ workspaceIds, tombstonesDeletedBefore }) + const result = await pruneLargeValueMetadata({ + workspaceIds, + tombstonesDeletedBefore, + dbClient: cleanupDb, + }) logger.info( `[${label}/execution_large_value_metadata] Pruned ${result.referencesDeleted} stale references, ${result.dependenciesDeleted} dependencies, ${result.tombstonesDeleted} tombstones` ) @@ -377,8 +384,9 @@ async function cleanupWorkflowExecutionLogs( tableDef: workflowExecutionLogs, workspaceIds, tableName: `${label}/workflow_execution_logs`, + dbClient: cleanupDb, selectChunk: (chunkIds, limit) => - db + cleanupDb .select({ id: workflowExecutionLogs.id, files: workflowExecutionLogs.files, @@ -465,6 +473,7 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/job_execution_logs`, + dbClient: cleanupDb, }) if (runGlobalHousekeeping && plan === 'free') { diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index c104d9b7351..7c0e29603bf 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -66,13 +66,18 @@ const { } }) -vi.mock('@sim/db', () => ({ - db: { +vi.mock('@sim/db', () => { + const db = { delete: mockDelete, select: mockSelect, transaction: mockTransaction, - }, -})) + } + return { + db, + // Cleanup-pool client shares the instance so the seeded chains still apply. + dbFor: () => db, + } +}) vi.mock('@sim/db/schema', () => { const table = (cols: string[]) => diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 75e9c42ae20..7db8b2cc8f0 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbFor } from '@sim/db' import { copilotChats, document, @@ -36,6 +36,13 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata' const logger = createLogger('CleanupSoftDeletes') +/** + * Cleanup queries run on the dedicated cleanup pool. The one exception is the + * billable-file transaction below, which couples row deletion with a storage + * billing decrement — billing writes stay on the default client. + */ +const cleanupDb = dbFor('cleanup') + const KB_ORPHAN_BINDING_BATCH_SIZE = 500 const KB_ORPHAN_BINDING_TOTAL_LIMIT = 5_000 /** @@ -80,7 +87,7 @@ async function selectExpiredWorkspaceFiles( ): Promise { const [legacyRows, multiContextRows] = await Promise.all([ selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workspaceFile.id, key: workspaceFile.key, @@ -97,7 +104,7 @@ async function selectExpiredWorkspaceFiles( .limit(chunkLimit) ), selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workspaceFiles.id, key: workspaceFiles.key, @@ -199,7 +206,7 @@ async function deleteExpiredLegacyWorkspaceFileRows( const result = { deleted: 0, failed: 0 } for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workspaceFile) .where( and( @@ -239,7 +246,7 @@ async function deleteExpiredUnbilledWorkspaceFileRows( for (const [context, contextRows] of rowsByContext) { for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workspaceFiles) .where( and( @@ -342,7 +349,7 @@ async function hardDeleteKnowledgeBaseDocuments( label: string ): Promise { for (let batch = 0; batch < KB_DOCUMENT_DELETE_MAX_BATCHES; batch++) { - const documentRows = await db + const documentRows = await cleanupDb .select({ id: document.id }) .from(document) .where(inArray(document.knowledgeBaseId, knowledgeBaseIds)) @@ -357,7 +364,7 @@ async function hardDeleteKnowledgeBaseDocuments( } } - const remaining = await db + const remaining = await cleanupDb .select({ id: document.id }) .from(document) .where(inArray(document.knowledgeBaseId, knowledgeBaseIds)) @@ -377,8 +384,9 @@ async function cleanupExpiredKnowledgeBases( workspaceIds, tableName: `${label}/knowledgeBase`, batchSize: KB_RETENTION_BATCH_SIZE, + dbClient: cleanupDb, selectChunk: (chunkIds, limit) => - db + cleanupDb .select({ id: knowledgeBase.id }) .from(knowledgeBase) .where( @@ -457,7 +465,7 @@ async function cleanupOrphanedKnowledgeBaseBindings( KB_ORPHAN_BINDING_BATCH_SIZE, KB_ORPHAN_BINDING_TOTAL_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: workspaceFiles.key }) .from(workspaceFiles) .where( @@ -535,7 +543,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // prematurely purge data. const [doomedWorkflows, fileScope, expiredSoftDeletedChats] = await Promise.all([ selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workflow.id }) .from(workflow) .where( @@ -549,7 +557,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise ), selectExpiredWorkspaceFiles(workspaceIds, retentionDate), selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where( @@ -570,7 +578,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise const doomedChatIds = new Set(softDeletedChatIds) if (doomedWorkflowIds.length > 0) { const workflowChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where(inArray(copilotChats.workflowId, chunkIds)) @@ -595,7 +603,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // spares their backend data and files. for (const batch of chunkArray(doomedWorkflowIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workflow) .where( and( @@ -618,7 +626,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // also re-checks row existence before purging external data). for (const batch of chunkArray(softDeletedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(copilotChats) .where( and( @@ -667,6 +675,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise retentionDate, tableName: `${label}/${target.name}`, requireTimestampNotNull: true, + dbClient: cleanupDb, }) totalDeleted += result.deleted } diff --git a/apps/sim/background/cleanup-tasks.ts b/apps/sim/background/cleanup-tasks.ts index e987ac41078..1745e3bba14 100644 --- a/apps/sim/background/cleanup-tasks.ts +++ b/apps/sim/background/cleanup-tasks.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { copilotAsyncToolCalls, copilotChats, @@ -22,6 +22,9 @@ import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup' const logger = createLogger('CleanupTasks') +/** All cleanup queries run on the dedicated cleanup pool. */ +const cleanupDb = dbFor('cleanup') + /** * Delete copilot run checkpoints and async tool calls via join through copilotRuns. * These tables don't have a direct workspaceId — we find qualifying run IDs first. @@ -47,7 +50,7 @@ async function cleanupRunChildren( if (workspaceIds.length === 0) return [] const runIds = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotRuns.id }) .from(copilotRuns) .where( @@ -63,7 +66,9 @@ async function cleanupRunChildren( const ids = runIds.map((r) => r.id) return Promise.all( - RUN_CHILD_TABLES.map((t) => deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`)) + RUN_CHILD_TABLES.map((t) => + deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`, cleanupDb) + ) ) } @@ -82,7 +87,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise ) const doomedChats = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where( @@ -110,6 +115,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/copilotRuns`, + dbClient: cleanupDb, }) // Delete copilot chats using the exact IDs collected above so the chat @@ -122,7 +128,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise const chatsResult = { deleted: 0, failed: 0 } for (const batch of chunkArray(doomedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(copilotChats) .where(and(inArray(copilotChats.id, batch), lt(copilotChats.updatedAt, retentionDate))) .returning({ id: copilotChats.id }) @@ -141,6 +147,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/mothershipInboxTask`, + dbClient: cleanupDb, }) const totalDeleted = diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index d523800cd59..7b0ede50fd9 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -5,6 +5,13 @@ import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' const logger = createLogger('BatchDelete') +/** + * Structural client surface the delete helpers need. Satisfied by the global + * `db`, a `dbFor(...)` sub-pool client, and a transaction handle, so callers + * pick which pool the deletes run on (cleanup jobs pass `dbFor('cleanup')`). + */ +export type BatchDeleteClient = Pick + export const DEFAULT_BATCH_SIZE = 2000 /** 50 × 2000 = 100K row cap per cleanup run; drains long-tail tenants in days, not weeks. */ export const DEFAULT_MAX_BATCHES_PER_TABLE = 50 @@ -84,6 +91,8 @@ export interface ChunkedBatchDeleteOptions { */ totalRowLimit?: number workspaceChunkSize?: number + /** Client the DELETEs run on. Defaults to the global pool. */ + dbClient?: BatchDeleteClient } /** @@ -107,6 +116,7 @@ export async function chunkedBatchDelete({ maxBatches = DEFAULT_MAX_BATCHES_PER_TABLE, totalRowLimit = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, workspaceChunkSize = DEFAULT_WORKSPACE_CHUNK_SIZE, + dbClient = db, }: ChunkedBatchDeleteOptions): Promise { const result: TableCleanupResult = { table: tableName, deleted: 0, failed: 0 } @@ -149,7 +159,7 @@ export async function chunkedBatchDelete({ if (onBatch) await onBatch(rows) const ids = rows.map((r) => r.id) - const deleted = await db + const deleted = await dbClient .delete(tableDef) .where(inArray(sql`id`, ids)) .returning({ id: sql`id` }) @@ -189,6 +199,8 @@ export interface BatchDeleteOptions { batchSize?: number maxBatches?: number workspaceChunkSize?: number + /** Client the SELECTs and DELETEs run on. Defaults to the global pool. */ + dbClient?: BatchDeleteClient } /** @@ -204,16 +216,18 @@ export async function batchDeleteByWorkspaceAndTimestamp({ retentionDate, tableName, requireTimestampNotNull = false, + dbClient = db, ...rest }: BatchDeleteOptions): Promise { return chunkedBatchDelete({ tableDef, workspaceIds, tableName, + dbClient, selectChunk: (chunkIds, limit) => { const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)] if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol)) - return db + return dbClient .select({ id: sql`id` }) .from(tableDef) .where(and(...predicates)) @@ -232,6 +246,7 @@ export async function deleteRowsById( idCol: PgColumn, ids: string[], tableName: string, + dbClient: BatchDeleteClient = db, chunkSize: number = DEFAULT_DELETE_CHUNK_SIZE ): Promise { const result: TableCleanupResult = { table: tableName, deleted: 0, failed: 0 } @@ -240,7 +255,7 @@ export async function deleteRowsById( const chunks = chunkArray(ids, chunkSize) for (const [chunkIdx, chunkIds] of chunks.entries()) { try { - const deleted = await db + const deleted = await dbClient .delete(tableDef) .where(inArray(idCol, chunkIds)) .returning({ id: idCol }) diff --git a/apps/sim/lib/cleanup/chat-cleanup.ts b/apps/sim/lib/cleanup/chat-cleanup.ts index 55f6a96832c..ba84f1f0279 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { copilotChats, copilotMessages, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, inArray, isNull } from 'drizzle-orm' @@ -10,6 +10,9 @@ import { isUsingCloudStorage, StorageService } from '@/lib/uploads' const logger = createLogger('ChatCleanup') +/** Chat cleanup only ever runs from cleanup jobs, so its reads use the cleanup pool. */ +const cleanupDb = dbFor('cleanup') + const COPILOT_CLEANUP_BATCH_SIZE = 1000 /** Bounds how many chats' `copilot_messages` rows are scanned per query. */ const CHAT_FILE_COLLECT_CHUNK_SIZE = 500 @@ -42,7 +45,7 @@ export async function collectChatFiles(chatIds: string[]): Promise { for (const chunk of chunkArray(chatIds, CHAT_FILE_COLLECT_CHUNK_SIZE)) { const [linkedFiles, messageRows] = await Promise.all([ - db + cleanupDb .select({ key: workspaceFiles.key, context: workspaceFiles.context, @@ -58,7 +61,7 @@ export async function collectChatFiles(chatIds: string[]): Promise { ), // Scan every message row for the chat (no deleted_at filter): this is a // deletion path collecting blob keys, so attachments on any row count. - db + cleanupDb .select({ content: copilotMessages.content, chatId: copilotMessages.chatId }) .from(copilotMessages) .where(inArray(copilotMessages.chatId, chunk)), @@ -205,7 +208,7 @@ export async function prepareChatCleanup( // whose rows are actually gone, so a surviving row never loses its data. const survivors = new Set() for (const chunk of chunkArray(chatIds, CHAT_FILE_COLLECT_CHUNK_SIZE)) { - const rows = await db + const rows = await cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where(inArray(copilotChats.id, chunk)) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 27aa7019f63..d7d51b7b518 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -25,6 +25,8 @@ export const env = createEnv({ DATABASE_URL_WEB: z.string().url().optional(), // Per-role primary URL override; @sim/db falls back to DATABASE_URL DATABASE_URL_TRIGGER: z.string().url().optional(), // Per-role primary URL override (trigger) DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime) + DATABASE_URL_CLEANUP: z.string().url().optional(), // Sub-process pool URL override (cleanup jobs, via dbFor) + DATABASE_URL_EXEC: z.string().url().optional(), // Sub-process pool URL override (inline execution writes, via dbFor) DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger) DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime) diff --git a/apps/sim/lib/execution/payloads/large-value-metadata.test.ts b/apps/sim/lib/execution/payloads/large-value-metadata.test.ts index 6df41ce599b..b7b0b337f15 100644 --- a/apps/sim/lib/execution/payloads/large-value-metadata.test.ts +++ b/apps/sim/lib/execution/payloads/large-value-metadata.test.ts @@ -83,15 +83,20 @@ const { } }) -vi.mock('@sim/db', () => ({ - db: { +vi.mock('@sim/db', () => { + const db = { delete: mockDelete, execute: mockExecute, insert: mockInsert, select: mockSelect, transaction: mockTransaction, - }, -})) + } + return { + db, + // Exec-pool client shares the instance so the seeded chains still apply. + dbFor: () => db, + } +}) vi.mock('@sim/db/schema', () => ({ executionLargeValueDependencies: { diff --git a/apps/sim/lib/execution/payloads/large-value-metadata.ts b/apps/sim/lib/execution/payloads/large-value-metadata.ts index fd95938c40f..41d56fbf2e2 100644 --- a/apps/sim/lib/execution/payloads/large-value-metadata.ts +++ b/apps/sim/lib/execution/payloads/large-value-metadata.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbFor } from '@sim/db' import { executionLargeValueDependencies, executionLargeValueReferences, @@ -54,6 +54,8 @@ interface PruneLargeValueMetadataOptions { tombstonesDeletedBefore: Date batchSize?: number maxRowsPerTable?: number + /** Client the prune DELETEs run on. Defaults to the global pool; cleanup jobs pass `dbFor('cleanup')`. */ + dbClient?: LargeValueMetadataClient } function parseLargeValueStorageKey(key: string): LargeValueStorageKeyParts | null { @@ -186,7 +188,7 @@ export async function registerLargeValueOwner( return false } - await db.transaction(async (tx) => { + await dbFor('exec').transaction(async (tx) => { await tx .insert(executionLargeValues) .values({ @@ -309,7 +311,8 @@ export async function addLargeValueReference( return } - const [existingRef] = await db + const execDb = dbFor('exec') + const [existingRef] = await execDb .select({ key: executionLargeValueReferences.key }) .from(executionLargeValueReferences) .where( @@ -326,7 +329,7 @@ export async function addLargeValueReference( return } - const existingRefs = await db + const existingRefs = await execDb .select({ key: executionLargeValueReferences.key }) .from(executionLargeValueReferences) .where( @@ -344,7 +347,7 @@ export async function addLargeValueReference( ) } - await db + await execDb .insert(executionLargeValueReferences) .values({ key: boundedKey, @@ -363,24 +366,31 @@ export async function replaceLargeValueReferences( const referenceKeys = scope.workspaceId ? collectLargeValueReferenceKeys(value, scope.workspaceId) : [] - await db.transaction(async (tx) => { + await dbFor('exec').transaction(async (tx) => { await replaceLargeValueReferenceKeysWithClient(tx, scope, referenceKeys) }) } -export async function markLargeValuesDeleted(keys: string[]): Promise { +export async function markLargeValuesDeleted( + keys: string[], + dbClient: LargeValueMetadataClient = db +): Promise { if (keys.length === 0) { return } - await db + await dbClient .update(executionLargeValues) .set({ deletedAt: new Date() }) .where(inArray(executionLargeValues.key, keys)) } -async function pruneStaleReferences(workspaceIds: string[], batchSize: number): Promise { - const rows = await db.execute<{ count: number }>(sql` +async function pruneStaleReferences( + workspaceIds: string[], + batchSize: number, + dbClient: LargeValueMetadataClient +): Promise { + const rows = await dbClient.execute<{ count: number }>(sql` WITH deleted AS ( DELETE FROM ${executionLargeValueReferences} AS ref WHERE ref.ctid IN ( @@ -418,9 +428,10 @@ async function pruneStaleReferences(workspaceIds: string[], batchSize: number): async function pruneDeletedParentDependencies( workspaceIds: string[], - batchSize: number + batchSize: number, + dbClient: LargeValueMetadataClient ): Promise { - const rows = await db.execute<{ count: number }>(sql` + const rows = await dbClient.execute<{ count: number }>(sql` WITH deleted AS ( DELETE FROM ${executionLargeValueDependencies} AS dependency WHERE dependency.ctid IN ( @@ -452,9 +463,10 @@ async function pruneDeletedParentDependencies( async function pruneDeletedLargeValueTombstones( workspaceIds: string[], deletedBefore: Date, - batchSize: number + batchSize: number, + dbClient: LargeValueMetadataClient ): Promise { - const rows = await db.execute<{ count: number }>(sql` + const rows = await dbClient.execute<{ count: number }>(sql` WITH deleted AS ( DELETE FROM ${executionLargeValues} AS value WHERE value.ctid IN ( @@ -482,6 +494,7 @@ export async function pruneLargeValueMetadata({ tombstonesDeletedBefore, batchSize = LARGE_VALUE_METADATA_PRUNE_BATCH_SIZE, maxRowsPerTable = LARGE_VALUE_METADATA_PRUNE_MAX_ROWS_PER_TABLE, + dbClient = db, }: PruneLargeValueMetadataOptions): Promise { const result: LargeValueMetadataPruneResult = { referencesDeleted: 0, @@ -498,7 +511,8 @@ export async function pruneLargeValueMetadata({ if (referencesRemaining > 0) { result.referencesDeleted += await pruneStaleReferences( workspaceChunk, - Math.min(batchSize, referencesRemaining) + Math.min(batchSize, referencesRemaining), + dbClient ) } @@ -506,7 +520,8 @@ export async function pruneLargeValueMetadata({ if (dependenciesRemaining > 0) { result.dependenciesDeleted += await pruneDeletedParentDependencies( workspaceChunk, - Math.min(batchSize, dependenciesRemaining) + Math.min(batchSize, dependenciesRemaining), + dbClient ) } @@ -515,7 +530,8 @@ export async function pruneLargeValueMetadata({ result.tombstonesDeleted += await pruneDeletedLargeValueTombstones( workspaceChunk, tombstonesDeletedBefore, - Math.min(batchSize, tombstonesRemaining) + Math.min(batchSize, tombstonesRemaining), + dbClient ) } diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index e1c6e326af2..4b83f955541 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -21,14 +21,17 @@ vi.mock('@sim/db', () => { update: txUpdateMock, execute: dbExecuteMock, } + const db = { + select: dbSelectMock, + insert: vi.fn(), + update: vi.fn(), + execute: dbExecuteMock, + transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), + } return { - db: { - select: dbSelectMock, - insert: vi.fn(), - update: vi.fn(), - execute: dbExecuteMock, - transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), - }, + db, + // Exec-pool client shares the instance so call-order seeding still applies. + dbFor: () => db, } }) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index eb48ec1768f..0f6c642e457 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbFor } from '@sim/db' import { member, organization, @@ -69,6 +69,13 @@ import { emitExecutionCompletedEvent } from '@/lib/workspace-events/emitter' import type { SerializableExecutionState } from '@/executor/execution/types' const logger = createLogger('ExecutionLogger') + +/** + * Execution-log persistence (reads and writes on `workflow_execution_logs`, + * including the completion transaction) runs on the dedicated exec pool. + * Billing/usage-ledger work stays on the global `db`. + */ +const execDb = dbFor('exec') const MAX_EXECUTION_DATA_BYTES = 3 * 1024 * 1024 const MAX_TRACE_IO_BYTES = 8 * 1024 const MAX_WORKFLOW_VALUE_BYTES = 512 * 1024 @@ -546,7 +553,7 @@ export class ExecutionLogger implements IExecutionLoggerService { execLog.debug('Starting workflow execution') // Check if execution log already exists (idempotency check) - const existingLog = await db + const existingLog = await execDb .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) @@ -583,7 +590,7 @@ export class ExecutionLogger implements IExecutionLoggerService { const startTime = new Date() - const [workflowLog] = await db + const [workflowLog] = await execDb .insert(workflowExecutionLogs) .values({ id: generateId(), @@ -742,7 +749,7 @@ export class ExecutionLogger implements IExecutionLoggerService { let execLog = logger.withMetadata({ executionId }) execLog.debug('Completing workflow execution', { isResume }) - const [existingLog] = await db + const [existingLog] = await execDb .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) @@ -933,7 +940,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } const completedExecutionLargeValueKeys = collectLargeValueReferenceKeys(storedExecutionData) - const updatedLog = await db.transaction(async (tx) => { + const updatedLog = await execDb.transaction(async (tx) => { await setExecutionLogWriteTimeouts(tx) const [log] = await tx @@ -1163,7 +1170,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } async getWorkflowExecution(executionId: string): Promise { - const [workflowLog] = await db + const [workflowLog] = await execDb .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index 21947c152c2..d07fe618eb5 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -47,13 +47,18 @@ const { releaseExecutionSlotMock: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { +vi.mock('@sim/db', () => { + const db = { select: dbMocks.select, update: dbMocks.update, execute: dbMocks.execute, - }, -})) + } + return { + db, + // Exec-pool client shares the instance so the seeded chains still apply. + dbFor: () => db, + } +}) vi.mock('drizzle-orm', () => ({ eq: dbMocks.eq, diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index ed63effb669..d8ba08e5ad5 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, toError } from '@sim/utils/errors' @@ -79,6 +79,9 @@ function buildCompletedMarkerPersistenceQuery(params: { ) <= ${params.marker.endedAt}` } +/** Progress-marker and status writes on `workflow_execution_logs` use the exec pool. */ +const execDb = dbFor('exec') + const logger = createLogger('LoggingSession') type CompletionAttempt = 'complete' | 'error' | 'cancelled' | 'paused' @@ -193,7 +196,7 @@ export class LoggingSession { return } try { - await db.execute( + await execDb.execute( buildStartedMarkerPersistenceQuery({ executionId: this.executionId, workflowId: this.workflowId, @@ -219,7 +222,7 @@ export class LoggingSession { return } try { - await db.execute( + await execDb.execute( buildCompletedMarkerPersistenceQuery({ executionId: this.executionId, workflowId: this.workflowId, @@ -486,7 +489,7 @@ export class LoggingSession { this.completing = true try { - const currentLog = await db + const currentLog = await execDb .select({ status: workflowExecutionLogs.status }) .from(workflowExecutionLogs) .where( @@ -617,7 +620,7 @@ export class LoggingSession { const endTime = endedAt ? new Date(endedAt) : new Date() const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 - const currentLog = await db + const currentLog = await execDb .select({ status: workflowExecutionLogs.status }) .from(workflowExecutionLogs) .where( @@ -711,7 +714,7 @@ export class LoggingSession { const endTime = endedAt ? new Date(endedAt) : new Date() const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 - const currentLog = await db + const currentLog = await execDb .select({ status: workflowExecutionLogs.status }) .from(workflowExecutionLogs) .where( @@ -1090,7 +1093,7 @@ export class LoggingSession { ELSE ${executionData} END` } - await db + await execDb .update(workflowExecutionLogs) .set({ level: 'error', status: 'failed', executionData }) .where( diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index 21b14a4ea6b..c6cc2d22ddd 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' @@ -51,7 +51,7 @@ export class SnapshotService implements ISnapshotService { * out-of-line storage is reused, so the per-execution write drops from the * full blob to a tiny heap tuple. */ - const [upsertedSnapshot] = await db + const [upsertedSnapshot] = await dbFor('exec') .insert(workflowExecutionSnapshots) .values(snapshotData) .onConflictDoUpdate({ @@ -81,7 +81,7 @@ export class SnapshotService implements ISnapshotService { } async getSnapshot(id: string): Promise { - const [snapshot] = await db + const [snapshot] = await dbFor('exec') .select() .from(workflowExecutionSnapshots) .where(eq(workflowExecutionSnapshots.id, id)) @@ -102,7 +102,9 @@ export class SnapshotService implements ISnapshotService { return sha256Hex(stateString) } + /** Only invoked from the cleanup-logs background job, so it runs on the cleanup pool. */ async cleanupOrphanedSnapshots(olderThanDays: number): Promise { + const cleanupDb = dbFor('cleanup') const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - olderThanDays) @@ -113,14 +115,14 @@ export class SnapshotService implements ISnapshotService { let stoppedEarly = false for (let batch = 0; batch < MAX_BATCHES; batch++) { - const candidates = await db + const candidates = await cleanupDb .select({ id: workflowExecutionSnapshots.id }) .from(workflowExecutionSnapshots) .where( and( lt(workflowExecutionSnapshots.createdAt, cutoffDate), notExists( - db + cleanupDb .select({ one: sql`1` }) .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id)) @@ -132,13 +134,13 @@ export class SnapshotService implements ISnapshotService { if (candidates.length === 0) break const ids = candidates.map((c) => c.id) - const deleted = await db + const deleted = await cleanupDb .delete(workflowExecutionSnapshots) .where( and( inArray(workflowExecutionSnapshots.id, ids), notExists( - db + cleanupDb .select({ one: sql`1` }) .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id)) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 343bed6a853..3be39c26d7f 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' @@ -54,6 +54,13 @@ import { hasExecutionResult } from '@/executor/utils/errors' import { filterOutputForLog } from '@/executor/utils/output-filter' import type { SerializedConnection } from '@/serializer/types' +/** + * All paused-execution / resume-queue / execution-log persistence in this + * module runs on the exec pool, mirroring the completion writes in + * `lib/logs/execution/logger.ts`. + */ +const execDb = dbFor('exec') + const logger = createLogger('HumanInTheLoopManager') const RUN_BUFFER_UNAVAILABLE_ERROR = 'Run buffer temporarily unavailable' const TERMINAL_PUBLISH_ERROR = 'Run buffer terminal event publish failed' @@ -373,7 +380,7 @@ export class PauseResumeManager { ...resumeMetadata, } - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { const existing = await tx .select() .from(pausedExecutions) @@ -489,7 +496,7 @@ export class PauseResumeManager { static async enqueueOrStartResume(args: EnqueueResumeArgs): Promise { const { executionId, workflowId, contextId, resumeInput, userId, allowedPauseKinds } = args - return await db.transaction(async (tx) => { + return await execDb.transaction(async (tx) => { const pausedExecution = await tx .select() .from(pausedExecutions) @@ -815,7 +822,7 @@ export class PauseResumeManager { } = args const parentExecutionId = pausedExecution.executionId - await db + await execDb .update(workflowExecutionLogs) .set({ status: 'running' }) .where(eq(workflowExecutionLogs.executionId, parentExecutionId)) @@ -1653,7 +1660,7 @@ export class PauseResumeManager { const { resumeEntryId, pausedExecutionId, parentExecutionId, contextId } = args const now = new Date() - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { await tx .update(resumeQueue) .set({ status: 'completed', completedAt: now, failureReason: null }) @@ -1720,7 +1727,7 @@ export class PauseResumeManager { }): Promise { const now = new Date() - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { await tx .update(resumeQueue) .set({ status: 'failed', failureReason: args.failureReason, completedAt: now }) @@ -1752,7 +1759,7 @@ export class PauseResumeManager { }): Promise { const now = new Date() - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { const pausedExecution = args.preserveForRetry ? await tx .select({ @@ -1843,7 +1850,7 @@ export class PauseResumeManager { }): Promise { const { pausedExecutionId, contextId, pauseBlockId, executionState } = args - const pausedExecution = await db + const pausedExecution = await execDb .select() .from(pausedExecutions) .where(eq(pausedExecutions.id, pausedExecutionId)) @@ -1905,7 +1912,7 @@ export class PauseResumeManager { ? collectLargeValueReferenceKeys(snapshotReferenceValue, snapshotWorkspaceId) : [] - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { await tx .update(pausedExecutions) .set({ @@ -1937,7 +1944,7 @@ export class PauseResumeManager { static async beginPausedCancellation(executionId: string, workflowId: string): Promise { const now = new Date() - return await db.transaction(async (tx) => { + return await execDb.transaction(async (tx) => { const pausedExecution = await tx .select({ id: pausedExecutions.id, status: pausedExecutions.status }) .from(pausedExecutions) @@ -1992,7 +1999,7 @@ export class PauseResumeManager { ): Promise { const now = new Date() - return await db.transaction(async (tx) => { + return await execDb.transaction(async (tx) => { const pausedExecution = await tx .select({ id: pausedExecutions.id, status: pausedExecutions.status }) .from(pausedExecutions) @@ -2033,7 +2040,7 @@ export class PauseResumeManager { ): Promise { const now = new Date() - return await db.transaction(async (tx) => { + return await execDb.transaction(async (tx) => { const pausedExecution = await tx .select({ id: pausedExecutions.id }) .from(pausedExecutions) @@ -2077,7 +2084,7 @@ export class PauseResumeManager { workflowId: string ): Promise { const now = new Date() - await db + await execDb .update(pausedExecutions) .set({ status: sql`CASE WHEN resumed_count > 0 THEN 'partially_resumed' ELSE 'paused' END`, @@ -2097,7 +2104,7 @@ export class PauseResumeManager { executionId: string, workflowId: string ): Promise<'cancelling' | 'cancelled' | null> { - const activeResume = await db + const activeResume = await execDb .select({ id: resumeQueue.id }) .from(resumeQueue) .where(and(eq(resumeQueue.parentExecutionId, executionId), eq(resumeQueue.status, 'claimed'))) @@ -2108,7 +2115,7 @@ export class PauseResumeManager { return null } - const pausedExecution = await db + const pausedExecution = await execDb .select({ status: pausedExecutions.status }) .from(pausedExecutions) .where( @@ -2135,7 +2142,7 @@ export class PauseResumeManager { }): Promise { const now = new Date() - await db.transaction(async (tx) => { + await execDb.transaction(async (tx) => { const pausedExecution = await tx .select({ automaticResumeRetryCount: pausedExecutions.automaticResumeRetryCount, @@ -2228,7 +2235,7 @@ export class PauseResumeManager { pausedExecutionId: string nextResumeAt: Date | null }): Promise { - await db + await execDb .update(pausedExecutions) .set({ nextResumeAt: args.nextResumeAt }) .where( @@ -2260,7 +2267,7 @@ export class PauseResumeManager { } } - const rows = await db + const rows = await execDb .select() .from(pausedExecutions) .where(whereClause) @@ -2278,7 +2285,7 @@ export class PauseResumeManager { static async getPausedExecutionById( id: string ): Promise { - const rows = await db + const rows = await execDb .select() .from(pausedExecutions) .where(eq(pausedExecutions.id, id)) @@ -2292,7 +2299,7 @@ export class PauseResumeManager { }): Promise { const { workflowId, executionId } = options - const row = await db + const row = await execDb .select() .from(pausedExecutions) .where( @@ -2308,7 +2315,7 @@ export class PauseResumeManager { return null } - const queueEntries = await db + const queueEntries = await execDb .select() .from(resumeQueue) .where(eq(resumeQueue.parentExecutionId, executionId)) @@ -2386,7 +2393,7 @@ export class PauseResumeManager { } | null = null while (!pendingEntry) { - const selection = await db.transaction(async (tx) => { + const selection = await execDb.transaction(async (tx) => { const pausedExecution = await tx .select() .from(pausedExecutions) diff --git a/packages/db/db.ts b/packages/db/db.ts index a122948995e..df1ed7de7d3 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -1,9 +1,12 @@ +import { createLogger } from '@sim/logger' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { resolveDbUrl } from './connection-url' import * as schema from './schema' import { instrumentPoolClient } from './tx-tripwire' +const logger = createLogger('Db') + /** * Per-role pool profiles. Starting numbers — validate against real per-role * process counts (PgBouncer transaction mode, max_connections=200). @@ -14,17 +17,24 @@ export const DB_POOL_PROFILES = { // overlapping logging writes); 3 risks intra-run deadlock. trigger: { primaryMax: 5, replicaMax: 2, appName: 'sim-trigger' }, realtime: { primaryMax: 5, replicaMax: 3, appName: 'sim-realtime' }, + // Sub-process pools, selected per call-site via dbFor() — never via SIM_DB_ROLE. + cleanup: { primaryMax: 5, replicaMax: 2, appName: 'sim-cleanup' }, + exec: { primaryMax: 10, replicaMax: 4, appName: 'sim-exec' }, } as const -type DbRole = keyof typeof DB_POOL_PROFILES +/** Roles a whole process runs as (via SIM_DB_ROLE). */ +const PROCESS_ROLES = ['web', 'trigger', 'realtime'] as const + +type ProcessDbRole = (typeof PROCESS_ROLES)[number] +type SubProcessDbRole = Exclude const roleEnv = process.env.SIM_DB_ROLE?.trim() -if (roleEnv && !Object.hasOwn(DB_POOL_PROFILES, roleEnv)) { +if (roleEnv && !PROCESS_ROLES.includes(roleEnv as ProcessDbRole)) { throw new Error( - `Invalid SIM_DB_ROLE '${roleEnv}' — expected one of ${Object.keys(DB_POOL_PROFILES).join(', ')} (or unset for web)` + `Invalid SIM_DB_ROLE '${roleEnv}' — expected one of ${PROCESS_ROLES.join(', ')} (or unset for web)` ) } -const role = (roleEnv as DbRole) || 'web' +const role = (roleEnv as ProcessDbRole) || 'web' const profile = DB_POOL_PROFILES[role] const connectionString = resolveDbUrl('DATABASE_URL', role) @@ -71,3 +81,58 @@ export const dbReplica: typeof db = replicaUrl } ) : db + +const subPoolClients = new Map() + +/** Which env var the process connection came from — named in dbFor fallback logs. */ +const processUrlEnvVar = process.env[`DATABASE_URL_${role.toUpperCase()}`] + ? `DATABASE_URL_${role.toUpperCase()}` + : 'DATABASE_URL' + +/** + * Per-workload drizzle client with its own pool, built lazily on first call and + * cached per role. Unlike the process-wide `db` (selected by `SIM_DB_ROLE`), + * these are selected per call-site so a workload running inside an existing + * process — cleanup jobs in the trigger worker, inline execution log writes in + * the web server — gets its own connection budget and PgBouncer pool. + * + * Resolves `DATABASE_URL_` with fallback to the URL the process itself + * resolved (`DATABASE_URL_`, then base `DATABASE_URL`), so an + * unset sub-pool URL changes nothing about where this process's traffic lands. + * Always uses the role profile's `appName` — the `DB_APP_NAME` override applies + * only to the process-wide clients. + */ +export function dbFor(role: SubProcessDbRole): typeof db { + const existing = subPoolClients.get(role) + if (existing) return existing + + const keyedEnvVar = `DATABASE_URL_${role.toUpperCase()}` + const keyedUrl = process.env[keyedEnvVar] + const url = keyedUrl ?? connectionString + if (!url) { + throw new Error('Missing DATABASE_URL environment variable') + } + + if (keyedUrl) { + logger.info(`'${role}' pool using dedicated ${keyedEnvVar}`) + } else { + logger.info( + `${keyedEnvVar} not set — '${role}' pool falling back to the process connection (${processUrlEnvVar})` + ) + } + + const subProfile = DB_POOL_PROFILES[role] + const client = drizzle( + instrumentPoolClient( + postgres(url, { + ...poolOptions, + max: subProfile.primaryMax, + connection: { application_name: subProfile.appName }, + }), + role + ), + { schema } + ) + subPoolClients.set(role, client) + return client +} diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 3c832d79652..d701630239a 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -260,6 +260,8 @@ export const dbChainMock = { db: dbChainInstance, /** Same instance as `db` so per-test chain overrides cover both clients. */ dbReplica: dbChainInstance, + /** Sub-pool clients (`dbFor('cleanup' | 'exec')`) share the same instance too. */ + dbFor: () => dbChainInstance, runOutsideTransactionContext: (fn: () => T): T => fn(), instrumentPoolClient: (client: T): T => client, } @@ -333,6 +335,8 @@ export const databaseMock = { db: mockDbInstance, /** Same instance as `db` so per-test overrides cover both clients. */ dbReplica: mockDbInstance, + /** Sub-pool clients (`dbFor('cleanup' | 'exec')`) share the same instance too. */ + dbFor: () => mockDbInstance, sql: createMockSql(), runOutsideTransactionContext: (fn: () => T): T => fn(), instrumentPoolClient: (client: T): T => client, From 936536e553ca5dbad5a89ac028cde038cdf07dc4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 12:16:31 -0700 Subject: [PATCH 05/24] =?UTF-8?q?improvement(url-state):=20platform-wide?= =?UTF-8?q?=20nuqs=20audit=20=E2=80=94=20migrate=20remaining=20view-state,?= =?UTF-8?q?=20codify=20conventions=20(#5851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(url-state): migrate ee settings sections to nuqs deep-linkable view-state - audit-logs: types/time-range/start-date/end-date filters move to a co-located search-params.ts (reusing the logs kebab-token time-range parser); search binds to the shared settings ?search= via useSettingsSearch, replacing a hand-rolled debounce effect - access-control: group detail deep-links via ?group-id (push/replace-on-close); search via useSettingsSearch - custom-blocks: block detail deep-links via ?custom-block-id; create flow stays local; search via useSettingsSearch - data-drains + forks: search via useSettingsSearch; forks close now replaces history like the mcp reference pattern - polish: nullable-reason comment on logs startDate/endDate, stale debounce TSDoc now references useDebouncedSearchSetter * fix(url-state): review fixes — resolve custom-block deep links before opening detail, per-surface time-range fallback - custom-blocks: gate the detail view on the resolved block (matching mcp/ access-control), so a dead or still-loading ?custom-block-id no longer flashes a bogus create screen - parseAsTimeRange: unknown tokens now parse to null so each surface's .withDefault applies (logs keeps 'All time'; audit-logs keeps 'Past 30 days' instead of silently widening to all time on a malformed link) - audit-logs: date-picker cancel target can never be 'Custom range' itself on a dateless custom deep link - refresh shared ?search= consumer lists in TSDoc * improvement(url-state): platform-wide sweep — migrate the last three view-state stragglers, codify conventions Sweep across landing, workspace, and settings surfaces found only three remaining candidates (everything else verified clean or correctly non-URL): - knowledge document page: chunk enabled-status filter joins page/search/sort in the URL (?enabled=), resetting page in the same write - workflow-mcp-servers: detail Details/Workflows tab deep-links via ?server-tab, cleared alongside the server id on close - byok: provider search binds the shared settings ?search= via a controlled prop pair (modal/embedded consumers keep local state) Rule updates (.claude/rules/sim-url-state.md): shallow defaults documented, urlKeys kebab remapping, throttleMs deprecation, startTransition with shallow:false, shared-parser null-fallback rule, resolve-before-open gating, close-with-replace, and the reusable-component controlled-search pattern. * improvement(url-state): cleanup pass — replace-on-close for the fork activity view, TSDoc form for the logs nullable comment * fix(url-state): honor a custom time range only when both bounds are present A partial ?time-range=custom deep link (missing start/end) now falls back to the default preset window instead of displaying 'Custom range' while querying an unbounded result set. * fix(url-state): verification-round fixes — reject unparseable date params, tighten docs - new parseAsDateString parser (logs + audit-logs): an unparseable ?start-date=/?end-date= now parses to null (missing bound) instead of crashing the audit-logs render via Invalid Date .toISOString(), and hardens the same class in logs - audit-logs: remove the provably dead cancel-revert branch and its ref — the URL only holds 'Custom range' after Apply writes both bounds atomically - workflow-mcp-servers: reset a lingering ?server-tab= when opening a server so a dead deep link can't re-target the next open - knowledge document: drop the unreachable 'N selected' label branch - sim-url-state.md fact-check corrections: cover apps/sim/ee in paths, focusedBlockId -> currentBlockId (the real store field), note that history/clearOnDefault are nuqs v2 defaults, fix the Suspense cross-reference and parseAsIsoDate serialize detail, clarify the *UrlKeys naming convention; list byok in the shared-search consumer docs * fix(url-state): hold first paint while a custom-block deep link can still resolve A valid ?custom-block-id= no longer flashes the list while the blocks query is pending; a dead id still falls back to the list once loaded. * fix(url-state): include permissions loading in the custom-block deep-link paint hold canAdmin reads false while the permissions context loads, so the hold must gate on permissionsLoading too; drop the blocksPending conjunct — it shares one query with useCanPublishCustomBlock, so isLoading already covers it. --- .claude/rules/sim-url-state.md | 27 +++++--- .../(landing)/integrations/search-params.ts | 4 +- .../sim/app/(landing)/models/search-params.ts | 4 +- .../integrations/search-params.ts | 4 +- .../knowledge/[id]/[documentId]/document.tsx | 42 +++++++++---- .../[id]/[documentId]/search-params.ts | 8 ++- .../[workspaceId]/knowledge/search-params.ts | 4 +- .../[workspaceId]/logs/search-params.ts | 30 +++++++-- .../settings/[section]/search-params.ts | 47 ++++++++++++++ .../components/byok/byok-key-manager.tsx | 11 +++- .../settings/components/byok/byok.tsx | 4 ++ .../components/inbox/search-params.ts | 2 +- .../settings/components/search-params.ts | 7 ++- .../components/use-settings-search.ts | 4 +- .../workflow-mcp-servers.tsx | 28 +++++++-- .../[workspaceId]/skills/search-params.ts | 3 +- .../components/access-control.tsx | 19 ++++-- .../ee/audit-logs/components/audit-logs.tsx | 61 ++++++++++--------- apps/sim/ee/audit-logs/search-params.ts | 38 ++++++++++++ .../components/custom-blocks.tsx | 42 ++++++++++--- .../components/data-drains-settings.tsx | 3 +- .../ee/workspace-forking/components/forks.tsx | 7 ++- 22 files changed, 303 insertions(+), 96 deletions(-) create mode 100644 apps/sim/ee/audit-logs/search-params.ts diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a841f40098c..1c45eae5416 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -3,6 +3,8 @@ paths: - "apps/sim/app/**/*.tsx" - "apps/sim/app/**/*.ts" - "apps/sim/app/**/search-params.ts" + - "apps/sim/ee/**/*.tsx" + - "apps/sim/ee/**/*.ts" --- # URL / Query-Param State (nuqs) @@ -50,11 +52,13 @@ Co-locate a `search-params.ts` next to the feature. Export the parser map (and s Conventions: -- `.withDefault(...)` on every parser so reads are non-null. -- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn. +- `.withDefault(...)` on every parser so reads are non-null. A deliberately **nullable** parser (dynamic default, custom-range-only dates, nullable sort) must carry a comment saying why. +- Filter / search / toggle / pagination options: `{ history: 'replace', clearOnDefault: true }` — clean URLs, no back-stack churn. Note all three of `history: 'replace'`, `clearOnDefault: true`, and `shallow: true` are already the nuqs v2 defaults — writing the first two explicitly is documentation (and guards the groups whose options differ, e.g. `history: 'push'`), and `shallow: true` may be omitted entirely. - Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`. -- `shallow: false` **only** when a Server Component / loader must re-read the param. -- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. +- `shallow: false` **only** when a Server Component / loader must re-read the param. For loading states during the server re-render, pass React's `startTransition` via `.withOptions({ startTransition, shallow: false })`. +- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. When the parser-map key is camelCase (for clean destructuring), remap the wire key via the `urlKeys` option in the shared options object (see `files/search-params.ts` `uploadedBy: 'uploaded-by'`, `ee/audit-logs/search-params.ts` `timeRange: 'time-range'`); nuqs also exports a `UrlKeys` type helper for standalone mappings. +- `throttleMs` is deprecated in nuqs — rate-limit URL writes with `limitUrlUpdates: throttle(ms)` / `debounce(ms)` (the debounced-search hook below already does this). +- A parser **shared across surfaces with different defaults** (e.g. `parseAsTimeRange`) must `parse` unknown tokens to `null` — never to one surface's default — so each consumer's `.withDefault(...)` decides the fallback. - For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers). - A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq` — `clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`. @@ -75,11 +79,12 @@ export const thingsParsers = { /** Clean URLs, no back-stack churn for filter changes. */ export const thingsUrlKeys = { history: 'replace', - shallow: true, clearOnDefault: true, } as const ``` +(The `*UrlKeys` suffix is the repo's naming convention for a feature's shared **options** object — which may itself contain a nuqs `urlKeys` key-remapping entry; the two are different things.) + ### Client — `useQueryStates` (grouped) / `useQueryState` (single) ```typescript @@ -182,7 +187,7 @@ Sort params live alongside — not inside — the feature's grouped filter parse A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters. -**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). +**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString().slice(0, 10)`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/hooks/use-calendar.ts`. @@ -200,7 +205,11 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, { const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null ``` -Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`. +Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`. + +**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp.tsx`, `workflow-mcp-servers.tsx`, access-control, custom-blocks, forks). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update. + +**Reusable components** rendered both as a settings/list page and inside a modal (e.g. `BYOKKeyManager`) expose an optional controlled `searchTerm`/`onSearchTermChange` prop pair: the page consumer binds the URL (`useSettingsSearch()`), modal consumers omit the props and keep local state. Never bind URL state from inside a component that can mount in a non-destination context. ## Read-then-strip deep links @@ -217,8 +226,8 @@ The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/so Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery: -- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load. -- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. +- **Panel `activeTab`** — a persisted local *preference* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`); moving it would unwind that machinery and risk tab-flash on load. **Canvas mode** (`mode` on `useCanvasModeStore`) is likewise a persisted layout preference, not a destination. +- **The panel editor's `currentBlockId`** (`stores/panel/editor/store.ts` — a would-be "look at this block" deep link) — the only genuinely shareable candidate, but it is persisted and entangled with panel-open orchestration. Adding a URL param for it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas. diff --git a/apps/sim/app/(landing)/integrations/search-params.ts b/apps/sim/app/(landing)/integrations/search-params.ts index 1672807299c..d93113b80d0 100644 --- a/apps/sim/app/(landing)/integrations/search-params.ts +++ b/apps/sim/app/(landing)/integrations/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `category` filters by integration type (`''` = all). */ export const integrationsParsers = { diff --git a/apps/sim/app/(landing)/models/search-params.ts b/apps/sim/app/(landing)/models/search-params.ts index 21c7ee09ec6..210556703ab 100644 --- a/apps/sim/app/(landing)/models/search-params.ts +++ b/apps/sim/app/(landing)/models/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `provider` filters by provider id (`''` = all). */ export const modelsParsers = { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts index f03533261f6..a5775bde969 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts @@ -15,8 +15,8 @@ export const CONNECTED_LABEL = 'Connected' * pseudo-categories and are derived from the data set, so a plain string is * used; the `All` default clears from the URL. * - `search` is the integration search term. The input is controlled directly by - * the nuqs value; only its URL write is debounced via `limitUrlUpdates` - * (`debounce`) on the setter — never written on every keystroke. + * the nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const integrationsParsers = { category: parseAsString.withDefault(ALL_CATEGORY), diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 696c3fd82c2..f34ed657dbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -137,7 +137,12 @@ export function Document({ const { workspaceId } = useParams() const router = useRouter() const [ - { page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery }, + { + page: currentPageFromURL, + chunk: chunkFromURL, + search: searchQuery, + enabled: enabledFilterParam, + }, setDocumentParams, ] = useQueryStates(documentParsers, documentUrlKeys) const userPermissions = useUserPermissionsContext() @@ -160,16 +165,31 @@ export function Document({ ) /** Raw URL value drives the input; the chunk search query always sees it trimmed. */ const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim() - const [enabledFilter, setEnabledFilter] = useState([]) const { activeSort, onSort: onSortColumn, onClear: onClearSort, } = useUrlSort(documentChunkSortParams, documentUrlKeys) - const enabledFilterParam = useMemo( - () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), - [enabledFilter] + /** Multi-select UI view of the scalar `enabled` param (`all` ↔ nothing selected). */ + const enabledFilter = useMemo( + () => (enabledFilterParam === 'all' ? [] : [enabledFilterParam]), + [enabledFilterParam] + ) + + /** + * Collapses the dropdown's multi-select values to the scalar param (one value + * filters; none or both mean `all`) and resets `page` in the same write so a + * filter change always lands on the first page. + */ + const setEnabledFilter = useCallback( + (values: string[]) => { + void setDocumentParams({ + enabled: values.length === 1 ? (values[0] as 'enabled' | 'disabled') : null, + page: null, + }) + }, + [setDocumentParams] ) const { @@ -619,8 +639,7 @@ export function Document({ const enabledDisplayLabel = useMemo(() => { if (enabledFilter.length === 0) return 'All' - if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' - return `${enabledFilter.length} selected` + return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' }, [enabledFilter]) const filterContent = useMemo( @@ -638,7 +657,6 @@ export function Document({ onMultiSelectChange={(values) => { setEnabledFilter(values) setSelectedChunks(new Set()) - void goToPage(1) }} overlayContent={ {enabledDisplayLabel} @@ -654,7 +672,6 @@ export function Document({ onClick={() => { setEnabledFilter([]) setSelectedChunks(new Set()) - void goToPage(1) }} className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]' > @@ -663,7 +680,7 @@ export function Document({ )} ), - [enabledFilter, enabledDisplayLabel, goToPage] + [enabledFilter, enabledDisplayLabel, setEnabledFilter] ) const filterTags: FilterTag[] = useMemo( @@ -671,12 +688,11 @@ export function Document({ enabledFilter.map((value) => ({ label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`, onRemove: () => { - setEnabledFilter((prev) => prev.filter((v) => v !== value)) + setEnabledFilter(enabledFilter.filter((v) => v !== value)) setSelectedChunks(new Set()) - void goToPage(1) }, })), - [enabledFilter, goToPage] + [enabledFilter, setEnabledFilter] ) const handleChunkClick = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts index 072e08d66ef..2ac0878d3a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -1,6 +1,9 @@ -import { parseAsInteger, parseAsString } from 'nuqs/server' +import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' +/** Chunk `enabled` filter buckets, matching the status filter dropdown. */ +const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const + /** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */ export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const @@ -24,11 +27,14 @@ export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS) * - `search` is the chunk content search. The input is controlled directly by * the instant nuqs value; only its URL write is debounced via * `useDebouncedSearchSetter` — never written on every keystroke. + * - `enabled` filters chunks by enabled status (`all` clears from the URL), + * mirroring the same filter on the knowledge base document list. */ export const documentParsers = { page: parseAsInteger.withDefault(1), chunk: parseAsString, search: parseAsString.withDefault(''), + enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'), } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts index 8a10c0796d0..e27c2e3c8b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -27,8 +27,8 @@ export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, { * * - `search` is the knowledge base name/description filter. The input is * controlled directly by the instant nuqs value; only its URL write is - * debounced via `limitUrlUpdates` (`debounce`) on the setter — never written - * on every keystroke. + * debounced via `useDebouncedSearchSetter` — never written on every + * keystroke. * - `connector` filters by connector presence; `content` filters by document * presence; `owner` filters by creator id. All are multi-select arrays. * diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts index d2171915c21..8640ba2449f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -41,12 +41,13 @@ const TOKEN_TO_TIME_RANGE: Record = Object.fromEntries( ) as Record /** - * Parser for the `timeRange` param. Serializes labels to kebab tokens and - * tolerantly maps unknown tokens back to the default ("All time"). + * Parser for the `timeRange` param. Serializes labels to kebab tokens. Unknown + * tokens parse to `null` so each consuming surface's `.withDefault(...)` decides + * the fallback (logs: "All time"; audit-logs: "Past 30 days"). */ export const parseAsTimeRange = createParser({ parse(value) { - return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE + return TOKEN_TO_TIME_RANGE[value] ?? null }, serialize(value) { return TIME_RANGE_TO_TOKEN[value] ?? 'all-time' @@ -76,6 +77,21 @@ export const parseAsLogLevel = createParser({ }, }) +/** + * Parser for free-form date/datetime strings (`startDate`/`endDate`). Rejects + * unparseable values at the URL boundary — an invalid date string reaching + * `new Date(...).toISOString()` throws, so a malformed deep link must parse to + * `null` (treated as a missing bound) instead of crashing the consumer. + */ +export const parseAsDateString = createParser({ + parse(value) { + return Number.isNaN(Date.parse(value)) ? null : value + }, + serialize(value) { + return value + }, +}) + const CORE_TRIGGER_SET = new Set(CORE_TRIGGER_TYPES) /** @@ -104,8 +120,12 @@ export const parseAsTriggers = createParser({ */ export const logFilterParsers = { timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE), - startDate: parseAsString, - endDate: parseAsString, + /** + * Deliberately nullable: only populated when timeRange is "Custom range"; + * every preset range derives its window from the label instead. + */ + startDate: parseAsDateString, + endDate: parseAsDateString, level: parseAsLogLevel.withDefault('all'), workflowIds: parseAsArrayOf(parseAsString).withDefault([]), folderIds: parseAsArrayOf(parseAsString).withDefault([]), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bdf4eb6350c..85b4a56b913 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -49,6 +49,53 @@ export const forkViewUrlKeys = { clearOnDefault: true, } as const +/** + * `server-tab` is the active tab (Details / Workflows) inside the deep-linked + * workflow MCP server detail view, so a shared `mcpServerId` link can land on + * either tab. + */ +export const serverTabParam = { + key: 'server-tab', + parser: parseAsStringLiteral(['details', 'workflows'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const serverTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-id` deep-links the Access Control settings tab to a specific + * permission group's detail sub-view (mirrors `mcpServerId` on the MCP tab). + */ +export const groupIdParam = { + key: 'group-id', + parser: parseAsString, +} as const + +/** Opening a group's detail is a destination → push to history; clear on close. */ +export const groupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** + * `custom-block-id` deep-links the Custom Blocks settings tab to a specific + * block's detail sub-view. The "create new" flow stays in local state — only + * existing entities are deep-linkable. + */ +export const customBlockIdParam = { + key: 'custom-block-id', + parser: parseAsString, +} as const + +/** Opening a block's detail is a destination → push to history; clear on close. */ +export const customBlockIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index bc1c0b2d394..ba957ffde7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -66,6 +66,13 @@ interface BYOKKeyManagerBaseProps { description?: string /** Show the provider search box (hidden when there are only a couple). */ showSearch?: boolean + /** + * Controlled search value + setter. The BYOK settings page passes the shared + * `?search=` binding (`useSettingsSearch`) so the search is deep-linkable; + * modal/embedded consumers omit both and keep local state. + */ + searchTerm?: string + onSearchTermChange?: (value: string) => void } /** One key per provider; saving replaces the stored key. */ @@ -138,7 +145,9 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { showSearch = true, } = props - const [searchTerm, setSearchTerm] = useState('') + const [localSearchTerm, setLocalSearchTerm] = useState('') + const searchTerm = props.searchTerm ?? localSearchTerm + const setSearchTerm = props.onSearchTermChange ?? setLocalSearchTerm const [editing, setEditing] = useState(null) const [apiKeyInput, setApiKeyInput] = useState('') const [nameInput, setNameInput] = useState('') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 5798db7b329..ff34151ca34 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -48,6 +48,7 @@ import { type BYOKProviderSection, } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useBYOKKeys, useDeleteBYOKKey, useUpsertBYOKKey } from '@/hooks/queries/byok-keys' import type { BYOKProviderId } from '@/tools/types' @@ -354,6 +355,7 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const workspacePermissions = useUserPermissionsContext() const canManage = canMutateWorkspaceSettingsSection('byok', workspacePermissions) + const [searchTerm, setSearchTerm] = useSettingsSearch() const { data, isLoading } = useBYOKKeys(workspaceId) const upsertKey = useUpsertBYOKKey() @@ -381,6 +383,8 @@ export function BYOK() { isSaving={upsertKey.isPending} isDeleting={deleteKey.isPending} readOnly={!canManage} + searchTerm={searchTerm} + onSearchTermChange={setSearchTerm} onSaveKey={async ({ providerId, apiKey, keyId, name }) => { await upsertKey.mutateAsync({ workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts index f21a66f2d11..d1aa319c771 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts @@ -18,7 +18,7 @@ export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number] * - `status` is the active status filter (feeds the tasks query key). * - `search` is the subject/sender/body name filter. The input is controlled * directly by the nuqs value; only its URL write is debounced via - * `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke. + * `useDebouncedSearchSetter` — never written per keystroke. */ export const inboxTaskParsers = { status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts index 8340051100e..1884a1f22b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -2,9 +2,10 @@ import { parseAsString } from 'nuqs/server' /** * Shared URL query-param definition for the settings list search boxes - * (teammates, api-keys, copilot, custom-tools, mcp, secrets, - * workflow-mcp-servers). Settings sections never co-render, so they all share - * the `search` key without collisions. + * (teammates, api-keys, byok, copilot, custom-tools, mcp, secrets, + * workflow-mcp-servers, and the ee sections: audit-logs, access-control, + * custom-blocks, data-drains, forks). Settings sections never co-render, so + * they all share the `search` key without collisions. * * Consume via `useSettingsSearch` (`settings/components/use-settings-search`), * which owns the debounced-write wiring — the input is controlled directly by diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts index 2da76cad0ee..c5749effaa4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -9,7 +9,9 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** * The shared `?search=` binding for settings list search boxes (teammates, - * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). + * api-keys, byok, copilot, custom-tools, mcp, secrets, workflow-mcp-servers, + * and the ee sections: audit-logs, access-control, custom-blocks, data-drains, + * forks). * Composes `useDebouncedSearchSetter`, so it carries the canonical semantics: * the value updates instantly (drives the controlled input and the in-memory * filter), non-empty URL writes are debounced, and clearing (or a diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index b5c46b36462..ebbfa9883cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -32,6 +32,8 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { mcpServerIdParam, mcpServerIdUrlKeys, + serverTabParam, + serverTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' @@ -108,7 +110,10 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe const [editServerName, setEditServerName] = useState('') const [editServerDescription, setEditServerDescription] = useState('') const [editServerIsPublic, setEditServerIsPublic] = useState(false) - const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') + const [activeServerTab, setActiveServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) @@ -401,7 +406,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe { value: 'workflows', label: 'Workflows' }, ]} value={activeServerTab} - onChange={(value) => setActiveServerTab(value as 'workflows' | 'details')} + onChange={(value) => void setActiveServerTab(value as 'workflows' | 'details')} />
@@ -892,6 +897,11 @@ export function WorkflowMcpServers() { }) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(() => new Set()) + /** Cleared alongside the server id on close so the tab never lingers on the list URL. */ + const [, setServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const filteredServers = useMemo(() => { if (!searchTerm.trim()) return servers @@ -948,7 +958,10 @@ export function WorkflowMcpServers() { canManage={canAdmin} workspaceId={workspaceId} serverId={selectedServerId} - onBack={() => setSelectedServerId(null, { history: 'replace' })} + onBack={() => { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} /> ) } @@ -1011,7 +1024,14 @@ export function WorkflowMcpServers() { setSelectedServerId(server.id) }, + { + label: 'Details', + onSelect: () => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }, + }, ...(canAdmin ? [ { diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index ae63f33ebc4..554005fc76d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -23,8 +23,7 @@ export const skillIdUrlKeys = { /** * `search` filters the skills list by name/description. The input is controlled * directly by the instant nuqs value; only its URL write is debounced via - * `limitUrlUpdates` (`debounce`) on the setter — never written on every - * keystroke. + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const skillSearchParam = { key: 'search', diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 11c3341d9cc..813b2d9df80 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -16,11 +16,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { + groupIdParam, + groupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { GroupDetail } from '@/ee/access-control/components/group-detail' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { @@ -74,8 +80,11 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon const createPermissionGroup = useCreatePermissionGroup() - const [searchTerm, setSearchTerm] = useState('') - const [selectedGroupId, setSelectedGroupId] = useState(null) + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedGroupId, setSelectedGroupId] = useQueryState(groupIdParam.key, { + ...groupIdParam.parser, + ...groupIdUrlKeys, + }) const [showCreateModal, setShowCreateModal] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') @@ -160,8 +169,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon workspaceOptions={workspaceOptions} organizationWorkspaces={organizationWorkspaces} workspacesLoading={workspacesLoading} - onBack={() => setSelectedGroupId(null)} - onDeleted={() => setSelectedGroupId(null)} + onBack={() => void setSelectedGroupId(null, { history: 'replace' })} + onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })} /> ) } @@ -198,7 +207,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
) + // The stopped row renders in the tail region below, in the + // shimmer's place — a stop while the shimmer is visible must read + // as an in-place replacement, not the shimmer vanishing from the + // tail while a row mounts up here. case 'stopped': - return ( -
- - Stopped by user -
- ) + return null } })} @@ -957,8 +972,8 @@ function MessageContentInner({ // Fixed-height placeholder for the NEXT piece of output: the shimmer // and arriving output trade places via opacity only, so mid-turn swaps // can't move layout. A sibling of the space-y stack (not a child), so - // it carries no stray sibling margin — pt-[10px] is its own gap. -
+ // it carries no stray sibling margin. +
+ ) : // The settled tail takes the slot's place in the SAME render and at the + // SAME extent (TAIL_REGION_CLASSES), so the swap is height-neutral by + // construction — no reflow for the pinned scroller to absorb. A stopped + // turn instead stacks compact natural rows (10px gaps, no 36px boxes): + // its extra height is glided-in growth either way, so only the + // shimmer-swap occupant needs the fixed extent. + lastSegment?.type === 'stopped' ? ( + <> +
+ + Stopped by user +
+ {actions &&
{actions}
} + ) : ( - // The actions row takes the slot's place in the SAME render — a single - // ~10px reflow instead of a collapse the buttons would ride upward or a - // late mount the chase would visibly scroll to. - actions &&
{actions}
+ actions &&
{actions}
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index db903e8398a..dcac9da0b56 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -12,6 +12,7 @@ import { } from 'react' import { cn } from '@sim/emcn' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' +import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled') const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: - 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]', + 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [overflow-anchor:none] [scrollbar-gutter:stable_both-edges]', sizer: 'relative mx-auto w-full max-w-[48rem]', rowGap: 'pb-6', userRow: 'flex flex-col items-end gap-[6px] pt-3', @@ -120,7 +121,8 @@ const LAYOUT_STYLES = { footerInner: 'mx-auto max-w-[48rem]', }, 'copilot-view': { - scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4', + scrollContainer: + 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]', sizer: 'relative w-full', rowGap: 'pb-4', userRow: 'flex flex-col items-end gap-[6px] pt-2', @@ -302,6 +304,9 @@ export function MothershipChat({ const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) + const sizerFloorAppliedRef = useRef(0) + const floorDrainRafRef = useRef(0) + useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) /** * Sizer floor while streaming: `scrollHeight` must never dip below the @@ -318,6 +323,15 @@ export function MothershipChat({ * Active on the same signal as auto-scroll: the reveal keeps re-parsing * markdown (and shrinking) after the network stream closes, so the floor * must hold through `lastRowAnimating` too. + * + * Release is DRAINED, not cliffed: while active the floor forbids + * scrollHeight from dropping, so permanent shrinks over the turn accrue as + * phantom space (debt). Clearing min-height in one commit released that + * whole debt as a single clamp — the end-of-turn downward jump. Instead the + * floor glides down to the natural size at the chase's rate; the browser's + * clamp follows a few px per frame, which reads as the same eased settle as + * the rest of the stream. Instant-clears when the debt is sub-pixel or the + * user isn't pinned (shrinking below-viewport space is invisible then). */ const floorActive = isStreamActive || lastRowAnimating useLayoutEffect(() => { @@ -325,9 +339,41 @@ export function MothershipChat({ const el = scrollElementRef.current if (!sizer || !el) return if (!floorActive) { - sizer.style.minHeight = '' + if (sizerFloorAppliedRef.current === 0) return + // A drain already in flight keeps its own rAF cadence — settle-burst + // commits re-enter this branch and must not add extra steps in layout, + // which would accelerate the release past the eased rate. + if (floorDrainRafRef.current !== 0) return + scrollerPaddingRef.current = null + const drain = () => { + const target = virtualizer.getTotalSize() + const current = sizerFloorAppliedRef.current + if (current === 0) { + floorDrainRafRef.current = 0 + return + } + // Instant-clear only when the whole remaining debt sits BELOW the + // viewport (debt ≤ distance-from-bottom) — then the shrink is + // invisible. A merely-unpinned viewport with debt larger than its + // slack would still clamp, so it keeps the eased drain instead. + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + const debt = current - target + if (debt <= 1 || debt <= distance) { + sizerFloorAppliedRef.current = 0 + floorDrainRafRef.current = 0 + sizer.style.minHeight = '' + return + } + const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE)) + sizerFloorAppliedRef.current = next + sizer.style.minHeight = `${next}px` + floorDrainRafRef.current = requestAnimationFrame(drain) + } + floorDrainRafRef.current = requestAnimationFrame(drain) return } + cancelAnimationFrame(floorDrainRafRef.current) + floorDrainRafRef.current = 0 if (!scrollerPaddingRef.current) { const style = getComputedStyle(el) scrollerPaddingRef.current = { @@ -336,7 +382,21 @@ export function MothershipChat({ } } const padding = scrollerPaddingRef.current - const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom) + // Math.floor, not the raw float: a fractional min-height can round + // scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap + // re-derives 1px higher after every chase step — a visible 1px/frame + // upward creep whenever the floor is what's holding scrollHeight. + const floor = Math.max( + 0, + Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom) + ) + // Dead-band: the floor feeds back into its own inputs (a floored value can + // land a fraction BELOW the extent, the browser clamps scrollTop, and the + // next commit re-derives from the clamped position — a visible ~1px×N + // downward cascade on fractional-scrollTop displays). Sub-pixel deltas are + // rounding noise from that loop, never real growth; only apply real moves. + if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return + sizerFloorAppliedRef.current = floor sizer.style.minHeight = `${floor}px` }) const setScrollElement = useCallback( diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index d5edf8977ba..d20a68c10f1 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -25,22 +25,12 @@ const USER_GESTURE_WINDOW = 250 * in the listener) is the other upward shortcut; plain `Space` pages down. */ const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home']) -/** How long to keep chasing the bottom while a CSS height animation plays. */ -const ANIMATION_FOLLOW_WINDOW = 500 /** - * How long to keep chasing the bottom after streaming stops. End-of-turn content - * mounts just after `isStreaming` flips false — the suggested-follow-up options, - * the actions row (swapped into the thinking slot's place), and the - * virtualizer's re-measure of the grown row — so a single final scroll fires - * before it lays out and leaves it clipped behind the input. Following for a - * short window pulls it into view. + * How long the chase idle-follows after stream teardown. Covers content that + * mounts with the observers already gone — a stop's stopped-row and actions + * append only after the abort round-trips. */ -const POST_STREAM_SETTLE_WINDOW = 300 - -interface UseAutoScrollOptions { - scrollOnMount?: boolean -} - +const POST_STOP_SETTLE_WINDOW = 800 /** * Manages sticky auto-scroll for a streaming chat container. * @@ -50,20 +40,15 @@ interface UseAutoScrollOptions { * of the bottom to re-engage. Each streaming start re-seeds stickiness from the * current scroll position, so a user who scrolled up beforehand stays put. * - * Returns `ref` (callback ref for the scroll container) and `scrollToBottom` - * for imperative use after layout-changing events like panel expansion. + * Returns `ref`, the callback ref for the scroll container. */ -export function useAutoScroll( - isStreaming: boolean, - { scrollOnMount = false }: UseAutoScrollOptions = {} -) { +export function useAutoScroll(isStreaming: boolean) { const containerRef = useRef(null) const stickyRef = useRef(true) const userDetachedRef = useRef(false) const prevScrollTopRef = useRef(0) const prevScrollHeightRef = useRef(0) const touchStartYRef = useRef(0) - const scrollOnMountRef = useRef(scrollOnMount) /** * Whether the user is actively dragging the scrollbar — a pointer press on the * container itself rather than its content. Reset on teardown so a pointer held @@ -77,21 +62,25 @@ export function useAutoScroll( */ const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY) - const scrollToBottom = useCallback(() => { - const el = containerRef.current - if (!el) return - el.scrollTop = el.scrollHeight - }, []) - const callbackRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el - if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight }, []) + /** + * Cancels the previous teardown's settle window (chase, temp listeners, + * removal timer). Invoked when a new stream starts — a stop-then-resend must + * not leave the old settle chase writing beside the new stream's chase — and + * on unmount. + */ + const settleCleanupRef = useRef<(() => void) | null>(null) + useEffect(() => () => settleCleanupRef.current?.(), []) + useEffect(() => { if (!isStreaming) return const el = containerRef.current if (!el) return + settleCleanupRef.current?.() + settleCleanupRef.current = null /** * Eased bottom-chase shared by the mutation observer and the seed below — @@ -190,42 +179,38 @@ export function useAutoScroll( prevScrollHeightRef.current = scrollHeight } - const onMutation = () => { + /** + * The single growth signal: the transcript sizer's height. Every source of + * scrollHeight growth flows through it — virtualizer re-measures per + * streamed token, CSS height animations (each frame re-measures the row), + * the sizer min-height floor — so one ResizeObserver replaces a subtree + * MutationObserver plus an `animationstart` deadline machine, and there is + * exactly one reason the chase ever runs. + */ + const sizer = el.firstElementChild + const onSizerResize = () => { prevScrollHeightRef.current = el.scrollHeight if (!stickyRef.current) return chase.kick() } - /** - * CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream) - * grow scrollHeight without triggering MutationObserver, so auto-scroll stops - * following. Keep the one chase loop alive for a short window so the - * container stays pinned while the animation runs. `animationstart` fires - * for every child animation in the transcript (segment fade-ins, loader - * keyframes, label crossfades) — kickUntil coalesces them into a single - * extended deadline on the single loop; anything more snaps the glide. - */ - const onAnimationStart = () => chase.kickUntil(ANIMATION_FOLLOW_WINDOW) - el.addEventListener('wheel', onWheel, { passive: true }) el.addEventListener('touchstart', onTouchStart, { passive: true }) el.addEventListener('touchmove', onTouchMove, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true }) - el.addEventListener('animationstart', onAnimationStart) el.addEventListener('pointerdown', onPointerDown, { passive: true }) el.addEventListener('keydown', onKeyDown, { passive: true }) window.addEventListener('pointerup', onPointerUp, { passive: true }) window.addEventListener('pointercancel', onPointerUp, { passive: true }) - const observer = new MutationObserver(onMutation) - observer.observe(el, { childList: true, subtree: true, characterData: true }) + const observer = new ResizeObserver(onSizerResize) + if (sizer) observer.observe(sizer) return () => { el.removeEventListener('wheel', onWheel) el.removeEventListener('touchstart', onTouchStart) el.removeEventListener('touchmove', onTouchMove) el.removeEventListener('scroll', onScroll) - el.removeEventListener('animationstart', onAnimationStart) el.removeEventListener('pointerdown', onPointerDown) el.removeEventListener('keydown', onKeyDown) window.removeEventListener('pointerup', onPointerUp) @@ -234,12 +219,37 @@ export function useAutoScroll( chase.cancel() pointerDownRef.current = false lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY - // End-of-turn content mounts just after teardown; follow it briefly. The - // chase's own upward-move interrupt still protects a real user scroll - // even with the gesture listeners gone. - chase.kickUntil(POST_STREAM_SETTLE_WINDOW) + // Growth can land after teardown with no observer alive: options mounted + // late in the reveal, and a stop's stopped-row/actions appending once the + // abort completes. Idle-follow briefly so that content isn't stranded + // behind the input; a user who scrolled away stays put via the sticky + // check. + chase.kickUntil(POST_STOP_SETTLE_WINDOW) + // The main gesture listeners are gone, so give the settle window its own + // kill switch: the chase's clamp-aware interrupt catches wheel-scale + // moves, but a slow trackpad glide during a concurrent shrink could slip + // under it for up to the window's duration. + const cancelOnGesture = (event: Event) => { + if (event.type === 'wheel' && (event as WheelEvent).deltaY >= 0) return + chase.cancel() + } + el.addEventListener('wheel', cancelOnGesture, { passive: true }) + el.addEventListener('touchmove', cancelOnGesture, { passive: true }) + const removeGestureGuard = () => { + el.removeEventListener('wheel', cancelOnGesture) + el.removeEventListener('touchmove', cancelOnGesture) + } + const guardTimeout = setTimeout(() => { + removeGestureGuard() + settleCleanupRef.current = null + }, POST_STOP_SETTLE_WINDOW + 100) + settleCleanupRef.current = () => { + chase.cancel() + clearTimeout(guardTimeout) + removeGestureGuard() + } } - }, [isStreaming, scrollToBottom]) + }, [isStreaming]) - return { ref: callbackRef, scrollToBottom } + return { ref: callbackRef } } diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 90c3b9d8e84..e2b928e8f24 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -28,10 +28,9 @@ export interface SmoothBottomChaseHandle { kick: () => void /** * Keep the loop alive for `durationMs` even while the gap is at rest, - * re-checking every frame. Covers growth that arrives over several frames - * with no observable trigger — a CSS height animation, or a virtualizer - * re-measure settling after streaming stops. Repeat calls extend the - * deadline; there is never more than one loop. + * re-checking every frame. For growth that lands with no observable trigger + * — content mounting just after a stream's observers tear down (a stop's + * stopped-row/actions). Repeat calls extend the deadline; one loop only. */ kickUntil: (durationMs: number) => void cancel: () => void @@ -45,9 +44,12 @@ export interface SmoothBottomChaseHandle { * * Self-interrupting: chase writes only ever move the offset down, and content * growth leaves it where the last write put it — so an offset that moved UP - * since the last write can only be a user scrolling away, and the loop parks - * instead of fighting them. `shouldContinue` layers any caller-owned stickiness - * on top (checked every frame). + * since the last write, MORE than the bottom itself moved up, can only be a + * user scrolling away, and the loop parks instead of fighting them. (A + * content-shrink clamp moves the offset and the bottom together — e.g. the + * transcript's floor drain — and must not read as a user scroll.) + * `shouldContinue` layers any caller-owned stickiness on top (checked every + * frame). */ export function createSmoothBottomChase( target: SmoothBottomChaseTarget, @@ -55,14 +57,14 @@ export function createSmoothBottomChase( ): SmoothBottomChaseHandle { let raf: number | null = null let lastTop: number | null = null + let lastBottomTop: number | null = null let deadline = 0 const park = () => { if (raf !== null) cancelAnimationFrame(raf) raf = null lastTop = null - // A stale deadline must not leak into a later plain kick() — kick alone - // parks at rest, only a live kickUntil window idles through it. + lastBottomTop = null deadline = 0 } @@ -77,14 +79,21 @@ export function createSmoothBottomChase( return } const top = target.getTop() - if (lastTop !== null && top < lastTop - 1) { + const bottomTop = target.getBottomTop() + // A user scroll is an ACTUAL upward top move (first clause — growth alone + // must not trip this) that exceeds any upward move of the bottom itself + // (second clause — a shrink clamp moves both together). + const topDrop = lastTop === null ? 0 : lastTop - top + const bottomDrop = lastBottomTop === null ? 0 : lastBottomTop - bottomTop + if (topDrop > 1 && topDrop > bottomDrop + 1) { park() return } - const gap = target.getBottomTop() - top + lastBottomTop = bottomTop + const gap = bottomTop - top if (gap <= CHASE_REST_GAP) { - // Within a kickUntil deadline the loop idles at rest instead of parking, - // so growth in the deadline window is chased without a fresh trigger. + // Within a kickUntil deadline, idle at rest instead of parking so + // trigger-less growth inside the window is still chased. if (performance.now() >= deadline) { park() return @@ -105,12 +114,12 @@ export function createSmoothBottomChase( * Seed the upward-move interrupt baseline at (re)start so a user scroll-up * between the kick and the first frame parks the loop immediately — without * it the first step has no baseline and writes one downward frame against - * the user (relevant on the teardown kickUntil, where the gesture listeners - * are already gone). + * the user. */ const start = () => { if (raf !== null) return lastTop = target.getTop() + lastBottomTop = target.getBottomTop() raf = requestAnimationFrame(step) } From 5338485ac102d9f0f68fdcd710419df337670d18 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 13:53:58 -0700 Subject: [PATCH 09/24] feat(sidebar): add Slack Community link to help dropdown (#5858) * feat(sidebar): add Slack Community link to help dropdown * chore(sidebar): use existing Slack community invite link --- .../[workspaceId]/w/components/sidebar/sidebar.tsx | 13 +++++++++++++ apps/sim/lib/posthog/events.ts | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 66a04dfd0d4..e7a22926be7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -39,6 +39,7 @@ import { MoreHorizontal, Pin } from 'lucide-react' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { SlackIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isMacPlatform } from '@/lib/core/utils/platform' @@ -115,6 +116,9 @@ import { useSidebarStore } from '@/stores/sidebar/store' const logger = createLogger('Sidebar') +const SLACK_COMMUNITY_URL = + 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA' + export function SidebarTooltip({ children, label, @@ -1170,6 +1174,11 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) }, [posthog]) + const handleOpenSlackCommunity = useCallback(() => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + }, [posthog]) + const handleChatRenameBlur = useCallback( () => void chatFlyoutRename.saveRename(), [chatFlyoutRename.saveRename] @@ -1670,6 +1679,10 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { Docs + + + Slack Community + Report an issue diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index abef034dcd7..61534aeb1a3 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -547,6 +547,10 @@ export interface PostHogEventMap { block_type?: string } + slack_community_opened: { + source: 'help_menu' + } + search_result_selected: { result_type: | 'block' From 6f33a9485b2a62af5a8f671240559befd9963a00 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 14:03:07 -0700 Subject: [PATCH 10/24] feat(workflows): IDE-style reference viewer for workflows (#5854) * feat(workflows): add IDE-style reference viewer for workflows Adds a "Show references" viewer so you can see how workflows connect: which workflows call a given workflow ("Used by") and which workflows it calls ("Uses"), rendered as recursive, clickable trees. - Opened via Cmd/Ctrl+click on a sidebar workflow row and a "Show references" context-menu item. - Resolves references through both the workflow / workflow_input blocks (reusing isWorkflowBlockType) and published custom blocks (custom_block_* -> source workflow), scoped to the workspace. - Builds the whole workspace reference graph once from live workflow_blocks state; cycle-safe DFS marks A->B->A loops as (cycle) leaves. - Contract-bound GET /api/workflows/[id]/references with workspace-level authz; React Query hook gated to fetch only when the modal opens. - Unit tests for the pure graph/tree logic (cycles, self-refs, dangling drop, custom-block + workflow_input resolution) and route tests (401/400/403/200). * fix(workflows): correct reference resolution for active mode, cycles, cache, and graph size Addresses review findings on the reference viewer: - Resolve the workflow-block child via resolveActiveCanonicalValue (the shared SOT) instead of basic-first `||`, so an advanced-mode block whose old basic workflowId value lingers resolves to the active manual value. - Keep self-references (A -> A) and render them as a cycle leaf instead of dropping the edge, matching the cycle-safe viewer's purpose. - Set the references query staleTime to 0 so reopening the always-mounted modal refetches live editor state instead of serving a stale cached graph. - Bound converging paths: a node already expanded elsewhere in the tree is emitted once more as a plain leaf (edge stays visible) rather than re-expanded, so a densely reconverging graph can't grow exponentially. * improvement(workflows): align reference viewer auth, coverage, and UI with platform conventions - authorize via authorizeWorkflowByWorkspacePermission and derive the workspace server-side (404/403 semantics; drops the client-supplied workspaceId query param from the contract, hook, and modal) - add workflow-tool call edges: workflow_input tools inside tool-input sub-blocks now appear in both trees; non-call selector shapes stay deliberately excluded (documented against remap-internal-ids) - restore native cmd/ctrl+click open-in-new-tab on sidebar workflow rows; references stay reachable from the context menu - mount ReferencesModal on demand per row, deleting the prevIsOpen reset, the enabled knob, and the staleTime-0 workaround (now 30s) - align the tree with design tokens (--text-icon, --surface-hover, px-4 text gutter) and drop the hardcoded brand hex - escape LIKE wildcards in the custom_block_ prefix match; import MAX_CALL_CHAIN_DEPTH instead of mirroring it; remove dead fallbacks, the duplicate not-found scan, and the redundant custom-block row map * improvement(workflows): final polish on the reference viewer - drop the vestigial isOpen prop (conditional mount owns visibility) - unify on the emcn Workflow icon in tree rows - inline the static className and derive nodes without an annotation - remove one restating test comment * fix(workflows): resolve tool references by active canonical mode and keep the reference cache live - workflow_input tools inside tool-input now resolve basic/advanced via the index-scoped canonicalModes override, mirroring execution (Cursor finding) - staleTime back to 0: no mutation invalidates this key, so a reopen must background-refetch; on-demand mounting keeps the cached tree painting instantly (Greptile P1) - modal header uses the em-dash label-entity convention; tree items carry aria-level instead of a static aria-selected * fix(workflows): cover legacy workflow-typed tools and retry depth-truncated expansions - toolInputCallees matches both workflow tool type spellings via isWorkflowBlockType and passes the tool's own type as the legacy canonicalModes fallback, matching providers/utils resolution - a depth-capped expansion no longer poisons the expanded set, so a shallower path re-expands the node in full (Cursor finding) - the allowed-but-workspaceless auth branch now returns 403, not the authz result's 200 - tests: legacy tool type + per-tool index-scope isolation, diamond re-expansion with a real subtree, depth ceiling, shallow-path retry --------- Co-authored-by: Marcus Chandra --- .../workflows/[id]/references/route.test.ts | 94 +++++ .../api/workflows/[id]/references/route.ts | 37 ++ .../components/context-menu/context-menu.tsx | 19 +- .../reference-tree/reference-tree.tsx | 68 ++++ .../references-modal/references-modal.tsx | 74 ++++ .../workflow-item/workflow-item.tsx | 17 + apps/sim/hooks/queries/workflow-references.ts | 39 +++ .../lib/api/contracts/workflow-references.ts | 54 +++ .../workflows/references/operations.test.ts | 309 +++++++++++++++++ .../lib/workflows/references/operations.ts | 327 ++++++++++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 11 files changed, 1039 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/references/route.test.ts create mode 100644 apps/sim/app/api/workflows/[id]/references/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx create mode 100644 apps/sim/hooks/queries/workflow-references.ts create mode 100644 apps/sim/lib/api/contracts/workflow-references.ts create mode 100644 apps/sim/lib/workflows/references/operations.test.ts create mode 100644 apps/sim/lib/workflows/references/operations.ts diff --git a/apps/sim/app/api/workflows/[id]/references/route.test.ts b/apps/sim/app/api/workflows/[id]/references/route.test.ts new file mode 100644 index 00000000000..d6ef6045816 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockAuthorizeWorkflow, mockGetWorkflowReferences } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), + mockGetWorkflowReferences: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, +})) + +vi.mock('@/lib/workflows/references/operations', () => ({ + getWorkflowReferences: mockGetWorkflowReferences, +})) + +import { GET } from '@/app/api/workflows/[id]/references/route' + +const REFERENCES = { + callers: [{ id: 'b', name: 'B', cycle: false, children: [] }], + callees: [], +} + +function callRoute(id = 'wf-1') { + const url = `http://localhost:3000/api/workflows/${id}/references` + return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) }) +} + +describe('GET /api/workflows/[id]/references', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: 'read', + }) + mockGetWorkflowReferences.mockResolvedValue(REFERENCES) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(401) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + const response = await callRoute() + expect(response.status).toBe(404) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns 403 when the user cannot read the workflow', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to read this workflow', + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: null, + }) + const response = await callRoute() + expect(response.status).toBe(403) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns the reference trees scoped to the workflow workspace', async () => { + const response = await callRoute() + expect(response.status).toBe(200) + expect(await response.json()).toEqual(REFERENCES) + expect(mockAuthorizeWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'read', + }) + expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1') + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/references/route.ts b/apps/sim/app/api/workflows/[id]/references/route.ts new file mode 100644 index 00000000000..c2e5505e391 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.ts @@ -0,0 +1,37 @@ +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowReferences } from '@/lib/workflows/references/operations' + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getWorkflowReferencesContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const auth = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: session.user.id, + action: 'read', + }) + if (!auth.allowed || !auth.workflow?.workspaceId) { + return NextResponse.json( + { error: auth.message ?? 'Access denied' }, + { status: auth.allowed ? 403 : auth.status } + ) + } + + const references = await getWorkflowReferences(auth.workflow.workspaceId, id) + return NextResponse.json(references) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 13e4dc6ab16..bd35b5b2614 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -22,6 +22,7 @@ import { SquareArrowUpRight, Trash, Unlock, + Workflow, } from '@sim/emcn/icons' import { Pin, PinOff } from 'lucide-react' @@ -31,6 +32,7 @@ interface ContextMenuProps { menuRef: React.RefObject onClose: () => void onOpenInNewTab?: () => void + onFindReferences?: () => void onMarkAsRead?: () => void onMarkAsUnread?: () => void onTogglePin?: () => void @@ -53,6 +55,7 @@ interface ContextMenuProps { onExport?: () => void onDelete: () => void showOpenInNewTab?: boolean + showFindReferences?: boolean showMarkAsRead?: boolean showMarkAsUnread?: boolean showPin?: boolean @@ -93,6 +96,7 @@ export function ContextMenu({ menuRef, onClose, onOpenInNewTab, + onFindReferences, onMarkAsRead, onMarkAsUnread, onTogglePin, @@ -104,6 +108,7 @@ export function ContextMenu({ onExport, onDelete, showOpenInNewTab = false, + showFindReferences = false, showMarkAsRead = false, showMarkAsUnread = false, showPin = false, @@ -133,7 +138,8 @@ export function ContextMenu({ showUploadLogo = false, disableUploadLogo = false, }: ContextMenuProps) { - const hasNavigationSection = showOpenInNewTab && onOpenInNewTab + const hasNavigationSection = + (showOpenInNewTab && onOpenInNewTab) || (showFindReferences && onFindReferences) const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) || @@ -195,6 +201,17 @@ export function ContextMenu({ Open in new tab )} + {showFindReferences && onFindReferences && ( + { + onFindReferences() + onClose() + }} + > + + Show references + + )} {hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx new file mode 100644 index 00000000000..ae4def07afa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx @@ -0,0 +1,68 @@ +'use client' + +import { Workflow } from '@sim/emcn/icons' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' + +const CONFIG = { + /** Horizontal indent added per tree level, in pixels. */ + INDENT_PER_LEVEL: 16, + /** Base row padding: lands depth-0 content on the modal's px-4 text gutter. */ + BASE_INDENT: 8, +} as const + +interface ReferenceTreeProps { + nodes: ReferenceNode[] + /** Invoked with a workflow id when a row is activated. */ + onNavigate: (workflowId: string) => void +} + +interface ReferenceTreeItemProps { + node: ReferenceNode + depth: number + onNavigate: (workflowId: string) => void +} + +function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) { + return ( +
+ + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +/** + * Read-only recursive tree of workflow references. Each row navigates to its + * workflow on click; cyclic leaves are marked and render no children. + */ +export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) { + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx new file mode 100644 index 00000000000..e507111c956 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn' +import { useRouter } from 'next/navigation' +import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree' +import { useWorkflowReferences } from '@/hooks/queries/workflow-references' + +type ReferencesTab = 'callers' | 'callees' + +const TABS = [ + { value: 'callers', label: 'Used by' }, + { value: 'callees', label: 'Uses' }, +] as const + +const EMPTY_MESSAGE: Record = { + callers: 'No workflows call this workflow.', + callees: "This workflow doesn't call any other workflows.", +} + +interface ReferencesModalProps { + onClose: () => void + workspaceId: string + workflowId: string + workflowName: string +} + +/** + * IDE-style reference viewer for a workflow. "Used by" lists the workflows that + * call it (inbound); "Uses" lists the workflows it calls (outbound). Both are + * recursive trees whose rows navigate to the referenced workflow. Mounted on + * demand by the owning row, so state initializes fresh per open. + */ +export function ReferencesModal({ + onClose, + workspaceId, + workflowId, + workflowName, +}: ReferencesModalProps) { + const router = useRouter() + const [activeTab, setActiveTab] = useState('callers') + + const { data, isPending, isError } = useWorkflowReferences(workflowId) + + const handleNavigate = (targetId: string) => { + router.push(`/workspace/${workspaceId}/w/${targetId}`) + onClose() + } + + const nodes = data?.[activeTab] ?? [] + + return ( + !next && onClose()} srTitle='References'> + References — {workflowName} + + setActiveTab(value as ReferencesTab)} + aria-label='Reference direction' + /> + {isPending ? ( +

Loading references…

+ ) : isError ? ( +

Failed to load references.

+ ) : nodes.length === 0 ? ( +

{EMPTY_MESSAGE[activeTab]}

+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index fcc7d8a28b8..372d093b014 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -10,6 +10,7 @@ import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import { ReferencesModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal' import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars' import { useContextMenu, @@ -80,6 +81,7 @@ export const WorkflowItem = memo(function WorkflowItem({ const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId }) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isReferencesOpen, setIsReferencesOpen] = useState(false) const [deleteItemType, setDeleteItemType] = useState<'workflow' | 'mixed'>('workflow') const [deleteModalNames, setDeleteModalNames] = useState('') const [canDeleteSelection, setCanDeleteSelection] = useState(true) @@ -398,6 +400,10 @@ export const WorkflowItem = memo(function WorkflowItem({ [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) + const handleFindReferences = useCallback(() => { + setIsReferencesOpen(true) + }, []) + return ( <> + + {isReferencesOpen && ( + setIsReferencesOpen(false)} + workspaceId={workspaceId} + workflowId={workflow.id} + workflowName={workflow.name} + /> + )} ) }) diff --git a/apps/sim/hooks/queries/workflow-references.ts b/apps/sim/hooks/queries/workflow-references.ts new file mode 100644 index 00000000000..6ae68da75b4 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-references.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getWorkflowReferencesContract, + type WorkflowReferencesResponse, +} from '@/lib/api/contracts/workflow-references' + +/** + * Zero — the graph reflects live editor state, and no workflow-edit mutation + * invalidates this key (edits arrive over the socket, not through React Query). + * The modal mounts on demand, so every open refetches; a reopen paints the + * cached tree instantly while the background refetch reconciles it. + */ +export const WORKFLOW_REFERENCES_STALE_TIME = 0 + +export const workflowReferenceKeys = { + all: ['workflow-references'] as const, + details: () => [...workflowReferenceKeys.all, 'detail'] as const, + detail: (workflowId?: string) => [...workflowReferenceKeys.details(), workflowId ?? ''] as const, +} + +async function fetchWorkflowReferences( + workflowId: string, + signal?: AbortSignal +): Promise { + return requestJson(getWorkflowReferencesContract, { + params: { id: workflowId }, + signal, + }) +} + +export function useWorkflowReferences(workflowId?: string) { + return useQuery({ + queryKey: workflowReferenceKeys.detail(workflowId), + queryFn: ({ signal }) => fetchWorkflowReferences(workflowId as string, signal), + enabled: Boolean(workflowId), + staleTime: WORKFLOW_REFERENCES_STALE_TIME, + }) +} diff --git a/apps/sim/lib/api/contracts/workflow-references.ts b/apps/sim/lib/api/contracts/workflow-references.ts new file mode 100644 index 00000000000..c298c4f6bec --- /dev/null +++ b/apps/sim/lib/api/contracts/workflow-references.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** + * One node in a workflow reference tree. Recursive, so the Zod schema below needs + * `z.lazy` plus this explicit interface annotation — TypeScript cannot infer a + * self-referential type. Shared by the server graph builder (its return element + * type) and the client hook, keeping both off `z.output<...>`. + */ +export interface ReferenceNode { + /** Referenced workflow id. */ + id: string + /** Referenced workflow name. */ + name: string + /** + * True when this node closes a cycle already on the current path (e.g. the + * root, or `A → B → A`). Cyclic nodes carry no `children`. + */ + cycle: boolean + children: ReferenceNode[] +} + +export const referenceNodeSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + name: z.string(), + cycle: z.boolean(), + children: z.array(referenceNodeSchema), + }) +) + +export const workflowReferencesParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) + +export const workflowReferencesResponseSchema = z.object({ + /** Workflows that call this workflow (inbound), each recursively expanded. */ + callers: z.array(referenceNodeSchema), + /** Workflows this workflow calls (outbound), each recursively expanded. */ + callees: z.array(referenceNodeSchema), +}) + +export type WorkflowReferencesResponse = z.output + +export const getWorkflowReferencesContract = defineRouteContract({ + method: 'GET', + path: '/api/workflows/[id]/references', + params: workflowReferencesParamsSchema, + response: { + mode: 'json', + schema: workflowReferencesResponseSchema, + }, +}) diff --git a/apps/sim/lib/workflows/references/operations.test.ts b/apps/sim/lib/workflows/references/operations.test.ts new file mode 100644 index 00000000000..02ad33e67c9 --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -0,0 +1,309 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type CustomBlockLink, + type ReferenceBlockRow, + resolveWorkflowReferences, + type WorkflowNode, +} from '@/lib/workflows/references/operations' + +const workflows: WorkflowNode[] = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + { id: 'd', name: 'D' }, +] + +function workflowBlock( + parentId: string, + childId: string, + mode: 'basic' | 'manual' = 'basic', + type: 'workflow' | 'workflow_input' = 'workflow' +): ReferenceBlockRow { + return { + parentId, + type, + childFromSelector: mode === 'basic' ? childId : null, + childFromManual: mode === 'manual' ? childId : null, + canonicalModes: null, + toolInputValues: null, + } +} + +describe('resolveWorkflowReferences', () => { + it('resolves direct callers and callees', () => { + const blocks = [workflowBlock('a', 'b'), workflowBlock('a', 'c')] + const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, []) + + expect(callers).toEqual([]) + expect(callees.map((n) => n.id)).toEqual(['b', 'c']) + + const bResult = resolveWorkflowReferences('b', workflows, blocks, []) + expect(bResult.callers.map((n) => n.id)).toEqual(['a']) + expect(bResult.callees).toEqual([]) + }) + + it('resolves references made through workflow_input blocks', () => { + const blocks = [ + workflowBlock('a', 'b', 'basic', 'workflow_input'), + workflowBlock('b', 'c', 'basic', 'workflow_input'), + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + expect(callees[0].children.map((n) => n.id)).toEqual(['c']) + + const cResult = resolveWorkflowReferences('c', workflows, blocks, []) + expect(cResult.callers.map((n) => n.id)).toEqual(['b']) + expect(cResult.callers[0].children.map((n) => n.id)).toEqual(['a']) + }) + + it('resolves the advanced-mode manualWorkflowId value', () => { + const blocks = [workflowBlock('a', 'b', 'manual')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + }) + + it('uses the active mode, not a retained inactive value', () => { + // Advanced mode active (canonicalModes override), but a stale basic value + // (`b`) lingers. Must resolve to the advanced value (`c`), not the stale basic. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'workflow', + childFromSelector: 'b', + childFromManual: 'c', + canonicalModes: { workflowId: 'advanced' }, + toolInputValues: null, + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) + + it('marks cycles as leaves and stops recursing', () => { + const blocks = [workflowBlock('a', 'b'), workflowBlock('b', 'a')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + + expect(callees).toHaveLength(1) + expect(callees[0]).toMatchObject({ id: 'b', cycle: false }) + expect(callees[0].children).toHaveLength(1) + expect(callees[0].children[0]).toMatchObject({ id: 'a', cycle: true, children: [] }) + }) + + it('shows a self-reference as a cycle leaf', () => { + // A → A: the reference is real and belongs in the cycle-safe viewer. + const blocks = [workflowBlock('a', 'a')] + const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }]) + expect(callers).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }]) + }) + + it('bounds converging paths (diamond) instead of re-expanding', () => { + // A → B, A → C, B → D, C → D, D → E. D reconverges; it must appear under both + // B and C but expand its subtree (E) only under the first-visited branch. + const workflowsWithE = [...workflows, { id: 'e', name: 'E' }] + const blocks = [ + workflowBlock('a', 'b'), + workflowBlock('a', 'c'), + workflowBlock('b', 'd'), + workflowBlock('c', 'd'), + workflowBlock('d', 'e'), + ] + const { callees } = resolveWorkflowReferences('a', workflowsWithE, blocks, []) + const b = callees.find((n) => n.id === 'b') + const c = callees.find((n) => n.id === 'c') + // D expands under the first-visited branch (B) and is a collapsed leaf under C. + expect(b?.children).toEqual([ + { + id: 'd', + name: 'D', + cycle: false, + children: [{ id: 'e', name: 'E', cycle: false, children: [] }], + }, + ]) + expect(c?.children).toEqual([{ id: 'd', name: 'D', cycle: false, children: [] }]) + }) + + it('truncates expansion at the depth ceiling', () => { + // A linear chain longer than MAX_REFERENCE_DEPTH (25): w0 → w1 → … → w29. + const chain = Array.from({ length: 30 }, (_, i) => ({ id: `w${i}`, name: `W${i}` })) + const blocks = Array.from({ length: 29 }, (_, i) => workflowBlock(`w${i}`, `w${i + 1}`)) + const { callees } = resolveWorkflowReferences('w0', chain, blocks, []) + let depth = 0 + let node = callees[0] + while (node) { + depth += 1 + node = node.children[0] + } + expect(depth).toBe(25) + }) + + it('re-expands a depth-truncated node when a shallower path reaches it', () => { + // Root fans out to a 25-deep chain (visited first by name sort: "A…") whose + // tail X gets truncated at the ceiling, and a direct edge (via "Z") to X. + // The shallow path must still show X's child Y instead of a collapsed leaf. + const nodes = [ + { id: 'root', name: 'Root' }, + ...Array.from({ length: 24 }, (_, i) => ({ + id: `a${i}`, + name: `A${String(i).padStart(2, '0')}`, + })), + { id: 'x', name: 'X' }, + { id: 'y', name: 'Y' }, + { id: 'z', name: 'Z' }, + ] + const blocks = [ + workflowBlock('root', 'a0'), + ...Array.from({ length: 23 }, (_, i) => workflowBlock(`a${i}`, `a${i + 1}`)), + workflowBlock('a23', 'x'), + workflowBlock('root', 'z'), + workflowBlock('z', 'x'), + workflowBlock('x', 'y'), + ] + const { callees } = resolveWorkflowReferences('root', nodes, blocks, []) + const z = callees.find((n) => n.id === 'z') + const xUnderZ = z?.children.find((n) => n.id === 'x') + expect(xUnderZ?.children.map((n) => n.id)).toEqual(['y']) + }) + + it('drops dangling / out-of-workspace child ids', () => { + const blocks = [workflowBlock('a', 'missing')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees).toEqual([]) + }) + + it('resolves references made through custom blocks', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'd', + type: 'custom_block_x', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: null, + }, + ] + const customBlocks: CustomBlockLink[] = [{ type: 'custom_block_x', workflowId: 'c' }] + + const cResult = resolveWorkflowReferences('c', workflows, blocks, customBlocks) + expect(cResult.callers.map((n) => n.id)).toEqual(['d']) + + const dResult = resolveWorkflowReferences('d', workflows, blocks, customBlocks) + expect(dResult.callees.map((n) => n.id)).toEqual(['c']) + }) + + it('ignores custom blocks with no bound source in scope', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'd', + type: 'custom_block_unknown', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: null, + }, + ] + const { callees } = resolveWorkflowReferences('d', workflows, blocks, []) + expect(callees).toEqual([]) + }) + + it('returns empty trees when the workflow is not a workspace node', () => { + const blocks = [workflowBlock('a', 'b')] + const result = resolveWorkflowReferences('unknown', workflows, blocks, []) + expect(result).toEqual({ callers: [], callees: [] }) + }) + + it('sorts children by name', () => { + // Names: B, C — insert in reverse to prove sorting. + const blocks = [workflowBlock('a', 'c'), workflowBlock('a', 'b')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.name)).toEqual(['B', 'C']) + }) + + it('resolves workflow tools inside tool-input sub-blocks', () => { + // Agent block on A carrying a workflow_input tool that calls B; a non-workflow + // tool and a malformed entry must be ignored. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: [ + [ + { type: 'workflow_input', params: { workflowId: 'b' } }, + { type: 'function', params: {} }, + { type: 'workflow_input' }, + ], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + + const bResult = resolveWorkflowReferences('b', workflows, blocks, []) + expect(bResult.callers.map((n) => n.id)).toEqual(['a']) + }) + + it('resolves a workflow tool to its active advanced-mode value', () => { + // Tool 0 is toggled to advanced via the index-scoped canonicalModes key; the + // stale basic selector (`b`) must not mask the live manual value (`c`). + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: { '0:workflowId': 'advanced' }, + toolInputValues: [ + [{ type: 'workflow_input', params: { workflowId: 'b', manualWorkflowId: 'c' } }], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) + + it('resolves legacy workflow-typed tools and isolates index-scoped modes per tool', () => { + // Tool 0 is a legacy `workflow`-typed entry (still rendered/executed by the + // editor); tool 1 is advanced-mode via its own index-scoped key. Tool 0 must + // stay basic (`b`) — tool 1's override must not bleed into it. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: { '1:workflowId': 'advanced' }, + toolInputValues: [ + [ + { type: 'workflow', params: { workflowId: 'b' } }, + { type: 'workflow_input', params: { workflowId: 'c', manualWorkflowId: 'd' } }, + ], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b', 'd']) + }) + + it('resolves workflow tools from a JSON-stringified tool-input value', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: [ + JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'c' } }]), + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) +}) diff --git a/apps/sim/lib/workflows/references/operations.ts b/apps/sim/lib/workflows/references/operations.ts new file mode 100644 index 00000000000..768539b286d --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.ts @@ -0,0 +1,327 @@ +import { db } from '@sim/db' +import { workflow, workflowBlocks } from '@sim/db/schema' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' +import { MAX_CALL_CHAIN_DEPTH } from '@/lib/execution/call-chain' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import { + type CanonicalGroup, + type CanonicalModeOverrides, + resolveActiveCanonicalValue, + scopeCanonicalModesForTool, +} from '@/lib/workflows/subblocks/visibility' +import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config' +import { BlockType, isWorkflowBlockType } from '@/executor/constants' + +/** + * Depth ceiling for a reference tree — the runtime call-chain bound; a display + * tree never needs to show more than the executor allows. The per-path visited + * set is the real cycle guard; this is a belt-and-suspenders bound on + * pathological graphs. + */ +const MAX_REFERENCE_DEPTH = MAX_CALL_CHAIN_DEPTH + +/** + * The `workflowId` canonical pair on a workflow / workflow_input block: the basic + * `workflowId` selector and the advanced `manualWorkflowId` input. Used with + * {@link resolveActiveCanonicalValue} so the reference resolves to the value of the + * block's ACTIVE mode — never a dormant mode's retained (stale) value. + * (`remapWorkflowReferencesInSubBlocks` in + * `@/lib/workflows/persistence/remap-internal-ids` builds the same pair from + * positional keys — keep the two in sync if the pair ever changes.) + */ +const WORKFLOW_ID_CANONICAL_GROUP: CanonicalGroup = { + canonicalId: 'workflowId', + basicId: 'workflowId', + advancedIds: ['manualWorkflowId'], +} + +/** `custom_block_` with LIKE wildcards (`_`) escaped, for the SQL prefix match. */ +const CUSTOM_BLOCK_LIKE_PREFIX = CUSTOM_BLOCK_TYPE_PREFIX.replace(/[\\%_]/g, '\\$&') + +/** A workspace-local, non-archived workflow node. */ +export interface WorkflowNode { + id: string + name: string +} + +/** A placed block that may reference another workflow (raw, pre-resolution). */ +export interface ReferenceBlockRow { + parentId: string + type: string + /** `workflowId` sub-block value (basic mode), if a workflow block. */ + childFromSelector: string | null + /** `manualWorkflowId` sub-block value (advanced mode), if a workflow block. */ + childFromManual: string | null + /** + * The block's `data.canonicalModes` override (basic/advanced per canonical id), + * used to pick the active `workflowId` value. Absent for most blocks. + */ + canonicalModes: CanonicalModeOverrides | null + /** + * Aggregated `tool-input` sub-block values on this block (agent-style tool + * lists). Each entry is one sub-block's raw value — an array of tool objects, + * or that array JSON-stringified. `workflow_input` tools carry the callee id + * in `params.workflowId` (basic) or `params.manualWorkflowId` (advanced). + * Null when the block has no tool-input sub-blocks. + */ + toolInputValues: unknown[] | null +} + +/** A custom-block type slug bound to its source workflow. */ +export interface CustomBlockLink { + type: string + workflowId: string +} + +/** + * The workspace-wide reference graph derived from live editor state: every + * workflow node's name, plus forward (callee) and reverse (caller) adjacency. + */ +interface ReferenceGraph { + nameById: Map + forward: Map> + reverse: Map> +} + +/** + * Callee workflow ids referenced by a block's tool-input values: workflow tools + * (`workflow_input`, plus legacy stored entries typed `workflow`) resolved to + * their ACTIVE canonical member — the basic `params.workflowId` selector or the + * advanced `params.manualWorkflowId` input, per the tool's index-scoped + * `canonicalModes` override ({@link scopeCanonicalModesForTool}) — mirroring how + * execution picks the live value. + */ +function toolInputCallees( + toolInputValues: unknown[] | null, + canonicalModes: CanonicalModeOverrides | null +): string[] { + if (!toolInputValues) return [] + const callees: string[] = [] + for (const value of toolInputValues) { + const { array } = coerceObjectArray(value) + if (!array) continue + array.forEach((tool, toolIndex) => { + if ( + !isRecord(tool) || + typeof tool.type !== 'string' || + !isWorkflowBlockType(tool.type) || + !isRecord(tool.params) + ) { + return + } + const scoped = scopeCanonicalModesForTool(canonicalModes ?? undefined, toolIndex, tool.type) + const active = resolveActiveCanonicalValue( + WORKFLOW_ID_CANONICAL_GROUP, + { + workflowId: typeof tool.params.workflowId === 'string' ? tool.params.workflowId : null, + manualWorkflowId: + typeof tool.params.manualWorkflowId === 'string' ? tool.params.manualWorkflowId : null, + }, + scoped + ) + if (typeof active === 'string' && active) callees.push(active) + }) + } + return callees +} + +/** + * Build the directed reference graph from raw workspace rows. Pure (no I/O) so it + * can be unit-tested directly. Resolves three call-edge shapes: + * - direct **workflow blocks** (`workflow` and `workflow_input`), whose child id + * is the `workflowId` (basic) or `manualWorkflowId` (advanced) sub-block value; + * - **custom blocks** (`custom_block_`), whose type slug maps to a bound + * source workflow via `customBlocks`; and + * - **workflow tools** — `workflow_input` entries inside a block's `tool-input` + * sub-blocks (an agent invoking another workflow as a tool). + * + * Non-call reference shapes (the logs block's `workflowSelector` monitor list and + * the workspace-event trigger's `workflowIds`; see + * `remapWorkflowReferencesInSubBlocks`) are deliberately excluded — the viewer + * shows call relationships. + * + * The workflow-block child is the value of the block's ACTIVE mode + * ({@link resolveActiveCanonicalValue}), so a dormant basic/advanced value can't + * mask the live one. Edges are scoped to workspace-local, non-archived workflows; + * empty values and ids outside `workflows` are dropped. A workflow that calls + * itself is kept — the tree builder renders it as a `cycle` leaf. + */ +function buildReferenceGraph( + workflows: WorkflowNode[], + blocks: ReferenceBlockRow[], + customBlocks: CustomBlockLink[] +): ReferenceGraph { + const nameById = new Map() + for (const node of workflows) nameById.set(node.id, node.name) + + const sourceByCustomType = new Map() + for (const link of customBlocks) sourceByCustomType.set(link.type, link.workflowId) + + const forward = new Map>() + const reverse = new Map>() + + const addEdge = (parentId: string, childId: string) => { + if (!childId) return + if (!nameById.has(parentId) || !nameById.has(childId)) return + let callees = forward.get(parentId) + if (!callees) forward.set(parentId, (callees = new Set())) + callees.add(childId) + let callers = reverse.get(childId) + if (!callers) reverse.set(childId, (callers = new Set())) + callers.add(parentId) + } + + for (const block of blocks) { + if (isWorkflowBlockType(block.type)) { + const active = resolveActiveCanonicalValue( + WORKFLOW_ID_CANONICAL_GROUP, + { workflowId: block.childFromSelector, manualWorkflowId: block.childFromManual }, + block.canonicalModes ?? undefined + ) + if (typeof active === 'string' && active) addEdge(block.parentId, active) + } else { + const sourceId = sourceByCustomType.get(block.type) + if (sourceId) addEdge(block.parentId, sourceId) + } + for (const calleeId of toolInputCallees(block.toolInputValues, block.canonicalModes)) { + addEdge(block.parentId, calleeId) + } + } + + return { nameById, forward, reverse } +} + +/** + * Expand a direction of the graph into a tree rooted at `rootId`. `adjacency` is + * either the forward (callees) or reverse (callers) map. + * + * A node already on the current DFS path is emitted as a `cycle: true` leaf and + * not re-expanded, so `A → B → A` (and a self-call `A → A`) terminates. A node + * already fully expanded elsewhere in this tree (reachable via another acyclic + * path — a diamond) is emitted once more as a plain leaf without re-expanding its + * subtree: the edge stays visible, but a densely reconverging graph can't blow up + * exponentially. Children are sorted by name for stable rendering. + */ +function buildTree( + rootId: string, + adjacency: Map>, + nameById: Map +): ReferenceNode[] { + const path = new Set([rootId]) + const expanded = new Set() + const nameOf = (id: string) => nameById.get(id) as string + + const expand = (id: string, depth: number): ReferenceNode[] => { + if (depth >= MAX_REFERENCE_DEPTH) return [] + const neighbors = adjacency.get(id) + if (!neighbors || neighbors.size === 0) return [] + + const sorted = [...neighbors].sort((a, b) => nameOf(a).localeCompare(nameOf(b))) + + const nodes: ReferenceNode[] = [] + for (const childId of sorted) { + const name = nameOf(childId) + if (path.has(childId)) { + nodes.push({ id: childId, name, cycle: true, children: [] }) + continue + } + if (expanded.has(childId)) { + nodes.push({ id: childId, name, cycle: false, children: [] }) + continue + } + expanded.add(childId) + path.add(childId) + nodes.push({ id: childId, name, cycle: false, children: expand(childId, depth + 1) }) + path.delete(childId) + // A depth-capped expansion is incomplete — allow a shallower path to retry + // it in full instead of collapsing to a leaf. Each retry starts strictly + // shallower, so this stays bounded. + if (depth + 1 >= MAX_REFERENCE_DEPTH) expanded.delete(childId) + } + return nodes + } + + return expand(rootId, 0) +} + +/** + * Resolve the reference trees for one workflow from raw workspace rows. Pure so it + * can be unit-tested without a database. Returns empty arrays when the workflow is + * not a workspace-local node or has no references. + */ +export function resolveWorkflowReferences( + workflowId: string, + workflows: WorkflowNode[], + blocks: ReferenceBlockRow[], + customBlocks: CustomBlockLink[] +): { callers: ReferenceNode[]; callees: ReferenceNode[] } { + const { nameById, forward, reverse } = buildReferenceGraph(workflows, blocks, customBlocks) + + if (!nameById.has(workflowId)) { + return { callers: [], callees: [] } + } + + return { + callers: buildTree(workflowId, reverse, nameById), + callees: buildTree(workflowId, forward, nameById), + } +} + +/** + * Resolve the reference trees for one workflow: `callers` (workflows that call + * it, inbound) and `callees` (workflows it calls, outbound), read from the live + * `workflowBlocks` (draft) table — the state the sidebar and editor show. The + * root itself is not a node; each array holds its direct references, recursively + * expanded. + */ +export async function getWorkflowReferences( + workspaceId: string, + workflowId: string +): Promise<{ callers: ReferenceNode[]; callees: ReferenceNode[] }> { + const hasToolInput = sql`EXISTS ( + SELECT 1 FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv + WHERE kv.value ->> 'type' = 'tool-input' + )` + + const [workflowRows, blockRows, customBlockRows] = await Promise.all([ + db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), + db + .select({ + parentId: workflowBlocks.workflowId, + type: workflowBlocks.type, + childFromSelector: sql< + string | null + >`${workflowBlocks.subBlocks} -> 'workflowId' ->> 'value'`, + childFromManual: sql< + string | null + >`${workflowBlocks.subBlocks} -> 'manualWorkflowId' ->> 'value'`, + canonicalModes: sql`${workflowBlocks.data} -> 'canonicalModes'`, + toolInputValues: sql`( + SELECT jsonb_agg(kv.value -> 'value') + FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv + WHERE kv.value ->> 'type' = 'tool-input' + )`, + }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + or( + inArray(workflowBlocks.type, [BlockType.WORKFLOW, BlockType.WORKFLOW_INPUT]), + sql`${workflowBlocks.type} LIKE ${`${CUSTOM_BLOCK_LIKE_PREFIX}%`} ESCAPE '\\'`, + hasToolInput + ) + ) + ), + getCustomBlockRowsForWorkspace(workspaceId), + ]) + + return resolveWorkflowReferences(workflowId, workflowRows, blockRows, customBlockRows) +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 14a54b13f51..e7644a4d00d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 966, - zodRoutes: 966, + totalRoutes: 967, + zodRoutes: 967, nonZodRoutes: 0, } as const From ae7b5eff6c95e0d74fb1e80b333a446b853a7bbd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 14:06:29 -0700 Subject: [PATCH 11/24] feat(copilot): service account setup & reconnect in chat (#5786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(copilot): add service_account_get_setup_link handler Resolves a loosely-specified integration name to the catalog slug whose detail page mounts ConnectServiceAccountModal, and returns `/integrations/{slug}?connect=service-account`. The agent surfaces it via the existing tag, so the user gets a Connect button and supplies the key material in Sim's own form — the agent never handles the secret. Exact matches beat fuzzy ones so a caller naming a specific service lands on it (gmail stays Gmail rather than collapsing to Drive), and family names resolve through an explicit canonical map rather than to whichever member sorts first. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * fix(copilot): reject service account ids in oauth_get_auth_link The fuzzy provider match falls back to substring containment, so `slack-custom-bot` contains `slack` and resolved to the Slack OAuth service. The tool then returned a personal-OAuth authorize URL and reported success — a user who asked for a shared custom bot got a Connect button that linked their own account instead. Every service account id degraded this way (notion-, salesforce-, zoom-, linear-), always silently. Guard runs before the fuzzy pass and points at service_account_get_setup_link. Keys off the id being a service-account id, not off the integration offering one, so `slack` and `notion` still resolve for OAuth. Moves the narrowing predicate out of the integration catalog module so callers that need only the predicate skip the integrations.json load and the OAUTH_PROVIDERS walk. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * feat(copilot): open the service account form in-chat instead of linking out The tool handed back a /integrations/{slug}?connect=service-account URL, so accepting the agent's offer navigated away from the conversation that asked for the credential. Adds a `service_account` credential tag that mounts ConnectServiceAccountModal over the chat; setup_url stays as the headless/MCP fallback. The tag carries a provider and no value — the secret is typed into Sim's own form and never enters the transcript — so the validator gets a branch alongside secret_input/sim_key rather than falling through to the value-required check. Extracts useServiceAccountConnectTarget so the chat and the integrations page share one source of truth for the connect label and the preview gate. Custom Slack bots ride the slack_v2 flag; without the shared gate the chat would have surfaced a setup form the integrations page hides. Modal is lazy-loaded off the deep path (not the barrel) to keep three provider-specific setup forms out of the chat's initial chunk. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * fix(copilot): gate service account tool on the same preview flag as the UI The in-chat connect button hides itself when the provider's gating block is preview-hidden (a custom Slack bot needs slack_v2). The tool didn't check this, so it returned success for slack-custom-bot even when slack_v2 was preview-gated off — the agent said "here's the setup form" and the button silently rendered nothing, leaving the user with no form at all. Adds getServiceAccountGatingBlockType as the single source for the provider→gating-block mapping, consumed by both the tool (server-side, via getBlockVisibilityForCopilot) and the connect hook (client overlay). When the gating block is hidden the tool now fails with a fall-back-to-OAuth message instead of promising an invisible form. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds * feat(copilot): make the tool own service-account discovery Removes the VFS auth-metadata exposure and returns connectNoun from the service_account_get_setup_link result instead. The VFS aggregate was a second, viewer-independent source of truth that couldn't agree with the per-viewer preview gate (it always hid slack-custom-bot, even for viewers with slack_v2 revealed, while the tool accepts it for them). The tool now resolves the provider, applies the per-viewer gate, and returns either the in-chat button + connectNoun or a fall-back-to-oauth error — one source of truth. connectNoun stays DRY via getServiceAccountConnectNoun, shared with the connect-button label. * feat(copilot): make service-account setup a direct tag, no tool The agent now emits the service_account credential tag directly from intent — like secret_input — instead of round-tripping through a tool. Removes service_account_get_setup_link (handler, registration, display title, Go tool def) and restores auth.serviceAccount as the VFS discovery field so the agent knows which providers support a service account. The link-vs-tag distinction was the wrong axis: only oauth needs a tool, because its button carries a minted URL that can't be reconstructed. The service_account tag carries just a provider name the agent already knows, so it needs no tool — discovery lives in the VFS (auth.serviceAccount, GA-only, so slack's preview-gated custom bot is never proactively offered), and the per-viewer gate lives in the renderer, which renders nothing when a provider isn't available for the viewer (no OAuth fallback — a shared credential and a personal one are different intents). oauth_get_auth_link's service-account-id guard now points at the tag. * feat(copilot): support service-account reconnect from chat Reconnect had no service-account path — it required oauth_get_auth_link and a link tag for every repair, so rotating a workspace service account either errored or pushed the user through OAuth. The service_account tag now takes an optional credentialId; when present the renderer opens the modal in reconnect mode (rotates the secret on that credential in place, id preserved) and labels the button "Reconnect X". credentials.json now carries each credential's type (oauth vs service_account) so the agent can branch: service accounts reconnect via the tag + credentialId, oauth via oauth_get_auth_link as before. * fix(copilot): coherent service-account rejection in oauth_get_auth_link Review round on #5786: - The service-account-id guard threw into the generic catch, which overwrote its recovery hint with a "connect manually" message and a workspace oauth_url — contradictory signals. It now returns a coherent failure directly, before the try, with no oauth_url. - Normalize spaces/underscores before the check so a readable form ("slack custom bot", "google service account") is caught too, not passed to the fuzzy OAuth resolver. - Remove listServiceAccountIntegrationNames — dead after the tool was removed (its only caller was the deleted handler's error copy). * fix(copilot): service-account discovery must un-gate after the block GAs Review round on #5786: describeServiceAccountForOAuthProvider used `getBlock(...)?.preview ?? true`, which treats a GA'd gating block — one that dropped its `preview` flag, exactly slack_v2's documented migration — as still gated, so the custom bot would stay omitted from VFS discovery forever after GA even though the UI shows it. Reuse the canonical isHiddenUnder(null, block) predicate instead, so a non-preview block is visible. Adds service-account-gate.test.ts covering preview → omit, GA → include, and missing → fail-closed with a mocked getBlock (the block registry is globally stubbed, so the real slack_v2 preview flag isn't observable through serializeIntegrationSchema). * fix(copilot): align SA resolver normalization and reject blank credentialId Review round on #5786: - resolveServiceAccountIntegration only lowercased/trimmed, but the oauth_get_auth_link guard normalizes spaces/underscores to hyphens before rejecting a service-account id and steering the agent to a service_account tag. The chat renderer then couldn't resolve those same readable forms ("slack custom bot", "notion_service_account") and rendered nothing. Apply the same normalization to the id lookups (raw query still used for display-name matches). - service_account tag validation rejected a blank provider but allowed a whitespace-only credentialId, which is truthy — the renderer took the reconnect path and tried to rotate a non-existent credential. Reject a blank/whitespace credentialId. * refactor(credentials): route the editor SA picker through the canonical connect hook The workflow-editor credential selector (from #5800's merged picker) resolved its service-account setup surface inline and mounted the modal with NO preview gate — so a `credentialKind: 'service-account'` picker would offer a custom-bot setup even when slack_v2 is preview-gated off, the leak the integrations page and chat already guard against. Route it through the shared useServiceAccountConnectTarget hook (the same resolver chat and the integrations page use): suppress the setup action when `hidden`, and use the hook's vendor-accurate label ("Add private app token", "Set up a custom bot") as the default connect-row copy. Existing service accounts stay selectable; the per-block `credentialLabels.serviceAccountConnect` override still wins. One resolver now backs all three SA connect surfaces. * docs(add-block): document credentialKind and the service-account picker The add-block skill had no mention of credentialKind — the mechanism (#5800) that controls whether an oauth-input offers OAuth, service-account, or a merged picker — and its example was a plain oauth-input mislabeled "Service Account". Documents the three credentialKind modes, that a default oauth-input already lets users select an existing service account (they fold in), and the credentialLabels / allowServiceAccounts companions. Regenerates the .claude and .cursor projections. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/skills/add-block/SKILL.md | 19 +++ .claude/commands/add-block.md | 19 +++ .cursor/commands/add-block.md | 19 +++ .../special-tags/special-tags.test.ts | 84 ++++++++++++ .../components/special-tags/special-tags.tsx | 113 +++++++++++++++- .../[block]/integration-block-detail.tsx | 41 ++---- .../connect-service-account-modal/index.ts | 4 + .../use-service-account-connect.ts | 76 +++++++++++ .../credential-selector.tsx | 67 +++++++--- .../lib/copilot/tools/handlers/oauth.test.ts | 62 +++++++++ apps/sim/lib/copilot/tools/handlers/oauth.ts | 20 +++ apps/sim/lib/copilot/vfs/serializers.test.ts | 75 +++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 56 ++++++++ .../copilot/vfs/service-account-gate.test.ts | 45 +++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 24 +++- apps/sim/lib/credentials/environment.ts | 6 + .../service-account-provider-ids.test.ts | 65 +++++++++ .../service-account-provider-ids.ts | 77 +++++++++++ .../lib/integrations/oauth-service.test.ts | 54 +++++++- apps/sim/lib/integrations/oauth-service.ts | 125 ++++++++++++++---- 20 files changed, 975 insertions(+), 76 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts create mode 100644 apps/sim/lib/copilot/vfs/service-account-gate.test.ts create mode 100644 apps/sim/lib/credentials/service-account-provider-ids.test.ts create mode 100644 apps/sim/lib/credentials/service-account-provider-ids.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 45780ca4a55..eaf22ab3ed1 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -144,6 +144,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 507b1b756cb..882da919fdc 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -143,6 +143,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index d244ef68704..875da1a0bdf 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -138,6 +138,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 8076d88cf14..09444ecccbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -167,3 +167,87 @@ describe('parseSpecialTags with ', () => { ]) }) }) + +describe('service_account credential tag', () => { + it('parses a service_account tag into a credential segment', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'slack' }) + const { segments } = parseSpecialTags(`Set this up: ${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toBeDefined() + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'slack' }, + }) + }) + + it('carries no value — the secret is typed into Sim’s own form, never the transcript', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' }) + const { segments } = parseSpecialTags(`${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect((credential as { data: { value?: string } }).data.value).toBeUndefined() + }) + + it('suppresses the tag while it is still streaming', () => { + // A half-streamed tag must not flash raw JSON into the message body. + const { segments, hasPendingTag } = parseSpecialTags( + 'Set this up: {"type": "service_a', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + const text = segments + .filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text') + .map((segment) => segment.content) + .join('') + expect(text).not.toContain('service_a') + }) +}) + +describe('service_account tag validation', () => { + it('rejects a provider-less tag, which would render an unresolvable control', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('rejects a blank provider', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account', provider: ' ' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('accepts an optional credentialId for reconnect and carries it through', () => { + const body = JSON.stringify({ + type: 'service_account', + provider: 'notion', + credentialId: 'cred_abc123', + }) + const { segments } = parseSpecialTags(`${body}`, false) + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' }, + }) + }) + + it('rejects a non-string credentialId', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it.each(['', ' '])( + 'rejects a blank credentialId (%j) so reconnect cannot target a missing credential', + (credentialId) => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index f44aab8407f..60227897d94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, useMemo, useState } from 'react' +import { createElement, lazy, Suspense, useMemo, useState } from 'react' import { ArrowRight, Button, @@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' +import { + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' @@ -27,6 +31,10 @@ import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' +// Deep import, not the barrel: the barrel also re-exports +// ConnectServiceAccountModal, and that edge would pull the modal into this +// chunk and defeat the lazy() split below. +import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -62,6 +70,17 @@ export interface UsageUpgradeTagData { message: string } +/** + * Kept out of the chat's initial chunk — it pulls in three provider-specific + * setup forms and is only mounted once a message actually offers a service + * account. + */ +const ConnectServiceAccountModal = lazy(() => + import( + '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' + ).then((m) => ({ default: m.ConnectServiceAccountModal })) +) + export const CREDENTIAL_TAG_TYPES = [ 'env_key', 'oauth_key', @@ -69,6 +88,7 @@ export const CREDENTIAL_TAG_TYPES = [ 'credential_id', 'link', 'secret_input', + 'service_account', ] as const export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number] @@ -88,6 +108,11 @@ export interface CredentialTagData { name?: string /** Where a secret_input value is persisted. Defaults to "workspace". */ scope?: SecretInputScope + /** + * Existing credential to reconnect in place (service_account only). Present = + * rotate the secret on this credential; absent = create a new one. + */ + credentialId?: string } export interface MothershipErrorTagData { @@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } + // A service_account tag is a control, not a value: it names the provider + // whose setup form to open, and the user types the secret into that form — + // so it never carries a `value`, but it is useless without a provider. An + // optional `credentialId` reconnects an existing service account in place; + // reject a blank one, since the renderer treats a truthy id as "reconnect" + // and would try to rotate a non-existent credential. + if (value.type === 'service_account') { + if (value.credentialId !== undefined) { + if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) { + return false + } + } + return typeof value.provider === 'string' && value.provider.trim().length > 0 + } // A sim_key chip is platform-filled: the model only marks where the workspace // API key belongs (it never holds the value) and Sim injects it from the tool // result, so the tag is valid with or without a `value`. Every other rendered @@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +/** + * Inline "set up a service account" control rendered for + * `{"type":"service_account","provider":"slack"}`. + * + * Opens `ConnectServiceAccountModal` over the chat rather than linking out to + * the integrations page — the user stays in the conversation that asked for + * the credential, and comes back to it with the credential in hand. + */ +function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { canEdit } = useUserPermissionsContext() + const [open, setOpen] = useState(false) + + const match = useMemo( + () => (data.provider ? resolveServiceAccountIntegration(data.provider) : null), + [data.provider] + ) + const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match]) + const target = useServiceAccountConnectTarget({ + serviceAccountProviderId: match?.serviceAccountProviderId, + serviceName: match?.serviceName, + serviceIcon: service?.serviceIcon, + }) + + // A credentialId reconnects (rotates the secret on) that existing service + // account in place rather than creating a new one — the modal keeps its id. + const reconnectCredentialId = data.credentialId + const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) + + // Creating a credential mutates the workspace — hide it from read-only + // members, and honour the provider's own preview gate (custom Slack bots + // ride the slack_v2 flag) so chat can't surface what the integrations page + // deliberately hides. + if (!target || target.hidden || !canEdit || !workspaceId) return null + + const label = reconnectCredentialId + ? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}` + : `${target.label} for ${target.serviceName}` + + return ( + <> + + {open && ( + + + + )} + + ) +} + function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() @@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { return } + if (data.type === 'service_account') { + return + } + if (data.type === 'sim_key') { // SecretReveal masks itself when there's no value, so a value-less tag (the // model's placeholder / persisted form) renders masked and a Sim-filled tag diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2d7abb20569..b11854646f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,25 +6,23 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' -import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' import { blockTypeToIconMap, type Integration, resolveOAuthServiceForIntegration, } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' -import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' +import { + ConnectServiceAccountModal, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' -import { getBlock } from '@/blocks' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { @@ -32,7 +30,6 @@ import { getTemplatesForBlock, type ScopedBlockTemplate, } from '@/blocks/registry' -import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -78,29 +75,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) - const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID - const blockOverlayVersion = useCustomBlockOverlayVersion() - // Custom Slack bots ride the slack_v2 preview flag: the setup surface stays - // hidden until that block is revealed for this viewer. - const slackBotPreviewHidden = useMemo(() => { - if (!isSlackBot) return false - const v2 = getBlock('slack_v2') - return !v2 || isHiddenUnder(overlayVisibility(), v2) - }, [isSlackBot, blockOverlayVersion]) - const hasServiceAccount = - Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden - // Vendor-accurate connect label: token-paste and client-credential - // providers use their own noun ("Add API key", "Add server-to-server app"); - // only true service-account providers (Google, Atlassian) say - // "Add service account". - const nounDescriptor = - getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ?? - getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId) - const serviceAccountConnectLabel = isSlackBot - ? 'Set up a custom bot' - : nounDescriptor - ? `Add ${nounDescriptor.connectNoun}` - : 'Add service account' + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: oauthService?.serviceAccountProviderId, + serviceName: oauthService?.serviceName, + serviceIcon: oauthService?.serviceIcon, + }) + const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden + const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account' const hasHandledConnectQueryRef = useRef(false) useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts index e09a3c81a74..f9c7989e1e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts @@ -2,3 +2,7 @@ export { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +export { + type ServiceAccountConnectTarget, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts new file mode 100644 index 00000000000..fdeda1993b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -0,0 +1,76 @@ +'use client' + +import { type ComponentType, useMemo } from 'react' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +import { getBlock } from '@/blocks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' + +/** + * Everything a caller needs to render a service-account connect control: + * whether to show it at all, what to call it, and the props the modal takes. + */ +export interface ServiceAccountConnectTarget { + serviceAccountProviderId: ServiceAccountProviderId + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** + * Vendor-accurate control label — token-paste and client-credential + * providers use their own noun ("Add API key", "Add server-to-server app"); + * only true service-account providers say "Add service account". + */ + label: string + /** + * True when the provider's setup surface must stay hidden for this viewer. + * Custom Slack bots ride the `slack_v2` preview flag, so any surface that + * offers one — the integrations page or the chat — has to honour it or the + * flag is trivially bypassed. + */ + hidden: boolean +} + +interface UseServiceAccountConnectTargetArgs { + serviceAccountProviderId: ServiceAccountProviderId | undefined + serviceName: string | undefined + serviceIcon: ComponentType<{ className?: string }> | undefined +} + +/** + * Derives the connect-control label and preview gating for a service-account + * provider. Shared by the integrations detail page and the chat's inline + * connect button so the two can't drift on either the wording or the gate. + */ +export function useServiceAccountConnectTarget({ + serviceAccountProviderId, + serviceName, + serviceIcon, +}: UseServiceAccountConnectTargetArgs): ServiceAccountConnectTarget | null { + const blockOverlayVersion = useCustomBlockOverlayVersion() + + const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID + + const hidden = useMemo(() => { + const gatingBlockType = serviceAccountProviderId + ? getServiceAccountGatingBlockType(serviceAccountProviderId) + : null + if (!gatingBlockType) return false + const gatingBlock = getBlock(gatingBlockType) + return !gatingBlock || isHiddenUnder(overlayVisibility(), gatingBlock) + // blockOverlayVersion is read to re-evaluate when the overlay changes. + }, [serviceAccountProviderId, blockOverlayVersion]) + + return useMemo(() => { + if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null + + const label = isSlackBot + ? 'Set up a custom bot' + : `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}` + + return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden } + }, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 8611bf7ac72..6674f8f6d6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -17,6 +17,7 @@ import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/conn import { ConnectServiceAccountModal, type ServiceAccountProviderId, + useServiceAccountConnectTarget, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' @@ -130,6 +131,20 @@ export function CredentialSelector({ [credentialKind, isMergedKinds, serviceId] ) + // Canonical resolver for the service-account connect control: the vendor- + // accurate label and — critically — the per-viewer preview gate (a custom + // Slack bot rides `slack_v2`). Shared with the integrations page and chat so + // the gate can't be bypassed here. When `hidden`, the setup action is + // suppressed; existing service-account credentials stay selectable. + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId as + | ServiceAccountProviderId + | undefined, + serviceName: serviceAccountService?.name, + serviceIcon: serviceAccountService?.icon, + }) + const serviceAccountConnectHidden = Boolean(serviceAccountTarget?.hidden) + const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === selectedId), [credentials, selectedId] @@ -249,19 +264,23 @@ export function CredentialSelector({ iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider), })) - options.push({ - label: - credentialKind === 'service-account' - ? (subBlock.credentialLabels?.serviceAccountConnect ?? - (credentials.length > 0 - ? `Add another ${getProviderName(provider)} key` - : `Add ${getProviderName(provider)} key`)) - : credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - iconElement: , - }) + // Suppress the setup action when the service-account flow is preview-gated + // for this viewer (a custom Slack bot needs slack_v2) — existing accounts + // above stay selectable. + if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) { + options.push({ + label: + credentialKind === 'service-account' + ? (subBlock.credentialLabels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`) + : credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + iconElement: , + }) + } return options }, [ @@ -271,6 +290,8 @@ export function CredentialSelector({ credentials, credentialKind, subBlock.credentialLabels, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, @@ -302,11 +323,19 @@ export function CredentialSelector({ section: labels?.serviceAccountGroup ?? 'Service accounts', items: [ ...credentials.filter((c) => c.type === 'service_account').map(toOption), - { - label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`, - value: '__connect_service_account__', - iconElement: , - }, + // Drop the setup action when the flow is preview-gated for this viewer. + ...(serviceAccountConnectHidden + ? [] + : [ + { + label: + labels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`, + value: '__connect_service_account__', + iconElement: , + }, + ]), ], }, ] @@ -314,6 +343,8 @@ export function CredentialSelector({ isMergedKinds, subBlock.credentialLabels, credentials, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 30870b9d636..1168528e56a 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -207,3 +207,65 @@ describe('executeOAuthGetAuthLink', () => { }) }) }) + +describe('executeOAuthGetAuthLink service account rejection', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) + }) + + /** + * Regression: a user asked for a "new custom bot", the agent correctly + * resolved that to `slack-custom-bot` and passed it here, and the fuzzy + * substring pass matched it to the Slack OAuth service — `slack-custom-bot` + * contains `slack`. The tool returned a personal-OAuth authorize URL and + * reported success, so the user connected their own account instead of a + * shared bot. Failing loudly is the point: a wrong link that looks right is + * worse than an error the agent can recover from. + */ + it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('service account') + expect(result.error).toContain('service_account credential tag') + const output = result.output as { setup_url?: string; oauth_url?: string; message: string } + // The rejection must not fall into the generic catch, which would attach a + // contradicting workspace oauth_url and a "connect manually" message — the + // agent would then surface a workspace link instead of the tag. + expect(output.setup_url).toBeUndefined() + expect(output.oauth_url).toBeUndefined() + expect(output.message).toContain('service_account credential tag') + expect(output.message).not.toContain('Connect manually') + }) + + it.each([ + 'notion-service-account', + 'salesforce-service-account', + 'google-service-account', + 'atlassian-service-account', + 'SLACK-CUSTOM-BOT', + // Readable forms must be normalized (spaces/underscores → hyphens) so they + // are caught too, not passed to the fuzzy OAuth resolver. + 'slack custom bot', + 'google service account', + 'notion_service_account', + ])('rejects %s', async (providerName) => { + const result = await executeOAuthGetAuthLink({ providerName }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('service_account credential tag') + }) + + it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { + // `slack` and `notion` must keep working — the guard keys off the id being + // a service-account id, not off the integration having a service-account flow. + for (const providerName of ['slack', 'google-email']) { + const result = await executeOAuthGetAuthLink({ providerName }, context) + expect(result.success).toBe(true) + expect((result.output as { oauth_url: string }).oauth_url).toContain( + '/api/auth/oauth2/authorize' + ) + } + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index e3b55c36bae..e392833e6e8 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -3,6 +3,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' import { getCredentialActorContext } from '@/lib/credentials/access' +import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' import { getAllOAuthServices } from '@/lib/oauth/utils' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -14,6 +15,25 @@ export async function executeOAuthGetAuthLink( const rawCredentialId = rawParams.credentialId || rawParams.credential_id const credentialId = rawCredentialId ? String(rawCredentialId) : undefined const baseUrl = getBaseUrl() + + // A service account is not an OAuth provider. Catch it here — before the fuzzy + // resolver's substring match can swallow e.g. `slack-custom-bot` into the + // Slack OAuth service — and return a coherent failure directly rather than + // throwing into the generic catch below, which would attach a contradicting + // workspace `oauth_url` and "connect manually" message. Normalize spaces and + // underscores so a readable form ("slack custom bot") is caught too. + const serviceAccountId = providerName + .toLowerCase() + .trim() + .replace(/[\s_]+/g, '-') + if (isServiceAccountProviderId(serviceAccountId)) { + const message = + `"${providerName}" is a service account, not an OAuth provider. ` + + `Emit a service_account credential tag with the service's OAuth provider ` + + `value instead (e.g. "slack") — it opens the service account setup form in chat.` + return { success: false, error: message, output: { message } } + } + try { if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 728db16cd6d..daf3c3c8086 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -8,6 +8,7 @@ import type { ToolConfig } from '@/tools/types' import { serializeApiKeyIntegrations, serializeBlockSchema, + serializeCredentials, serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, @@ -284,3 +285,77 @@ describe('serializeKBMeta', () => { expect(missing).not.toHaveProperty('tagDefinitions') }) }) + +function oauthTool(id: string, provider: string): ToolConfig { + return { + id, + name: id, + description: `Run ${id}`, + version: '1.0.0', + params: {}, + request: { url: 'https://example.com', method: 'POST', headers: () => ({}) }, + oauth: { required: true, provider }, + } +} + +describe('serializeIntegrationSchema — service-account auth', () => { + it('marks an OAuth service that also offers a service account, with its secret noun', () => { + // Notion connects via OAuth or via an internal integration token; the agent + // must be able to discover the second option from the same auth field. + const schema = JSON.parse(serializeIntegrationSchema(oauthTool('notion_read', 'notion'))) + expect(schema.auth).toMatchObject({ + type: 'oauth', + provider: 'notion', + serviceAccount: { connectNoun: 'integration secret' }, + }) + }) + + it('omits serviceAccount for an OAuth service that has no service-account flow', () => { + const schema = JSON.parse(serializeIntegrationSchema(oauthTool('gh_read', 'github'))) + expect(schema.auth.type).toBe('oauth') + expect(schema.auth.serviceAccount).toBeUndefined() + }) + + // The preview-gate behavior (slack custom bot ↔ slack_v2) is covered in + // service-account-gate.test.ts, which mocks getBlock — the block registry is + // globally stubbed here, so slack_v2's real `preview: true` isn't observable + // through serializeIntegrationSchema. +}) + +describe('serializeCredentials — type distinguishes reconnect flow', () => { + const now = new Date('2026-07-21T00:00:00.000Z') + + it('marks a service account so the agent reconnects it via the tag, not oauth', () => { + const json = JSON.parse( + serializeCredentials([ + { + id: 'c1', + providerId: 'notion-service-account', + scope: null, + credentialType: 'service_account', + createdAt: now, + }, + { + id: 'c2', + providerId: 'google-email', + scope: null, + credentialType: 'oauth', + createdAt: now, + }, + ]) + ) + expect(json[0]).toMatchObject({ + id: 'c1', + provider: 'notion-service-account', + type: 'service_account', + }) + expect(json[1]).toMatchObject({ id: 'c2', provider: 'google-email', type: 'oauth' }) + }) + + it('leaves env-var credentials typeless', () => { + const json = JSON.parse( + serializeCredentials([{ providerId: 'OPENAI_API_KEY', scope: 'workspace', createdAt: now }]) + ) + expect(json[0].type).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 2750383005e..d394ed07347 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,18 +1,39 @@ import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import { isHiddenUnder } from '@/blocks/visibility/context' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' +/** The service-account alternative to OAuth for a service, when it offers one. */ +export interface VfsServiceAccountAuth { + /** Vendor noun for the secret it collects — "private app token", "server-to-server app", … */ + connectNoun: string +} + export type VfsToolAuth = | { type: 'oauth' required: boolean provider: string + /** + * Present when this OAuth service also accepts a shared service-account + * credential (connect AS AN APPLICATION, not as the user). The agent emits + * a `service_account` credential tag with this entry's OAuth `provider` to + * open the in-chat setup form. Omitted when the service has no + * service-account flow, or its flow is gated by a preview block. + */ + serviceAccount?: VfsServiceAccountAuth } | { type: 'api_key' @@ -22,6 +43,33 @@ export type VfsToolAuth = condition?: ToolHostingCondition } +/** + * Whether an OAuth provider value also exposes a service-account flow, and the + * noun for the secret it collects. The single composition point behind both the + * per-tool `auth.serviceAccount` field and the `oauth-integrations.json` + * roll-up, so the two never disagree. Returns `undefined` when the service has + * no service-account flow, or its flow is gated by a preview block (a custom + * Slack bot needs slack_v2) — GA-only discovery, so the agent never proactively + * offers a preview flow, matching the per-viewer gate the renderer applies. + */ +export function describeServiceAccountForOAuthProvider( + oauthProvider: string +): VfsServiceAccountAuth | undefined { + const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider) + if (!serviceAccountProviderId) return undefined + const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId) + if (gatingBlockType) { + const gatingBlock = getBlock(gatingBlockType) + // Omit when the gating block is missing (fail-closed) or hidden by the + // canonical predicate. Passing `null` vis reduces `isHiddenUnder` to the + // static preview check — so once the block GAs and drops `preview`, it is + // no longer hidden and discovery includes it again, matching the renderer. + // Hand-rolling `?.preview ?? true` would keep it omitted forever after GA. + if (!gatingBlock || isHiddenUnder(null, gatingBlock)) return undefined + } + return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) } +} + export interface ComponentSerializationOptions { hosted?: boolean toolConfigs?: ReadonlyMap @@ -33,10 +81,12 @@ export interface ComponentSerializationOptions { */ export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined { if (tool.oauth) { + const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider) return { type: 'oauth', required: tool.oauth.required, provider: tool.oauth.provider, + ...(serviceAccount ? { serviceAccount } : {}), } } @@ -589,6 +639,8 @@ export function serializeCredentials( displayName?: string | null role?: string | null scope: string | null + /** 'service_account' for a shared app credential; omitted/undefined for a personal OAuth connection. */ + credentialType?: 'oauth' | 'service_account' createdAt: Date }> ): string { @@ -599,6 +651,10 @@ export function serializeCredentials( displayName: a.displayName || undefined, role: a.role || undefined, scope: a.scope || undefined, + // 'oauth' (personal connection) vs 'service_account' (shared app + // credential) — they reconnect differently, so the agent must branch on + // this. Env-var credentials carry no type. + type: a.credentialType, connectedAt: a.createdAt.toISOString(), })), null, diff --git a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts new file mode 100644 index 00000000000..e525dae124b --- /dev/null +++ b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) +vi.mock('@/blocks', () => ({ getBlock: mockGetBlock })) + +import { describeServiceAccountForOAuthProvider } from '@/lib/copilot/vfs/serializers' + +describe('describeServiceAccountForOAuthProvider — preview gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('omits a service account whose gating block is still a preview block', () => { + mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) + expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() + }) + + it('includes it once the gating block GAs and drops preview', () => { + // slack_v2's documented GA migration removes `preview`. Discovery must then + // surface the custom bot, matching what the UI shows. A hand-rolled + // `?.preview ?? true` would keep it omitted forever — the "sticks after GA" + // regression; reusing isHiddenUnder(null, block) fixes it. + mockGetBlock.mockReturnValue({ type: 'slack_v2' }) + expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' }) + }) + + it('fail-closes (omits) when the gating block is missing entirely', () => { + mockGetBlock.mockReturnValue(undefined) + expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() + }) + + it('includes an ungated provider without consulting the block registry', () => { + expect(describeServiceAccountForOAuthProvider('notion')).toEqual({ + connectNoun: 'integration secret', + }) + expect(mockGetBlock).not.toHaveBeenCalled() + }) + + it('returns undefined for a provider with no service-account flow', () => { + expect(describeServiceAccountForOAuthProvider('github')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1a6096870c4..2e07211fc51 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -51,8 +51,13 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers' +import type { + DeploymentData, + KbTagDefinitionSummary, + VfsServiceAccountAuth, +} from '@/lib/copilot/vfs/serializers' import { + describeServiceAccountForOAuthProvider, serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, @@ -246,7 +251,15 @@ function getStaticComponentFiles(): Map { let integrationCount = 0 - const oauthServices = new Map() + // `serviceAccount` marks services that also accept a shared service-account + // credential (connect AS AN APPLICATION, not as the user) — the same + // `auth.serviceAccount` shape the per-operation schemas carry, so the agent + // discovers all three auth modes (oauth / api_key / service account) from one + // uniform field instead of a separate file. + const oauthServices = new Map< + string, + { provider: string; operations: string[]; serviceAccount?: VfsServiceAccountAuth } + >() // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the @@ -267,7 +280,11 @@ function getStaticComponentFiles(): Map { if (existing) { existing.operations.push(operation) } else { - oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] }) + oauthServices.set(service, { + provider: tool.oauth.provider, + operations: [operation], + serviceAccount: describeServiceAccountForOAuthProvider(tool.oauth.provider), + }) } } } @@ -2554,6 +2571,7 @@ export class WorkspaceVFS { displayName: c.displayName, role: c.role, scope: null, + credentialType: c.type, createdAt: c.updatedAt, })), ]) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 5fd7b711adf..9f2332b7cb1 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -481,6 +481,8 @@ export interface AccessibleOAuthCredential { providerId: string displayName: string role: 'admin' | 'member' + /** Distinguishes a personal OAuth connection from a shared service account. */ + type: 'oauth' | 'service_account' updatedAt: Date } @@ -498,6 +500,7 @@ export async function getAccessibleOAuthCredentials( id: credential.id, providerId: credential.providerId, displayName: credential.displayName, + type: credential.type, updatedAt: credential.updatedAt, }) .from(credential) @@ -515,6 +518,7 @@ export async function getAccessibleOAuthCredentials( providerId: row.providerId, displayName: row.displayName, role: 'admin' as const, + type: row.type as AccessibleOAuthCredential['type'], updatedAt: row.updatedAt, })) } @@ -525,6 +529,7 @@ export async function getAccessibleOAuthCredentials( providerId: credential.providerId, displayName: credential.displayName, role: credentialMember.role, + type: credential.type, updatedAt: credential.updatedAt, }) .from(credential) @@ -550,6 +555,7 @@ export async function getAccessibleOAuthCredentials( providerId: row.providerId!, displayName: row.displayName, role: row.role, + type: row.type as AccessibleOAuthCredential['type'], updatedAt: row.updatedAt, })) } diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts new file mode 100644 index 00000000000..2e89d228f68 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, + isServiceAccountProviderId, +} from '@/lib/credentials/service-account-provider-ids' + +describe('isServiceAccountProviderId', () => { + it('recognizes every family of service-account id', () => { + expect(isServiceAccountProviderId('google-service-account')).toBe(true) + expect(isServiceAccountProviderId('atlassian-service-account')).toBe(true) + expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true) + expect(isServiceAccountProviderId('notion-service-account')).toBe(true) + expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) + }) + + it('is case- and whitespace-insensitive', () => { + expect(isServiceAccountProviderId(' SLACK-CUSTOM-BOT ')).toBe(true) + }) + + it('rejects OAuth provider values and unknowns', () => { + // The distinction the oauth_get_auth_link guard depends on: `slack` is an + // OAuth provider value, not a service-account id, even though Slack offers a + // custom bot. + expect(isServiceAccountProviderId('slack')).toBe(false) + expect(isServiceAccountProviderId('google-email')).toBe(false) + expect(isServiceAccountProviderId('github')).toBe(false) + expect(isServiceAccountProviderId('')).toBe(false) + }) +}) + +describe('getServiceAccountGatingBlockType', () => { + it('maps the custom Slack bot to slack_v2 and leaves everything else ungated', () => { + expect(getServiceAccountGatingBlockType('slack-custom-bot')).toBe('slack_v2') + expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull() + }) +}) + +describe('getServiceAccountConnectNoun', () => { + it('names the token-paste secret each provider actually collects', () => { + expect(getServiceAccountConnectNoun('notion-service-account')).toBe('integration secret') + expect(getServiceAccountConnectNoun('hubspot-service-account')).toBe('private app token') + expect(getServiceAccountConnectNoun('linear-service-account')).toBe('API key') + }) + + it('names the client-credential secret', () => { + expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app') + }) + + it('calls a custom Slack bot a custom bot', () => { + expect(getServiceAccountConnectNoun('slack-custom-bot')).toBe('custom bot') + }) + + it('falls back to the generic noun for bespoke providers with no descriptor', () => { + // Google (paste a JSON key) and Atlassian (token + domain) have no + // token/client descriptor, so they read as a plain "service account". + expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account') + expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account') + }) +}) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts new file mode 100644 index 00000000000..9e838b0a313 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -0,0 +1,77 @@ +import { + getClientCredentialAccountDescriptor, + isClientCredentialAccountProviderId, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + getTokenServiceAccountDescriptor, + isTokenServiceAccountProviderId, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' + +/** + * Narrows a runtime provider-id string to the {@link ServiceAccountProviderId} + * union. Anything outside the union is unsupported by + * `ConnectServiceAccountModal`. + * + * Lives here rather than beside the integration catalog so callers that only + * need the predicate — not a slug — avoid pulling in `integrations.json` and + * the `OAUTH_PROVIDERS` walk. + */ +export function asServiceAccountProviderId( + value: string | undefined +): ServiceAccountProviderId | undefined { + if ( + value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID || + value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID || + value === SLACK_CUSTOM_BOT_PROVIDER_ID || + isTokenServiceAccountProviderId(value) || + isClientCredentialAccountProviderId(value) + ) { + return value + } + return undefined +} + +/** + * Whether a string is itself a service-account provider id + * (`slack-custom-bot`, `notion-service-account`, …) rather than an OAuth + * provider value. + * + * Note this asks what the id *is*, not whether the named integration happens + * to offer a service-account flow: `slack` is an OAuth provider value and + * returns false even though Slack also supports a custom bot. + */ +export function isServiceAccountProviderId(value: string): boolean { + return asServiceAccountProviderId(value.toLowerCase().trim()) !== undefined +} + +/** + * The block type whose preview gate governs a service-account provider's setup + * surface, or `null` when the provider is ungated. A custom Slack bot is only + * usable through `slack_v2`, so its setup form must stay hidden wherever that + * block is preview-hidden — both the in-chat connect button and the tool that + * offers it read this so they can't disagree on availability. + */ +export function getServiceAccountGatingBlockType(providerId: string): string | null { + return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null +} + +/** + * Vendor-accurate noun for the credential a service-account provider collects + * ("private app token", "server-to-server app", …), for connect-control labels + * and agent-facing discovery. Token-paste and client-credential providers name + * their own; bespoke providers (Google JSON key, Atlassian token) fall back to + * the generic "service account". Single source shared by the connect hook and + * the VFS catalog so the wording can't drift. + */ +export function getServiceAccountConnectNoun(providerId: string): string { + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot' + const descriptor = + getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId) + return descriptor?.connectNoun ?? 'service account' +} diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..dbd70af7c58 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest' import integrationsJson from '@/lib/integrations/integrations.json' -import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service' +import { + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' const INTEGRATIONS = integrationsJson.integrations as readonly Integration[] @@ -129,3 +132,52 @@ describe('resolveOAuthServiceForSlug', () => { expect(unexpected).toEqual([]) }) }) + +describe('resolveServiceAccountIntegration', () => { + it.concurrent('keeps a named service instead of collapsing to the family default', () => { + // Every Google integration issues the same google-service-account + // credential, so a fuzzy matcher can silently answer Drive for all of + // them. The user asked about Sheets; the link must land on Sheets. + expect(resolveServiceAccountIntegration('google-sheets')?.slug).toBe('google-sheets') + expect(resolveServiceAccountIntegration('gmail')?.slug).toBe('gmail') + expect(resolveServiceAccountIntegration('confluence')?.slug).toBe('confluence') + }) + + it.concurrent('resolves a family name to its canonical slug, not an arbitrary member', () => { + // Without an explicit canonical entry these fall through to fuzzy + // matching, which answers whichever member sorts first (BigQuery). + expect(resolveServiceAccountIntegration('google')?.slug).toBe('google-drive') + expect(resolveServiceAccountIntegration('google-service-account')?.slug).toBe('google-drive') + expect(resolveServiceAccountIntegration('atlassian')?.slug).toBe('jira') + expect(resolveServiceAccountIntegration('atlassian-service-account')?.slug).toBe('jira') + }) + + it.concurrent('accepts provider values, display names, and stray casing', () => { + expect(resolveServiceAccountIntegration('google-email')?.slug).toBe('gmail') + expect(resolveServiceAccountIntegration('slack-custom-bot')?.slug).toBe('slack') + expect(resolveServiceAccountIntegration('calcom')?.slug).toBe('cal-com') + expect(resolveServiceAccountIntegration('Cal.com')?.slug).toBe('cal-com') + expect(resolveServiceAccountIntegration(' NOTION ')?.slug).toBe('notion') + }) + + it.concurrent( + 'accepts the space/underscore-normalized id forms the oauth guard steers toward', + () => { + // oauth_get_auth_link rejects `slack custom bot` / `notion_service_account` + // and tells the agent to emit a service_account tag; the renderer must then + // resolve those same readable forms or the connect control renders nothing. + expect(resolveServiceAccountIntegration('slack custom bot')?.slug).toBe('slack') + expect(resolveServiceAccountIntegration('notion_service_account')?.slug).toBe('notion') + expect(resolveServiceAccountIntegration('google service account')?.slug).toBe('google-drive') + } + ) + + it.concurrent('returns null rather than inventing a link for unsupported input', () => { + // The handler turns null into "use oauth_get_auth_link instead"; a wrong + // match here would send the user to a modal that cannot take their key. + expect(resolveServiceAccountIntegration('github')).toBeNull() + expect(resolveServiceAccountIntegration('dropbox')).toBeNull() + expect(resolveServiceAccountIntegration('')).toBeNull() + expect(resolveServiceAccountIntegration(' ')).toBeNull() + }) +}) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 4cba5ce809e..6ef45d07179 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,10 +1,8 @@ import type { ComponentType } from 'react' -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { asServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' import integrationsJson from '@/lib/integrations/integrations.json' import type { Integration } from '@/lib/integrations/types' import { getServiceConfigByServiceId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' const INTEGRATIONS_DATA: readonly Integration[] = @@ -29,26 +27,6 @@ export interface OAuthServiceMatch { serviceAccountProviderId?: ServiceAccountProviderId } -/** - * Narrows the runtime `OAuthServiceConfig.serviceAccountProviderId` string to - * the {@link ServiceAccountProviderId} union. Anything outside the union is - * unsupported by `ConnectServiceAccountModal` and is silently ignored. - */ -function asServiceAccountProviderId( - value: string | undefined -): ServiceAccountProviderId | undefined { - if ( - value === 'google-service-account' || - value === 'atlassian-service-account' || - value === SLACK_CUSTOM_BOT_PROVIDER_ID || - isTokenServiceAccountProviderId(value) || - isClientCredentialAccountProviderId(value) - ) { - return value - } - return undefined -} - /** * Looks up the OAuth service entry registered under the integration's * `oauthServiceId` — the service id its block declares on the `oauth-input` @@ -81,3 +59,104 @@ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | nu if (!integration) return null return resolveOAuthServiceForIntegration(integration) } + +/** + * An integration that exposes a service-account connect flow, resolved to the + * catalog slug whose detail page mounts `ConnectServiceAccountModal`. + */ +export interface ServiceAccountIntegrationMatch { + slug: string + serviceAccountProviderId: ServiceAccountProviderId + serviceName: string + providerId: string +} + +/** + * Slug to prefer for a query that names a family rather than one of its + * integrations — either the shared service-account provider id or the bare + * base provider. Every Google integration issues the same + * `google-service-account` credential and every Atlassian one the same + * `atlassian-service-account`, so an unqualified request has to land + * somewhere; these are the most general surface of each family. + * + * Without the bare-provider entries, fuzzy matching resolves `google` to + * whichever Google integration sorts first in the catalog (BigQuery), which is + * both arbitrary and a poor landing page. A caller that names a specific + * integration still gets that integration. + */ +const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly> = { + 'google-service-account': 'google-drive', + google: 'google-drive', + 'atlassian-service-account': 'jira', + atlassian: 'jira', +} as const + +/** + * Every integration that offers a service-account flow, in catalog order. + * Built once — `resolveOAuthServiceForIntegration` walks `OAUTH_PROVIDERS` per + * entry, which is wasted work to repeat on each lookup. + */ +const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = + INTEGRATIONS_DATA.flatMap((integration) => { + const match = resolveOAuthServiceForIntegration(integration) + if (!match?.serviceAccountProviderId) return [] + return [ + { + slug: integration.slug, + serviceAccountProviderId: match.serviceAccountProviderId, + serviceName: integration.name, + providerId: match.providerId, + }, + ] + }) + +/** + * Resolves a loosely-specified integration name to the service-account setup + * surface for it. Accepts a catalog slug (`google-sheets`), an OAuth provider + * value (`google-email`), a service-account provider id + * (`google-service-account`), or a display name (`Google Sheets`). + * + * Exact matches are tried before fuzzy ones so a caller naming a specific + * integration always lands on it: `gmail` must not fall through to Drive just + * because both issue the same Google service account. A bare service-account + * provider id names no single integration, so it resolves through + * {@link CANONICAL_SERVICE_ACCOUNT_SLUGS}. + * + * The id-based lookups also accept the space/underscore-normalized form + * (`slack custom bot`, `notion_service_account`) that `oauth_get_auth_link`'s + * guard rejects and steers toward a `service_account` tag — otherwise the chat + * renderer would fail to resolve exactly the forms the guard produced. + * + * Returns `null` when nothing matches or the named integration has no + * service-account flow — callers should fall back to OAuth rather than + * inventing a link. + */ +export function resolveServiceAccountIntegration( + providerName: string +): ServiceAccountIntegrationMatch | null { + const query = providerName.toLowerCase().trim() + if (!query) return null + // Hyphenated form for id lookups (slug / providerId / SA-id are hyphenated); + // the raw query is kept for display-name matches, which aren't hyphenated. + const idQuery = query.replace(/[\s_]+/g, '-') + + const canonicalSlug = CANONICAL_SERVICE_ACCOUNT_SLUGS[idQuery] + if (canonicalSlug) { + const canonical = SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === canonicalSlug) + if (canonical) return canonical + } + + return ( + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === idQuery) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.providerId.toLowerCase() === idQuery) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find( + (entry) => entry.serviceAccountProviderId.toLowerCase() === idQuery + ) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.serviceName.toLowerCase() === query) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find( + (entry) => + entry.serviceName.toLowerCase().includes(query) || entry.slug.replace(/-/g, ' ') === query + ) ?? + null + ) +} From 3215a12da9190bac620a156f4d0c53900b1e6d28 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 14:15:44 -0700 Subject: [PATCH 12/24] improvement(testing): consolidate @sim/db mocks into one table-aware chain mock (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(testing): consolidate @sim/db mocks into one table-aware chain mock - back databaseMock and dbChainMock with the SAME db instance so a module bound to either export hits identical chain fns — rival-mock divergence between the two @sim/testing db mocks is structurally impossible now - add queueTableRows(table, rows): FIFO per-table select routing keyed by schema-mock table identity, consumed at where() materialization and resolved by every downstream terminal (limit/orderBy/groupBy/for/joins) - delete createMockDb (duplicate chain implementation, no external users) - migrate the five suites that hand-rolled table routing + databaseMock delegation (billing plan/usage/usage-log, admin dashboard-organizations, workspaces/utils) onto queueTableRows; net -295 lines - add a contract test for the mock itself and a test script to @sim/testing so its tests actually run under turbo * fix(testing): harden table routing — join-table queues, direct-await from, mutation isolation - track the chain's tables as a list (from + joins) so rows queued for a join-only table route correctly; from-table queue checked first - make the from/join builder a lazy thenable so awaiting a select with no where clause resolves queued rows (dequeue at await, never double-consumed) - update/delete/set clear the routing context so a mutation's where() can never consume rows queued for a select - document the left-to-right chain-construction assumption; contract tests for all three behaviors * fix(testing): close routing over each chain's own tables for direct-await builders * refactor(testing): move all chain routing state into per-chain closures - shared dbChainMockFns entries become pure spy/override ports: their default implementation returns a sentinel that chain-local builders replace, while any mock* override on the spy wins verbatim - each select().from() captures its own immutable table list; where(), joins, terminals, and direct awaits all resolve through that closure, so partially-built chains for different tables interleave without cross-talk - no module-level routing state remains * fix(testing): lazy queue consumption at resolution and wrapper restore on reset - each chain holds one lazy rows supplier: the queued set is dequeued only when a default thenable actually resolves, so a chain answered by a per-test terminal override leaves its queued rows for the next chain - resetDbChainMock also mockReset()s the stable db entry-point wrappers so direct overrides on databaseMock.db.* cannot outlive a suite --- .../lib/admin/dashboard-organizations.test.ts | 150 ++----- apps/sim/lib/billing/core/plan.test.ts | 161 ++----- apps/sim/lib/billing/core/usage-log.test.ts | 49 +- apps/sim/lib/billing/core/usage.test.ts | 46 +- apps/sim/lib/workspaces/utils.test.ts | 73 +-- packages/testing/package.json | 1 + .../testing/src/mocks/database.mock.test.ts | 156 +++++++ packages/testing/src/mocks/database.mock.ts | 422 ++++++++++-------- packages/testing/src/mocks/index.ts | 2 +- 9 files changed, 488 insertions(+), 572 deletions(-) create mode 100644 packages/testing/src/mocks/database.mock.test.ts diff --git a/apps/sim/lib/admin/dashboard-organizations.test.ts b/apps/sim/lib/admin/dashboard-organizations.test.ts index 00c87bc32cc..3bf092f9585 100644 --- a/apps/sim/lib/admin/dashboard-organizations.test.ts +++ b/apps/sim/lib/admin/dashboard-organizations.test.ts @@ -1,15 +1,12 @@ /** @vitest-environment node */ -import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import type { Mock } from 'vitest' +import { member, organization, permissions, subscription } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.unmock('drizzle-orm') const mocks = vi.hoisted(() => ({ - queryRows: [] as unknown[][], - selectCalls: 0, - selectDistinctOnCalls: 0, provisionings: new Map(), })) @@ -65,78 +62,8 @@ vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn() })) import { listDashboardOrganizations, toDashboardConfigurationUpdate } from '@/lib/admin/dashboard' -/** - * `@sim/db` behavior is driven through the SHARED `dbChainMockFns` instances - * instead of a file-local factory object. This file mocks `@sim/db` with - * `dbChainMock` and installs the queued-rows select implementation in - * `beforeEach`; the setup-level `databaseMock` entry points are mirrored onto - * the same chain fns. Under `isolate: false` the module under test may have - * been loaded by an earlier suite in this worker with `@sim/db` bound to - * `databaseMock` — configuring both shared instances keeps either binding - * correct. - */ -function queryChain(rows: unknown[]) { - const chain: Record = {} - for (const method of ['from', 'innerJoin', 'leftJoin', 'where', 'orderBy', 'limit']) { - chain[method] = () => chain - } - chain.offset = () => Promise.resolve(rows) - chain.groupBy = () => Promise.resolve(rows) - chain.then = (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => - Promise.resolve(rows).then(resolve, reject) - return chain -} - -function queuedSelect() { - const rows = mocks.queryRows[mocks.selectCalls] ?? [] - mocks.selectCalls += 1 - return queryChain(rows) -} - -function queuedSelectDistinctOn() { - const rows = mocks.queryRows[mocks.selectCalls] ?? [] - mocks.selectCalls += 1 - mocks.selectDistinctOnCalls += 1 - return queryChain(rows) -} - -const GLOBAL_DB_KEYS = [ - 'select', - 'selectDistinct', - 'selectDistinctOn', - 'insert', - 'update', - 'delete', - 'transaction', -] as const - -const globalDb = databaseMock.db as unknown as Record<(typeof GLOBAL_DB_KEYS)[number], Mock> -const savedGlobalDbImpls = new Map< - (typeof GLOBAL_DB_KEYS)[number], - ((...args: unknown[]) => unknown) | undefined ->() - -/** Mirrors the setup-level databaseMock entry points onto the shared chain fns. */ -function delegateGlobalDbToChainMocks(): void { - for (const key of GLOBAL_DB_KEYS) { - const fn = globalDb[key] - if (typeof fn?.mockImplementation !== 'function') continue - if (!savedGlobalDbImpls.has(key)) savedGlobalDbImpls.set(key, fn.getMockImplementation()) - fn.mockImplementation((...args: unknown[]) => (dbChainMockFns[key] as Mock)(...args)) - } -} - -/** Restores the databaseMock entry points captured before this suite ran. */ -function restoreGlobalDb(): void { - for (const [key, impl] of savedGlobalDbImpls) { - if (impl) globalDb[key].mockImplementation(impl) - else globalDb[key].mockReset() - } -} - afterAll(() => { resetDbChainMock() - restoreGlobalDb() }) describe('toDashboardConfigurationUpdate', () => { @@ -173,48 +100,41 @@ describe('listDashboardOrganizations', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - dbChainMockFns.select.mockImplementation(queuedSelect) - dbChainMockFns.selectDistinctOn.mockImplementation(queuedSelectDistinctOn) - delegateGlobalDbToChainMocks() - mocks.selectCalls = 0 - mocks.selectDistinctOnCalls = 0 mocks.provisionings = new Map() }) it('loads a page with a fixed batch of queries instead of querying once per organization', async () => { - mocks.queryRows = [ - [{ total: 2 }], - [ - { id: 'org-1', name: 'One', orgUsageLimit: '10', creditBalance: '1' }, - { id: 'org-2', name: 'Two', orgUsageLimit: '20', creditBalance: '2' }, - ], - [ - { - organizationId: 'org-1', - memberCount: 2, - ownerId: 'owner-1', - ownerName: 'Owner One', - ownerEmail: 'one@example.com', - }, - { - organizationId: 'org-2', - memberCount: 1, - ownerId: 'owner-2', - ownerName: 'Owner Two', - ownerEmail: 'two@example.com', - }, - ], - [{ organizationId: 'org-1', externalCollaboratorCount: 3 }], - [ - { - id: 'sub-1', - referenceId: 'org-1', - plan: 'team_6000', - status: 'active', - metadata: null, - }, - ], - ] + queueTableRows(organization, [{ total: 2 }]) + queueTableRows(organization, [ + { id: 'org-1', name: 'One', orgUsageLimit: '10', creditBalance: '1' }, + { id: 'org-2', name: 'Two', orgUsageLimit: '20', creditBalance: '2' }, + ]) + queueTableRows(member, [ + { + organizationId: 'org-1', + memberCount: 2, + ownerId: 'owner-1', + ownerName: 'Owner One', + ownerEmail: 'one@example.com', + }, + { + organizationId: 'org-2', + memberCount: 1, + ownerId: 'owner-2', + ownerName: 'Owner Two', + ownerEmail: 'two@example.com', + }, + ]) + queueTableRows(permissions, [{ organizationId: 'org-1', externalCollaboratorCount: 3 }]) + queueTableRows(subscription, [ + { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team_6000', + status: 'active', + metadata: null, + }, + ]) const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 }) @@ -231,7 +151,7 @@ describe('listDashboardOrganizations', () => { externalCollaboratorCount: 0, planLabel: 'No plan', }) - expect(mocks.selectCalls).toBe(5) - expect(mocks.selectDistinctOnCalls).toBe(1) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(4) + expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/billing/core/plan.test.ts b/apps/sim/lib/billing/core/plan.test.ts index a9e855f4861..9e776770eb9 100644 --- a/apps/sim/lib/billing/core/plan.test.ts +++ b/apps/sim/lib/billing/core/plan.test.ts @@ -2,8 +2,7 @@ * @vitest-environment node */ import { member, organization, subscription } from '@sim/db/schema' -import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import type { Mock } from 'vitest' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => dbChainMock) @@ -25,88 +24,20 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' /** - * Drizzle mock for `getHighestPrioritySubscription`. It issues up to four - * queries keyed by table: + * `getHighestPrioritySubscription` issues up to four queries keyed by table: * - `subscription` for the user's personal subs (parallelized with members) * - `member` for the user's org memberships (parallelized with subs) * - `organization` for the org-existence follow-up * - `subscription` again for the org-scoped subs follow-up * - * The mock routes results by the table object passed to `.from()`, serving the - * (twice-read) `subscription` table from a FIFO queue (first read = personal, - * second = org). It records which tables were queried so we can assert the - * parallelized pair both run and that follow-ups are skipped when appropriate. - * - * The routing implementation is installed in `beforeEach` onto the SHARED - * `dbChainMockFns.select` (this file mocks `@sim/db` with `dbChainMock`) AND - * mirrored onto the setup-level `databaseMock` entry points. Under - * `isolate: false` the module under test may have been loaded by an earlier - * suite in this worker with `@sim/db` bound to `databaseMock` — pointing both - * shared instances at the same routing keeps either binding correct. Table - * identity likewise relies on the setup-level `@sim/db/schema` mock (no local - * schema factory), which is stable across suites in the shared worker. + * Results are routed by the table object passed to `.from()` via + * `queueTableRows` (FIFO per table: first `subscription` read = personal, + * second = org). `dbChainMockFns.from` call args record which tables were + * queried so we can assert the parallelized pair both run and that follow-ups + * are skipped when appropriate. */ -type TableName = 'subscription' | 'member' | 'organization' - -const TABLE_NAMES = new Map([ - [subscription, 'subscription'], - [member, 'member'], - [organization, 'organization'], -]) - -const resultsByTable: Record = { - subscription: [], - member: [], - organization: [], -} -const fromCalls: string[] = [] - -function routedSelect() { - return { - from: (table: unknown) => { - const name = TABLE_NAMES.get(table) - if (name) fromCalls.push(name) - const where = () => { - const queue = name ? resultsByTable[name] : undefined - const next = queue && queue.length > 0 ? queue.shift() : [] - return Promise.resolve(next ?? []) - } - return { where } - }, - } -} - -const GLOBAL_DB_KEYS = [ - 'select', - 'selectDistinct', - 'insert', - 'update', - 'delete', - 'transaction', -] as const - -const globalDb = databaseMock.db as unknown as Record<(typeof GLOBAL_DB_KEYS)[number], Mock> -const savedGlobalDbImpls = new Map< - (typeof GLOBAL_DB_KEYS)[number], - ((...args: unknown[]) => unknown) | undefined ->() - -/** Mirrors the setup-level databaseMock entry points onto the shared chain fns. */ -function delegateGlobalDbToChainMocks(): void { - for (const key of GLOBAL_DB_KEYS) { - const fn = globalDb[key] - if (typeof fn?.mockImplementation !== 'function') continue - if (!savedGlobalDbImpls.has(key)) savedGlobalDbImpls.set(key, fn.getMockImplementation()) - fn.mockImplementation((...args: unknown[]) => (dbChainMockFns[key] as Mock)(...args)) - } -} - -/** Restores the databaseMock entry points captured before this suite ran. */ -function restoreGlobalDb(): void { - for (const [key, impl] of savedGlobalDbImpls) { - if (impl) globalDb[key].mockImplementation(impl) - else globalDb[key].mockReset() - } +function fromTables(): unknown[] { + return dbChainMockFns.from.mock.calls.map(([table]) => table) } interface SubRow { @@ -124,32 +55,21 @@ function orgEnterprise(orgId: string): SubRow { return { id: 'sub-org-enterprise', referenceId: orgId, plan: 'enterprise', status: 'active' } } -function queue(table: TableName, rows: unknown[]) { - resultsByTable[table].push(rows) -} - describe('getHighestPrioritySubscription', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - resultsByTable.subscription = [] - resultsByTable.member = [] - resultsByTable.organization = [] - fromCalls.length = 0 - dbChainMockFns.select.mockImplementation(routedSelect) - delegateGlobalDbToChainMocks() }) afterAll(() => { resetDbChainMock() - restoreGlobalDb() }) it('picks the org Enterprise sub over a personal Pro sub (priority order)', async () => { - queue('subscription', [personalPro('user-1')]) // personalSubs query - queue('member', [{ organizationId: 'org-1' }]) // memberships query - queue('organization', [{ id: 'org-1' }]) // org-existence query - queue('subscription', [orgEnterprise('org-1')]) // org-subscriptions query + queueTableRows(subscription, [personalPro('user-1')]) // personalSubs query + queueTableRows(member, [{ organizationId: 'org-1' }]) // memberships query + queueTableRows(organization, [{ id: 'org-1' }]) // org-existence query + queueTableRows(subscription, [orgEnterprise('org-1')]) // org-subscriptions query const result = await getHighestPrioritySubscription('user-1') @@ -159,10 +79,10 @@ describe('getHighestPrioritySubscription', () => { }) it('selection is deterministic regardless of which parallelized query resolves first', async () => { - queue('subscription', [personalPro('user-1')]) - queue('member', [{ organizationId: 'org-1' }]) - queue('organization', [{ id: 'org-1' }]) - queue('subscription', [orgEnterprise('org-1')]) + queueTableRows(subscription, [personalPro('user-1')]) + queueTableRows(member, [{ organizationId: 'org-1' }]) + queueTableRows(organization, [{ id: 'org-1' }]) + queueTableRows(subscription, [orgEnterprise('org-1')]) const result = await getHighestPrioritySubscription('user-1') @@ -170,35 +90,38 @@ describe('getHighestPrioritySubscription', () => { }) it('issues BOTH the personal-subscriptions and memberships queries (parallelized pair)', async () => { - queue('subscription', [personalPro('user-1')]) - queue('member', [{ organizationId: 'org-1' }]) - queue('organization', [{ id: 'org-1' }]) - queue('subscription', [orgEnterprise('org-1')]) + queueTableRows(subscription, [personalPro('user-1')]) + queueTableRows(member, [{ organizationId: 'org-1' }]) + queueTableRows(organization, [{ id: 'org-1' }]) + queueTableRows(subscription, [orgEnterprise('org-1')]) await getHighestPrioritySubscription('user-1') - expect(fromCalls).toContain('subscription') - expect(fromCalls).toContain('member') + expect(fromTables()).toContain(subscription) + expect(fromTables()).toContain(member) // First two queries are exactly the parallelized pair (in either order). - expect(fromCalls.slice(0, 2).sort()).toEqual(['member', 'subscription']) + const firstTwo = fromTables().slice(0, 2) + expect(firstTwo).toHaveLength(2) + expect(firstTwo).toContain(subscription) + expect(firstTwo).toContain(member) }) it('returns the personal sub and skips org follow-ups when there are no memberships', async () => { - queue('subscription', [personalPro('user-1')]) - queue('member', []) + queueTableRows(subscription, [personalPro('user-1')]) + queueTableRows(member, []) const result = await getHighestPrioritySubscription('user-1') expect(result?.id).toBe('sub-personal-pro') expect(result?.plan).toBe('pro') // org-existence + org-subscription follow-ups are NOT issued. - expect(fromCalls).not.toContain('organization') - expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1) + expect(fromTables()).not.toContain(organization) + expect(fromTables().filter((t) => t === subscription)).toHaveLength(1) }) it('returns null when neither personal nor org subscriptions exist', async () => { - queue('subscription', []) - queue('member', []) + queueTableRows(subscription, []) + queueTableRows(member, []) const result = await getHighestPrioritySubscription('user-1') @@ -206,27 +129,27 @@ describe('getHighestPrioritySubscription', () => { }) it('excludes orphaned org memberships whose organization row no longer exists', async () => { - queue('subscription', []) - queue('member', [{ organizationId: 'ghost-org' }]) // membership points at a deleted org - queue('organization', []) + queueTableRows(subscription, []) + queueTableRows(member, [{ organizationId: 'ghost-org' }]) // membership points at a deleted org + queueTableRows(organization, []) const result = await getHighestPrioritySubscription('user-1') // Org subs are never fetched (no valid org ids) -> falls back to null. expect(result).toBeNull() - expect(fromCalls).toContain('organization') + expect(fromTables()).toContain(organization) // Only the initial personal-subs read on `subscription`; org-subs query skipped. - expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1) + expect(fromTables().filter((t) => t === subscription)).toHaveLength(1) }) it('falls back to the personal sub when the only org is orphaned', async () => { - queue('subscription', [personalPro('user-1')]) - queue('member', [{ organizationId: 'ghost-org' }]) - queue('organization', []) + queueTableRows(subscription, [personalPro('user-1')]) + queueTableRows(member, [{ organizationId: 'ghost-org' }]) + queueTableRows(organization, []) const result = await getHighestPrioritySubscription('user-1') expect(result?.id).toBe('sub-personal-pro') - expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1) + expect(fromTables().filter((t) => t === subscription)).toHaveLength(1) }) }) diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 23206179b9f..6b1861ed1ac 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -2,8 +2,7 @@ * @vitest-environment node */ import { usageLog } from '@sim/db/schema' -import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import type { Mock } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -49,59 +48,17 @@ import { } from '@/lib/billing/core/usage-log' /** - * `@sim/db` behavior is driven through the SHARED mock instances rather than a - * file-local factory object. This file mocks `@sim/db` with `dbChainMock` and - * wires its `insert` / `transaction` entry points to this file's `mockInsert` - * / `mockTransaction` in `beforeEach`; the setup-level `databaseMock` entry - * points are mirrored onto the same chain fns. Under `isolate: false` the - * module under test may have been loaded by an earlier suite in this worker - * with `@sim/db` bound to `databaseMock` — configuring both shared instances - * keeps either binding correct. + * Re-wires the shared db mocks (`dbChainMockFns`, backing the single shared + * `@sim/db` mock instance) to this file's insert/transaction chain. */ -const GLOBAL_DB_KEYS = [ - 'select', - 'selectDistinct', - 'insert', - 'update', - 'delete', - 'transaction', -] as const - -const globalDb = databaseMock.db as unknown as Record<(typeof GLOBAL_DB_KEYS)[number], Mock> -const savedGlobalDbImpls = new Map< - (typeof GLOBAL_DB_KEYS)[number], - ((...args: unknown[]) => unknown) | undefined ->() - -/** Mirrors the setup-level databaseMock entry points onto the shared chain fns. */ -function delegateGlobalDbToChainMocks(): void { - for (const key of GLOBAL_DB_KEYS) { - const fn = globalDb[key] - if (typeof fn?.mockImplementation !== 'function') continue - if (!savedGlobalDbImpls.has(key)) savedGlobalDbImpls.set(key, fn.getMockImplementation()) - fn.mockImplementation((...args: unknown[]) => (dbChainMockFns[key] as Mock)(...args)) - } -} - -/** Restores the databaseMock entry points captured before this suite ran. */ -function restoreGlobalDb(): void { - for (const [key, impl] of savedGlobalDbImpls) { - if (impl) globalDb[key].mockImplementation(impl) - else globalDb[key].mockReset() - } -} - -/** Re-wires the shared db mocks to this file's insert/transaction chain. */ function installSharedDbMocks(): void { resetDbChainMock() dbChainMockFns.insert.mockImplementation((...args: unknown[]) => mockInsert(...args)) dbChainMockFns.transaction.mockImplementation((...args: unknown[]) => mockTransaction(...args)) - delegateGlobalDbToChainMocks() } afterAll(() => { resetDbChainMock() - restoreGlobalDb() }) describe('recordUsage', () => { diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index 443cea29f7a..a46caa1517a 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -8,55 +8,13 @@ * * @vitest-environment node */ -import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import type { Mock } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => dbChainMock) -/** - * Under `isolate: false` the module under test may have been loaded by an - * earlier suite in this shared worker with `@sim/db` bound to the setup-level - * `databaseMock` instead of this file's `dbChainMock`. Delegating the - * databaseMock entry points to the same shared chain fns keeps either binding - * correct. - */ -const GLOBAL_DB_KEYS = [ - 'select', - 'selectDistinct', - 'insert', - 'update', - 'delete', - 'transaction', -] as const - -const globalDb = databaseMock.db as unknown as Record<(typeof GLOBAL_DB_KEYS)[number], Mock> -const savedGlobalDbImpls = new Map< - (typeof GLOBAL_DB_KEYS)[number], - ((...args: unknown[]) => unknown) | undefined ->() - -/** Mirrors the setup-level databaseMock entry points onto the shared chain fns. */ -function delegateGlobalDbToChainMocks(): void { - for (const key of GLOBAL_DB_KEYS) { - const fn = globalDb[key] - if (typeof fn?.mockImplementation !== 'function') continue - if (!savedGlobalDbImpls.has(key)) savedGlobalDbImpls.set(key, fn.getMockImplementation()) - fn.mockImplementation((...args: unknown[]) => (dbChainMockFns[key] as Mock)(...args)) - } -} - -/** Restores the databaseMock entry points captured before this suite ran. */ -function restoreGlobalDb(): void { - for (const [key, impl] of savedGlobalDbImpls) { - if (impl) globalDb[key].mockImplementation(impl) - else globalDb[key].mockReset() - } -} - afterAll(() => { resetDbChainMock() - restoreGlobalDb() }) const { @@ -131,7 +89,6 @@ describe('getUserUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - delegateGlobalDbToChainMocks() mockIsOrgScopedSubscription.mockReturnValue(false) mockGetHighestPrioritySubscription.mockResolvedValue(null) }) @@ -213,7 +170,6 @@ describe('syncUsageLimitsFromSubscription', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - delegateGlobalDbToChainMocks() mockIsOrgScopedSubscription.mockReturnValue(false) }) diff --git a/apps/sim/lib/workspaces/utils.test.ts b/apps/sim/lib/workspaces/utils.test.ts index 9cbf74c2c3e..a1cd55d2ad7 100644 --- a/apps/sim/lib/workspaces/utils.test.ts +++ b/apps/sim/lib/workspaces/utils.test.ts @@ -1,8 +1,7 @@ /** * @vitest-environment node */ -import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import type { Mock } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockChangeWorkspaceStoragePayerInTx } = vi.hoisted(() => ({ @@ -21,55 +20,8 @@ import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, } from '@/lib/workspaces/utils' -/** - * `@sim/db` behavior is driven through the SHARED `dbChainMockFns` instances - * instead of a file-local factory object. Under `isolate: false` the module - * under test may have been loaded by an earlier suite in this shared worker - * with `@sim/db` bound to the setup-level `databaseMock` instead of this - * file's `dbChainMock`; delegating the databaseMock entry points to the same - * chain fns keeps either binding correct. - */ -const mockDb = { - select: dbChainMockFns.select, - transaction: dbChainMockFns.transaction, -} - -const GLOBAL_DB_KEYS = [ - 'select', - 'selectDistinct', - 'insert', - 'update', - 'delete', - 'transaction', -] as const - -const globalDb = databaseMock.db as unknown as Record<(typeof GLOBAL_DB_KEYS)[number], Mock> -const savedGlobalDbImpls = new Map< - (typeof GLOBAL_DB_KEYS)[number], - ((...args: unknown[]) => unknown) | undefined ->() - -/** Mirrors the setup-level databaseMock entry points onto the shared chain fns. */ -function delegateGlobalDbToChainMocks(): void { - for (const key of GLOBAL_DB_KEYS) { - const fn = globalDb[key] - if (typeof fn?.mockImplementation !== 'function') continue - if (!savedGlobalDbImpls.has(key)) savedGlobalDbImpls.set(key, fn.getMockImplementation()) - fn.mockImplementation((...args: unknown[]) => (dbChainMockFns[key] as Mock)(...args)) - } -} - -/** Restores the databaseMock entry points captured before this suite ran. */ -function restoreGlobalDb(): void { - for (const [key, impl] of savedGlobalDbImpls) { - if (impl) globalDb[key].mockImplementation(impl) - else globalDb[key].mockReset() - } -} - afterAll(() => { resetDbChainMock() - restoreGlobalDb() }) function createMockChain(finalResult: unknown) { @@ -114,7 +66,6 @@ describe('reassignBilledAccountForUser', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - delegateGlobalDbToChainMocks() }) it('routes each resolved workspace through the payer helper in its own transaction', async () => { @@ -122,19 +73,19 @@ describe('reassignBilledAccountForUser', () => { const tx = { update: vi.fn().mockReturnValue(updateChain), } - mockDb.select.mockReturnValueOnce( + dbChainMockFns.select.mockReturnValueOnce( createMockChain([ { id: 'workspace-personal', ownerId: 'owner-1', organizationId: null }, { id: 'workspace-org', ownerId: 'owner-2', organizationId: 'org-1' }, ]) ) - mockDb.transaction.mockImplementation( + dbChainMockFns.transaction.mockImplementation( async (callback: (transaction: typeof tx) => Promise) => callback(tx) ) const result = await reassignBilledAccountForUser('departing-user') - expect(mockDb.transaction).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2) expect(mockChangeWorkspaceStoragePayerInTx).toHaveBeenNthCalledWith(1, tx, { workspaceId: 'workspace-personal', organizationId: null, @@ -169,7 +120,7 @@ describe('reassignBilledAccountForUser', () => { const tx = { update: vi.fn().mockReturnValue(updateChain), } - mockDb.select + dbChainMockFns.select .mockReturnValueOnce( createMockChain([ { id: 'workspace-admin', ownerId: 'departing-user', organizationId: null }, @@ -178,7 +129,7 @@ describe('reassignBilledAccountForUser', () => { ) .mockReturnValueOnce(createMockChain([{ userId: 'admin-1' }])) .mockReturnValueOnce(createMockChain([])) - mockDb.transaction.mockImplementation( + dbChainMockFns.transaction.mockImplementation( async (callback: (transaction: typeof tx) => Promise) => callback(tx) ) @@ -203,13 +154,13 @@ describe('reassignBilledAccountForUser', () => { const tx = { update: vi.fn().mockReturnValue(updateChain), } - mockDb.select.mockReturnValueOnce( + dbChainMockFns.select.mockReturnValueOnce( createMockChain([ { id: 'workspace-1', ownerId: 'owner-1', organizationId: null }, { id: 'workspace-2', ownerId: 'owner-2', organizationId: null }, ]) ) - mockDb.transaction.mockImplementation( + dbChainMockFns.transaction.mockImplementation( async (callback: (transaction: typeof tx) => Promise) => callback(tx) ) mockChangeWorkspaceStoragePayerInTx.mockRejectedValueOnce(new Error('payer transfer failed')) @@ -218,7 +169,7 @@ describe('reassignBilledAccountForUser', () => { 'payer transfer failed' ) - expect(mockDb.transaction).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) expect(mockChangeWorkspaceStoragePayerInTx).toHaveBeenCalledTimes(1) expect(tx.update).not.toHaveBeenCalled() }) @@ -228,7 +179,6 @@ describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - delegateGlobalDbToChainMocks() }) it('reassigns departing member workflows to the workspace billed account', async () => { @@ -327,13 +277,12 @@ describe('listAccessibleWorkspaceRowsForUser', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - delegateGlobalDbToChainMocks() }) it('elevates an org admin to admin on an org workspace where they hold a lower explicit grant', async () => { const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' } - mockDb.select + dbChainMockFns.select .mockReturnValueOnce(createMockChain([{ workspace: orgWorkspace, permissionType: 'write' }])) .mockReturnValueOnce(createMockChain([{ organizationId: 'org-1', role: 'admin' }])) .mockReturnValueOnce(createMockChain([orgWorkspace])) @@ -352,7 +301,7 @@ describe('listAccessibleWorkspaceRowsForUser', () => { } const orgWorkspace = { id: 'ws-1', name: 'Shared', ownerId: 'owner-x', organizationId: 'org-1' } - mockDb.select + dbChainMockFns.select .mockReturnValueOnce( createMockChain([{ workspace: externalWorkspace, permissionType: 'write' }]) ) diff --git a/packages/testing/package.json b/packages/testing/package.json index 216129ae6c1..ee8f36d1bff 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -42,6 +42,7 @@ } }, "scripts": { + "test": "vitest run", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", diff --git a/packages/testing/src/mocks/database.mock.test.ts b/packages/testing/src/mocks/database.mock.test.ts new file mode 100644 index 00000000000..c32fc940156 --- /dev/null +++ b/packages/testing/src/mocks/database.mock.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + databaseMock, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from './database.mock' + +const workflowTable = { id: 'id', name: 'name' } +const memberTable = { id: 'id', userId: 'userId' } + +type MockDb = Record + +const db = dbChainMock.db as MockDb + +describe('database mock', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('shares one db instance between dbChainMock and databaseMock', () => { + expect(databaseMock.db).toBe(dbChainMock.db) + expect(databaseMock.dbReplica).toBe(dbChainMock.db) + expect(dbChainMock.dbFor()).toBe(dbChainMock.db) + }) + + it('resolves empty arrays by default at every terminal', async () => { + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([]) + await expect(db.select().from(workflowTable).where({}).limit(5)).resolves.toEqual([]) + await expect(db.select().from(workflowTable).where({}).orderBy('id')).resolves.toEqual([]) + await expect(db.insert(workflowTable).values({}).returning()).resolves.toEqual([]) + await expect(db.update(workflowTable).set({}).where({})).resolves.toEqual([]) + await expect(db.delete(workflowTable).where({})).resolves.toEqual([]) + }) + + it('routes queued rows to the chain reading that table', async () => { + queueTableRows(workflowTable, [{ id: 'w-1' }]) + queueTableRows(memberTable, [{ id: 'm-1' }, { id: 'm-2' }]) + + await expect(db.select().from(memberTable).where({})).resolves.toEqual([ + { id: 'm-1' }, + { id: 'm-2' }, + ]) + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'w-1' }]) + }) + + it('consumes queued sets FIFO per table and falls back to empty', async () => { + queueTableRows(workflowTable, [{ id: 'first' }]) + queueTableRows(workflowTable, [{ id: 'second' }]) + + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'first' }]) + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'second' }]) + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([]) + }) + + it('resolves queued rows through downstream terminals (limit/orderBy/joins)', async () => { + queueTableRows(workflowTable, [{ id: 'w-1' }]) + await expect(db.select().from(workflowTable).where({}).limit(1)).resolves.toEqual([ + { id: 'w-1' }, + ]) + + queueTableRows(workflowTable, [{ id: 'w-2' }]) + await expect( + db.select().from(workflowTable).innerJoin(memberTable, {}).where({}).orderBy('id') + ).resolves.toEqual([{ id: 'w-2' }]) + }) + + it('resolves queued rows when a from-chain is awaited directly (no where)', async () => { + queueTableRows(workflowTable, [{ id: 'direct' }]) + await expect(db.select().from(workflowTable)).resolves.toEqual([{ id: 'direct' }]) + await expect(db.select().from(workflowTable)).resolves.toEqual([]) + }) + + it('routes two direct-await builders constructed before either resolves', async () => { + queueTableRows(workflowTable, [{ id: 'w-first' }]) + queueTableRows(memberTable, [{ id: 'm-second' }]) + const first = db.select().from(workflowTable) + const second = db.select().from(memberTable) + await expect(first).resolves.toEqual([{ id: 'w-first' }]) + await expect(second).resolves.toEqual([{ id: 'm-second' }]) + }) + + it('routes rows queued for a table referenced only by a join', async () => { + queueTableRows(memberTable, [{ id: 'joined' }]) + await expect( + db.select().from(workflowTable).leftJoin(memberTable, {}).where({}) + ).resolves.toEqual([{ id: 'joined' }]) + }) + + it('prefers the from-table queue over a join-table queue', async () => { + queueTableRows(workflowTable, [{ id: 'from-row' }]) + queueTableRows(memberTable, [{ id: 'join-row' }]) + await expect( + db.select().from(workflowTable).innerJoin(memberTable, {}).where({}) + ).resolves.toEqual([{ id: 'from-row' }]) + }) + + it('never lets mutation chains consume select queues', async () => { + queueTableRows(workflowTable, [{ id: 'kept' }]) + await expect(db.update(workflowTable).set({}).where({})).resolves.toEqual([]) + await expect(db.delete(workflowTable).where({})).resolves.toEqual([]) + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'kept' }]) + }) + + it('routes selectDistinctOn chains through the same table queues', async () => { + queueTableRows(memberTable, [{ id: 'm-1' }]) + await expect(db.selectDistinctOn(['id']).from(memberTable).where({})).resolves.toEqual([ + { id: 'm-1' }, + ]) + expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1) + }) + + it('lets per-test ...Once overrides win over queued rows downstream', async () => { + queueTableRows(workflowTable, [{ id: 'queued' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'override' }]) + await expect(db.select().from(workflowTable).where({}).limit(1)).resolves.toEqual([ + { id: 'override' }, + ]) + }) + + it('preserves a queued set when a terminal override resolves the chain', async () => { + queueTableRows(workflowTable, [{ id: 'queued' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'override' }]) + await expect(db.select().from(workflowTable).where({}).limit(1)).resolves.toEqual([ + { id: 'override' }, + ]) + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([{ id: 'queued' }]) + }) + + it('restores directly-overridden db entry points on resetDbChainMock', async () => { + ;(db.select as ReturnType).mockImplementation(() => { + throw new Error('broken') + }) + expect(() => db.select()).toThrow('broken') + resetDbChainMock() + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([]) + }) + + it('clears queues and rewires defaults on resetDbChainMock', async () => { + queueTableRows(workflowTable, [{ id: 'stale' }]) + dbChainMockFns.where.mockReturnValue('broken' as never) + resetDbChainMock() + await expect(db.select().from(workflowTable).where({})).resolves.toEqual([]) + }) + + it('runs transactions against the same shared instance', async () => { + queueTableRows(workflowTable, [{ id: 'tx-row' }]) + const rows = await db.transaction(async (tx: MockDb) => { + expect(tx).toBe(db) + return tx.select().from(workflowTable).where({}) + }) + expect(rows).toEqual([{ id: 'tx-row' }]) + }) +}) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index c70960d8eb5..a432bc43972 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -11,13 +11,11 @@ export function createMockSql() { toSQL: () => ({ sql: strings.join('?'), params: values }), }) - // Add sql.raw method used by some queries sqlFn.raw = (rawSql: string) => ({ rawSql, toSQL: () => ({ sql: rawSql, params: [] }), }) - // Add sql.join method used to combine multiple SQL fragments sqlFn.join = (fragments: any[], separator: any) => ({ fragments, separator, @@ -62,113 +60,209 @@ export function createMockSqlOperators() { } } +/** + * Table-routed result queues. + * + * `queueTableRows(schemaMock.member, [rowA, rowB])` enqueues one result set for + * the next select chain whose `.from()` (or a subsequent `innerJoin`/ + * `leftJoin`) references that table. Each chain consumes at most one queued + * set (FIFO per table, `.from()` table checked before join tables); chains + * against tables with no queued sets resolve the chain-fn defaults (empty + * array). Mutation chains (`update`/`delete`/`insert`) never consume select + * queues. Queues are cleared by `resetDbChainMock()`. + * + * The queue is keyed by table object identity, so pass the same schema-mock + * table object the code under test passes to `.from()` / the join. + */ +const tableRowQueues = new Map() + +/** + * Enqueues one result set for the next select chain reading `table`. + */ +export function queueTableRows(table: unknown, rows: unknown[]): void { + const queue = tableRowQueues.get(table) + if (queue) queue.push(rows) + else tableRowQueues.set(table, [rows]) +} + +/** Dequeues the first queued set among the given chain's tables (from before joins). */ +function dequeueChainRows(tables: unknown[]): unknown[] | null { + for (const table of tables) { + const queue = tableRowQueues.get(table) + if (queue && queue.length > 0) return queue.shift() ?? null + } + return null +} + /** * Pre-wired chain of vi.fn()s for drizzle-style DB queries. * - * Each builder step is a stable, module-level `vi.fn()` — safe to reference - * inside hoisted `vi.mock()` factories (same pattern as `authMockFns`). Chains - * are wired at module load time: + * Each chain step is recorded on a stable, module-level `vi.fn()` spy + * (`dbChainMockFns.*`) — safe to reference inside hoisted `vi.mock()` + * factories (same pattern as `authMockFns`): * * - `select().from().where()` → returns a builder with `.limit` / `.orderBy` / * `.returning` / `.groupBy` / `.for` terminals * - `select().from().innerJoin()|leftJoin()` → returns the same where-builder * - `insert().values().returning()` / `update().set().where()` / `delete().where()` * - * Terminals (`limit`, `orderBy`, `returning`, `groupBy`, `for`, `values`) - * default to resolving `[]` (or `undefined` for `values`). Override per-test - * with `dbChainMockFns.limit.mockResolvedValueOnce([...])`. `for` mirrors - * drizzle's `.for('update')` — it returns a Promise with `.limit` / `.orderBy` - * / `.returning` / `.groupBy` attached, so both `await .where().for('update')` - * (terminal) and `await .where().for('update').limit(1)` (chained) work. - * Override the terminal result with `dbChainMockFns.for.mockResolvedValueOnce( - * [...])`; override the chained result by mocking the downstream terminal - * (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`). + * Results resolve, in priority order: + * 1. a per-test override (`dbChainMockFns.limit.mockResolvedValueOnce([...])`) + * 2. rows queued for one of the chain's tables via `queueTableRows` + * 3. the default empty array + * + * Routing state lives in per-chain closures: each `select().from(t)` captures + * its own table list, so partially-built chains for different tables can be + * interleaved or awaited in any order without cross-talk. The shared spies + * carry only call history and per-test overrides — a spy's default + * implementation returns a sentinel that the chain replaces with the + * chain-local builder, while any `mock*` override on the spy wins verbatim. + * + * `for` mirrors drizzle's `.for('update')` — it returns a Promise with + * `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both + * `await .where().for('update')` (terminal) and + * `await .where().for('update').limit(1)` (chained) work. * * `vi.clearAllMocks()` clears call history but preserves default wiring. Tests - * that replace a wiring with `mockReturnValue(...)` (not `...Once`) must re-wire - * in their own `beforeEach`. + * that replace a wiring with `mockReturnValue(...)` (not `...Once`) must + * re-wire via `resetDbChainMock()` in their own `beforeEach`. * * @example * ```ts - * import { dbChainMock, dbChainMockFns } from '@sim/testing' - * vi.mock('@sim/db', () => dbChainMock) + * import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' + * + * beforeEach(() => { + * vi.clearAllMocks() + * resetDbChainMock() + * }) * * it('finds rows', async () => { - * dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'w-1' }]) - * // ... exercise code that hits db.select().from().where().limit() ... + * queueTableRows(schemaMock.workflow, [{ id: 'w-1' }]) + * // ... exercise code that hits db.select().from(workflow).where() ... * expect(dbChainMockFns.where).toHaveBeenCalled() * }) * ``` */ -const offset = vi.fn(() => Promise.resolve([] as unknown[])) -// `.limit()` returns a builder that is awaitable (default empty page) and also -// exposes `.offset()` for keyset/OFFSET paging (`.limit(n).offset(m)`). -const limitBuilder = () => { - const thenable: any = Promise.resolve([] as unknown[]) - thenable.offset = offset - return thenable -} -const limit = vi.fn(limitBuilder) +const CHAIN_DEFAULT = Symbol('db-chain-default') + +type ChainSpy = ReturnType any>> + +const chainSpy = (): ChainSpy => vi.fn((..._args: any[]) => CHAIN_DEFAULT as any) + +/** + * Records the call on the shared spy, honoring any per-test override; when the + * spy still has its default implementation, builds the chain-local default. + */ +const spyOrDefault = (spy: ChainSpy, buildDefault: (...args: any[]) => unknown) => + vi.fn((...args: any[]) => { + const result = spy(...args) + return result === CHAIN_DEFAULT ? buildDefault(...args) : result + }) + +// Shared spies: structural steps default to the sentinel (chain-local builders +// take over); value terminals keep real defaults. +const select = chainSpy() +const selectDistinct = chainSpy() +const selectDistinctOn = chainSpy() +const from = chainSpy() +const where = chainSpy() +const limit = chainSpy() +const offset = chainSpy() +const orderBy = chainSpy() +const groupBy = chainSpy() +const having = chainSpy() +const forClause = chainSpy() +const innerJoin = chainSpy() +const leftJoin = chainSpy() +const insert = chainSpy() +const update = chainSpy() +const set = chainSpy() +const del = chainSpy() const returning = vi.fn(() => Promise.resolve([] as unknown[])) const execute = vi.fn(() => Promise.resolve([] as unknown[])) +const query = vi.fn(() => Promise.resolve([] as unknown[])) +const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise) +const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise) +const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing })) +const transaction: ReturnType = vi.fn( + async (cb: (tx: any) => unknown): Promise => cb(dbChainMock.db) +) -const terminalBuilder = () => { - const thenable: any = Promise.resolve([] as unknown[]) - thenable.limit = limit - thenable.orderBy = orderBy - thenable.returning = returning - thenable.groupBy = groupBy - thenable.for = forClause - return thenable +/** + * Lazy per-chain rows supplier: dequeues once, at the moment the FIRST default + * thenable actually resolves. A chain whose result comes from a per-test + * override never reaches a default resolution, so its queued set stays + * available for the next chain on that table. + */ +type RowsSupplier = () => unknown[] | null + +const chainRowsSupplier = (tables: unknown[]): RowsSupplier => { + let consumed = false + let rows: unknown[] | null = null + return () => { + if (!consumed) { + consumed = true + rows = dequeueChainRows(tables) + } + return rows + } } -const orderBy = vi.fn(terminalBuilder) -const having = vi.fn(terminalBuilder) -const groupBy = vi.fn(() => { - const builder = terminalBuilder() - builder.having = having - return builder +const noRows: RowsSupplier = () => null + +/** An awaitable chain step that resolves `getRows()` only when actually awaited. */ +const lazyRowsThenable = (getRows: RowsSupplier): any => ({ + then: (onFulfilled?: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => + Promise.resolve((getRows() ?? []) as unknown[]).then(onFulfilled, onRejected), + catch: (onRejected?: (reason: unknown) => unknown) => + Promise.resolve((getRows() ?? []) as unknown[]).catch(onRejected), + finally: (onFinally?: () => void) => + Promise.resolve((getRows() ?? []) as unknown[]).finally(onFinally), }) -const forBuilder = terminalBuilder -const forClause = vi.fn(forBuilder) -const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise) -const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise) +// `.limit()` returns a builder that is awaitable and also exposes `.offset()` +// for keyset/OFFSET paging (`.limit(n).offset(m)`). +const limitBuilder = (getRows: RowsSupplier) => { + const thenable = lazyRowsThenable(getRows) + thenable.offset = spyOrDefault(offset, () => lazyRowsThenable(getRows)) + return thenable +} -const whereBuilder = () => { - // Some call sites (e.g. `db.select().from(t).where(eq(...))` with no - // limit/orderBy) await the where directly. Make the builder a thenable so - // those calls resolve to the default empty array. - const thenable: any = Promise.resolve([] as unknown[]) - thenable.limit = limit - thenable.orderBy = orderBy +const terminalBuilder = (getRows: RowsSupplier): any => { + const thenable = lazyRowsThenable(getRows) + thenable.limit = spyOrDefault(limit, () => limitBuilder(getRows)) + thenable.orderBy = spyOrDefault(orderBy, () => terminalBuilder(getRows)) thenable.returning = returning - thenable.groupBy = groupBy - thenable.for = forClause + thenable.groupBy = spyOrDefault(groupBy, () => { + const builder = terminalBuilder(getRows) + builder.having = spyOrDefault(having, () => terminalBuilder(getRows)) + return builder + }) + thenable.for = spyOrDefault(forClause, () => terminalBuilder(getRows)) return thenable } -const where = vi.fn(whereBuilder) -const joinBuilder = (): { where: typeof where; innerJoin: any; leftJoin: any } => ({ - where, - innerJoin, - leftJoin, +// The from/join builder is itself a thenable so `await db.select().from(t)` +// (no where clause) also resolves table-routed rows; the chain's single lazy +// supplier means it never double-consumes no matter which step is awaited. +const joinBuilder = (tables: unknown[]): any => { + const getRows = chainRowsSupplier(tables) + const builder = lazyRowsThenable(getRows) + builder.where = spyOrDefault(where, () => terminalBuilder(getRows)) + builder.innerJoin = spyOrDefault(innerJoin, (table: unknown) => joinBuilder([...tables, table])) + builder.leftJoin = spyOrDefault(leftJoin, (table: unknown) => joinBuilder([...tables, table])) + return builder +} + +const selectBuilder = () => ({ + from: spyOrDefault(from, (table: unknown) => joinBuilder([table])), }) -const innerJoin: ReturnType = vi.fn(joinBuilder) -const leftJoin: ReturnType = vi.fn(joinBuilder) -const from = vi.fn(joinBuilder) -const select = vi.fn(() => ({ from })) -const selectDistinct = vi.fn(() => ({ from })) -const selectDistinctOn = vi.fn(() => ({ from })) -const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing })) -const insert = vi.fn(() => ({ values })) -const set = vi.fn(() => ({ where })) -const update = vi.fn(() => ({ set })) -const del = vi.fn(() => ({ where })) -const transaction: ReturnType = vi.fn( - async (cb: (tx: any) => unknown): Promise => cb(dbChainMock.db) -) +// Mutation chains route nothing: their where() resolves the plain default so a +// mutation can never consume rows queued for a select. +const mutationWhere = () => ({ + where: spyOrDefault(where, () => terminalBuilder(noRows)), +}) export const dbChainMockFns = { select, @@ -197,44 +291,78 @@ export const dbChainMockFns = { } /** - * Re-applies the default chain wiring to every `dbChainMockFns` entry. Call - * this in `beforeEach` (after `vi.clearAllMocks()`) if any test uses - * `mockReturnValue` / `mockResolvedValue` (permanent overrides) — this + * Restores every `dbChainMockFns` entry to its default wiring and clears all + * table-routed row queues. Call this in `beforeEach` (after + * `vi.clearAllMocks()`) if any test uses `mockReturnValue` / + * `mockResolvedValue` (permanent overrides) or `queueTableRows` — this * guarantees the next test starts with fresh defaults. * * Not needed if tests exclusively use the `...Once` variants, since those * auto-expire after one call. */ export function resetDbChainMock(): void { - select.mockImplementation(() => ({ from })) - selectDistinct.mockImplementation(() => ({ from })) - selectDistinctOn.mockImplementation(() => ({ from })) - from.mockImplementation(joinBuilder) - innerJoin.mockImplementation(joinBuilder) - leftJoin.mockImplementation(joinBuilder) - where.mockImplementation(whereBuilder) - insert.mockImplementation(() => ({ values })) - values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing })) - onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise) - onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise) - update.mockImplementation(() => ({ set })) - set.mockImplementation(() => ({ where })) - del.mockImplementation(() => ({ where })) - limit.mockImplementation(limitBuilder) - offset.mockImplementation(() => Promise.resolve([] as unknown[])) - orderBy.mockImplementation(terminalBuilder) + tableRowQueues.clear() + for (const spy of [ + select, + selectDistinct, + selectDistinctOn, + from, + where, + limit, + offset, + orderBy, + groupBy, + having, + forClause, + innerJoin, + leftJoin, + insert, + update, + set, + del, + ]) { + spy.mockImplementation(() => CHAIN_DEFAULT) + } returning.mockImplementation(() => Promise.resolve([] as unknown[])) - having.mockImplementation(terminalBuilder) - groupBy.mockImplementation(() => { - const builder = terminalBuilder() - builder.having = having - return builder - }) execute.mockImplementation(() => Promise.resolve([] as unknown[])) - forClause.mockImplementation(forBuilder) + query.mockImplementation(() => Promise.resolve([] as unknown[])) + onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise) + onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise) + values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing })) transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) => cb(dbChainMock.db) ) + // The stable db-instance entry points are wrappers around the spies above; a + // suite may have overridden them directly, so restore their original + // implementations too (mockReset restores the fn passed to vi.fn()). + for (const key of [ + 'select', + 'selectDistinct', + 'selectDistinctOn', + 'insert', + 'update', + 'delete', + ] as const) { + ;(dbInstance[key] as ChainSpy).mockReset() + } +} + +/** + * The single shared `@sim/db` mock instance backing BOTH `dbChainMock` and + * `databaseMock`. Because every binding resolves to the same chain spies, a + * module bound to either export behaves identically — there is exactly one + * db-mock state to configure and reset. + */ +const dbInstance = { + select: spyOrDefault(select, selectBuilder), + selectDistinct: spyOrDefault(selectDistinct, selectBuilder), + selectDistinctOn: spyOrDefault(selectDistinctOn, selectBuilder), + insert: spyOrDefault(insert, () => ({ values })), + update: spyOrDefault(update, () => ({ set: spyOrDefault(set, mutationWhere) })), + delete: spyOrDefault(del, mutationWhere), + execute, + query, + transaction, } /** @@ -245,104 +373,30 @@ export function resetDbChainMock(): void { * vi.mock('@sim/db', () => dbChainMock) * ``` */ -const dbChainInstance = { - select, - selectDistinct, - selectDistinctOn, - insert, - update, - delete: del, - execute, - transaction, -} - export const dbChainMock = { - db: dbChainInstance, + db: dbInstance, /** Same instance as `db` so per-test chain overrides cover both clients. */ - dbReplica: dbChainInstance, + dbReplica: dbInstance, /** Sub-pool clients (`dbFor('cleanup' | 'exec')`) share the same instance too. */ - dbFor: () => dbChainInstance, + dbFor: () => dbInstance, runOutsideTransactionContext: (fn: () => T): T => fn(), instrumentPoolClient: (client: T): T => client, } /** - * Creates a mock database connection. - */ -export function createMockDb() { - // A `where(...)` result that is both awaitable (resolves to `[]`) and exposes - // `.limit`/`.orderBy`, so `select().from()[.leftJoin()].where()[.limit()]` - // works whether or not a terminal is chained. - const whereResult = () => { - const thenable: any = Promise.resolve([]) - thenable.limit = vi.fn(() => Promise.resolve([])) - thenable.orderBy = vi.fn(() => Promise.resolve([])) - return thenable - } - const fromBuilder = () => ({ - where: vi.fn(whereResult), - leftJoin: vi.fn(() => ({ where: vi.fn(whereResult) })), - innerJoin: vi.fn(() => ({ where: vi.fn(whereResult) })), - }) - - return { - select: vi.fn(() => ({ - from: vi.fn(fromBuilder), - })), - selectDistinct: vi.fn(() => ({ - from: vi.fn(fromBuilder), - })), - selectDistinctOn: vi.fn(() => ({ - from: vi.fn(fromBuilder), - })), - insert: vi.fn(() => ({ - values: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve([])), - onConflictDoUpdate: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve([])), - })), - onConflictDoNothing: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve([])), - })), - })), - })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve([])), - })), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve([])), - })), - })), - transaction: vi.fn(async (callback) => callback(createMockDb())), - query: vi.fn(() => Promise.resolve([])), - } -} - -/** - * Mock module for @sim/db. - * Use with vi.mock() to replace the real database. + * Mock module for `@sim/db` installed globally in vitest.setup.ts. Shares its + * `db` instance (and therefore all chain spies and table queues) with + * `dbChainMock`; additionally exposes the `sql` template tag and operator + * exports the real module provides. * * @example * ```ts * vi.mock('@sim/db', () => databaseMock) * ``` */ -const mockDbInstance = createMockDb() - export const databaseMock = { - db: mockDbInstance, - /** Same instance as `db` so per-test overrides cover both clients. */ - dbReplica: mockDbInstance, - /** Sub-pool clients (`dbFor('cleanup' | 'exec')`) share the same instance too. */ - dbFor: () => mockDbInstance, + ...dbChainMock, sql: createMockSql(), - runOutsideTransactionContext: (fn: () => T): T => fn(), - instrumentPoolClient: (client: T): T => client, ...createMockSqlOperators(), } diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 5ddbd73aa95..e12a09a6b48 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -39,13 +39,13 @@ export { export { copilotHttpMock, copilotHttpMockFns } from './copilot-http.mock' // Database mocks export { - createMockDb, createMockSql, createMockSqlOperators, databaseMock, dbChainMock, dbChainMockFns, drizzleOrmMock, + queueTableRows, resetDbChainMock, } from './database.mock' // Encryption mocks From 49d3804bee1429fe52adb4010e9aa20e30655624 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 14:28:38 -0700 Subject: [PATCH 13/24] fix(ci): save the Next.js build cache every run instead of freezing it at the lockfile key (#5859) * fix(ci): save the Next.js build cache every run instead of freezing it at the lockfile key The cache key was only runner.os + bun.lock hash, and GitHub caches are immutable per key: the first run after a lockfile change saved the cache once, then every later run hit the primary key and skipped the save ('Cache hit occurred on the primary key ... not saving cache'), so builds compiled against a cache stale since the last lockfile bump. Suffix the key with the commit SHA so each run saves its refreshed cache, and restore via prefix match to the most recent entry. * fix(ci): make the Next.js cache key unique per run attempt so reruns can save too --- .github/workflows/test-build.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index ed348a41446..285c7ec53f6 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -236,12 +236,20 @@ jobs: key: ${{ github.repository }}-turbo-cache-build-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo + # Keyed per commit so EVERY run saves its refreshed cache — a key without + # a unique suffix is written once and then frozen (GitHub caches are + # immutable per key; a primary-key hit skips the save), which left builds + # compiling against a cache stale since the last lockfile change. The + # restore-keys prefix match picks the most recently saved cache for this + # lockfile, falling back across lockfile changes. - name: Restore Next.js build cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ./apps/sim/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}- + ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}- ${{ runner.os }}-nextjs- - name: Install dependencies From c083be9defa3c4a7c092e8c00535652a6d13c755 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 14:41:02 -0700 Subject: [PATCH 14/24] improvement(auth): bump better-auth to 1.6.23 and add trusted-proxy client IP resolution (#5857) * improvement(auth): bump better-auth to 1.6.23 and add trusted-proxy client IP resolution * chore(billing): record checkout-scope mirror re-verification against @better-auth/stripe 1.6.23 * chore(deploy): expose AUTH_TRUSTED_PROXIES in docker-compose.prod and Helm chart --- apps/sim/.env.example | 1 + apps/sim/lib/auth/auth.ts | 16 ++++++++++ apps/sim/lib/billing/authorization.ts | 4 ++- apps/sim/lib/core/config/env.ts | 3 ++ apps/sim/package.json | 6 ++-- bun.lock | 42 +++++++++++++++------------ docker-compose.prod.yml | 6 ++++ helm/sim/values.schema.json | 4 +++ helm/sim/values.yaml | 5 ++++ packages/auth/package.json | 2 +- 10 files changed, 66 insertions(+), 23 deletions(-) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 08003939323..795a002a6ad 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -19,6 +19,7 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. +# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index a18c757f6c2..27543c2ffce 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -193,6 +193,17 @@ if (validStripeKey) { }) } +/** + * Reverse-proxy hops trusted for forwarded-IP resolution. When configured, + * Better Auth walks the x-forwarded-for chain right to left, skips these + * hops, and records the first untrusted address as the session client IP — + * preventing header spoofing behind multi-hop proxies. + */ +const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + export const auth = betterAuth({ baseURL: getBaseUrl(), trustedOrigins: [ @@ -216,6 +227,11 @@ export const auth = betterAuth({ updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry) freshAge: 0, }, + advanced: { + ipAddress: { + ...(trustedProxies.length > 0 ? { trustedProxies } : {}), + }, + }, user: { deleteUser: { enabled: false, diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 68586a1ff69..b6edd341c26 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -16,7 +16,9 @@ const logger = createLogger('BillingAuthorization') /** * Classify a `/subscription/upgrade` request as a personal checkout using * the same reference resolution as the Better Auth Stripe plugin - * (`@better-auth/stripe` 1.6.13): an explicit `referenceId` defines the + * (`@better-auth/stripe` 1.6.23, `referenceMiddleware` — classification + * unchanged from 1.6.13; 1.6.23 only moved the resolved `referenceId` into + * the middleware return value): an explicit `referenceId` defines the * reference (personal iff it is the session user); without one, the * reference defaults to the user unless `customerType: 'organization'` * selects the session's active organization. diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index d7d51b7b518..c899ccbf01b 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -477,6 +477,9 @@ export const env = createEnv({ REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only) REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only) + // Network / proxy trust + AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth walks the forwarded-IP chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP. Leave unset to trust only single-value IP headers. + // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type diff --git a/apps/sim/package.json b/apps/sim/package.json index 71134235527..8e13a844bd9 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -60,8 +60,8 @@ "@aws-sdk/s3-request-presigner": "3.1032.0", "@azure/communication-email": "1.0.0", "@azure/storage-blob": "12.27.0", - "@better-auth/sso": "1.6.13", - "@better-auth/stripe": "1.6.13", + "@better-auth/sso": "1.6.23", + "@better-auth/stripe": "1.6.23", "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", @@ -129,7 +129,7 @@ "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", "ajv": "8.18.0", - "better-auth": "1.6.13", + "better-auth": "1.6.23", "binary-extensions": "3.1.0", "browser-image-compression": "^2.0.2", "busboy": "1.6.0", diff --git a/bun.lock b/bun.lock index 33a2bf8d972..5dcae64ea52 100644 --- a/bun.lock +++ b/bun.lock @@ -128,8 +128,8 @@ "@aws-sdk/s3-request-presigner": "3.1032.0", "@azure/communication-email": "1.0.0", "@azure/storage-blob": "12.27.0", - "@better-auth/sso": "1.6.13", - "@better-auth/stripe": "1.6.13", + "@better-auth/sso": "1.6.23", + "@better-auth/stripe": "1.6.23", "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", @@ -197,7 +197,7 @@ "@trigger.dev/sdk": "4.4.3", "@typescript/typescript6": "^6.0.2", "ajv": "8.18.0", - "better-auth": "1.6.13", + "better-auth": "1.6.23", "binary-extensions": "3.1.0", "browser-image-compression": "^2.0.2", "busboy": "1.6.0", @@ -345,7 +345,7 @@ "version": "0.1.0", "dependencies": { "@sim/db": "workspace:*", - "better-auth": "1.6.13", + "better-auth": "1.6.23", }, "devDependencies": { "@sim/tsconfig": "workspace:*", @@ -871,27 +871,27 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@better-auth/core": ["@better-auth/core@1.6.13", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-3YNjiLUmlNt5T9qQ/weu0tZgGgXDSYax4EE/uLUBIBBGtQI9Q3KdEnO6tfPgDedborcSE1bIspuAIaHpaHwxZQ=="], + "@better-auth/core": ["@better-auth/core@1.6.23", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-0V6e+e7TnIZZDjhQP/tvAberSrdrf5yfbDSx5oDFsfI5MCh2ATvbuTPNxGWbLdbGnUYfbX4K9FZwzKMj8RpLmg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-r+TeBL9dJecuCaSMqL3106qwaXYL3GAkoJDfmtbZ2eZ/Ejr9xVj5msJnSULb0ZqyQ1g5SCbnM39WZaCOFirziQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1" } }, "sha512-upmNncEwm9Q0MpWLVOdx9Pe3fU/aqobO80zwI+WVCavxmL59SufW5Ud7194/J5ushw4Dd52XNn0XWPJT1ZUThg=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2" } }, "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-u0g5KThZQInx4QxsaXDJ+Yg5A9z/ia/3EBwi+gI7+kSTKkeT9PZZ6J+erwJ5Sh4d0JUQsEX2DX2YRsg/mYnXWQ=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-gjmUIdqmxWb4WoNEN5rTQYQli6A9fPopAaVDiLh/gwO3ET10/PuOEwfESePEwUbArlKLLK3hPEWWe0RBojyxgQ=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ=="], - "@better-auth/sso": ["@better-auth/sso@1.6.13", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.13", "better-call": "1.3.5" } }, "sha512-GMhdpiYJ4yi1E5BZ32jDAZgT0zboQLqEh9fMQXgxj0OJbMxgW1/nVq5nB45m8HOhdlX0DAhF5yuQpY5df/nLbg=="], + "@better-auth/sso": ["@better-auth/sso@1.6.23", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.23", "better-call": "1.3.7" } }, "sha512-nAO25rH25SL2t/BkK/iPqGsnhwI7tOGxktVupuyIBysju13QpV4tJjytnSbVnnDwPo5p6M4Ld6WF83XCEJYCzg=="], - "@better-auth/stripe": ["@better-auth/stripe@1.6.13", "", { "dependencies": { "defu": "^6.1.5", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.13", "better-auth": "^1.6.13", "better-call": "1.3.5", "stripe": "^18 || ^19 || ^20 || ^21 || ^22" } }, "sha512-T++Tki8X+tdHGkGr6SLsq/SKFSMxHunoKT+RPOsGDXoBF8oHch/X6xSOM2y5T7/ztN4Xi/B4e8nfp0/SFG4xnA=="], + "@better-auth/stripe": ["@better-auth/stripe@1.6.23", "", { "dependencies": { "defu": "^6.1.5", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.23", "better-auth": "^1.6.23", "better-call": "1.3.7", "stripe": "^18 || ^19 || ^20 || ^21 || ^22" } }, "sha512-hsMGJQ947k82ZRP5ULWXbNiHLSs57XeVdZ1n2XoWFgRX4ilnvRsQwLTWnwvde4eCIV4x/aT+GEoN8T42t4KlSg=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.6.13", "", { "peerDependencies": { "@better-auth/core": "^1.6.13", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21" } }, "sha512-CXfPPL55mZrGH1FUhZOw9REp2WRJoVjCh9egn+cIx3ReB/OnPz+eHSRft/IVLD2PQyP1FNr1Au89SXd2oPBUPg=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA=="], - "@better-auth/utils": ["@better-auth/utils@0.4.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA=="], + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], "@biomejs/biome": ["@biomejs/biome@2.0.0-beta.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.0-beta.5", "@biomejs/cli-darwin-x64": "2.0.0-beta.5", "@biomejs/cli-linux-arm64": "2.0.0-beta.5", "@biomejs/cli-linux-arm64-musl": "2.0.0-beta.5", "@biomejs/cli-linux-x64": "2.0.0-beta.5", "@biomejs/cli-linux-x64-musl": "2.0.0-beta.5", "@biomejs/cli-win32-arm64": "2.0.0-beta.5", "@biomejs/cli-win32-x64": "2.0.0-beta.5" }, "bin": { "biome": "bin/biome" } }, "sha512-1ldO4AepieVvg4aLi1ubZkA7NsefQT2UTNssbJbDiQTGem8kCHx/PZCwLxIR6UzFpGIjh0xsDzivyVvhnmqmuA=="], @@ -2181,9 +2181,9 @@ "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], - "better-auth": ["better-auth@1.6.13", "", { "dependencies": { "@better-auth/core": "1.6.13", "@better-auth/drizzle-adapter": "1.6.13", "@better-auth/kysely-adapter": "1.6.13", "@better-auth/memory-adapter": "1.6.13", "@better-auth/mongo-adapter": "1.6.13", "@better-auth/prisma-adapter": "1.6.13", "@better-auth/telemetry": "1.6.13", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-jn8ATnGWDzMwpO4a/3iyW1/RayOF/aoPQOfAeqyCVnQCdqkaONVas9CjbY6PovMsTMa/MG+GRABySfzqtj5J/g=="], + "better-auth": ["better-auth@1.6.23", "", { "dependencies": { "@better-auth/core": "1.6.23", "@better-auth/drizzle-adapter": "1.6.23", "@better-auth/kysely-adapter": "1.6.23", "@better-auth/memory-adapter": "1.6.23", "@better-auth/mongo-adapter": "1.6.23", "@better-auth/prisma-adapter": "1.6.23", "@better-auth/telemetry": "1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ=="], - "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -4193,10 +4193,16 @@ "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + "@better-auth/sso/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@better-auth/stripe/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4563,7 +4569,7 @@ "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + "better-auth/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 67266fbc780..8182cfea0c4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,6 +17,12 @@ services: # addition to NEXT_PUBLIC_APP_URL. Use when serving from multiple domains # (apex + www, alias hostnames, reverse-proxy IPs). Empty by default. - TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-} + # AUTH_TRUSTED_PROXIES: comma-separated reverse-proxy IPs or CIDR ranges in + # front of the app (ingress, load balancer). Better Auth walks + # x-forwarded-for right to left, skips these hops, and uses the first + # untrusted address as the client IP. Required for correct session IPs and + # rate-limit keying behind a multi-hop proxy chain. Empty by default. + - AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index b3abcbf2703..f2dd2007c34 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -159,6 +159,10 @@ "type": "string", "description": "Comma-separated additional public origins to trust for auth (e.g. 'https://app.example.com,https://www.example.com'). Merged into Better Auth trustedOrigins." }, + "AUTH_TRUSTED_PROXIES": { + "type": "string", + "description": "Comma-separated reverse-proxy IPs or CIDR ranges in front of the app (e.g. '10.0.0.0/16'). Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP." + }, "SSO_TRUSTED_PROVIDER_IDS": { "type": "string", "description": "Comma-separated SSO provider IDs to trust for automatic account linking when an SSO sign-in matches an existing account's email. Only needed for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders." diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 3f8d0a183c4..543e24f1ffc 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -85,6 +85,11 @@ app: # TRUSTED_ORIGINS: comma-separated extra public origins to trust for auth (e.g. apex+www, alias hostnames). # Merged into Better Auth `trustedOrigins` alongside NEXT_PUBLIC_APP_URL. Leave empty when serving from a single origin. TRUSTED_ORIGINS: "" + # AUTH_TRUSTED_PROXIES: comma-separated reverse-proxy IPs or CIDR ranges in front of the app + # (ingress controller, load balancer). Better Auth walks x-forwarded-for right to left, skips + # these hops, and uses the first untrusted address as the client IP. Required for correct + # session IPs and rate-limit keying behind a multi-hop proxy chain (e.g. "10.0.0.0/16"). + AUTH_TRUSTED_PROXIES: "" # SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service) # NEXT_PUBLIC_SOCKET_URL: public WebSocket URL for browsers. Leave empty to default to the # page's own origin (assumes the ingress/reverse proxy routes /socket.io to the realtime service). diff --git a/packages/auth/package.json b/packages/auth/package.json index 4d960fd53b7..b6b88fb8fc8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@sim/db": "workspace:*", - "better-auth": "1.6.13" + "better-auth": "1.6.23" }, "devDependencies": { "@sim/tsconfig": "workspace:*", From 62a8ce49534543308e6fe15362cb31d1cab33e00 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 14:53:17 -0700 Subject: [PATCH 15/24] =?UTF-8?q?improvement(tests):=20db-mock=20migration?= =?UTF-8?q?=20tranche=201=20=E2=80=94=20knowledge,=20billing/org,=20workfl?= =?UTF-8?q?ows/background=20(#5861)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(tests): migrate knowledge, billing/org, and workflows/background suites off private @sim/db factories * improvement(tests): db-mock migration tranche 1 — knowledge, billing/org, workflows/background - migrate 19 suites off private vi.mock('@sim/db') factories onto the shared dbChainMock + queueTableRows API (net ~-1,260 lines of bespoke chain plumbing); resolves the known shared-worker rival pairs (knowledge processing-queue vs api utils; billing polluters; persistence/utils vs schedules/deploy) - add .for() to the mock's limit builder (drizzle .limit(1).for('update')) with a contract test - document the join-table queue fallback footgun on queueTableRows --- apps/sim/app/api/folders/[id]/route.test.ts | 129 ++-- .../[id]/documents/[documentId]/route.test.ts | 48 +- .../knowledge/[id]/documents/route.test.ts | 51 +- .../[id]/documents/upsert/route.test.ts | 25 +- apps/sim/app/api/knowledge/[id]/route.test.ts | 38 +- apps/sim/app/api/knowledge/route.test.ts | 46 +- .../app/api/knowledge/search/route.test.ts | 68 +- apps/sim/app/api/knowledge/utils.test.ts | 236 ++----- .../[id]/invitations/route.test.ts | 230 +++---- .../[id]/permission-groups/utils.test.ts | 97 +-- .../organizations/[id]/roster/route.test.ts | 176 ++--- apps/sim/app/api/organizations/route.test.ts | 91 +-- apps/sim/app/api/workflows/[id]/route.test.ts | 113 +-- apps/sim/app/api/workflows/route.test.ts | 89 +-- apps/sim/background/cleanup-logs.test.ts | 174 +---- .../background/cleanup-soft-deletes.test.ts | 151 +--- .../background/tiktok-webhook-targets.test.ts | 106 ++- .../lib/admin/dashboard-credit-grant.test.ts | 140 ++-- .../lib/workflows/persistence/utils.test.ts | 647 ++++-------------- .../testing/src/mocks/database.mock.test.ts | 8 + packages/testing/src/mocks/database.mock.ts | 10 +- 21 files changed, 713 insertions(+), 1960 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 95cb3d53b05..bbe7dfeba63 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -7,17 +7,22 @@ import { auditMock, authMockFns, createMockRequest, + dbChainMock, + dbChainMockFns, type MockUser, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, mockDbRef } = vi.hoisted(() => { +const { mockLogger } = vi.hoisted(() => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -29,7 +34,6 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { } return { mockLogger: logger, - mockDbRef: { current: null as any }, } }) @@ -45,23 +49,12 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/db', () => ({ - get db() { - return mockDbRef.current - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' -interface FolderDbMockOptions { - folderLookupResult?: any - updateResult?: any[] - throwError?: boolean - circularCheckResults?: any[] -} - const TEST_USER: MockUser = { id: 'user-123', email: 'test@example.com', @@ -80,57 +73,16 @@ const mockFolder = { updatedAt: new Date('2024-01-01T00:00:00Z'), } -function createFolderDbMock(options: FolderDbMockOptions = {}) { - const { - folderLookupResult = mockFolder, - updateResult = [{ ...mockFolder, name: 'Updated Folder' }], - throwError = false, - circularCheckResults = [], - } = options - - let callCount = 0 - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - if (throwError) { - throw new Error('Database error') - } - - callCount++ - if (callCount === 1) { - const result = folderLookupResult === undefined ? [] : [folderLookupResult] - return Promise.resolve(callback(result)) - } - if (callCount > 1 && circularCheckResults.length > 0) { - const index = callCount - 2 - const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] - return Promise.resolve(callback(result)) - } - return Promise.resolve(callback([])) - }), - })), - })), - })) - - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue(updateResult), - })), - })), - })) - - const mockDelete = vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => Promise.resolve()), - })) +/** Queues the folder-existence lookup the route runs before authorizing. */ +function queueFolderLookup(folder: Record = mockFolder) { + queueTableRows(schemaMock.workflowFolder, [folder]) +} - return { - select: mockSelect, - update: mockUpdate, - delete: mockDelete, - } +/** Makes the next folder lookup throw, exercising the route's 500 path. */ +function failFolderLookup() { + dbChainMockFns.where.mockImplementationOnce(() => { + throw new Error('Database error') + }) } function mockAuthenticatedUser(user?: MockUser) { @@ -142,11 +94,15 @@ function mockUnauthenticated() { } describe('Individual Folder API Route', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDbRef.current = createFolderDbMock() mockPerformDeleteFolder.mockResolvedValue({ success: true, deletedItems: { folders: 1, workflows: 0 }, @@ -193,6 +149,7 @@ describe('Individual Folder API Route', () => { it('should update folder successfully', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder Name', color: '#FF0000', @@ -213,6 +170,7 @@ describe('Individual Folder API Route', () => { it('should update parent folder successfully', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', parentId: 'parent-folder-1', @@ -244,6 +202,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('read') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -261,6 +220,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -278,6 +238,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('admin') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -294,6 +255,7 @@ describe('Individual Folder API Route', () => { it('should return 400 when trying to set folder as its own parent', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', parentId: 'folder-1', @@ -311,6 +273,7 @@ describe('Individual Folder API Route', () => { it('should trim folder name when updating', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) @@ -325,9 +288,7 @@ describe('Individual Folder API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - throwError: true, - }) + failFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', @@ -350,6 +311,7 @@ describe('Individual Folder API Route', () => { it('should handle empty folder name', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: '', }) @@ -383,13 +345,11 @@ describe('Individual Folder API Route', () => { it('should prevent circular references when updating parent', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - folderLookupResult: { - id: 'folder-3', - parentId: null, - name: 'Folder 3', - workspaceId: 'workspace-123', - }, + queueFolderLookup({ + id: 'folder-3', + parentId: null, + name: 'Folder 3', + workspaceId: 'workspace-123', }) workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true) @@ -417,9 +377,7 @@ describe('Individual Folder API Route', () => { it('should delete folder and all contents successfully', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -457,6 +415,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('read') + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -472,9 +431,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -492,9 +449,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -511,9 +466,7 @@ describe('Individual Folder API Route', () => { it('should handle database errors during deletion', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - throwError: true, - }) + failFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 7e08b67fa71..a512d203d43 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -3,26 +3,17 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } - return { mockDbChain } -}) +import { + auditMock, + authMockFns, + createMockRequest, + dbChainMock, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) @@ -82,20 +73,9 @@ describe('Document By ID API Route', () => { deletedAt: null, } - const resetMocks = () => { - vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset() - if (fn !== mockDbChain.transaction) { - fn.mockReturnThis() - } - } - }) - } - beforeEach(() => { - resetMocks() + vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -106,6 +86,10 @@ describe('Document By ID API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]/documents/[documentId]', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 80af0ba8cc1..26ec37a6786 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -3,29 +3,17 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - offset: vi.fn().mockReturnThis(), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } - return { mockDbChain } -}) +import { + auditMock, + authMockFns, + createMockRequest, + dbChainMock, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) @@ -96,20 +84,9 @@ describe('Knowledge Base Documents API Route', () => { deletedAt: null, } - const resetMocks = () => { - vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset() - if (fn !== mockDbChain.transaction) { - fn.mockReturnThis() - } - } - }) - } - beforeEach(() => { - resetMocks() + vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -120,6 +97,10 @@ describe('Knowledge Base Documents API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]/documents', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts index 3feda21aa92..e48ef65e164 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -6,23 +6,15 @@ import { auditMock, createMockRequest, + dbChainMock, hybridAuthMock, hybridAuthMockFns, knowledgeApiUtilsMock, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDbChain } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), - } - return { mockDbChain: chain } -}) - -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) @@ -57,10 +49,7 @@ describe('POST /api/knowledge/[id]/documents/upsert', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.limit.mockResolvedValue([]) + resetDbChainMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, @@ -81,6 +70,10 @@ describe('POST /api/knowledge/[id]/documents/upsert', () => { vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined as any) }) + afterAll(() => { + resetDbChainMock() + }) + const baseBody = { filename: 'note.txt', fileSize: 11, diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 2d0dc1ce2e2..c146ee9a13c 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -3,24 +3,17 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } - return { mockDbChain } -}) +import { + auditMock, + authMockFns, + createMockRequest, + dbChainMock, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) @@ -64,15 +57,12 @@ describe('Knowledge Base By ID API Route', () => { const resetMocks = () => { vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset().mockReturnThis() - } - }) + resetDbChainMock() } beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -83,6 +73,10 @@ describe('Knowledge Base By ID API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 4ad2aad2acf..499efa0d812 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -7,29 +7,15 @@ import { auditMock, authMockFns, createMockRequest, + dbChainMock, + dbChainMockFns, permissionsMock, permissionsMockFns, + resetDbChainMock, } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockResolvedValue(undefined), - } - return { mockDbChain } -}) +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) @@ -40,15 +26,7 @@ import { GET, POST } from '@/app/api/knowledge/route' describe('Knowledge Base API Route', () => { beforeEach(() => { vi.clearAllMocks() - - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear() - if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) { - fn.mockReturnThis() - } - } - }) + resetDbChainMock() permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -61,6 +39,10 @@ describe('Knowledge Base API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge', () => { it('should return unauthorized for unauthenticated user', async () => { authMockFns.mockGetSession.mockResolvedValue(null) @@ -77,7 +59,7 @@ describe('Knowledge Base API Route', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' }, }) - mockDbChain.orderBy.mockRejectedValue(new Error('Database error')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('GET') const response = await GET(req) @@ -113,7 +95,7 @@ describe('Knowledge Base API Route', () => { expect(data.success).toBe(true) expect(data.data.name).toBe(validKnowledgeBaseData.name) expect(data.data.description).toBe(validKnowledgeBaseData.description) - expect(mockDbChain.insert).toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() }) it('should return unauthorized for unauthenticated user', async () => { @@ -169,7 +151,7 @@ describe('Knowledge Base API Route', () => { expect(data.error).toBe( 'User does not have permission to create knowledge bases in this workspace' ) - expect(mockDbChain.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('should validate chunking config constraints', async () => { @@ -219,7 +201,7 @@ describe('Knowledge Base API Route', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' }, }) - mockDbChain.values.mockRejectedValue(new Error('Database error')) + dbChainMockFns.values.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', validKnowledgeBaseData) const response = await POST(req) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 385fd1a3ac1..b389990c744 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -8,16 +8,18 @@ import { createEnvMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + resetDbChainMock, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbChain, mockGetDocumentTagDefinitions, mockHandleTagOnlySearch, mockHandleVectorOnlySearch, @@ -26,17 +28,6 @@ const { mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ - mockDbChain: { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - having: vi.fn().mockReturnThis(), - }, mockGetDocumentTagDefinitions: vi.fn(), mockHandleTagOnlySearch: vi.fn(), mockHandleVectorOnlySearch: vi.fn(), @@ -60,9 +51,7 @@ vi.mock('drizzle-orm', () => ({ })), })) -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -142,12 +131,7 @@ describe('Knowledge Search API Route', () => { beforeEach(() => { vi.clearAllMocks() - - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReturnThis() - } - }) + resetDbChainMock() mockHandleTagOnlySearch.mockClear() mockHandleVectorOnlySearch.mockClear() @@ -187,6 +171,10 @@ describe('Knowledge Search API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('POST /api/knowledge/search', () => { const validSearchData = { knowledgeBaseIds: 'kb-123', @@ -216,7 +204,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -263,7 +251,7 @@ describe('Knowledge Search API Route', () => { .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[0] }) .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[1] }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -308,7 +296,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -506,7 +494,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) // Search results + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) // Search results mockFetch.mockResolvedValue({ ok: true, @@ -526,7 +514,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle OpenAI API errors', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('OpenAI API error: 401 Unauthorized - Invalid API key') @@ -542,7 +530,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle missing OpenAI API key', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce(new Error('OPENAI_API_KEY not configured')) @@ -556,7 +544,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle database errors during search', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error')) @@ -570,7 +558,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle invalid OpenAI response format', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('Invalid response format from OpenAI embeddings API') @@ -599,7 +587,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -647,7 +635,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -702,7 +690,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -774,7 +762,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) @@ -820,7 +808,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) @@ -957,7 +945,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -1015,7 +1003,7 @@ describe('Knowledge Search API Route', () => { mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) const req = createMockRequest('POST', multiKbTagData) const response = await POST(req) @@ -1080,7 +1068,7 @@ describe('Knowledge Search API Route', () => { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], @@ -1154,7 +1142,7 @@ describe('Knowledge Search API Route', () => { .fn() .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], @@ -1227,7 +1215,7 @@ describe('Knowledge Search API Route', () => { .fn() .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index ad9e59113cf..d61e8273ef6 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -6,7 +6,14 @@ * This file contains unit tests for the knowledge base utility functions, * including access checks, document processing, and embedding generation. */ -import { defaultMockEnv } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + defaultMockEnv, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as billingAttributionModule from '@/lib/billing/core/billing-attribution' import { env } from '@/lib/core/config/env' @@ -20,6 +27,7 @@ afterAll(() => { delete (env as Record)[key] } Object.assign(env, envSnapshot) + resetDbChainMock() retrySpy.mockRestore() vi.mocked(workspacesUtilsModule.getWorkspaceBilledAccountUserId).mockRestore() vi.mocked(billingAttributionModule.assertBillingAttributionSnapshot).mockRestore() @@ -28,13 +36,6 @@ afterAll(() => { vi.mocked(billingAttributionModule.toBillingContext).mockRestore() }) -vi.mock('drizzle-orm', () => ({ - and: (...args: any[]) => args, - eq: (...args: any[]) => args, - isNull: () => true, - sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }), -})) - /** * Spy on the real documents/utils namespace instead of vi.mock: the shared * `@/lib/knowledge/embeddings` module may be cached bound to the real module, @@ -110,26 +111,6 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({ }), })) -const dbOps: { - order: string[] - insertRecords: any[][] - updatePayloads: any[] -} = { - order: [], - insertRecords: [], - updatePayloads: [], -} - -let kbRows: any[] = [] -let docRows: any[] = [] -let chunkRows: any[] = [] - -function resetDatasets() { - kbRows = [] - docRows = [] - chunkRows = [] -} - function createEmbeddingFetchMock() { return vi.fn().mockResolvedValue({ ok: true, @@ -145,138 +126,7 @@ function createEmbeddingFetchMock() { vi.stubGlobal('fetch', createEmbeddingFetchMock()) -vi.mock('@sim/db', async () => { - const { schemaMock } = (await import('@sim/testing')) as typeof import('@sim/testing') - const tableNameFor = (table: any) => { - if (table === schemaMock.knowledgeBase) return 'knowledge_base' - if (table === schemaMock.document) return 'document' - if (table === schemaMock.embedding) return 'embedding' - return '' - } - const selectBuilder = { - from(table: any) { - return { - where() { - return { - limit(n: number) { - const tableName = tableNameFor(table) - - if (tableName === 'knowledge_base') { - return Promise.resolve(kbRows.slice(0, n)) - } - if (tableName === 'document') { - return Promise.resolve(docRows.slice(0, n)) - } - if (tableName === 'embedding') { - return Promise.resolve(chunkRows.slice(0, n)) - } - - return Promise.resolve([]) - }, - } - }, - innerJoin() { - // document × knowledge_base context JOIN — return the first kb and - // doc row merged (covers processDocumentAsync's prefetch). - return { - leftJoin: () => ({ - where: () => ({ - limit: (n: number) => - Promise.resolve( - kbRows.length > 0 && docRows.length > 0 - ? [ - { ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' }, - ].slice(0, n) - : [] - ), - }), - }), - where: () => ({ - limit: (n: number) => - Promise.resolve( - kbRows.length > 0 && docRows.length > 0 - ? [{ ...kbRows[0], ...docRows[0] }].slice(0, n) - : [] - ), - }), - } - }, - } - }, - } - - return { - db: { - select: vi.fn(() => selectBuilder), - update: (table: any) => ({ - set: (payload: any) => ({ - where: () => { - const tableName = tableNameFor(table) - if (tableName === 'knowledge_base') { - dbOps.order.push('updateKb') - dbOps.updatePayloads.push(payload) - } else if (tableName === 'document') { - if (payload.processingStatus !== 'processing') { - dbOps.order.push('updateDoc') - dbOps.updatePayloads.push(payload) - } - } - return Promise.resolve() - }, - }), - }), - delete: () => ({ - where: () => Promise.resolve(), - }), - insert: () => ({ - values: (records: any) => { - dbOps.order.push('insert') - dbOps.insertRecords.push(records) - return Promise.resolve() - }, - }), - transaction: vi.fn(async (fn: any) => { - await fn({ - select: () => ({ - from: () => ({ - innerJoin: () => ({ - where: () => ({ - limit: () => Promise.resolve([{ id: 'doc1' }]), - }), - }), - where: () => ({ - limit: () => Promise.resolve([{}]), - }), - }), - }), - delete: () => ({ - where: () => Promise.resolve(), - }), - insert: () => ({ - values: (records: any) => { - dbOps.order.push('insert') - dbOps.insertRecords.push(records) - return Promise.resolve() - }, - }), - update: () => ({ - set: (payload: any) => ({ - where: () => { - dbOps.updatePayloads.push(payload) - const label = payload.processingStatus !== undefined ? 'updateDoc' : 'updateKb' - dbOps.order.push(label) - return Promise.resolve() - }, - }), - }), - }) - }), - }, - document: {}, - knowledgeBase: {}, - embedding: {}, - } -}) +vi.mock('@sim/db', () => dbChainMock) import { processDocumentAsync } from '@/lib/knowledge/documents/service' import { generateEmbeddings } from '@/lib/knowledge/embeddings' @@ -288,11 +138,8 @@ import { describe('Knowledge Utils', () => { beforeEach(() => { - dbOps.order.length = 0 - dbOps.insertRecords.length = 0 - dbOps.updatePayloads.length = 0 - resetDatasets() vi.clearAllMocks() + resetDbChainMock() // `unstubGlobals: true` removes the module-scope fetch stub after the // first test in the worker; re-stub it per test. vi.stubGlobal('fetch', createEmbeddingFetchMock()) @@ -310,14 +157,19 @@ describe('Knowledge Utils', () => { describe('processDocumentAsync', () => { it('should insert embeddings before updating document counters', async () => { - kbRows.push({ - id: 'kb1', - userId: 'user1', - workspaceId: 'workspace1', - embeddingModel: 'text-embedding-3-small', - chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, - }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1' }) + /** Context prefetch JOIN (document × knowledge_base × workspace). */ + queueTableRows(schemaMock.document, [ + { + workspaceId: 'workspace1', + knowledgeBaseUserId: 'user1', + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, + embeddingModel: 'text-embedding-3-small', + billedAccountUserId: 'billing-user-1', + uploadedBy: null, + }, + ]) + /** In-transaction active-document recheck. */ + queueTableRows(schemaMock.document, [{ id: 'doc1' }]) await processDocumentAsync( 'kb1', @@ -343,25 +195,29 @@ describe('Knowledge Utils', () => { } ) - // Embeddings are inserted first, then the document counter update. A - // usage_log billing insert (recordUsage) may trail after updateDoc and is - // irrelevant to this ordering invariant, so assert position rather than - // exact array equality. - expect(dbOps.order[0]).toBe('insert') - expect(dbOps.order.indexOf('updateDoc')).toBeGreaterThan(0) - - expect(dbOps.updatePayloads[0]).toMatchObject({ + /** + * Embeddings are inserted first, then the document counter update. The + * status→'processing' update precedes both and a usage_log billing insert + * (recordUsage) may trail after — assert relative order via the shared + * spies' invocation order rather than exact call sequences. + */ + const setPayloads = dbChainMockFns.set.mock.calls.map((call) => call[0]) + const completedIndex = setPayloads.findIndex((p) => p?.processingStatus === 'completed') + expect(setPayloads[completedIndex]).toMatchObject({ processingStatus: 'completed', chunkCount: 2, }) - expect(dbOps.insertRecords[0].length).toBe(2) + expect(dbChainMockFns.values.mock.calls[0][0]).toHaveLength(2) + expect(dbChainMockFns.values.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.set.mock.invocationCallOrder[completedIndex] + ) }) }) describe('checkKnowledgeBaseAccess', () => { it('should return success for owner', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) const result = await checkKnowledgeBaseAccess('kb1', 'user1') expect(result.hasAccess).toBe(true) @@ -377,7 +233,7 @@ describe('Knowledge Utils', () => { describe('checkDocumentAccess', () => { it('should return unauthorized when user mismatch', async () => { - kbRows.push({ id: 'kb1', userId: 'owner' }) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'owner' }]) const result = await checkDocumentAccess('kb1', 'doc1', 'intruder') expect(result.hasAccess).toBe(false) @@ -389,8 +245,10 @@ describe('Knowledge Utils', () => { describe('checkChunkAccess', () => { it('should fail when document is not completed', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' }) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) + queueTableRows(schemaMock.document, [ + { id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' }, + ]) const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1') @@ -401,9 +259,11 @@ describe('Knowledge Utils', () => { }) it('should return success for valid access', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'completed' }) - chunkRows.push({ id: 'chunk1', documentId: 'doc1' }) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) + queueTableRows(schemaMock.document, [ + { id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'completed' }, + ]) + queueTableRows(schemaMock.embedding, [{ id: 'chunk1', documentId: 'doc1' }]) const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1') diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index 43f4ff6ce8b..1066801bef8 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -1,11 +1,19 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, organization, permissions, user, workspace } from '@sim/db/schema' +import { + auditMock, + createMockRequest, + createSession, + dbChainMock, + loggerMock, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, mockGetSession, mockValidateInvitationsAllowed, mockValidateSeatAvailability, @@ -14,9 +22,6 @@ const { mockCancelPendingInvitation, mockGrantWorkspaceAccessDirectly, } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as any[], - }, mockGetSession: vi.fn(), mockValidateInvitationsAllowed: vi.fn(), mockValidateSeatAvailability: vi.fn(), @@ -26,77 +31,7 @@ const { mockGrantWorkspaceAccessDirectly: vi.fn(), })) -function createSelectChain() { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockReturnValue(chain) - chain.leftJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.orderBy = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const rows = mockDbState.selectResults.shift() ?? [] - return Promise.resolve(callback(rows)) - }) - return chain -} - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => createSelectChain()), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - invitation: { - id: 'invitation.id', - organizationId: 'invitation.organizationId', - status: 'invitation.status', - email: 'invitation.email', - kind: 'invitation.kind', - role: 'invitation.role', - inviterId: 'invitation.inviterId', - expiresAt: 'invitation.expiresAt', - createdAt: 'invitation.createdAt', - }, - member: { - organizationId: 'member.organizationId', - userId: 'member.userId', - role: 'member.role', - }, - organization: { - id: 'organization.id', - name: 'organization.name', - }, - user: { - id: 'user.id', - name: 'user.name', - email: 'user.email', - }, - workspace: { - id: 'workspace.id', - name: 'workspace.name', - organizationId: 'workspace.organizationId', - workspaceMode: 'workspace.workspaceMode', - }, - permissions: { - entityId: 'permissions.entityId', - entityType: 'permissions.entityType', - userId: 'permissions.userId', - }, - invitationWorkspaceGrant: { - invitationId: 'invitationWorkspaceGrant.invitationId', - workspaceId: 'invitationWorkspaceGrant.workspaceId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/logger', () => loggerMock) @@ -140,10 +75,23 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ import { POST } from '@/app/api/organizations/[id]/invitations/route' +/** Queues the caller's admin-role check followed by the org-name lookup. */ +function queueOwnerAndOrg() { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Org One' }]) +} + +/** Queues the inviter-details lookup that precedes invitation/email sends. */ +function queueInviterRow() { + queueTableRows(user, [{ name: 'Owner', email: 'owner@example.com' }]) +} + +afterAll(resetDbChainMock) + describe('POST /api/organizations/[id]/invitations', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() mockValidateInvitationsAllowed.mockResolvedValue(undefined) mockValidateSeatAvailability.mockResolvedValue({ canInvite: true, @@ -164,13 +112,11 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + // Explicit empty existing-members set: the query joins `user`, so it must + // not fall through to the inviter row queued on the user table. + queueTableRows(member, []) + queueInviterRow() const response = await POST( createMockRequest( @@ -202,17 +148,16 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [{ userId: 'user-2', workspaceId: 'ws-1' }], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(workspace, [ + { id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueTableRows(permissions, [{ userId: 'user-2', workspaceId: 'ws-1' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -259,17 +204,15 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGrantWorkspaceAccessDirectly .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) .mockRejectedValueOnce(new Error('db blip')) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(workspace, [ + { id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -301,19 +244,15 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGrantWorkspaceAccessDirectly .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) .mockRejectedValueOnce(new Error('db blip')) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [ - { userId: 'user-a', userEmail: 'a@example.com' }, - { userId: 'user-b', userEmail: 'b@example.com' }, - ], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [ + { userId: 'user-a', userEmail: 'a@example.com' }, + { userId: 'user-b', userEmail: 'b@example.com' }, + ]) + queueInviterRow() const response = await POST( createMockRequest( @@ -340,15 +279,12 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [{ userId: 'user-2', workspaceId: 'ws-1' }], - [], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueTableRows(permissions, [{ userId: 'user-2', workspaceId: 'ws-1' }]) const response = await POST( createMockRequest( @@ -373,16 +309,12 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -427,12 +359,8 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - ] + queueOwnerAndOrg() + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) const response = await POST( createMockRequest( @@ -456,13 +384,11 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + // Explicit empty existing-members set: the query joins `user`, so it must + // not fall through to the inviter row queued on the user table. + queueTableRows(member, []) + queueInviterRow() mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' }) const response = await POST( diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 43f46c0beea..2e89f6db56f 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -1,32 +1,13 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsOrganizationAdminOrOwner, - mockIsOrganizationOnEnterprisePlan, - mockConflictRows, - mockAllMembersRows, -} = vi.hoisted(() => ({ +import { permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), - mockConflictRows: { - value: [] as Array<{ - userId: string - userName: string | null - userEmail: string | null - otherGroupId: string - otherGroupName: string - }>, - }, - mockAllMembersRows: { - value: [] as Array<{ - conflictingGroupId: string - conflictingGroupName: string - workspaceName: string - }>, - }, })) vi.mock('@/lib/billing', () => ({ @@ -37,51 +18,20 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => { - const chain: Record = {} - chain.from = vi.fn(() => chain) - chain.innerJoin = vi.fn(() => chain) - chain.leftJoin = vi.fn(() => chain) - chain.where = vi.fn(() => chain) - chain.orderBy = vi.fn(() => chain) - // findAllMembersWorkspaceConflict ends in `.limit(1)`; findScopeConflicts - // awaits the builder directly after `.where()`. - chain.limit = vi.fn(() => Promise.resolve(mockAllMembersRows.value)) - chain.then = (onFulfilled: (rows: unknown) => unknown) => - Promise.resolve(mockConflictRows.value).then(onFulfilled) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - permissionGroup: {}, - permissionGroupMember: {}, - permissionGroupWorkspace: {}, - user: {}, - workspace: {}, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - asc: vi.fn(), - eq: vi.fn(), - inArray: vi.fn(), - ne: vi.fn(), - sql: vi.fn(), -})) +vi.mock('@sim/db', () => dbChainMock) import { authorizeOrgAccessControl, findAllMembersWorkspaceConflict, findScopeConflicts, -} from './utils' +} from '@/app/api/organizations/[id]/permission-groups/utils' + +afterAll(resetDbChainMock) describe('authorizeOrgAccessControl', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('returns a 403 when the user is not an organization admin/owner', async () => { @@ -122,7 +72,7 @@ describe('authorizeOrgAccessControl', () => { describe('findScopeConflicts', () => { beforeEach(() => { vi.clearAllMocks() - mockConflictRows.value = [] + resetDbChainMock() }) const baseParams = { @@ -141,7 +91,7 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when there are no candidate users', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) @@ -149,7 +99,7 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when there are no target workspaces', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) @@ -157,7 +107,7 @@ describe('findScopeConflicts', () => { }) it('flags a candidate already in another group that shares a workspace', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts(baseParams) @@ -166,7 +116,10 @@ describe('findScopeConflicts', () => { }) it('returns at most one conflict per user', async () => { - mockConflictRows.value = [conflictRow('user-1', 'Marketing'), conflictRow('user-1', 'Sales')] + queueTableRows(permissionGroupMember, [ + conflictRow('user-1', 'Marketing'), + conflictRow('user-1', 'Sales'), + ]) const conflicts = await findScopeConflicts(baseParams) @@ -175,8 +128,6 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when the query finds no overlapping memberships', async () => { - mockConflictRows.value = [] - const conflicts = await findScopeConflicts(baseParams) expect(conflicts).toEqual([]) @@ -186,7 +137,7 @@ describe('findScopeConflicts', () => { describe('findAllMembersWorkspaceConflict', () => { beforeEach(() => { vi.clearAllMocks() - mockAllMembersRows.value = [] + resetDbChainMock() }) const baseParams = { @@ -196,9 +147,9 @@ describe('findAllMembersWorkspaceConflict', () => { } it('returns null when there are no target workspaces', async () => { - mockAllMembersRows.value = [ + queueTableRows(permissionGroup, [ { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ] + ]) const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) @@ -206,9 +157,9 @@ describe('findAllMembersWorkspaceConflict', () => { }) it('returns the conflicting all-members group sharing a workspace', async () => { - mockAllMembersRows.value = [ + queueTableRows(permissionGroup, [ { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ] + ]) const conflict = await findAllMembersWorkspaceConflict(baseParams) @@ -220,8 +171,6 @@ describe('findAllMembersWorkspaceConflict', () => { }) it('returns null when no other all-members group targets the workspaces', async () => { - mockAllMembersRows.value = [] - const conflict = await findAllMembersWorkspaceConflict(baseParams) expect(conflict).toBeNull() diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 82a3eef2fa8..338e7a5e598 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -1,87 +1,29 @@ /** * @vitest-environment node */ -import { createMockRequest, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbState, mockExpireStaleInvitations, mockGetSession } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as unknown[][], - }, +import { + invitation, + invitationWorkspaceGrant, + member, + permissions, + workspace, +} from '@sim/db/schema' +import { + createMockRequest, + createSession, + dbChainMock, + loggerMock, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExpireStaleInvitations, mockGetSession } = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), mockGetSession: vi.fn(), })) -function createSelectChain() { - const chain = { - from: vi.fn(), - innerJoin: vi.fn(), - leftJoin: vi.fn(), - where: vi.fn(), - limit: vi.fn(), - then: vi.fn(), - } - chain.from.mockReturnValue(chain) - chain.innerJoin.mockReturnValue(chain) - chain.leftJoin.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - chain.limit.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then.mockImplementation((resolve: (rows: unknown[]) => unknown) => - Promise.resolve(resolve(mockDbState.selectResults.shift() ?? [])) - ) - return chain -} - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => createSelectChain()), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - invitation: { - id: 'invitation.id', - email: 'invitation.email', - role: 'invitation.role', - kind: 'invitation.kind', - membershipIntent: 'invitation.membershipIntent', - organizationId: 'invitation.organizationId', - status: 'invitation.status', - createdAt: 'invitation.createdAt', - expiresAt: 'invitation.expiresAt', - }, - invitationWorkspaceGrant: { - invitationId: 'invitationWorkspaceGrant.invitationId', - workspaceId: 'invitationWorkspaceGrant.workspaceId', - permission: 'invitationWorkspaceGrant.permission', - }, - member: { - id: 'member.id', - organizationId: 'member.organizationId', - userId: 'member.userId', - role: 'member.role', - createdAt: 'member.createdAt', - }, - permissions: { - userId: 'permissions.userId', - entityId: 'permissions.entityId', - entityType: 'permissions.entityType', - permissionType: 'permissions.permissionType', - createdAt: 'permissions.createdAt', - }, - user: { - id: 'user.id', - name: 'user.name', - email: 'user.email', - image: 'user.image', - }, - workspace: { - id: 'workspace.id', - name: 'workspace.name', - organizationId: 'workspace.organizationId', - archivedAt: 'workspace.archivedAt', - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/logger', () => loggerMock) @@ -89,14 +31,6 @@ vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) - vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) @@ -128,16 +62,19 @@ const MEMBER_ROWS = [ }, ] +afterAll(resetDbChainMock) + describe('GET /api/organizations/[id]/roster', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() mockExpireStaleInvitations.mockResolvedValue(undefined) }) it('returns a redacted roster to a target-organization member', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) - mockDbState.selectResults = [[{ role: 'member' }], MEMBER_ROWS] + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, MEMBER_ROWS) const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), @@ -179,7 +116,6 @@ describe('GET /api/organizations/[id]/roster', () => { it('denies a workspace collaborator who is not a target-organization member', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'external-user' })) - mockDbState.selectResults = [[]] const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), @@ -195,37 +131,39 @@ describe('GET /api/organizations/[id]/roster', () => { it('preserves the full management roster for organization admins', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'user-admin' })) - mockDbState.selectResults = [ - [{ role: 'admin' }], - MEMBER_ROWS, - [{ id: 'workspace-1', name: 'Workspace One' }], - [{ userId: 'user-reader', workspaceId: 'workspace-1', permission: 'write' }], - [ - { - userId: 'external-user', - userName: 'External User', - userEmail: 'external@example.com', - userImage: null, - workspaceId: 'workspace-1', - permission: 'read', - createdAt: new Date('2026-03-01T00:00:00.000Z'), - }, - ], - [ - { - id: 'invitation-1', - email: 'pending@example.com', - role: 'member', - kind: 'workspace', - membershipIntent: 'external', - createdAt: new Date('2026-04-01T00:00:00.000Z'), - expiresAt: new Date('2026-04-08T00:00:00.000Z'), - inviteeName: null, - inviteeImage: null, - }, - ], - [{ invitationId: 'invitation-1', workspaceId: 'workspace-1', permission: 'read' }], - ] + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, MEMBER_ROWS) + queueTableRows(workspace, [{ id: 'workspace-1', name: 'Workspace One' }]) + queueTableRows(permissions, [ + { userId: 'user-reader', workspaceId: 'workspace-1', permission: 'write' }, + ]) + queueTableRows(permissions, [ + { + userId: 'external-user', + userName: 'External User', + userEmail: 'external@example.com', + userImage: null, + workspaceId: 'workspace-1', + permission: 'read', + createdAt: new Date('2026-03-01T00:00:00.000Z'), + }, + ]) + queueTableRows(invitation, [ + { + id: 'invitation-1', + email: 'pending@example.com', + role: 'member', + kind: 'workspace', + membershipIntent: 'external', + createdAt: new Date('2026-04-01T00:00:00.000Z'), + expiresAt: new Date('2026-04-08T00:00:00.000Z'), + inviteeName: null, + inviteeImage: null, + }, + ]) + queueTableRows(invitationWorkspaceGrant, [ + { invitationId: 'invitation-1', workspaceId: 'workspace-1', permission: 'read' }, + ]) const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index a3a17208d84..04f0146bff6 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -1,11 +1,18 @@ /** * @vitest-environment node */ -import { auditMock, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, subscription } from '@sim/db/schema' +import { + auditMock, + createSession, + dbChainMock, + loggerMock, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, mockGetSession, mockSetActiveOrganizationForCurrentSession, mockCreateOrganizationForTeamPlan, @@ -13,9 +20,6 @@ const { mockAttachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as any[], - }, mockGetSession: vi.fn(), mockSetActiveOrganizationForCurrentSession: vi.fn().mockResolvedValue(undefined), mockCreateOrganizationForTeamPlan: vi.fn(), @@ -24,50 +28,7 @@ const { WorkspaceOrganizationMembershipConflictError: class WorkspaceOrganizationMembershipConflictError extends Error {}, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then = vi - .fn() - .mockImplementation((callback: (rows: any[]) => any) => - Promise.resolve(callback(mockDbState.selectResults.shift() ?? [])) - ) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, - organization: { - id: 'organization.id', - name: 'organization.name', - }, - subscription: { - id: 'subscription.id', - plan: 'subscription.plan', - referenceId: 'subscription.referenceId', - status: 'subscription.status', - seats: 'subscription.seats', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, value: unknown[]) => ({ field, value })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/logger', () => loggerMock) @@ -106,10 +67,12 @@ vi.mock('@/lib/workspaces/organization-workspaces', () => ({ import { POST } from '@/app/api/organizations/route' +afterAll(resetDbChainMock) + describe('POST /api/organizations', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() }) it('recovers an owner org when the subscription was already moved onto the organization', async () => { @@ -120,10 +83,10 @@ describe('POST /api/organizations', () => { name: 'Owner', }) ) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }, + ]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -164,10 +127,10 @@ describe('POST /api/organizations', () => { status: 'active', seats: 5, }) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }, + ]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -202,7 +165,7 @@ describe('POST /api/organizations', () => { name: 'Member', }) ) - mockDbState.selectResults = [[{ organizationId: 'org-1', role: 'member' }]] + queueTableRows(member, [{ organizationId: 'org-1', role: 'member' }]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -230,10 +193,10 @@ describe('POST /api/organizations', () => { name: 'Owner', }) ) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }, + ]) mockAttachOwnedWorkspacesToOrganization.mockRejectedValueOnce( new WorkspaceOrganizationMembershipConflictError([ { userId: 'user-2', organizationId: 'org-2' }, diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2315712fdb5..56b0ffd8b11 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -7,8 +7,11 @@ import { auditMock, + dbChainMock, + dbChainMockFns, envMock, hybridAuthMockFns, + resetDbChainMock, telemetryMock, workflowAuthzMockFns, workflowsOrchestrationMock, @@ -19,7 +22,7 @@ import { workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' const mockLoadWorkflowFromNormalizedTables = @@ -30,12 +33,6 @@ const mockAuthorizeWorkflowByWorkspacePermission = const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow -const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ - mockDbUpdate: vi.fn(), - mockDbSelect: vi.fn(), - mockDbTransaction: vi.fn(), -})) - /** * Helper to set mock auth state consistently across getSession and hybrid auth. */ @@ -67,20 +64,18 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@sim/db', () => ({ - db: { - update: () => mockDbUpdate(), - select: () => mockDbSelect(), - transaction: mockDbTransaction, - }, - workflow: {}, -})) +vi.mock('@sim/db', () => dbChainMock) import { DELETE, GET, PUT } from './route' describe('Workflow By ID API Route', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'), @@ -103,18 +98,6 @@ describe('Workflow By ID API Route', () => { archivedAt: null, }, })) - mockDbTransaction.mockImplementation(async (callback) => - callback({ - execute: vi.fn().mockResolvedValue(undefined), - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - }), - }) - ) }) describe('GET /api/workflows/[id]', () => { @@ -515,16 +498,6 @@ describe('Workflow By ID API Route', () => { }) describe('PUT /api/workflows/[id]', () => { - function mockDuplicateCheck(results: Array<{ id: string }> = []) { - mockDbSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(results), - }), - }), - }) - } - it('should allow user with write permission to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', @@ -534,8 +507,6 @@ describe('Workflow By ID API Route', () => { } const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) @@ -546,16 +517,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -578,8 +539,6 @@ describe('Workflow By ID API Route', () => { } const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) @@ -590,16 +549,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -761,8 +710,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -772,16 +719,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ name: 'Unique Name' }), @@ -804,8 +741,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -815,17 +750,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - // No duplicate in target folder - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ folderId: 'folder-2' }), @@ -883,12 +807,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { - ...mockWorkflow, - description: 'Updated description', - updatedAt: new Date(), - } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -898,14 +816,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ description: 'Updated description' }), @@ -915,8 +825,7 @@ describe('Workflow By ID API Route', () => { const response = await PUT(req, { params }) expect(response.status).toBe(200) - // db.select should NOT have been called since no name/folder change - expect(mockDbSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) it('should deny forkSyncExcluded update for non-admin users', async () => { diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 261b8bf4b5d..732d4931200 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -4,43 +4,28 @@ import { auditMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowAuthzMockFns, workflowsApiUtilsMock, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockWorkflowCreated, mockDbSelect, mockDbInsert } = vi.hoisted(() => ({ +const { mockWorkflowCreated } = vi.hoisted(() => ({ mockWorkflowCreated: vi.fn(), - mockDbSelect: vi.fn(), - mockDbInsert: vi.fn(), })) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: (...args: unknown[]) => mockDbSelect(...args), - insert: (...args: unknown[]) => mockDbInsert(...args), - transaction: vi.fn(async (fn: (tx: Record) => Promise) => { - const tx = { - select: (...args: unknown[]) => mockDbSelect(...args), - insert: (...args: unknown[]) => mockDbInsert(...args), - } - await fn(tx) - }), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) @@ -67,8 +52,13 @@ vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock import { POST } from '@/app/api/workflows/route' describe('Workflows API Route - POST ordering', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('workflow-new-id'), @@ -102,33 +92,13 @@ describe('Workflows API Route - POST ordering', () => { const response = await POST(req) expect(response.status).toBe(423) - expect(mockDbInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('uses top insertion against mixed siblings (folders + workflows)', async () => { - const minResultsQueue: Array> = [ - [], - [{ minOrder: 5 }], - [{ minOrder: 2 }], - ] - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), - then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => - Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), - })), - }), - })) - - let insertedValues: Record | null = null - mockDbInsert.mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - insertedValues = values - return Promise.resolve(undefined) - }), - }) + queueTableRows(schemaMock.workflow, []) + queueTableRows(schemaMock.workflow, [{ minOrder: 5 }]) + queueTableRows(schemaMock.workflowFolder, [{ minOrder: 2 }]) const req = createMockRequest('POST', { name: 'New Workflow', @@ -141,31 +111,10 @@ describe('Workflows API Route - POST ordering', () => { const data = await response.json() expect(response.status).toBe(200) expect(data.sortOrder).toBe(1) - expect(insertedValues).not.toBeNull() - expect(insertedValues?.sortOrder).toBe(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 1 })) }) it('defaults to sortOrder 0 when there are no siblings', async () => { - const minResultsQueue: Array> = [[], [], []] - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), - then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => - Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), - })), - }), - })) - - let insertedValues: Record | null = null - mockDbInsert.mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - insertedValues = values - return Promise.resolve(undefined) - }), - }) - const req = createMockRequest('POST', { name: 'New Workflow', description: 'desc', @@ -177,6 +126,6 @@ describe('Workflows API Route - POST ordering', () => { const data = await response.json() expect(response.status).toBe(200) expect(data.sortOrder).toBe(0) - expect(insertedValues?.sortOrder).toBe(0) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })) }) }) diff --git a/apps/sim/background/cleanup-logs.test.ts b/apps/sim/background/cleanup-logs.test.ts index 9df5ec1ab77..1b0f77fce94 100644 --- a/apps/sim/background/cleanup-logs.test.ts +++ b/apps/sim/background/cleanup-logs.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' interface CleanupRow { id: string @@ -18,143 +20,34 @@ interface CapturedBatchDeleteOptions { } const { - mockAnd, mockBatchDeleteByWorkspaceAndTimestamp, mockChunkedBatchDelete, mockDeleteFileMetadata, mockDeleteFiles, - mockEq, - mockExecute, - mockFrom, - mockInArray, - mockIsNull, - mockLeftJoin, - mockLimit, - mockLt, mockMarkLargeValuesDeleted, - mockNotInArray, - mockOr, - mockOrderBy, mockPruneLargeValueMetadata, - mockSelect, mockTask, - mockWhere, -} = vi.hoisted(() => { - const mockLimit = vi.fn(async () => []) - const mockOrderBy = vi.fn(() => ({ limit: mockLimit })) - const mockWhere = vi.fn(() => ({ limit: mockLimit, orderBy: mockOrderBy })) - const mockLeftJoin = vi.fn(() => ({ where: mockWhere })) - const mockFrom = vi.fn(() => ({ leftJoin: mockLeftJoin, where: mockWhere })) - const mockSelect = vi.fn(() => ({ from: mockFrom })) - - return { - mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ - table: 'job', - deleted: 0, - failed: 0, - })), - mockChunkedBatchDelete: vi.fn(), - mockDeleteFileMetadata: vi.fn(async () => true), - mockDeleteFiles: vi.fn(async () => ({ deleted: 2, failed: [] })), - mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), - mockExecute: vi.fn(), - mockFrom, - mockInArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), - mockIsNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })), - mockLeftJoin, - mockLimit, - mockLt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })), - mockMarkLargeValuesDeleted: vi.fn(async () => undefined), - mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })), - mockOr: vi.fn((...args: unknown[]) => ({ op: 'or', args })), - mockOrderBy, - mockPruneLargeValueMetadata: vi.fn(async () => ({ - referencesDeleted: 0, - dependenciesDeleted: 0, - tombstonesDeleted: 0, - })), - mockSelect, - mockTask: vi.fn((config: unknown) => config), - mockWhere, - } -}) - -vi.mock('@sim/db', () => { - const db = { - execute: mockExecute, - select: mockSelect, - } - return { - db, - // Cleanup-pool client shares the instance so the seeded chains still apply. - dbFor: () => db, - } -}) - -vi.mock('@sim/db/schema', () => ({ - executionLargeValueDependencies: { - childKey: 'executionLargeValueDependencies.childKey', - parentKey: 'executionLargeValueDependencies.parentKey', - workspaceId: 'executionLargeValueDependencies.workspaceId', - }, - executionLargeValueReferences: { - executionId: 'executionLargeValueReferences.executionId', - key: 'executionLargeValueReferences.key', - source: 'executionLargeValueReferences.source', - }, - executionLargeValues: { - createdAt: 'executionLargeValues.createdAt', - deletedAt: 'executionLargeValues.deletedAt', - key: 'executionLargeValues.key', - workspaceId: 'executionLargeValues.workspaceId', - }, - jobExecutionLogs: { - startedAt: 'jobExecutionLogs.startedAt', - workspaceId: 'jobExecutionLogs.workspaceId', - }, - pausedExecutions: { - executionId: 'pausedExecutions.executionId', - status: 'pausedExecutions.status', - }, - workspaceFiles: { - context: 'workspaceFiles.context', - deletedAt: 'workspaceFiles.deletedAt', - key: 'workspaceFiles.key', - uploadedAt: 'workspaceFiles.uploadedAt', - workspaceId: 'workspaceFiles.workspaceId', - }, - workflowExecutionLogs: { - executionData: 'workflowExecutionLogs.executionData', - executionId: 'workflowExecutionLogs.executionId', - files: 'workflowExecutionLogs.files', - id: 'workflowExecutionLogs.id', - startedAt: 'workflowExecutionLogs.startedAt', - workspaceId: 'workflowExecutionLogs.workspaceId', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), +} = vi.hoisted(() => ({ + mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ + table: 'job', + deleted: 0, + failed: 0, + })), + mockChunkedBatchDelete: vi.fn(), + mockDeleteFileMetadata: vi.fn(async () => true), + mockDeleteFiles: vi.fn(async () => ({ deleted: 2, failed: [] })), + mockMarkLargeValuesDeleted: vi.fn(async () => undefined), + mockPruneLargeValueMetadata: vi.fn(async () => ({ + referencesDeleted: 0, + dependenciesDeleted: 0, + tombstonesDeleted: 0, })), + mockTask: vi.fn((config: unknown) => config), })) -vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) +vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - asc: vi.fn((column: unknown) => ({ op: 'asc', column })), - eq: mockEq, - inArray: mockInArray, - isNull: mockIsNull, - lt: mockLt, - notInArray: mockNotInArray, - or: mockOr, - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) +vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) vi.mock('@/lib/cleanup/batch-delete', () => ({ batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp, @@ -195,8 +88,13 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ import { cleanupLogsTask, runCleanupLogs } from '@/background/cleanup-logs' describe('cleanup logs worker', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockChunkedBatchDelete.mockImplementation(async (options: CapturedBatchDeleteOptions) => { await options.selectChunk(['workspace-1'], 500) await options.onBatch?.([ @@ -228,11 +126,11 @@ describe('cleanup logs worker', () => { totalRowLimit: 25_000, }) ) - expect(mockSelect).toHaveBeenCalledWith({ - id: 'workflowExecutionLogs.id', - files: 'workflowExecutionLogs.files', + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + id: schemaMock.workflowExecutionLogs.id, + files: schemaMock.workflowExecutionLogs.files, }) - expect(mockExecute).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).not.toHaveBeenCalled() expect(mockDeleteFiles).toHaveBeenCalledWith( ['execution-file-a', 'execution-file-b'], 'execution' @@ -247,7 +145,7 @@ describe('cleanup logs worker', () => { it('does not count large values as deleted when deleted_at marking fails', async () => { const largeValueKey = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json' - mockLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ key: largeValueKey }]) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ key: largeValueKey }]) mockDeleteFiles .mockResolvedValueOnce({ deleted: 2, failed: [] }) .mockResolvedValueOnce({ deleted: 1, failed: [] }) @@ -267,7 +165,7 @@ describe('cleanup logs worker', () => { it('cleans legacy large values from file metadata without selecting execution_data', async () => { const legacyKey = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json' - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ key: legacyKey }]) @@ -282,14 +180,14 @@ describe('cleanup logs worker', () => { workspaceIds: ['workspace-1'], }) - expect(mockSelect).toHaveBeenCalledWith({ - id: 'workflowExecutionLogs.id', - files: 'workflowExecutionLogs.files', + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + id: schemaMock.workflowExecutionLogs.id, + files: schemaMock.workflowExecutionLogs.files, }) - expect(mockSelect).not.toHaveBeenCalledWith( + expect(dbChainMockFns.select).not.toHaveBeenCalledWith( expect.objectContaining({ executionData: expect.anything() }) ) - const legacyWhereArgs = mockAnd.mock.calls + const legacyWhereArgs = drizzleOrmMock.and.mock.calls .flat() .filter((arg): arg is { strings: string[] } => { return ( diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index 7c0e29603bf..ea63df6a859 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -2,119 +2,38 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockBatchDeleteByWorkspaceAndTimestamp, mockChunkedBatchDelete, mockDecrementStorageUsageForBillingContextInTx, - mockDelete, - mockDeleteReturning, - mockDeleteWhere, mockDeleteFileMetadata, mockDeleteFiles, mockDeleteRowsById, mockHardDeleteDocuments, mockIsUsingCloudStorage, mockKnowledgeBaseContainerDelete, - mockLimit, - mockOrderBy, mockPrepareChatCleanup, mockResolveStorageBillingContext, - mockSelect, mockSelectRowsByIdChunks, - mockTask, - mockTransaction, - mockWhere, -} = vi.hoisted(() => { - const mockLimit = vi.fn(async () => [] as Array<{ key: string }>) - const mockOrderBy = vi.fn(() => ({ limit: mockLimit })) - const mockWhere = vi.fn(() => ({ orderBy: mockOrderBy, limit: mockLimit })) - const mockFrom = vi.fn(() => ({ - where: mockWhere, - leftJoin: vi.fn(() => ({ where: mockWhere })), - })) - const mockSelect = vi.fn(() => ({ from: mockFrom })) - const mockDeleteReturning = vi.fn(async () => [] as Array<{ id: string; size?: number }>) - const mockDeleteWhere = vi.fn(() => ({ returning: mockDeleteReturning })) - const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) - const mockKnowledgeBaseContainerDelete = vi.fn() - const mockChunkedBatchDelete = vi.fn(async () => ({ deleted: 0, failed: 0 })) - - return { - mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })), - mockChunkedBatchDelete, - mockDecrementStorageUsageForBillingContextInTx: vi.fn(async () => undefined), - mockDelete, - mockDeleteReturning, - mockDeleteWhere, - mockDeleteFileMetadata: vi.fn(async () => true), - mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })), - mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })), - mockHardDeleteDocuments: vi.fn(async (ids: string[]) => ids.length), - mockIsUsingCloudStorage: vi.fn(() => true), - mockKnowledgeBaseContainerDelete, - mockLimit, - mockOrderBy, - mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })), - mockResolveStorageBillingContext: vi.fn(), - mockSelect, - mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), - mockTask: vi.fn((config: unknown) => config), - mockTransaction: vi.fn(), - mockWhere, - } -}) - -vi.mock('@sim/db', () => { - const db = { - delete: mockDelete, - select: mockSelect, - transaction: mockTransaction, - } - return { - db, - // Cleanup-pool client shares the instance so the seeded chains still apply. - dbFor: () => db, - } -}) - -vi.mock('@sim/db/schema', () => { - const table = (cols: string[]) => - Object.fromEntries(cols.map((c) => [c, `col.${c}`])) as Record - const wsFileCols = ['id', 'key', 'context', 'size', 'workspaceId', 'deletedAt', 'uploadedAt'] - const softCols = ['id', 'archivedAt', 'deletedAt', 'workspaceId'] - return { - copilotChats: table(['id', 'workflowId']), - document: table(['id', 'storageKey', 'knowledgeBaseId']), - knowledgeBase: table(softCols), - mcpServers: table(softCols), - memory: table(softCols), - userTableDefinitions: table(softCols), - workflow: table(softCols), - workflowFolder: table(softCols), - workflowMcpServer: table(softCols), - workspaceFile: table(wsFileCols), - workspaceFiles: table(wsFileCols), - } -}) - -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })), +} = vi.hoisted(() => ({ + mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockChunkedBatchDelete: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockDecrementStorageUsageForBillingContextInTx: vi.fn(async () => undefined), + mockDeleteFileMetadata: vi.fn(async () => true), + mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })), + mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockHardDeleteDocuments: vi.fn(async (ids: string[]) => ids.length), + mockIsUsingCloudStorage: vi.fn(() => true), + mockKnowledgeBaseContainerDelete: vi.fn(), + mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })), + mockResolveStorageBillingContext: vi.fn(), + mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), })) -vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - asc: vi.fn((column: unknown) => ({ op: 'asc', column })), - eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), - inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), - isNotNull: vi.fn((...args: unknown[]) => ({ op: 'isNotNull', args })), - isNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })), - lt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/cleanup/batch-delete', () => ({ batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp, @@ -159,14 +78,17 @@ const basePayload = { } describe('cleanup soft deletes', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsUsingCloudStorage.mockReturnValue(true) - mockLimit.mockReset().mockResolvedValue([]) mockSelectRowsByIdChunks.mockReset().mockResolvedValue([]) mockDeleteFiles.mockReset().mockResolvedValue({ deleted: 0, failed: [] }) mockChunkedBatchDelete.mockReset().mockResolvedValue({ deleted: 0, failed: 0 }) - mockDeleteReturning.mockReset().mockResolvedValue([]) mockResolveStorageBillingContext.mockResolvedValue({ workspaceId: 'ws-1', billedAccountUserId: 'user-1', @@ -174,11 +96,6 @@ describe('cleanup soft deletes', () => { plan: 'free', customStorageLimitGB: null, }) - mockTransaction - .mockReset() - .mockImplementation(async (callback: (tx: { delete: typeof mockDelete }) => unknown) => - callback({ delete: mockDelete }) - ) }) it('keeps metadata rows whose object deletion failed', async () => { @@ -201,8 +118,8 @@ describe('cleanup soft deletes', () => { await runCleanupSoftDeletes(basePayload) - expect(mockTransaction).not.toHaveBeenCalled() - expect(mockDelete).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() expect( mockDeleteRowsById.mock.calls.some(([, , ids]) => (ids as string[]).includes('file-failed')) ).toBe(false) @@ -229,18 +146,18 @@ describe('cleanup soft deletes', () => { }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] }) - mockDeleteReturning.mockResolvedValueOnce([{ id: 'file-deleted', size: 7 }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-deleted', size: 7 }]) await runCleanupSoftDeletes(basePayload) expect(mockResolveStorageBillingContext).toHaveBeenCalledOnce() expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.objectContaining({ delete: mockDelete }), + dbChainMock.db, expect.objectContaining({ workspaceId: 'ws-1' }), 7 ) expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan( - mockTransaction.mock.invocationCallOrder[0] + dbChainMockFns.transaction.mock.invocationCallOrder[0] ) }) @@ -258,12 +175,12 @@ describe('cleanup soft deletes', () => { }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] }) - mockDeleteReturning.mockResolvedValueOnce([{ id: 'chat-file' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-file' }]) await runCleanupSoftDeletes(basePayload) expect(mockDeleteFiles).toHaveBeenCalledWith(['mothership/chat-file'], 'mothership') - expect(mockDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) @@ -280,7 +197,7 @@ describe('cleanup soft deletes', () => { return { deleted: 1, failed: 0 } } ) - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'doc-1' }]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) @@ -294,7 +211,7 @@ describe('cleanup soft deletes', () => { }) it('soft-deletes abandoned KB bindings and removes their storage objects', async () => { - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ key: 'kb/orphan-1' }, { key: 'kb/orphan-2' }]) .mockResolvedValueOnce([]) @@ -307,7 +224,7 @@ describe('cleanup soft deletes', () => { }) it('keeps an orphan KB binding when its object deletion fails', async () => { - mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-retry' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ key: 'kb/orphan-retry' }]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 0, failed: [{ key: 'kb/orphan-retry', error: 'storage unavailable' }], @@ -320,7 +237,7 @@ describe('cleanup soft deletes', () => { it('still removes bindings but skips object deletion without cloud storage', async () => { mockIsUsingCloudStorage.mockReturnValue(false) - mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([]) await runCleanupSoftDeletes(basePayload) @@ -329,7 +246,7 @@ describe('cleanup soft deletes', () => { }) it('stops the batch loop when binding deletion makes no progress', async () => { - mockLimit.mockResolvedValue([{ key: 'kb/stuck' }]) + dbChainMockFns.limit.mockResolvedValue([{ key: 'kb/stuck' }]) mockDeleteFileMetadata.mockRejectedValue(new Error('db down')) await runCleanupSoftDeletes(basePayload) @@ -341,7 +258,7 @@ describe('cleanup soft deletes', () => { it('does not run the sweep when there are no workspaces', async () => { await runCleanupSoftDeletes({ ...basePayload, workspaceIds: [] }) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mockDeleteFiles).not.toHaveBeenCalled() expect(mockDeleteFileMetadata).not.toHaveBeenCalled() }) diff --git a/apps/sim/background/tiktok-webhook-targets.test.ts b/apps/sim/background/tiktok-webhook-targets.test.ts index d8acfbf1299..76f7ed76cbc 100644 --- a/apps/sim/background/tiktok-webhook-targets.test.ts +++ b/apps/sim/background/tiktok-webhook-targets.test.ts @@ -2,51 +2,17 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAsc, - mockCredentialExpression, - mockEq, - mockGt, - mockLike, - mockLimit, - mockOrderBy, - mockSelect, - queryRows, -} = vi.hoisted(() => ({ +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAsc, mockCredentialExpression, mockEq, mockGt, mockLike, tables } = vi.hoisted(() => ({ mockAsc: vi.fn((value: unknown) => ({ asc: value })), mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })), mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })), - mockLimit: vi.fn(), - mockOrderBy: vi.fn(), - mockSelect: vi.fn(), - queryRows: { - rows: [] as Array<{ - accountId: string - webhookId: string - webhook: Record - workflow: Record - }>, - }, -})) - -vi.mock('@sim/db', () => { - const chain = { - from: vi.fn(() => chain), - innerJoin: vi.fn(() => chain), - leftJoin: vi.fn(() => chain), - where: vi.fn(() => chain), - orderBy: mockOrderBy, - limit: mockLimit, - } - mockOrderBy.mockImplementation(() => chain) - mockLimit.mockImplementation((limit: number) => Promise.resolve(queryRows.rows.slice(0, limit))) - mockSelect.mockImplementation(() => chain) - - return { + /** Table-qualified column names keep the eq/like assertions unambiguous. */ + tables: { account: { id: 'account.id', accountId: 'account.accountId', @@ -59,8 +25,6 @@ vi.mock('@sim/db', () => { type: 'credential.type', workspaceId: 'credential.workspaceId', }, - db: { select: mockSelect }, - webhookCredentialIdExpression: mockCredentialExpression, webhook: { deploymentVersionId: 'webhook.deploymentVersionId', isActive: 'webhook.isActive', @@ -80,8 +44,14 @@ vi.mock('@sim/db', () => { workflowId: 'workflowDeploymentVersion.workflowId', isActive: 'workflowDeploymentVersion.isActive', }, - } -}) + }, +})) + +vi.mock('@sim/db', () => ({ + ...dbChainMock, + ...tables, + webhookCredentialIdExpression: mockCredentialExpression, +})) vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => conditions), @@ -100,14 +70,30 @@ import { const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555' +/** Queues one page of joined rows, clipped like the SQL LIMIT would. */ +function queuePageRows( + rows: Array<{ + accountId: string + webhookId: string + webhook: Record + workflow: Record + }> +) { + queueTableRows(tables.account, rows.slice(0, TIKTOK_WEBHOOK_TARGET_PAGE_SIZE)) +} + describe('findTikTokWebhookTargetPage', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() - queryRows.rows = [] + resetDbChainMock() }) it('returns only rows whose stored account ID exactly matches user_openid', async () => { - queryRows.rows = [ + queuePageRows([ { accountId: `act.user-${ACCOUNT_UUID}`, webhookId: 'webhook-1', @@ -120,7 +106,7 @@ describe('findTikTokWebhookTargetPage', () => { webhook: { id: 'webhook-2' }, workflow: { id: 'workflow-2' }, }, - ] + ]) const page = await findTikTokWebhookTargetPage('act.user', 'request-1') @@ -151,20 +137,22 @@ describe('findTikTokWebhookTargetPage', () => { expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100') expect(mockAsc).toHaveBeenCalledWith('webhook.id') - expect(mockOrderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) - expect(mockLimit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) }) it('returns a continuation cursor when the fixed-size page is full', async () => { - queryRows.rows = Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { - const webhookId = `webhook-${String(index).padStart(3, '0')}` - return { - accountId: `act.user-${ACCOUNT_UUID}`, - webhookId, - webhook: { id: webhookId }, - workflow: { id: `workflow-${index}` }, - } - }) + queuePageRows( + Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { + const webhookId = `webhook-${String(index).padStart(3, '0')}` + return { + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId, + webhook: { id: webhookId }, + workflow: { id: `workflow-${index}` }, + } + }) + ) const page = await findTikTokWebhookTargetPage('act.user', 'request-4') @@ -188,6 +176,6 @@ describe('findTikTokWebhookTargetPage', () => { nextCursor: null, targets: [], }) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/admin/dashboard-credit-grant.test.ts b/apps/sim/lib/admin/dashboard-credit-grant.test.ts index 03dba00dbcf..57e9f4677f8 100644 --- a/apps/sim/lib/admin/dashboard-credit-grant.test.ts +++ b/apps/sim/lib/admin/dashboard-credit-grant.test.ts @@ -1,13 +1,12 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, organization, subscription, user, userStats, workspace } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - rows: [] as unknown[][], - returningRows: [] as unknown[][], billingSubscriptions: [] as unknown[], - updateSets: [] as Record[], idempotencyCalls: [] as { namespace: string; requestFingerprint: string }[], recordAudit: vi.fn(), acquireLock: vi.fn(), @@ -25,43 +24,7 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: { BILLING: 'billing' }, recordAudit: mocks.recordAudit, })) -vi.mock('@sim/db', () => { - const selectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.where = () => chain - chain.orderBy = () => chain - chain.for = () => chain - chain.limit = () => Promise.resolve(mocks.rows.shift() ?? []) - chain.then = (resolve: (value: unknown[]) => unknown) => - Promise.resolve(mocks.rows.shift() ?? []).then(resolve) - return chain - } - const update = () => { - const chain: Record = {} - chain.set = (values: Record) => { - mocks.updateSets.push(values) - return chain - } - chain.where = () => chain - chain.returning = () => Promise.resolve(mocks.returningRows.shift() ?? []) - chain.then = (resolve: (value: unknown[]) => unknown) => Promise.resolve([]).then(resolve) - return chain - } - const insert = () => { - const chain: Record = {} - chain.values = () => chain - chain.onConflictDoNothing = () => Promise.resolve([]) - return chain - } - const tx = { select: () => selectChain(), update, insert } - return { - db: { - select: () => selectChain(), - transaction: async (operation: (executor: typeof tx) => Promise) => operation(tx), - }, - } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/idempotency/transaction', () => ({ executeTransactionallyIdempotent: async ( _tx: unknown, @@ -113,23 +76,26 @@ import { grantDashboardUserBalance, } from '@/lib/admin/dashboard' +/** The values object passed to the nth `update(...).set(...)` call. */ +const updateSetValues = (index = 0): Record => + dbChainMockFns.set.mock.calls[index]?.[0] as Record + +afterAll(resetDbChainMock) + describe('grantDashboardOrganizationBalance', () => { beforeEach(() => { vi.clearAllMocks() - mocks.rows = [] - mocks.returningRows = [] + resetDbChainMock() mocks.billingSubscriptions = [] - mocks.updateSets = [] mocks.idempotencyCalls = [] }) it('SQL-adds the grant to both fields without absorbing it into a custom limit', async () => { - mocks.rows = [ - [{ id: 'org-1', creditBalance: '0.001', orgUsageLimit: '100' }], - [], - [{ value: 0 }], - ] - mocks.returningRows = [[{ creditBalance: '0.006', orgUsageLimit: '100.005' }]] + queueTableRows(organization, [{ id: 'org-1', creditBalance: '0.001', orgUsageLimit: '100' }]) + queueTableRows(member, [{ value: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { creditBalance: '0.006', orgUsageLimit: '100.005' }, + ]) const result = await grantDashboardOrganizationBalance( 'org-1', @@ -139,10 +105,10 @@ describe('grantDashboardOrganizationBalance', () => { { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' } ) - expect(mocks.updateSets[0].creditBalance).toBeDefined() - expect(mocks.updateSets[0].creditBalance).not.toBe('0.005') - expect(mocks.updateSets[0].orgUsageLimit).toBeDefined() - expect(mocks.updateSets).toHaveLength(1) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(updateSetValues().creditBalance).toBeDefined() + expect(updateSetValues().creditBalance).not.toBe('0.005') + expect(updateSetValues().orgUsageLimit).toBeDefined() expect(mocks.idempotencyCalls[0]?.namespace).toBe('admin-credit-grant') expect(result).toEqual({ prepaidBalanceDollars: 0.006, usageLimitDollars: 100.005 }) expect(mocks.recordAudit).toHaveBeenCalledWith( @@ -154,22 +120,18 @@ describe('grantDashboardOrganizationBalance', () => { describe('grantDashboardUserBalance', () => { beforeEach(() => { vi.clearAllMocks() - mocks.rows = [] - mocks.returningRows = [] + resetDbChainMock() mocks.billingSubscriptions = [] - mocks.updateSets = [] mocks.idempotencyCalls = [] }) it('resets a free account to free-plus-prepaid before adding the grant', async () => { - mocks.rows = [ - [{ id: 'user-1' }], - [], - [{ creditBalance: '0.001', currentUsageLimit: '100' }], - [], - ] + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(userStats, [{ creditBalance: '0.001', currentUsageLimit: '100' }]) mocks.billingSubscriptions = [null, null] - mocks.returningRows = [[{ creditBalance: '0.006', currentUsageLimit: '5.006' }]] + dbChainMockFns.returning.mockResolvedValueOnce([ + { creditBalance: '0.006', currentUsageLimit: '5.006' }, + ]) const result = await grantDashboardUserBalance( 'user-1', @@ -179,12 +141,12 @@ describe('grantDashboardUserBalance', () => { { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' } ) - expect(mocks.updateSets).toHaveLength(1) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) expect(mocks.acquireUserLock).toHaveBeenCalledWith(expect.anything(), 'user-1') expect(mocks.idempotencyCalls[0]?.namespace).toBe('admin-credit-grant') - expect(mocks.updateSets[0].creditBalance).toBeDefined() - expect(mocks.updateSets[0].currentUsageLimit).toBeDefined() - expect(JSON.stringify(mocks.updateSets[0].currentUsageLimit)).not.toContain('greatest') + expect(updateSetValues().creditBalance).toBeDefined() + expect(updateSetValues().currentUsageLimit).toBeDefined() + expect(JSON.stringify(updateSetValues().currentUsageLimit)).not.toContain('greatest') expect(result).toEqual({ prepaidBalanceDollars: 0.006, usageLimitDollars: 5.006 }) expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -200,14 +162,12 @@ describe('grantDashboardUserBalance', () => { status: 'active', plan: 'pro', } - mocks.rows = [ - [{ id: 'user-1' }], - [], - [{ creditBalance: '0.001', currentUsageLimit: '100' }], - [], - ] + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(userStats, [{ creditBalance: '0.001', currentUsageLimit: '100' }]) mocks.billingSubscriptions = [personalSubscription, personalSubscription] - mocks.returningRows = [[{ creditBalance: '0.006', currentUsageLimit: '100.005' }]] + dbChainMockFns.returning.mockResolvedValueOnce([ + { creditBalance: '0.006', currentUsageLimit: '100.005' }, + ]) const result = await grantDashboardUserBalance( 'user-1', @@ -217,7 +177,7 @@ describe('grantDashboardUserBalance', () => { { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' } ) - expect(JSON.stringify(mocks.updateSets[0].currentUsageLimit)).toContain('greatest') + expect(JSON.stringify(updateSetValues().currentUsageLimit)).toContain('greatest') expect(result).toEqual({ prepaidBalanceDollars: 0.006, usageLimitDollars: 100.005 }) }) @@ -227,12 +187,10 @@ describe('grantDashboardUserBalance', () => { status: 'active', plan: 'enterprise', } - mocks.rows = [ - [{ id: 'user-1' }], - [{ organizationId: 'org-1' }], - [{ creditBalance: '0', currentUsageLimit: null }], - [{ organizationId: 'org-1' }], - ] + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(member, [{ organizationId: 'org-1' }]) + queueTableRows(userStats, [{ creditBalance: '0', currentUsageLimit: null }]) + queueTableRows(member, [{ organizationId: 'org-1' }]) mocks.billingSubscriptions = [organizationSubscription] await expect( @@ -243,7 +201,7 @@ describe('grantDashboardUserBalance', () => { }) ).rejects.toThrow('grant prepaid balance from Organizations instead') - expect(mocks.updateSets).toHaveLength(0) + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(mocks.recordAudit).not.toHaveBeenCalled() }) }) @@ -251,15 +209,13 @@ describe('grantDashboardUserBalance', () => { describe('addDashboardOrganizationMember', () => { beforeEach(() => { vi.clearAllMocks() - mocks.rows = [] - mocks.returningRows = [] - mocks.updateSets = [] - mocks.transferMembership.mockReset() - mocks.moveWorkspace.mockReset() + resetDbChainMock() + mocks.billingSubscriptions = [] + mocks.idempotencyCalls = [] }) it('rejects an existing member inside the transaction before touching their cap', async () => { - mocks.rows = [[], [{ plan: 'enterprise' }]] + queueTableRows(subscription, [{ plan: 'enterprise' }]) mocks.ensureMembership.mockResolvedValue({ success: true, memberId: 'member-1', @@ -285,10 +241,8 @@ describe('addDashboardOrganizationMember', () => { }) it('uses the canonical transfer service and reports each selected personal workspace move', async () => { - mocks.rows = [ - [{ id: 'workspace-1' }, { id: 'workspace-2' }], - [{ id: 'member-old', organizationId: 'org-old' }], - ] + queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }]) + queueTableRows(member, [{ id: 'member-old', organizationId: 'org-old' }]) mocks.transferMembership.mockResolvedValue({ success: true, memberId: 'member-new', diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index c719913e6cb..e0fb92ded00 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -16,8 +16,13 @@ import { createParallelBlock, createStarterBlock, createWorkflowState, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BlockState as AppBlockState, WorkflowState as AppWorkflowState, @@ -49,78 +54,7 @@ function legacySubBlocks(subBlocks: Record): any { return subBlocks } -const { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } = vi.hoisted(() => { - const mockDb = { - select: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - transaction: vi.fn(), - } - - const mockWorkflowBlocks = { - workflowId: 'workflowId', - id: 'id', - type: 'type', - name: 'name', - positionX: 'positionX', - positionY: 'positionY', - enabled: 'enabled', - horizontalHandles: 'horizontalHandles', - height: 'height', - subBlocks: 'subBlocks', - outputs: 'outputs', - data: 'data', - parentId: 'parentId', - extent: 'extent', - } - - const mockWorkflowEdges = { - workflowId: 'workflowId', - id: 'id', - sourceBlockId: 'sourceBlockId', - targetBlockId: 'targetBlockId', - sourceHandle: 'sourceHandle', - targetHandle: 'targetHandle', - } - - const mockWorkflowSubflows = { - workflowId: 'workflowId', - id: 'id', - type: 'type', - config: 'config', - } - - return { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } -}) - -vi.mock('@sim/db', () => ({ - db: mockDb, - runOutsideTransactionContext: (fn: () => T): T => fn(), - workflowBlocks: mockWorkflowBlocks, - workflowEdges: mockWorkflowEdges, - workflowSubflows: mockWorkflowSubflows, - workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - version: 'version', - state: 'state', - isActive: 'isActive', - createdAt: 'createdAt', - createdBy: 'createdBy', - deployedBy: 'deployedBy', - }, - workflow: {}, - webhook: {}, - workflowDeploymentOperation: { - workflowId: 'workflowId', - status: 'status', - }, - workflowSchedule: { - workflowId: 'workflowId', - deploymentVersionId: 'deploymentVersionId', - archivedAt: 'archivedAt', - }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) const { mockSanitizeAgentToolsInBlocks } = vi.hoisted(() => ({ mockSanitizeAgentToolsInBlocks: vi.fn(), @@ -128,8 +62,8 @@ const { mockSanitizeAgentToolsInBlocks } = vi.hoisted(() => ({ /** * Default identity behavior for the mocked migration step. Re-applied in the - * cache describe block's `beforeEach` because the outer `afterEach` calls - * `vi.resetAllMocks()`, which clears implementations. + * outer `beforeEach` because `vi.clearAllMocks()` clears implementations set + * on the hoisted spy. */ const sanitizeIdentity = (blocks: unknown) => ({ blocks }) mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) @@ -142,6 +76,35 @@ import * as dbHelpers from '@/lib/workflows/persistence/utils' const mockWorkflowId = 'test-workflow-123' +/** + * Queues the four table-routed result sets consumed by + * `loadWorkflowFromNormalizedTablesRaw` (blocks, edges, subflows, workflow row). + */ +function queueLoadFixtures(options: { + blocks: unknown[] + edges?: unknown[] + subflows?: unknown[] + workspaceId?: string +}) { + queueTableRows(schemaMock.workflowBlocks, options.blocks) + queueTableRows(schemaMock.workflowEdges, options.edges ?? []) + queueTableRows(schemaMock.workflowSubflows, options.subflows ?? []) + queueTableRows(schemaMock.workflow, [{ workspaceId: options.workspaceId ?? 'test-workspace-id' }]) +} + +/** + * Returns the row arrays passed to `insert(table).values(rows)` for the given + * schema table. Insert/values chains run sequentially in the code under test, + * so the two spies' call lists stay index-aligned. + */ +function insertedRowsFor(table: unknown): Record[][] { + return dbChainMockFns.insert.mock.calls.flatMap(([calledTable], index) => + calledTable === table && Array.isArray(dbChainMockFns.values.mock.calls[index]?.[0]) + ? [dbChainMockFns.values.mock.calls[index][0] as Record[]] + : [] + ) +} + /** * Converts a BlockState to a mock database block row format. */ @@ -332,11 +295,12 @@ const mockWorkflowState = createWorkflowState({ describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) }) - afterEach(() => { - vi.resetAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('buildWorkflowDeploymentSnapshot', () => { @@ -377,29 +341,11 @@ describe('Database Helpers', () => { describe('loadWorkflowFromNormalizedTables', () => { it('should successfully load workflow data from normalized tables', async () => { - vi.clearAllMocks() - - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) { - return Promise.resolve(mockBlocksFromDb) - } - if (callCount === 2) { - return Promise.resolve(mockEdgesFromDb) - } - if (callCount === 3) { - return Promise.resolve(mockSubflowsFromDb) - } - if (callCount === 4) { - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ + blocks: mockBlocksFromDb, + edges: mockEdgesFromDb, + subflows: mockSubflowsFromDb, + }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -465,23 +411,15 @@ describe('Database Helpers', () => { }) it('should return null when no blocks are found', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }) - const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(result).toBeNull() }) it('should return null when database query fails', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockRejectedValue(new Error('Database connection failed')), - }), - }) + dbChainMockFns.where.mockImplementationOnce(() => + Promise.reject(new Error('Database connection failed')) + ) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -498,19 +436,10 @@ describe('Database Helpers', () => { }, ] - let callCount = 0 - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve(mockBlocksFromDb) - if (callCount === 2) return Promise.resolve(mockEdgesFromDb) - if (callCount === 3) return Promise.resolve(subflowsWithUnknownType) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), + queueLoadFixtures({ + blocks: mockBlocksFromDb, + edges: mockEdgesFromDb, + subflows: subflowsWithUnknownType, }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -538,20 +467,7 @@ describe('Database Helpers', () => { malformedBlocks[0].type = null as any malformedBlocks[0].name = null as any - let callCount = 0 - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve(malformedBlocks) - if (callCount === 2) return Promise.resolve([]) - if (callCount === 3) return Promise.resolve([]) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - }) + queueLoadFixtures({ blocks: malformedBlocks }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -565,11 +481,7 @@ describe('Database Helpers', () => { const connectionError = new Error('Connection refused') ;(connectionError as any).code = 'ECONNREFUSED' - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockRejectedValue(connectionError), - }), - }) + dbChainMockFns.where.mockImplementationOnce(() => Promise.reject(connectionError)) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -579,25 +491,6 @@ describe('Database Helpers', () => { describe('saveWorkflowToNormalizedTables', () => { it('should successfully save workflow data to normalized tables', async () => { - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue([]), - }), - } - return await callback(tx) - }) - - mockDb.transaction = mockTransaction - const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(mockWorkflowState) @@ -605,31 +498,12 @@ describe('Database Helpers', () => { expect(result.success).toBe(true) - expect(mockTransaction).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) }) it('should handle empty workflow state gracefully', async () => { const emptyWorkflowState = createWorkflowState() - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue([]), - }), - } - return await callback(tx) - }) - - mockDb.transaction = mockTransaction - const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(emptyWorkflowState) @@ -639,8 +513,7 @@ describe('Database Helpers', () => { }) it('should return error when transaction fails', async () => { - const mockTransaction = vi.fn().mockRejectedValue(new Error('Transaction failed')) - mockDb.transaction = mockTransaction + dbChainMockFns.transaction.mockRejectedValueOnce(new Error('Transaction failed')) const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, @@ -655,8 +528,7 @@ describe('Database Helpers', () => { const constraintError = new Error('Unique constraint violation') ;(constraintError as any).code = '23505' - const mockTransaction = vi.fn().mockRejectedValue(constraintError) - mockDb.transaction = mockTransaction + dbChainMockFns.transaction.mockRejectedValueOnce(constraintError) const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, @@ -668,42 +540,12 @@ describe('Database Helpers', () => { }) it('should properly format block data for database insertion', async () => { - let capturedBlockInserts: any[] = [] - let capturedEdgeInserts: any[] = [] - let capturedSubflowInserts: any[] = [] - - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((data) => { - if (data.length > 0) { - if (data[0].positionX !== undefined) { - capturedBlockInserts = data - } else if (data[0].sourceBlockId !== undefined) { - capturedEdgeInserts = data - } else if (data[0].type === 'loop' || data[0].type === 'parallel') { - capturedSubflowInserts = data - } - } - return Promise.resolve([]) - }), - }), - } - return await callback(tx) - }) - - mockDb.transaction = mockTransaction - await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(mockWorkflowState)) + const [capturedBlockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks) + const [capturedEdgeInserts = []] = insertedRowsFor(schemaMock.workflowEdges) + const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows) + expect(capturedBlockInserts).toHaveLength(5) expect(capturedBlockInserts).toEqual( expect.arrayContaining([ @@ -768,38 +610,14 @@ describe('Database Helpers', () => { }) it('should regenerate missing loop and parallel definitions from block data', async () => { - let capturedSubflowInserts: any[] = [] - - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((data) => { - if (data.length > 0 && (data[0].type === 'loop' || data[0].type === 'parallel')) { - capturedSubflowInserts = data - } - return Promise.resolve([]) - }), - }), - } - return await callback(tx) - }) - - mockDb.transaction = mockTransaction - const staleWorkflowState = structuredClone(mockWorkflowState) staleWorkflowState.loops = {} staleWorkflowState.parallels = {} await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(staleWorkflowState)) + const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows) + expect(capturedSubflowInserts).toHaveLength(2) expect(capturedSubflowInserts).toEqual( expect.arrayContaining([ @@ -816,13 +634,7 @@ describe('Database Helpers', () => { describe('workflowExistsInNormalizedTables', () => { it('should return true when workflow exists in normalized tables', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: 'block-1' }]), - }), - }), - }) + queueTableRows(schemaMock.workflowBlocks, [{ id: 'block-1' }]) const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) @@ -830,27 +642,13 @@ describe('Database Helpers', () => { }) it('should return false when workflow does not exist in normalized tables', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - }) - const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) expect(result).toBe(false) }) it('should return false when database query fails', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockRejectedValue(new Error('Database error')), - }), - }), - }) + dbChainMockFns.limit.mockImplementationOnce(() => Promise.reject(new Error('Database error'))) const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) @@ -859,60 +657,19 @@ describe('Database Helpers', () => { }) describe('workflow row locking', () => { - function createMissingWorkflowTx() { - const lockFor = vi.fn().mockResolvedValue([]) - const limit = vi.fn(() => ({ for: lockFor })) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - const select = vi.fn(() => ({ from })) - const update = vi.fn() - - return { - tx: { - execute: vi.fn().mockResolvedValue([{ id: mockWorkflowId }]), - select, - update, - }, - lockFor, - update, - } - } - it('returns an error when undeploy cannot lock a workflow row', async () => { - const { tx, update } = createMissingWorkflowTx() - mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) - const result = await dbHelpers.undeployWorkflow({ workflowId: mockWorkflowId }) expect(result).toEqual({ success: false, error: 'Workflow not found', }) - expect(update).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('supersedes in-flight operations and releases path claims during undeploy', async () => { - const versionRows = [{ id: 'dv-1' }, { id: 'dv-2' }] - const createWhereResult = () => ({ - limit: vi.fn(() => ({ - for: vi.fn().mockResolvedValue([{ id: mockWorkflowId }]), - })), - then: (resolve: (rows: typeof versionRows) => void) => resolve(versionRows), - }) - const setCalls: unknown[] = [] - const tx = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ where: vi.fn(() => createWhereResult()) })), - })), - update: vi.fn(() => ({ - set: vi.fn((payload: unknown) => { - setCalls.push(payload) - return { where: vi.fn().mockResolvedValue([]) } - }), - })), - delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), - } - mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) + queueTableRows(schemaMock.workflow, [{ id: mockWorkflowId }]) + queueTableRows(schemaMock.workflowDeploymentVersion, [{ id: 'dv-1' }, { id: 'dv-2' }]) const onUndeployTransaction = vi.fn().mockResolvedValue(undefined) const result = await dbHelpers.undeployWorkflow({ @@ -921,6 +678,7 @@ describe('Database Helpers', () => { }) expect(result).toEqual({ success: true }) + const setCalls = dbChainMockFns.set.mock.calls.map(([payload]) => payload) expect(setCalls[0]).toEqual(expect.objectContaining({ status: 'superseded' })) expect(setCalls).toEqual( expect.arrayContaining([ @@ -928,8 +686,8 @@ describe('Database Helpers', () => { expect.objectContaining({ isDeployed: false, deployedAt: null }), ]) ) - expect(tx.delete).toHaveBeenCalledTimes(2) - expect(onUndeployTransaction).toHaveBeenCalledWith(tx, { + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2) + expect(onUndeployTransaction).toHaveBeenCalledWith(dbChainMock.db, { deploymentVersionIds: ['dv-1', 'dv-2'], }) }) @@ -960,25 +718,6 @@ describe('Database Helpers', () => { const largeWorkflowState = createWorkflowState({ blocks, edges }) - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue([]), - }), - } - return await callback(tx) - }) - - mockDb.transaction = mockTransaction - const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(largeWorkflowState) @@ -1015,22 +754,7 @@ describe('Database Helpers', () => { testBlocks[0].advancedMode = true testBlocks[1].advancedMode = false - vi.clearAllMocks() - - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve(testBlocks) - if (callCount === 2) return Promise.resolve([]) - if (callCount === 3) return Promise.resolve([]) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ blocks: testBlocks }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -1056,20 +780,7 @@ describe('Database Helpers', () => { ), ] - vi.clearAllMocks() - - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve(blocksWithDefaultValues) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ blocks: blocksWithDefaultValues }) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) @@ -1125,22 +836,7 @@ describe('Database Helpers', () => { ) duplicatedBlock.advancedMode = true - vi.clearAllMocks() - - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([originalBlock, duplicatedBlock]) - if (callCount === 2) return Promise.resolve([]) - if (callCount === 3) return Promise.resolve([]) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ blocks: [originalBlock, duplicatedBlock] }) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() @@ -1154,44 +850,19 @@ describe('Database Helpers', () => { parallels: {}, } - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const mockTx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - insert: vi.fn().mockImplementation((_table) => ({ - values: vi.fn().mockImplementation((values) => { - if (Array.isArray(values)) { - values.forEach((blockInsert) => { - if (blockInsert.id === 'agent-original') { - expect(blockInsert.advancedMode).toBe(true) - } - if (blockInsert.id === 'agent-duplicate') { - expect(blockInsert.advancedMode).toBe(true) - } - }) - } - return Promise.resolve() - }), - })), - } - return await callback(mockTx) - }) - - mockDb.transaction = mockTransaction - const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, workflowState ) expect(saveResult.success).toBe(true) - expect(mockTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() + + const [blockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks) + const savedOriginal = blockInserts.find((row) => row.id === 'agent-original') + const savedDuplicate = blockInserts.find((row) => row.id === 'agent-duplicate') + expect(savedOriginal?.advancedMode).toBe(true) + expect(savedDuplicate?.advancedMode).toBe(true) }) it('should handle mixed advancedMode states correctly', async () => { @@ -1224,20 +895,7 @@ describe('Database Helpers', () => { ) advancedBlock.advancedMode = true - vi.clearAllMocks() - - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([basicBlock, advancedBlock]) - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ blocks: [basicBlock, advancedBlock] }) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() @@ -1263,67 +921,36 @@ describe('Database Helpers', () => { }, }) - const mockTransaction = vi.fn().mockImplementation(async (callback) => { - const mockTx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - }), - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - } - return await callback(mockTx) - }) - - mockDb.transaction = mockTransaction - const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, asAppState(testWorkflowState) ) expect(saveResult.success).toBe(true) - vi.clearAllMocks() - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) { - return Promise.resolve([ - { - id: 'block-1', - workflowId: mockWorkflowId, - type: 'agent', - name: 'Test Agent', - positionX: 100, - positionY: 100, - enabled: true, - horizontalHandles: true, - advancedMode: true, - height: 200, - subBlocks: { - systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System' }, - model: { id: 'model', type: 'select', value: 'gpt-4o' }, - }, - outputs: {}, - data: {}, - parentId: null, - extent: null, - }, - ]) - } - if (callCount === 4) - return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } - return Promise.resolve([]) - }), - }), - })) + queueLoadFixtures({ + blocks: [ + { + id: 'block-1', + workflowId: mockWorkflowId, + type: 'agent', + name: 'Test Agent', + positionX: 100, + positionY: 100, + enabled: true, + horizontalHandles: true, + advancedMode: true, + height: 200, + subBlocks: { + systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System' }, + model: { id: 'model', type: 'select', value: 'gpt-4o' }, + }, + outputs: {}, + data: {}, + parentId: null, + extent: null, + }, + ], + }) const loadedState = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loadedState).toBeDefined() @@ -1651,30 +1278,23 @@ describe('Database Helpers', () => { } /** - * Wires `db.select` to return a single active deployment-version row for the - * given id. Returns the inner `where` spy so tests can assert how many times - * the active-version SELECT ran. + * Queues one active deployment-version row for the next active-version + * SELECT; call once per expected `loadDeployedWorkflowState` invocation. + * Tests assert SELECT counts on `dbChainMockFns.where`. */ - function mockActiveVersionSelect(versionId: string, state: unknown) { - const where = vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: versionId, state, createdAt: new Date() }]), - }), - }) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ where }), - }) - return where + function queueActiveVersion(versionId: string, state: unknown) { + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: versionId, state, createdAt: new Date() }, + ]) } beforeEach(() => { - vi.clearAllMocks() - mockSanitizeAgentToolsInBlocks.mockImplementation(sanitizeIdentity) dbHelpers.invalidateDeployedStateCache() }) it('serves a cache HIT, skipping migrations on the second call for the same active version', async () => { - const where = mockActiveVersionSelect('dv-hit', buildDeployedState()) + queueActiveVersion('dv-hit', buildDeployedState()) + queueActiveVersion('dv-hit', buildDeployedState()) const first = await dbHelpers.loadDeployedWorkflowState('wf-1', 'workspace-1') const second = await dbHelpers.loadDeployedWorkflowState('wf-1', 'workspace-1') @@ -1682,20 +1302,22 @@ describe('Database Helpers', () => { expect(first).toBeDefined() expect(second).toBeDefined() expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1) - expect(where).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) }) it('still runs the active-version SELECT on every call so rollback/redeploy stays observable', async () => { - const where = mockActiveVersionSelect('dv-active', buildDeployedState()) + queueActiveVersion('dv-active', buildDeployedState()) + queueActiveVersion('dv-active', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1') await dbHelpers.loadDeployedWorkflowState('wf-2', 'workspace-1') - expect(where).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) }) it('deep-clones on read: mutating the first result does not corrupt the cached copy', async () => { - mockActiveVersionSelect('dv-clone', buildDeployedState()) + queueActiveVersion('dv-clone', buildDeployedState()) + queueActiveVersion('dv-clone', buildDeployedState()) const first = await dbHelpers.loadDeployedWorkflowState('wf-3', 'workspace-1') ;(first.blocks['block-1'] as any).name = 'MUTATED' @@ -1715,22 +1337,18 @@ describe('Database Helpers', () => { }) it('keys the cache by deploymentVersionId: a different active id triggers a fresh build', async () => { - mockActiveVersionSelect('dv-old', buildDeployedState()) + queueActiveVersion('dv-old', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1) - mockActiveVersionSelect('dv-new', buildDeployedState()) + queueActiveVersion('dv-new', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-4', 'workspace-1') expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(2) }) it('loads an admitted immutable deployment version even after a later cutover', async () => { const state = buildDeployedState() - const limit = vi.fn().mockResolvedValue([{ id: 'dv-admitted', state }]) - const where = vi.fn().mockReturnValue({ limit }) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ where }), - }) + queueTableRows(schemaMock.workflowDeploymentVersion, [{ id: 'dv-admitted', state }]) const result = await dbHelpers.loadWorkflowDeploymentVersionState( 'wf-admitted', @@ -1740,11 +1358,13 @@ describe('Database Helpers', () => { expect(result.deploymentVersionId).toBe('dv-admitted') expect(result.blocks).toEqual(state.blocks) - expect(where).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) }) it('invalidateDeployedStateCache(id) forces a rebuild on the next call', async () => { - mockActiveVersionSelect('dv-inv', buildDeployedState()) + queueActiveVersion('dv-inv', buildDeployedState()) + queueActiveVersion('dv-inv', buildDeployedState()) + queueActiveVersion('dv-inv', buildDeployedState()) await dbHelpers.loadDeployedWorkflowState('wf-5', 'workspace-1') await dbHelpers.loadDeployedWorkflowState('wf-5', 'workspace-1') @@ -1757,15 +1377,6 @@ describe('Database Helpers', () => { }) it('throws when there is no active deployment and does not cache the failure', async () => { - const where = vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ where }), - }) - await expect(dbHelpers.loadDeployedWorkflowState('wf-6', 'workspace-1')).rejects.toThrow( 'Workflow wf-6 has no active deployment' ) diff --git a/packages/testing/src/mocks/database.mock.test.ts b/packages/testing/src/mocks/database.mock.test.ts index c32fc940156..6e5f58fa178 100644 --- a/packages/testing/src/mocks/database.mock.test.ts +++ b/packages/testing/src/mocks/database.mock.test.ts @@ -97,6 +97,14 @@ describe('database mock', () => { ).resolves.toEqual([{ id: 'from-row' }]) }) + it('supports the .limit(n).for(mode) row-lock chain', async () => { + queueTableRows(workflowTable, [{ id: 'locked' }]) + await expect(db.select().from(workflowTable).where({}).limit(1).for('update')).resolves.toEqual( + [{ id: 'locked' }] + ) + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + }) + it('never lets mutation chains consume select queues', async () => { queueTableRows(workflowTable, [{ id: 'kept' }]) await expect(db.update(workflowTable).set({}).where({})).resolves.toEqual([]) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index a432bc43972..897d2c5f0d1 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -73,6 +73,12 @@ export function createMockSqlOperators() { * * The queue is keyed by table object identity, so pass the same schema-mock * table object the code under test passes to `.from()` / the join. + * + * Footgun: because a chain falls back to its JOIN tables when the `.from()` + * table has nothing queued, a `from(A).innerJoin(B)` chain you expect to + * resolve empty will consume a set queued for a LATER select on `B`. When a + * suite queues `B` for a subsequent query, queue an explicit empty set on `A` + * first (`queueTableRows(A, [])`) so the joined chain consumes that instead. */ const tableRowQueues = new Map() @@ -221,10 +227,12 @@ const lazyRowsThenable = (getRows: RowsSupplier): any => ({ }) // `.limit()` returns a builder that is awaitable and also exposes `.offset()` -// for keyset/OFFSET paging (`.limit(n).offset(m)`). +// for keyset/OFFSET paging (`.limit(n).offset(m)`) and `.for()` for drizzle's +// `.limit(1).for('update')` row-lock form. const limitBuilder = (getRows: RowsSupplier) => { const thenable = lazyRowsThenable(getRows) thenable.offset = spyOrDefault(offset, () => lazyRowsThenable(getRows)) + thenable.for = spyOrDefault(forClause, () => limitBuilder(getRows)) return thenable } From 52659d4a8988bb08c66fa23579987e0aafe45d40 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 15:17:43 -0700 Subject: [PATCH 16/24] =?UTF-8?q?improvement(tests):=20db-mock=20migration?= =?UTF-8?q?=20tranche=202=20=E2=80=94=20copilot,=20mothership,=20workspace?= =?UTF-8?q?s,=20connectors,=20mcp=20(#5863)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(tests): db-mock migration tranche 2 — copilot, mothership, workspaces, connectors, mcp * fix(testing): drain unconsumed ...Once overrides in resetDbChainMock vi.clearAllMocks clears call history only — a ...Once override queued by a previous test but never consumed survived into the next test. resetDbChainMock now mockReset()s every shared spy and stable wrapper, which restores the original implementation AND drains once-queues. --- .../copilot/api-keys/validate/route.test.ts | 19 ++- .../chat/update-messages/route.test.ts | 96 ++++--------- apps/sim/app/api/copilot/chats/route.test.ts | 62 +++----- .../copilot/checkpoints/revert/route.test.ts | 132 +++++++----------- .../app/api/copilot/checkpoints/route.test.ts | 84 ++++------- .../[connectorId]/documents/route.test.ts | 56 +++----- .../connectors/[connectorId]/route.test.ts | 72 ++++------ .../[connectorId]/sync/route.test.ts | 49 +++---- .../knowledge/[id]/connectors/route.test.ts | 53 +++---- .../mcp/servers/[id]/refresh/route.test.ts | 66 ++++----- apps/sim/app/api/mcp/servers/route.test.ts | 14 +- .../chats/[chatId]/fork/route.test.ts | 122 ++++------------ .../chats/[chatId]/restore/route.test.ts | 79 +++-------- .../mothership/chats/[chatId]/route.test.ts | 59 ++------ .../api/mothership/chats/read/route.test.ts | 49 ++----- .../app/api/mothership/chats/route.test.ts | 71 +++------- .../workspaces/[id]/api-keys/route.test.ts | 25 ++-- .../workspaces/[id]/byok-keys/route.test.ts | 125 +++++++---------- .../fork/excluded-workflows/route.test.ts | 39 +++--- .../api/workspaces/invitations/route.test.ts | 41 ++---- .../tools/handlers/deployment/manage.test.ts | 50 ++----- .../copilot/tools/handlers/resources.test.ts | 6 - .../copilot/tools/handlers/vfs-mutate.test.ts | 29 ++-- .../tools/handlers/workflow/mutations.test.ts | 7 +- apps/sim/lib/mcp/service-pool.test.ts | 56 ++++---- apps/sim/lib/mcp/service.test.ts | 79 +++++------ .../testing/src/mocks/database.mock.test.ts | 6 + packages/testing/src/mocks/database.mock.ts | 55 ++------ packages/testing/src/mocks/schema.mock.ts | 2 + 29 files changed, 538 insertions(+), 1065 deletions(-) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index ea9c68b2b58..7e8b86e5a87 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -1,11 +1,10 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbLimit, mockCheckInternalApiKey, mockCheckAttributedUsageLimits, mockCheckServerSideUsageLimits, @@ -21,7 +20,6 @@ const { mockGetWorkspaceBillingSettings, mockFlags, } = vi.hoisted(() => ({ - mockDbLimit: vi.fn(), mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), mockCheckServerSideUsageLimits: vi.fn(), @@ -77,12 +75,6 @@ const OLD_GO_OPAQUE_WORKSPACE_VALIDATE_BODY = { workspaceId: 'local-self-hosted-workspace', } as const -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }), - }, -})) - vi.mock('@/lib/billing/core/billing-attribution', () => ({ BILLING_ACCOUNT_DECISION_HEADER: 'x-sim-billing-account-decision', BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', @@ -151,9 +143,10 @@ function request(body: Record, headers: Record describe('POST /api/copilot/api-keys/validate billing protocols', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isCopilotBillingProtocolRequired = false mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockDbLimit.mockResolvedValue([{ id: 'user-1' }]) + queueTableRows(schemaMock.user, [{ id: 'user-1' }]) mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION) mockResolveLegacyV0BillingAttribution.mockResolvedValue(ATTRIBUTION) mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') @@ -193,6 +186,10 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) }) + afterAll(() => { + resetDbChainMock() + }) + it('keeps the exact old-Go validate bodies contract-compatible', () => { expect(validateCopilotApiKeyBodySchema.safeParse(OLD_GO_HOSTED_VALIDATE_BODY).success).toBe( true diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index b6ec71a7a9a..ab842b87480 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -3,52 +3,24 @@ * * @vitest-environment node */ -import { authMockFns } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockLimit, - mockUpdate, - mockSet, - mockUpdateWhere, - mockReturning, - mockReplaceCopilotChatMessages, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockUpdateWhere: vi.fn(), - mockReturning: vi.fn(), - mockReplaceCopilotChatMessages: vi.fn(), -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - transaction: async ( - cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown - ) => cb({ update: mockUpdate, select: mockSelect }), - }, +const { mockReplaceCopilotChatMessages } = vi.hoisted(() => ({ + mockReplaceCopilotChatMessages: vi.fn(), })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ replaceCopilotChatMessages: mockReplaceCopilotChatMessages, })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), -})) - import { POST } from '@/app/api/copilot/chat/update-messages/route' function createMockRequest(method: string, body: Record): NextRequest { @@ -62,21 +34,15 @@ function createMockRequest(method: string, body: Record): NextR describe('Copilot Chat Update Messages API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(null) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([]) - mockUpdate.mockReturnValue({ set: mockSet }) - mockSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdateWhere.mockReturnValue({ returning: mockReturning }) - mockReturning.mockResolvedValue([{ model: 'gpt-4' }]) + dbChainMockFns.returning.mockResolvedValue([{ model: 'gpt-4' }]) }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('POST', () => { @@ -181,7 +147,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should return 404 when chat is not found', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const req = createMockRequest('POST', { chatId: 'non-existent-chat', @@ -205,7 +171,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should return 404 when chat belongs to different user', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const req = createMockRequest('POST', { chatId: 'other-user-chat', @@ -234,7 +200,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { @@ -265,9 +231,9 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSelect).toHaveBeenCalled() - expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-123', messages, @@ -284,7 +250,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { @@ -328,7 +294,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-456', [ @@ -373,7 +339,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const req = createMockRequest('POST', { chatId: 'chat-789', @@ -389,7 +355,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 0, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-789', [], @@ -401,7 +367,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should handle database errors during chat lookup', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockRejectedValueOnce(new Error('Database connection failed')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database connection failed')) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -430,11 +396,9 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) - mockSet.mockReturnValueOnce({ - where: vi.fn().mockRejectedValue(new Error('Update operation failed')), - }) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('Update operation failed')) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -481,7 +445,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = Array.from({ length: 100 }, (_, i) => ({ id: `msg-${i + 1}`, @@ -504,7 +468,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 100, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-large', messages, @@ -521,7 +485,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 2ce59e2a41d..b0d55f718a6 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -3,32 +3,15 @@ * * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelectDistinctOn, mockFrom, mockLeftJoin, mockWhere, mockOrderBy } = vi.hoisted(() => ({ - mockSelectDistinctOn: vi.fn(), - mockFrom: vi.fn(), - mockLeftJoin: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - selectDistinctOn: mockSelectDistinctOn, - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - sql: vi.fn(), -})) +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -45,16 +28,11 @@ import { GET } from '@/app/api/copilot/chats/route' describe('Copilot Chats List API Route', () => { beforeEach(() => { vi.clearAllMocks() - - mockSelectDistinctOn.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ leftJoin: mockLeftJoin }) - mockLeftJoin.mockReturnValue({ leftJoin: mockLeftJoin, where: mockWhere }) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) - mockOrderBy.mockResolvedValue([]) + resetDbChainMock() }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -78,7 +56,7 @@ describe('Copilot Chats List API Route', () => { isAuthenticated: true, }) - mockOrderBy.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -111,7 +89,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -151,7 +129,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -176,7 +154,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -192,7 +170,7 @@ describe('Copilot Chats List API Route', () => { isAuthenticated: true, }) - mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database connection failed')) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -216,13 +194,13 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') await GET(request as any) - expect(mockSelectDistinctOn).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('should return 401 when userId is null despite isAuthenticated being true', async () => { diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 9ce957da1ef..4918057f055 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -3,25 +3,19 @@ * * @vitest-environment node */ -import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockThen, - mockDelete, - mockDeleteWhere, - mockGetAccessibleCopilotChat, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockThen: vi.fn(), - mockDelete: vi.fn(), - mockDeleteWhere: vi.fn(), +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ mockGetAccessibleCopilotChat: vi.fn(), })) @@ -39,28 +33,12 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - delete: mockDelete, - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), -})) - import { POST } from '@/app/api/copilot/checkpoints/revert/route' describe('Copilot Checkpoints Revert API Route', () => { - /** Queued results for successive `.then()` calls in the db select chain */ - let thenResults: unknown[] - beforeEach(() => { vi.clearAllMocks() - - thenResults = [] + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(null) @@ -69,24 +47,6 @@ describe('Copilot Checkpoints Revert API Route', () => { status: 200, }) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ then: mockThen }) - - // Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0]. - // We invoke the callback with our mock rows array so the route gets the expected value. - mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => { - const result = thenResults.shift() - if (result instanceof Error) { - return Promise.reject(result) - } - const rows = result === undefined ? [] : [result] - return Promise.resolve(callback(rows)) - }) - - // Mock delete chain - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) global.fetch = vi.fn() @@ -114,10 +74,13 @@ describe('Copilot Checkpoints Revert API Route', () => { }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + /** Helper to set authenticated state */ function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) { authMockFns.mockGetSession.mockResolvedValue({ user }) @@ -180,8 +143,7 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should return 404 when checkpoint is not found', async () => { setAuthenticated() - // Mock checkpoint not found - thenResults.push(undefined) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -199,8 +161,7 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should return 404 when checkpoint belongs to different user', async () => { setAuthenticated() - // Mock checkpoint not found (due to user mismatch in query) - thenResults.push(undefined) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -225,8 +186,8 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(undefined) // Workflow not found + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -256,8 +217,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'different-user', } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(mockWorkflow) // Workflow found but different user + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, @@ -298,8 +259,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(mockWorkflow) // Workflow found + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -380,8 +341,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -422,8 +383,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -464,8 +425,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -509,8 +470,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: false, @@ -533,8 +494,9 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should handle database errors during checkpoint lookup', async () => { setAuthenticated() - // Mock database error - thenResults.push(new Error('Database connection failed')) + dbChainMockFns.where.mockReturnValueOnce( + Promise.reject(new Error('Database connection failed')) + ) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -559,8 +521,10 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails + dbChainMockFns.where.mockReturnValueOnce(Promise.resolve([mockCheckpoint])) + dbChainMockFns.where.mockReturnValueOnce( + Promise.reject(new Error('Database error during workflow lookup')) + ) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -590,8 +554,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockRejectedValue(new Error('Network error')) @@ -641,8 +605,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -690,8 +654,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -758,8 +722,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index f7dc748786b..8ee9df0bc5d 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -3,43 +3,20 @@ * * @vitest-environment node */ -import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockLimit, - mockOrderBy, - mockInsert, - mockValues, - mockReturning, - mockGetAccessibleCopilotChat, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockOrderBy: vi.fn(), - mockInsert: vi.fn(), - mockValues: vi.fn(), - mockReturning: vi.fn(), - mockGetAccessibleCopilotChat: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - }, -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), +const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ + mockGetAccessibleCopilotChat: vi.fn(), })) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ @@ -62,19 +39,10 @@ function createMockRequest(method: string, body: Record): NextR describe('Copilot Checkpoints API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(null) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ - orderBy: mockOrderBy, - limit: mockLimit, - }) - mockOrderBy.mockResolvedValue([]) - mockLimit.mockResolvedValue([]) - mockInsert.mockReturnValue({ values: mockValues }) - mockValues.mockReturnValue({ returning: mockReturning }) mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123', @@ -85,8 +53,8 @@ describe('Copilot Checkpoints API Route', () => { }) }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('POST', () => { @@ -165,7 +133,7 @@ describe('Copilot Checkpoints API Route', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), } - mockReturning.mockResolvedValue([checkpoint]) + dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) const workflowState = { blocks: [], connections: [] } const req = createMockRequest('POST', { @@ -192,8 +160,8 @@ describe('Copilot Checkpoints API Route', () => { }, }) - expect(mockInsert).toHaveBeenCalled() - expect(mockValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith({ userId: 'user-123', workflowId: 'workflow-123', chatId: 'chat-123', @@ -214,7 +182,7 @@ describe('Copilot Checkpoints API Route', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), } - mockReturning.mockResolvedValue([checkpoint]) + dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) const workflowState = { blocks: [] } const req = createMockRequest('POST', { @@ -234,7 +202,7 @@ describe('Copilot Checkpoints API Route', () => { it('should handle database errors during checkpoint creation', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockReturning.mockRejectedValue(new Error('Database insert failed')) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database insert failed')) const req = createMockRequest('POST', { workflowId: 'workflow-123', @@ -317,7 +285,7 @@ describe('Copilot Checkpoints API Route', () => { }, ] - mockOrderBy.mockResolvedValue(mockCheckpoints) + queueTableRows(schemaMock.workflowCheckpoints, mockCheckpoints) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') @@ -349,15 +317,15 @@ describe('Copilot Checkpoints API Route', () => { ], }) - expect(mockSelect).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() - expect(mockOrderBy).toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() }) it('should handle database errors when fetching checkpoints', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockOrderBy.mockRejectedValue(new Error('Database query failed')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database query failed')) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') @@ -371,7 +339,7 @@ describe('Copilot Checkpoints API Route', () => { it('should return empty array when no checkpoints found', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockOrderBy.mockResolvedValue([]) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts index 4f39294d055..bcbf905bb27 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts @@ -4,31 +4,20 @@ import { auditMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, requestUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]), - } - return { mockDbChain: chain } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) @@ -39,15 +28,12 @@ describe('Connector Documents API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockResolvedValue([]) - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.update.mockReturnThis() - mockDbChain.set.mockReturnThis() - mockDbChain.returning.mockResolvedValue([]) + }) + + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -69,7 +55,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') const response = await GET(req as never, { params: mockParams }) @@ -85,8 +71,8 @@ describe('Connector Documents API Route', () => { mockCheckAccess.mockResolvedValue({ hasAccess: true }) const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false } - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.orderBy.mockResolvedValueOnce([doc]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([doc]) const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents' const req = createMockRequest('GET', undefined, undefined, url) @@ -106,8 +92,8 @@ describe('Connector Documents API Route', () => { }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.orderBy + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.orderBy .mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }]) .mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }]) @@ -143,7 +129,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) const req = createMockRequest('PATCH', { documentIds: [] }) const response = await PATCH(req as never, { params: mockParams }) @@ -157,7 +143,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) const response = await PATCH(req as never, { params: mockParams }) @@ -176,8 +162,8 @@ describe('Connector Documents API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) const response = await PATCH(req as never, { params: mockParams }) @@ -198,8 +184,8 @@ describe('Connector Documents API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }]) const req = createMockRequest('PATCH', { operation: 'exclude', diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 4f8f7e7fca4..4784c21943a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -5,39 +5,26 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain, mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), - execute: vi.fn().mockResolvedValue(undefined), - transaction: vi.fn(), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockResolvedValue(undefined), - update: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]), - } - return { - mockDbChain: chain, - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockValidateConfig: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => ({ + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockValidateConfig: vi.fn(), +})) const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) vi.mock('@/connectors/registry.server', () => ({ @@ -63,19 +50,11 @@ describe('Knowledge Connector By ID API Route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockReturnThis() - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.execute.mockResolvedValue(undefined) - mockDbChain.transaction.mockImplementation( - async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain) - ) - mockDbChain.update.mockReturnThis() - mockDbChain.delete.mockReturnThis() - mockDbChain.set.mockReturnThis() - mockDbChain.returning.mockResolvedValue([]) + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -110,7 +89,7 @@ describe('Knowledge Connector By ID API Route', () => { userId: 'user-1', }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') const response = await GET(req, { params: mockParams }) @@ -128,7 +107,7 @@ describe('Knowledge Connector By ID API Route', () => { const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' } const mockLogs = [{ id: 'log-1', status: 'completed' }] - mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs) + dbChainMockFns.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs) const req = createMockRequest('GET') const response = await GET(req, { params: mockParams }) @@ -175,7 +154,7 @@ describe('Knowledge Connector By ID API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } }) const response = await PATCH(req, { params: mockParams }) @@ -197,7 +176,7 @@ describe('Knowledge Connector By ID API Route', () => { mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 } - mockDbChain.limit.mockResolvedValueOnce([updatedConnector]) + dbChainMockFns.limit.mockResolvedValueOnce([updatedConnector]) const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) @@ -252,12 +231,9 @@ describe('Knowledge Connector By ID API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.where - .mockReturnValueOnce(mockDbChain) - .mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) - .mockReturnValueOnce(mockDbChain) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) + queueTableRows(schemaMock.document, [{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) const req = createMockRequest('DELETE') const response = await DELETE(req, { params: mockParams }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index e84f5cd4da6..54ef8f3fc4c 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -4,33 +4,24 @@ import { auditMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, requestUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDispatchSync, mockDbChain, mockResolveBillingAttribution } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } - return { - mockDispatchSync: vi.fn().mockResolvedValue(undefined), - mockDbChain: chain, - mockResolveBillingAttribution: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDispatchSync, mockResolveBillingAttribution } = vi.hoisted(() => ({ + mockDispatchSync: vi.fn().mockResolvedValue(undefined), + mockResolveBillingAttribution: vi.fn(), +})) const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/billing/core/billing-attribution', () => ({ requireBillingAttributionHeader: vi.fn(), @@ -48,14 +39,12 @@ describe('Connector Manual Sync API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockResolvedValue([]) - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.update.mockReturnThis() - mockDbChain.set.mockReturnThis() + }) + + afterAll(() => { + resetDbChainMock() }) it('returns 401 when unauthenticated', async () => { @@ -76,7 +65,7 @@ describe('Connector Manual Sync API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('POST') const response = await POST(req as never, { params: mockParams }) @@ -90,7 +79,7 @@ describe('Connector Manual Sync API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) const req = createMockRequest('POST') const response = await POST(req as never, { params: mockParams }) @@ -122,7 +111,7 @@ describe('Connector Manual Sync API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) mockResolveBillingAttribution.mockResolvedValue(billingAttribution) const req = createMockRequest('POST') @@ -166,7 +155,7 @@ describe('Connector Manual Sync API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) mockResolveBillingAttribution.mockResolvedValue(billingAttribution) const req = createMockRequest( diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts index 2ac24c18df0..d1f9f852ae4 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts @@ -5,45 +5,34 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCaptureServerEvent, - mockDbChain, mockDispatchSync, mockEncryptApiKey, mockHasWorkspaceLiveSyncAccess, mockResolveBillingAttribution, mockValidateConfig, -} = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), - execute: vi.fn(), - transaction: vi.fn(), - insert: vi.fn().mockReturnThis(), - values: vi.fn(), - } - return { - mockCaptureServerEvent: vi.fn(), - mockDbChain: chain, - mockDispatchSync: vi.fn(), - mockEncryptApiKey: vi.fn(), - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockValidateConfig: vi.fn(), - } -}) +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockDispatchSync: vi.fn(), + mockEncryptApiKey: vi.fn(), + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockValidateConfig: vi.fn(), +})) const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) @@ -103,20 +92,16 @@ describe('Knowledge Connectors API Route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.insert.mockReturnThis() - mockDbChain.execute.mockResolvedValue(undefined) - mockDbChain.values.mockResolvedValue(undefined) - mockDbChain.transaction.mockImplementation( - async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain) - ) + resetDbChainMock() mockDispatchSync.mockResolvedValue(undefined) mockEncryptApiKey.mockResolvedValue({ encrypted: 'encrypted-api-key' }) mockValidateConfig.mockResolvedValue({ valid: true }) }) + afterAll(() => { + resetDbChainMock() + }) + it('queues the authenticated actor with the paid workspace payer', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, @@ -135,7 +120,7 @@ describe('Knowledge Connectors API Route', () => { }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ { id: 'connector-1', knowledgeBaseId: 'knowledge-base-1', diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index e3f217fbdde..a04dd7edf74 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -1,22 +1,16 @@ /** * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockClearCache, mockDiscoverServerTools, mockSelect, mockUpdateSet } = vi.hoisted(() => ({ +const { mockClearCache, mockDiscoverServerTools } = vi.hoisted(() => ({ mockClearCache: vi.fn(), mockDiscoverServerTools: vi.fn(), - mockSelect: vi.fn(), - mockUpdateSet: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: vi.fn().mockReturnValue({ set: mockUpdateSet }), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: (handler: unknown) => handler, @@ -68,23 +62,16 @@ const persistedServer = { toolCount: 0, } -function selectRows(rows: unknown[]) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(rows), - }), - }), - } -} - describe('MCP server refresh route', () => { beforeEach(() => { vi.clearAllMocks() - mockSelect.mockReturnValueOnce(selectRows([initialServer])) - mockUpdateSet.mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([persistedServer]) }), - }) + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValueOnce([initialServer]) + dbChainMockFns.returning.mockResolvedValue([persistedServer]) + }) + + afterAll(() => { + resetDbChainMock() }) it('preserves the service-persisted OAuth pending status', async () => { @@ -102,7 +89,7 @@ describe('MCP server refresh route', () => { error: null, }) ) - expect(mockUpdateSet).not.toHaveBeenCalledWith( + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: expect.anything() }) ) }) @@ -112,11 +99,7 @@ describe('MCP server refresh route', () => { mockDiscoverServerTools.mockRejectedValueOnce( new Error(`Upstream reflected ${reflectedSecret}`) ) - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([initialServer]), - }), - }) + dbChainMockFns.returning.mockResolvedValueOnce([initialServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', @@ -142,11 +125,7 @@ describe('MCP server refresh route', () => { lastConnected: new Date(Date.now() + 60_000), toolCount: 7, } - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([newerSuccessfulServer]), - }), - }) + dbChainMockFns.returning.mockResolvedValueOnce([newerSuccessfulServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', @@ -175,12 +154,17 @@ describe('MCP server refresh route', () => { serverName: 'OAuth Server', }, ]) - // The route's server lookup consumes the first select (beforeEach). The sync's - // workflow select is left unmocked, so it throws — exercising the guard that - // keeps a secondary sync failure from turning a successful refresh into a 500. - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([initialServer]) }), - }) + // The route's server lookup consumes the first from() (verbatim mini-builder); + // the sync's workflow select then throws — exercising the guard that keeps a + // secondary sync failure from turning a successful refresh into a 500. + dbChainMockFns.from + .mockImplementationOnce(() => ({ + where: () => ({ limit: () => Promise.resolve([initialServer]) }), + })) + .mockImplementationOnce(() => { + throw new Error('workflow select unavailable') + }) + dbChainMockFns.returning.mockResolvedValueOnce([initialServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts index e831c802e0a..2d397432507 100644 --- a/apps/sim/app/api/mcp/servers/route.test.ts +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -1,18 +1,15 @@ /** * @vitest-environment node */ +import { dbChainMock, resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ mockPerformDeleteMcpServer: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/mcp/middleware', () => ({ getParsedBody: () => undefined, @@ -57,6 +54,11 @@ function createDeleteRequest(serverId = 'server-1') { describe('MCP servers DELETE route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('returns 404 when orchestration reports a missing server', async () => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index a3c42f0f07e..e7ab8202a6e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -1,13 +1,11 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockTransaction, - mockSelectRows, mockFilterForkableChatFiles, mockListForkableChatFiles, mockPlanChatFileCopies, @@ -18,11 +16,8 @@ const { mockFetchGo, mockPublishStatusChanged, mockCaptureServerEvent, - mockDeleteWhere, mockRemoveChatResources, } = vi.hoisted(() => ({ - mockTransaction: vi.fn(), - mockSelectRows: vi.fn(), // Real (pure) cut semantics so tests drive selection through row.messageId: // rows with a NULL/undefined messageId are kept in every fork. mockFilterForkableChatFiles: vi.fn( @@ -38,52 +33,9 @@ const { mockFetchGo: vi.fn(), mockPublishStatusChanged: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockDeleteWhere: vi.fn(), mockRemoveChatResources: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => mockSelectRows(), - }), - }), - }), - delete: () => ({ - where: mockDeleteWhere, - }), - transaction: mockTransaction, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - workspaceId: 'copilotChats.workspaceId', - title: 'copilotChats.title', - model: 'copilotChats.model', - resources: 'copilotChats.resources', - previewYaml: 'copilotChats.previewYaml', - planArtifact: 'copilotChats.planArtifact', - config: 'copilotChats.config', - deletedAt: 'copilotChats.deletedAt', - }, - workspaceFiles: { - id: 'workspaceFiles.id', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - vi.mock('@/lib/copilot/resources/persistence', () => ({ removeChatResources: mockRemoveChatResources, })) @@ -168,30 +120,6 @@ const threeMessages = [ }, ] -/** Chat rows inserted through the mock transaction, captured for title assertions. */ -let insertedChatRows: Array> = [] -/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */ -let updatedChatRows: Array> = [] - -function makeTx() { - return { - insert: () => ({ - values: (v: Record) => { - insertedChatRows.push(v) - return { - returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }], - } - }, - }), - update: () => ({ - set: (v: Record) => { - updatedChatRows.push(v) - return { where: async () => undefined } - }, - }), - } -} - function createRequest(chatId: string, body?: unknown) { return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, { method: 'POST', @@ -207,13 +135,13 @@ function makeContext(chatId: string) { describe('POST /api/mothership/chats/[chatId]/fork', () => { beforeEach(() => { vi.clearAllMocks() - insertedChatRows = [] - updatedChatRows = [] + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockSelectRows.mockResolvedValue([parentRow]) + dbChainMockFns.limit.mockResolvedValue([parentRow]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'row-id', workspaceId: 'ws-1' }]) mockListForkableChatFiles.mockResolvedValue([]) mockLoadCopilotChatMessages.mockResolvedValue(threeMessages) mockPlanChatFileCopies.mockResolvedValue({ @@ -223,13 +151,13 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { }) mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] }) mockAppendCopilotChatMessages.mockResolvedValue(undefined) - mockDeleteWhere.mockResolvedValue(undefined) mockRemoveChatResources.mockResolvedValue(undefined) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) mockFetchGo.mockResolvedValue({ ok: true }) - mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb(makeTx()) - ) + }) + + afterAll(() => { + resetDbChainMock() }) it('rejects unauthenticated callers', async () => { @@ -242,14 +170,14 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { }) it('404s when the chat belongs to another user', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(404) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('404s for non-mothership chats', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(404) }) @@ -257,13 +185,13 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { it('400s when upToMessageId is missing', async () => { const res = await POST(createRequest('chat-1', {}), makeContext('chat-1')) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('400s when upToMessageId is an empty string', async () => { const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1')) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('400s when the message is not in the chat', async () => { @@ -272,7 +200,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { makeContext('chat-1') ) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('applies the timeline cut: kept message ids drive the file selection', async () => { @@ -370,7 +298,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { ) // Forks are titled "Fork | ". - expect(insertedChatRows[0].title).toBe('Fork | Generate Logs') + expect(dbChainMockFns.values.mock.calls[0][0].title).toBe('Fork | Generate Logs') }) it('still succeeds when the copilot-service clone fails (best-effort)', async () => { @@ -393,9 +321,9 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { // The dead rows (committed, but no bytes behind them) are hard-deleted so // they vanish from VFS listings and name resolution… - expect(mockDeleteWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenCalledWith({ type: 'inArray', - field: 'workspaceFiles.id', + column: 'id', values: ['wf_dead1', 'wf_dead2'], }) // …and their resource chips are dropped from the new chat. @@ -411,7 +339,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1')) expect('failedFileCopies' in (await cleanRes.json())).toBe(false) - expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() expect(mockRemoveChatResources).not.toHaveBeenCalled() }) @@ -421,7 +349,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { // copies the kept upload AND the pre-cut apple; only the post-cut banana // stays behind, so only its resource is dropped — not left pointing at // the source chat. - mockSelectRows.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { ...parentRow, resources: [ @@ -457,8 +385,8 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { OLD_FILE_ID, 'wf_apple', ]) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([ + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0].resources).toEqual([ { type: 'file', id: NEW_FILE_ID, title: 'cat.png' }, { type: 'file', id: 'wf_apple_copy', title: 'apple.png' }, { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, @@ -469,7 +397,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { it('drops ghosts even when the fork copies no files at all', async () => { // Fork cut before the chat's only upload arrived: a guard that skips the // resources update when idMap is empty would leave the ghost in place. - mockSelectRows.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { ...parentRow, resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }], @@ -482,7 +410,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(200) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([]) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0].resources).toEqual([]) }) }) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts index 31d49d8a653..ee9438038b8 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts @@ -1,59 +1,15 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockDbSelect, - mockSelectFrom, - mockSelectWhere, - mockSelectLimit, - mockDbUpdate, - mockDbSet, - mockUpdateWhere, - mockDbReturning, - mockAssertActiveWorkspaceAccess, - mockPublishStatusChanged, -} = vi.hoisted(() => ({ - mockDbSelect: vi.fn(), - mockSelectFrom: vi.fn(), - mockSelectWhere: vi.fn(), - mockSelectLimit: vi.fn(), - mockDbUpdate: vi.fn(), - mockDbSet: vi.fn(), - mockUpdateWhere: vi.fn(), - mockDbReturning: vi.fn(), +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAssertActiveWorkspaceAccess, mockPublishStatusChanged } = vi.hoisted(() => ({ mockAssertActiveWorkspaceAccess: vi.fn(), mockPublishStatusChanged: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - update: mockDbUpdate, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - workspaceId: 'copilotChats.workspaceId', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - deletedAt: 'copilotChats.deletedAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), -})) - vi.mock('@/lib/copilot/request/http', () => ({ ...copilotHttpMock, createForbiddenResponse: vi.fn((message: string) => ({ @@ -92,21 +48,20 @@ function makeContext(chatId: string) { describe('POST /api/mothership/chats/[chatId]/restore', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockSelectLimit.mockResolvedValue([{ workspaceId: 'workspace-1' }]) - mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) - mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) - mockDbSelect.mockReturnValue({ from: mockSelectFrom }) - mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) - mockUpdateWhere.mockReturnValue({ returning: mockDbReturning }) - mockDbSet.mockReturnValue({ where: mockUpdateWhere }) - mockDbUpdate.mockReturnValue({ set: mockDbSet }) + dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns 401 when unauthenticated', async () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: null, @@ -115,16 +70,16 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(401) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('returns 404 when no soft-deleted chat is owned by the caller', async () => { - mockSelectLimit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const response = await POST(makeRequest('chat-missing'), makeContext('chat-missing')) expect(response.status).toBe(404) expect(mockAssertActiveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('returns 403 when the caller lost access to the workspace', async () => { @@ -132,7 +87,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(403) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('restores the chat, bumping updatedAt and lastSeenAt, and publishes the event', async () => { @@ -141,7 +96,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(mockDbSet).toHaveBeenCalledWith({ + expect(dbChainMockFns.set).toHaveBeenCalledWith({ deletedAt: null, updatedAt: expect.any(Date), lastSeenAt: expect.any(Date), @@ -154,7 +109,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { }) it('returns 404 when the chat is restored concurrently before the update lands', async () => { - mockDbReturning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([]) const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(404) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index 095c5a11cc5..d0f2a64c00e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -1,15 +1,11 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbUpdate, - mockDbSet, - mockDbReturning, - mockDbWhere, mockDecrementStorageUsageForBillingContext, mockDecrementStorageUsageForBillingContextInTx, mockGetAccessibleCopilotChat, @@ -18,10 +14,6 @@ const { mockReadFilePreviewSessions, mockGetLatestRunForStream, } = vi.hoisted(() => ({ - mockDbUpdate: vi.fn(), - mockDbSet: vi.fn(), - mockDbReturning: vi.fn(), - mockDbWhere: vi.fn(), mockDecrementStorageUsageForBillingContext: vi.fn(), mockDecrementStorageUsageForBillingContextInTx: vi.fn(), mockGetAccessibleCopilotChat: vi.fn(), @@ -31,38 +23,6 @@ const { mockGetLatestRunForStream: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - update: mockDbUpdate, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - workspaceId: 'copilotChats.workspaceId', - deletedAt: 'copilotChats.deletedAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - sql: Object.assign( - vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - type: 'sql', - strings, - values, - })), - { raw: vi.fn() } - ), -})) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ @@ -123,9 +83,14 @@ function createRequest(chatId: string) { }) } +afterAll(() => { + resetDbChainMock() +}) + describe('GET /api/mothership/chats/[chatId]', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -314,6 +279,7 @@ describe('GET /api/mothership/chats/[chatId]', () => { describe('DELETE /api/mothership/chats/[chatId]', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -323,10 +289,7 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { type: 'mothership', workspaceId: 'workspace-1', }) - mockDbUpdate.mockReturnValue({ set: mockDbSet }) - mockDbSet.mockReturnValue({ where: mockDbWhere }) - mockDbWhere.mockReturnValue({ returning: mockDbReturning }) - mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) }) it('soft-deletes an unbilled chat without decrementing workspace or payer storage', async () => { @@ -338,8 +301,8 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { ) expect(response.status).toBe(200) - expect(mockDbUpdate).toHaveBeenCalled() - expect(mockDbSet).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index ba2270510ab..f8e4a1b40ea 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -1,39 +1,14 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdate, mockSet, mockWhere, mockParseRequest } = vi.hoisted(() => ({ - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhere: vi.fn(), +const { mockParseRequest } = vi.hoisted(() => ({ mockParseRequest: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { update: mockUpdate }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - sql: vi.fn(() => ({ type: 'sql' })), -})) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} })) @@ -50,22 +25,24 @@ function createRequest() { describe('POST /api/mothership/chats/read', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } }) - mockWhere.mockResolvedValue(undefined) - mockSet.mockReturnValue({ where: mockWhere }) - mockUpdate.mockReturnValue({ set: mockSet }) + }) + + afterAll(() => { + resetDbChainMock() }) it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => { const res = await POST(createRequest()) expect(res.status).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(1) - const whereArg = mockWhere.mock.calls[0][0] as { + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + const whereArg = dbChainMockFns.where.mock.calls[0][0] as { type: string conditions: Array<{ type: string; conditions?: unknown[] }> } @@ -75,8 +52,8 @@ describe('POST /api/mothership/chats/read', () => { expect(orClause).toBeDefined() expect(orClause?.conditions).toEqual( expect.arrayContaining([ - { type: 'isNull', field: 'copilotChats.lastSeenAt' }, - { type: 'lt', field: 'copilotChats.lastSeenAt', value: 'copilotChats.updatedAt' }, + { type: 'isNull', column: 'lastSeenAt' }, + { type: 'lt', left: 'lastSeenAt', right: 'updatedAt' }, ]) ) }) @@ -88,6 +65,6 @@ describe('POST /api/mothership/chats/read', () => { }) const res = await POST(createRequest()) expect(res.status).toBe(401) - expect(mockUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/mothership/chats/route.test.ts b/apps/sim/app/api/mothership/chats/route.test.ts index 890e228a0b5..84296f0dd5f 100644 --- a/apps/sim/app/api/mothership/chats/route.test.ts +++ b/apps/sim/app/api/mothership/chats/route.test.ts @@ -1,47 +1,20 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns, permissionsMock } from '@sim/testing' +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + permissionsMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockReconcileChatStreamMarkers } = vi.hoisted( - () => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), - mockReconcileChatStreamMarkers: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - title: 'copilotChats.title', - userId: 'copilotChats.userId', - workspaceId: 'copilotChats.workspaceId', - type: 'copilotChats.type', - updatedAt: 'copilotChats.updatedAt', - conversationId: 'copilotChats.conversationId', - lastSeenAt: 'copilotChats.lastSeenAt', - pinned: 'copilotChats.pinned', - deletedAt: 'copilotChats.deletedAt', - }, -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - desc: vi.fn((field: unknown) => ({ type: 'desc', field })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), +const { mockReconcileChatStreamMarkers } = vi.hoisted(() => ({ + mockReconcileChatStreamMarkers: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -70,17 +43,13 @@ function createRequest(workspaceId: string) { describe('GET /api/mothership/chats', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockOrderBy.mockResolvedValue([]) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) - mockReconcileChatStreamMarkers.mockImplementation( async (candidates: Array<{ chatId: string; streamId: string | null }>) => new Map( @@ -98,7 +67,7 @@ describe('GET /api/mothership/chats', () => { it('clears activeStreamId on chats whose redis lock has expired (stuck-yellow bug)', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-stuck', title: 'Stuck chat', @@ -151,7 +120,7 @@ describe('GET /api/mothership/chats', () => { it('preserves chats when no chat has a stream marker set', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-1', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, { id: 'chat-2', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, ]) @@ -175,7 +144,7 @@ describe('GET /api/mothership/chats', () => { it('leaves activeStreamId untouched when redis confirms every lock is live', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-a', title: null, updatedAt: now, activeStreamId: 'stream-a', lastSeenAt: null }, { id: 'chat-b', title: null, updatedAt: now, activeStreamId: 'stream-b', lastSeenAt: null }, ]) @@ -191,7 +160,7 @@ describe('GET /api/mothership/chats', () => { it('uses Redis lock owner when it differs from a stale activeStreamId', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-mismatch', title: null, @@ -223,7 +192,11 @@ describe('GET /api/mothership/chats', () => { const response = await GET(createRequest('ws-1')) expect(response.status).toBe(401) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() }) + + afterAll(() => { + resetDbChainMock() + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts index 67b354a5268..d5d3b874c78 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts @@ -1,33 +1,19 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetApiKeyDisplayFormat, mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceById, - mockOrderBy, } = vi.hoisted(() => ({ mockGetApiKeyDisplayFormat: vi.fn(), mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceById: vi.fn(), - mockOrderBy: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: mockOrderBy, - })), - })), - })), - }, })) vi.mock('@/lib/api-key/auth', () => ({ @@ -53,11 +39,12 @@ import { GET } from '@/app/api/workspaces/[id]/api-keys/route' describe('GET /api/workspaces/[id]/api-keys', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'reader-1' } }) mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-1' }) mockGetUserEntityPermissions.mockResolvedValue('read') mockGetApiKeyDisplayFormat.mockResolvedValue('sim_••••legacy') - mockOrderBy.mockResolvedValue([ + queueTableRows(schemaMock.apiKey, [ { id: 'key-1', name: 'Legacy key', @@ -70,6 +57,10 @@ describe('GET /api/workspaces/[id]/api-keys', () => { ]) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns metadata without exposing the stored key value', async () => { const response = await GET(createMockRequest('GET'), { params: Promise.resolve({ id: 'workspace-1' }), diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts index 186839287d7..89b7f60b7b0 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts @@ -1,63 +1,29 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + auditMock, + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceById, mockEncryptSecret, mockDecryptSecret, - mockUpdateSet, - mockInsertValues, - mockDeleteWhere, -} = vi.hoisted(() => { - const state = { - selectResults: [] as unknown[][], - insertReturning: [] as unknown[], - deleteReturning: [] as unknown[], - } - return { - mockDbState: state, - mockGetSession: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceById: vi.fn(), - mockEncryptSecret: vi.fn(), - mockDecryptSecret: vi.fn(), - mockUpdateSet: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })), - mockInsertValues: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve(state.insertReturning)), - })), - mockDeleteWhere: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve(state.deleteReturning)), - })), - } -}) - -vi.mock('@sim/db', () => { - const dbMock: Record = { - select: vi.fn(() => { - const chain: Record = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockImplementation(() => { - const result: any = Promise.resolve(mockDbState.selectResults.shift() ?? []) - result.limit = vi.fn(() => result) - result.orderBy = vi.fn(() => result) - return result - }) - return chain - }), - update: vi.fn(() => ({ set: mockUpdateSet })), - insert: vi.fn(() => ({ values: mockInsertValues })), - delete: vi.fn(() => ({ where: mockDeleteWhere })), - execute: vi.fn(() => Promise.resolve([])), - } - dbMock.transaction = vi.fn(async (callback: (tx: unknown) => unknown) => callback(dbMock)) - return { db: dbMock } -}) +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockEncryptSecret: vi.fn(), + mockDecryptSecret: vi.fn(), +})) vi.mock('@sim/audit', () => auditMock) @@ -97,9 +63,7 @@ const storedKeyRow = (id: string, name: string | null = null) => ({ describe('workspace BYOK keys route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] - mockDbState.insertReturning = [] - mockDbState.deleteReturning = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -110,9 +74,16 @@ describe('workspace BYOK keys route', () => { })) }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET', () => { it('lists every stored key with name and masked value', async () => { - mockDbState.selectResults = [[storedKeyRow('key-1', 'Production'), storedKeyRow('key-2')]] + queueTableRows(schemaMock.workspaceBYOKKeys, [ + storedKeyRow('key-1', 'Production'), + storedKeyRow('key-2'), + ]) const res = await GET(createMockRequest('GET'), routeContext) @@ -143,14 +114,14 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(403) - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() }) it('adds a new key even when the provider already has keys', async () => { - mockDbState.selectResults = [[{ keyCount: 2 }]] - mockDbState.insertReturning = [ + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'key-3', providerId: 'openai', name: 'Backup', createdAt: new Date() }, - ] + ]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key', name: 'Backup' }), @@ -161,7 +132,7 @@ describe('workspace BYOK keys route', () => { const body = await res.json() expect(body.success).toBe(true) expect(body.key).toMatchObject({ id: 'key-3', name: 'Backup' }) - expect(mockInsertValues).toHaveBeenCalledWith( + expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: WORKSPACE_ID, providerId: 'openai', @@ -172,10 +143,10 @@ describe('workspace BYOK keys route', () => { }) it('stores a null name when none is provided', async () => { - mockDbState.selectResults = [[{ keyCount: 0 }]] - mockDbState.insertReturning = [ + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'key-1', providerId: 'openai', name: null, createdAt: new Date() }, - ] + ]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }), @@ -183,11 +154,11 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(200) - expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ name: null })) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ name: null })) }) it('rejects adding a key beyond the per-provider cap', async () => { - mockDbState.selectResults = [[{ keyCount: 10 }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 10 }]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }), @@ -197,12 +168,12 @@ describe('workspace BYOK keys route', () => { expect(res.status).toBe(400) const body = await res.json() expect(body.error).toContain('at most 10 keys') - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() expect(mockEncryptSecret).not.toHaveBeenCalled() }) it('updates the targeted key in place when keyId is provided', async () => { - mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ id: 'key-2', name: 'Old name' }]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'key-2' }), @@ -212,14 +183,14 @@ describe('workspace BYOK keys route', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.key).toMatchObject({ id: 'key-2', name: 'Old name' }) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ encryptedApiKey: 'encrypted-value', name: 'Old name' }) ) - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() }) it('clears the name when updating with an empty name', async () => { - mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ id: 'key-2', name: 'Old name' }]) const res = await POST( createMockRequest('POST', { @@ -232,11 +203,11 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(200) - expect(mockUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ name: null })) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ name: null })) }) it('returns 404 when the keyId does not exist in the workspace', async () => { - mockDbState.selectResults = [[]] + queueTableRows(schemaMock.workspaceBYOKKeys, []) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'missing' }), @@ -244,7 +215,7 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(404) - expect(mockUpdateSet).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(mockEncryptSecret).not.toHaveBeenCalled() }) @@ -260,7 +231,7 @@ describe('workspace BYOK keys route', () => { describe('DELETE', () => { it('deletes a single key when keyId is provided', async () => { - mockDbState.deleteReturning = [{ id: 'key-2' }] + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'key-2' }]) const res = await DELETE( createMockRequest('DELETE', { providerId: 'openai', keyId: 'key-2' }), @@ -272,7 +243,7 @@ describe('workspace BYOK keys route', () => { }) it('returns 404 when keyId is provided but no key matches', async () => { - mockDbState.deleteReturning = [] + dbChainMockFns.returning.mockResolvedValueOnce([]) const res = await DELETE( createMockRequest('DELETE', { providerId: 'openai', keyId: 'missing' }), @@ -283,7 +254,7 @@ describe('workspace BYOK keys route', () => { }) it('deletes all provider keys when keyId is omitted', async () => { - mockDbState.deleteReturning = [{ id: 'key-1' }, { id: 'key-2' }] + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'key-1' }, { id: 'key-2' }]) const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) @@ -292,7 +263,7 @@ describe('workspace BYOK keys route', () => { }) it('succeeds when keyId is omitted and the provider has no keys', async () => { - mockDbState.deleteReturning = [] + dbChainMockFns.returning.mockResolvedValueOnce([]) const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) @@ -306,7 +277,7 @@ describe('workspace BYOK keys route', () => { const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) expect(res.status).toBe(403) - expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts index f882cb9bf54..a623def8937 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -1,16 +1,22 @@ /** * @vitest-environment node */ -import { auditMock, auditMockFns, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession, mockAssertWorkspaceAdminAccess, mockDbUpdate, mockCaptureServerEvent } = - vi.hoisted(() => ({ +import { + auditMock, + auditMockFns, + createMockRequest, + dbChainMockFns, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted( + () => ({ mockGetSession: vi.fn(), mockAssertWorkspaceAdminAccess: vi.fn(), - mockDbUpdate: vi.fn(), mockCaptureServerEvent: vi.fn(), - })) + }) +) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -27,10 +33,6 @@ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) -vi.mock('@sim/db', () => ({ - db: { update: () => mockDbUpdate() }, -})) - import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route' const WORKSPACE_ID = 'workspace-1' @@ -38,23 +40,22 @@ const ADMIN_ID = 'user-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } function mockUpdateReturning(rows: Array<{ id: string; name: string }>) { - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue(rows), - }), - }), - }) + dbChainMockFns.returning.mockResolvedValue(rows) } describe('fork excluded-workflows route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } }) mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' }) mockUpdateReturning([]) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns 401 when there is no session', async () => { mockGetSession.mockResolvedValue(null) @@ -74,7 +75,7 @@ describe('fork excluded-workflows route', () => { ) expect(res.status).toBe(400) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('requires workspace admin (and the fork entitlement gate) before writing', async () => { diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index ddfe8a59213..d4eefc75af4 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -9,9 +9,11 @@ import { permissionsMock, permissionsMockFns, posthogServerMock, + queueTableRows, + resetDbChainMock, schemaMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetWorkspaceInvitePolicy, @@ -22,7 +24,6 @@ const { mockSendInvitationEmail, mockCancelPendingInvitation, mockFindPendingGrantForWorkspaceEmail, - mockDbResults, } = vi.hoisted(() => ({ mockGetWorkspaceInvitePolicy: vi.fn(), mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), @@ -32,30 +33,8 @@ const { mockSendInvitationEmail: vi.fn(), mockCancelPendingInvitation: vi.fn(), mockFindPendingGrantForWorkspaceEmail: vi.fn(), - mockDbResults: { value: [] as any[] }, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const result = mockDbResults.value.shift() || [] - return Promise.resolve(callback ? callback(result) : result) - }) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => schemaMock) - vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) @@ -112,7 +91,7 @@ import { POST } from '@/app/api/workspaces/invitations/batch/route' describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() - mockDbResults.value = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, }) @@ -149,6 +128,10 @@ describe('POST /api/workspaces/invitations/batch', () => { mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null) }) + afterAll(() => { + resetDbChainMock() + }) + it('blocks invites for personal workspaces with an upgrade prompt', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', @@ -165,7 +148,6 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -196,7 +178,6 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -234,7 +215,6 @@ describe('POST /api/workspaces/invitations/batch', () => { maxSeats: 5, availableSeats: 0, }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -277,7 +257,7 @@ describe('POST /api/workspaces/invitations/batch', () => { role: 'member', memberId: 'member-1', }) - mockDbResults.value = [[{ id: 'existing-user', email: 'new@example.com' }]] + queueTableRows(schemaMock.user, [{ id: 'existing-user', email: 'new@example.com' }]) const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -311,7 +291,6 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -336,7 +315,6 @@ describe('POST /api/workspaces/invitations/batch', () => { }) it('creates multiple workspace invitations in one batch request', async () => { - mockDbResults.value = [[], []] mockCreatePendingInvitation .mockResolvedValueOnce({ invitationId: 'inv-1', @@ -382,7 +360,6 @@ describe('POST /api/workspaces/invitations/batch', () => { success: false, error: 'mailer unavailable', }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 66ed0a0d8fc..eb2cc11467b 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -2,8 +2,15 @@ * @vitest-environment node */ -import { auditMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + auditMock, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowsOrchestrationMock, + workflowsOrchestrationMockFns, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() => ({ @@ -23,20 +30,6 @@ const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkfl listWorkflowVersionsMock: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, - chat: {}, - workflow: {}, - workflowDeploymentVersion: {}, - workflowMcpServer: {}, - workflowMcpTool: {}, -})) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/mcp/pubsub', () => ({ @@ -81,7 +74,6 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ updateDeploymentVersionMetadata: vi.fn(), })) -import { db } from '@sim/db' import { executeCheckDeploymentStatus, executeDiffWorkflows, @@ -90,16 +82,9 @@ import { executePromoteToLive, } from './manage' -function selectChain(result: unknown[], resolveOnWhere = false) { - const chain = { - from: vi.fn(() => chain), - innerJoin: vi.fn(() => chain), - where: vi.fn(() => (resolveOnWhere ? Promise.resolve(result) : chain)), - orderBy: vi.fn(() => Promise.resolve(result)), - limit: vi.fn(() => Promise.resolve(result)), - } - return chain -} +afterAll(() => { + resetDbChainMock() +}) describe('executeLoadDeployment', () => { beforeEach(() => { @@ -332,6 +317,7 @@ describe('executeDiffWorkflows', () => { describe('executeCheckDeploymentStatus', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, }) @@ -353,10 +339,7 @@ describe('executeCheckDeploymentStatus', () => { latestDeploymentAttempt: null, warnings: [], }) - vi.mocked(db.select) - .mockReturnValueOnce(selectChain([{ deployedAt: new Date('2026-05-28') }]) as never) - .mockReturnValueOnce(selectChain([]) as never) - .mockReturnValueOnce(selectChain([], true) as never) + queueTableRows(schemaMock.workflow, [{ deployedAt: new Date('2026-05-28') }]) checkNeedsRedeploymentMock.mockResolvedValueOnce(true) const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { @@ -376,10 +359,7 @@ describe('executeCheckDeploymentStatus', () => { }) it('does not check redeployment freshness for undeployed APIs', async () => { - vi.mocked(db.select) - .mockReturnValueOnce(selectChain([{ deployedAt: null }]) as never) - .mockReturnValueOnce(selectChain([]) as never) - .mockReturnValueOnce(selectChain([], true) as never) + queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index af6abf456fc..c27fbd737cd 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -9,12 +9,6 @@ const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(( resolveWorkspaceFileReferenceMock: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: {}, -})) - -vi.mock('@sim/db/schema', () => ({})) - vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: getWorkspaceFileMock, resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 30847fdcc07..1b7860da1df 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ ensureWorkspaceAccess: vi.fn(), @@ -25,19 +26,9 @@ const mocks = vi.hoisted(() => ({ getKnowledgeBases: vi.fn(), updateKnowledgeBase: vi.fn(), checkKnowledgeBaseWriteAccess: vi.fn(), - workflowRows: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => Promise.resolve(mocks.workflowRows()), - }), - }), - }, - workflow: { id: 'id', name: 'name', folderId: 'folderId', workspaceId: 'workspaceId' }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/platform-authz/workflow', () => ({ assertFolderMutable: mocks.assertFolderMutable, @@ -102,18 +93,22 @@ const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext describe('vfs mv/cp', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) mocks.assertFolderMutable.mockResolvedValue(undefined) mocks.assertWorkflowMutable.mockResolvedValue(undefined) mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) - mocks.workflowRows.mockReturnValue([]) mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') }) + afterAll(() => { + resetDbChainMock() + }) + describe('category rules', () => { it('rejects cross-category moves', async () => { const result = await executeVfsMv( @@ -286,7 +281,7 @@ describe('vfs mv/cp', () => { describe('workflows', () => { it('renames a workflow at root', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Old Name', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Old Name', folderId: null }]) mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) const result = await executeVfsMv( @@ -306,7 +301,7 @@ describe('vfs mv/cp', () => { mocks.listFolders.mockResolvedValue([ { folderId: 'fold-1', folderName: 'Archive', parentId: null }, ]) - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'My Workflow', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'My Workflow', folderId: null }]) mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) const result = await executeVfsMv( @@ -322,7 +317,7 @@ describe('vfs mv/cp', () => { }) it('surfaces locked-workflow rejections per item', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Locked One', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Locked One', folderId: null }]) mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) const result = await executeVfsMv( @@ -335,7 +330,7 @@ describe('vfs mv/cp', () => { }) it('duplicates a workflow with cp (locked source allowed)', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Template', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Template', folderId: null }]) mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) const result = await executeVfsCp( diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 2ec9cb72264..41e3c6c922e 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { createEnvMock, workflowAuthzMockFns } from '@sim/testing' +import { createEnvMock, dbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { ExecutionContext } from '@/lib/copilot/request/types' @@ -44,10 +44,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: recordAuditMock, })) -vi.mock('@sim/db', () => ({ - db: {}, - workflow: {}, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/api-key/orchestration', () => ({ performCreateWorkspaceApiKey: vi.fn(), diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index be7fb6de847..406471bcced 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -9,8 +9,8 @@ */ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, loggerMock, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { MockMcpClient, @@ -71,32 +71,24 @@ vi.mock('@/lib/mcp/client', () => ({ McpClient: MockMcpClient })) const WORKSPACE_ID = 'ws-1' const USER_ID = 'user-1' -vi.mock('@sim/db', () => { - const where = () => { - const rows = Promise.resolve([ - { - id: 'server-1', - name: 'Server 1', - description: null, - transport: 'streamable-http', - url: 'https://server-1.example.com/mcp', - authType: 'headers', - workspaceId: WORKSPACE_ID, - headers: {}, - timeout: 30000, - retries: 3, - enabled: true, - deletedAt: null, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-01T00:00:00Z'), - }, - ]) - return Object.assign(rows, { limit: (n: number) => rows.then((r) => r.slice(0, n)) }) - } - return { - db: { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where }) }) }, - } -}) +const SERVER_ROW = { + id: 'server-1', + name: 'Server 1', + description: null, + transport: 'streamable-http', + url: 'https://server-1.example.com/mcp', + authType: 'headers', + workspaceId: WORKSPACE_ID, + headers: {}, + timeout: 30000, + retries: 3, + enabled: true, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/mcp/domain-check', () => ({ isMcpDomainAllowed: () => true, validateMcpDomain: () => {}, @@ -121,11 +113,19 @@ import { mcpService } from '@/lib/mcp/service' describe('McpService connection reuse wiring', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + // Every select here is getServerConfig's `.where(...).limit(1)`; the + // persistent override keeps the row available across retries. + dbChainMockFns.limit.mockResolvedValue([SERVER_ROW]) mockResolveEnvVars.mockImplementation(async (config: unknown) => ({ config })) mockAcquire.mockResolvedValue({ client: poolClient, release: mockRelease }) mockCallTool.mockResolvedValue({ content: [] }) }) + afterAll(() => { + resetDbChainMock() + }) + it('leases from the pool (keyed by server+workspace+user) and never disconnects on a hit', async () => { await mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID) diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 1fc6e1e4b8f..04f33b2b4ff 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -3,8 +3,8 @@ */ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, loggerMock, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { MockMcpClient, @@ -17,13 +17,10 @@ const { mockValidateSsrf, mockIsDomainAllowed, mockCacheAdapter, - mockUpdateSet, - mockUpdateReturning, } = vi.hoisted(() => { const mockListTools = vi.fn() const mockConnect = vi.fn() const mockDisconnect = vi.fn() - const mockUpdateReturning = vi.fn().mockResolvedValue([{ id: 'server-1' }]) // In-memory cache adapter so the service never touches the real Redis the // local .env points at (unreachable in CI/sandbox → hangs). Honors TTL via // an expiry timestamp so negative-cache assertions behave like production. @@ -73,34 +70,27 @@ const { mockValidateDomain: vi.fn(), mockValidateSsrf: vi.fn(), mockIsDomainAllowed: vi.fn(() => true), - mockUpdateReturning, - mockUpdateSet: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: mockUpdateReturning }), - }), } }) -vi.mock('@sim/db', () => { - // `where(...)` resolves to the workspace's rows AND exposes `.limit()` for - // chains like `getServerConfig` that do `select().from().where().limit(1)`. - const where = (...args: unknown[]) => { - const rowsPromise = Promise.resolve(mockGetWorkspaceServersRows(...args)) - const thenable = Object.assign(rowsPromise, { - limit: (n: number) => rowsPromise.then((rows) => rows.slice(0, n)), - }) - return thenable - } - return { - db: { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ where }), - }), - update: vi.fn().mockReturnValue({ set: mockUpdateSet }), - insert: vi.fn(), - delete: vi.fn(), - }, - } -}) +vi.mock('@sim/db', () => dbChainMock) + +/** + * Routes every select chain to `mockGetWorkspaceServersRows`: `where(...)` + * resolves the workspace's rows AND exposes `.limit()` for chains like + * `getServerConfig` that do `select().from().where().limit(1)`. + */ +function wireSelectsToWorkspaceRows() { + dbChainMockFns.from.mockImplementation(() => { + const rows = Promise.resolve(mockGetWorkspaceServersRows()) + return { + where: () => + Object.assign(rows, { + limit: (n: number) => rows.then((r: unknown[]) => r.slice(0, n)), + }), + } + }) +} vi.mock('@/lib/mcp/client', () => ({ McpClient: MockMcpClient, @@ -173,6 +163,9 @@ function tool(name: string, serverId: string) { describe('McpService.discoverTools per-server caching', () => { beforeEach(async () => { vi.clearAllMocks() + resetDbChainMock() + wireSelectsToWorkspaceRows() + dbChainMockFns.returning.mockResolvedValue([{ id: 'server-1' }]) // `clearAllMocks` does not drain `.mockResolvedValueOnce` queues; reset // listTools so a previous test's unconsumed mock doesn't leak into the next. mockListTools.mockReset() @@ -184,12 +177,14 @@ describe('McpService.discoverTools per-server caching', () => { ) mockConnect.mockResolvedValue(undefined) mockDisconnect.mockResolvedValue(undefined) - mockUpdateReturning.mockReset() - mockUpdateReturning.mockResolvedValue([{ id: 'server-1' }]) // The McpService singleton holds cache state across imports. await mcpService.clearCache() }) + afterAll(() => { + resetDbChainMock() + }) + it('caches each server independently after first discovery', async () => { mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A'), dbRow('mcp-b', 'B')]) mockListTools @@ -349,7 +344,7 @@ describe('McpService.discoverTools per-server caching', () => { expect(first).toEqual([]) await vi.waitFor(() => { - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', lastError: 'Authentication failed', @@ -362,7 +357,7 @@ describe('McpService.discoverTools per-server caching', () => { expect.any(Number) ) }) - expect(JSON.stringify(mockUpdateSet.mock.calls)).not.toContain(reflectedCredential) + expect(JSON.stringify(dbChainMockFns.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockCacheAdapter.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential) @@ -380,7 +375,7 @@ describe('McpService.discoverTools per-server caching', () => { expect(first).toEqual([]) await vi.waitFor(() => { - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', lastError: null, @@ -462,7 +457,7 @@ describe('McpService.discoverTools per-server caching', () => { 'Request timed out' ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', // Raw SDK timeout text is mapped to a user-facing message before persisting. @@ -499,14 +494,14 @@ describe('McpService.discoverTools per-server caching', () => { reflectedCredential ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', lastError: 'Authentication failed', statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null }, }) ) - expect(JSON.stringify(mockUpdateSet.mock.calls)).not.toContain(reflectedCredential) + expect(JSON.stringify(dbChainMockFns.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockCacheAdapter.set.mock.calls)).not.toContain(reflectedCredential) expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential) @@ -538,7 +533,7 @@ describe('McpService.discoverTools per-server caching', () => { 'OAuth token rejected' ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', lastError: null, @@ -569,7 +564,7 @@ describe('McpService.discoverTools per-server caching', () => { 'Connection refused' ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'error', statusConfig: { consecutiveFailures: 3, lastSuccessfulDiscovery: null }, @@ -585,7 +580,7 @@ describe('McpService.discoverTools per-server caching', () => { 'OAuth authorization required' ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: 'disconnected', lastError: null, @@ -596,7 +591,7 @@ describe('McpService.discoverTools per-server caching', () => { it('does not negative-cache a failure older than a successful discovery', async () => { mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')]) mockListTools.mockRejectedValueOnce(new Error('Older request failed')) - mockUpdateReturning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([]) await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow( 'Older request failed' diff --git a/packages/testing/src/mocks/database.mock.test.ts b/packages/testing/src/mocks/database.mock.test.ts index 6e5f58fa178..94145bd3f0d 100644 --- a/packages/testing/src/mocks/database.mock.test.ts +++ b/packages/testing/src/mocks/database.mock.test.ts @@ -146,6 +146,12 @@ describe('database mock', () => { await expect(db.select().from(workflowTable).where({})).resolves.toEqual([]) }) + it('drains unconsumed ...Once overrides on resetDbChainMock', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'leftover' }]) + resetDbChainMock() + await expect(db.select().from(workflowTable).where({}).limit(1)).resolves.toEqual([]) + }) + it('clears queues and rewires defaults on resetDbChainMock', async () => { queueTableRows(workflowTable, [{ id: 'stale' }]) dbChainMockFns.where.mockReturnValue('broken' as never) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 897d2c5f0d1..f9699300af2 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -54,7 +54,9 @@ export function createMockSqlOperators() { exists: vi.fn((subquery) => ({ type: 'exists', subquery })), notExists: vi.fn((subquery) => ({ type: 'notExists', subquery })), like: vi.fn((column, pattern) => ({ type: 'like', column, pattern })), + notLike: vi.fn((column, pattern) => ({ type: 'notLike', column, pattern })), ilike: vi.fn((column, pattern) => ({ type: 'ilike', column, pattern })), + notIlike: vi.fn((column, pattern) => ({ type: 'notIlike', column, pattern })), desc: vi.fn((column) => ({ type: 'desc', column })), asc: vi.fn((column) => ({ type: 'asc', column })), } @@ -299,50 +301,23 @@ export const dbChainMockFns = { } /** - * Restores every `dbChainMockFns` entry to its default wiring and clears all - * table-routed row queues. Call this in `beforeEach` (after - * `vi.clearAllMocks()`) if any test uses `mockReturnValue` / - * `mockResolvedValue` (permanent overrides) or `queueTableRows` — this - * guarantees the next test starts with fresh defaults. - * - * Not needed if tests exclusively use the `...Once` variants, since those - * auto-expire after one call. + * Restores every `dbChainMockFns` entry to its default wiring, drains any + * unconsumed `...Once` overrides, and clears all table-routed row queues. + * Call this in `beforeEach` (after `vi.clearAllMocks()`) so each test starts + * from fresh defaults — a `...Once` override queued by a previous test but + * never consumed would otherwise leak into the next test (`vi.clearAllMocks` + * clears call history only, not once-queues). */ export function resetDbChainMock(): void { tableRowQueues.clear() - for (const spy of [ - select, - selectDistinct, - selectDistinctOn, - from, - where, - limit, - offset, - orderBy, - groupBy, - having, - forClause, - innerJoin, - leftJoin, - insert, - update, - set, - del, - ]) { - spy.mockImplementation(() => CHAIN_DEFAULT) + // mockReset restores the implementation passed to vi.fn() (the sentinel for + // structural spies, the real defaults for value terminals) AND drains any + // unconsumed ...Once overrides — covering the shared spies and the stable + // db-instance wrappers alike. + for (const spy of Object.values(dbChainMockFns)) { + ;(spy as ChainSpy).mockReset() } - returning.mockImplementation(() => Promise.resolve([] as unknown[])) - execute.mockImplementation(() => Promise.resolve([] as unknown[])) - query.mockImplementation(() => Promise.resolve([] as unknown[])) - onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise) - onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise) - values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing })) - transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) => - cb(dbChainMock.db) - ) - // The stable db-instance entry points are wrappers around the spies above; a - // suite may have overridden them directly, so restore their original - // implementations too (mockReset restores the fn passed to vi.fn()). + query.mockReset() for (const key of [ 'select', 'selectDistinct', diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 8bd29692bd7..ca251cba0ee 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -725,6 +725,8 @@ export const schemaMock = { config: 'config', resources: 'resources', lastSeenAt: 'lastSeenAt', + pinned: 'pinned', + deletedAt: 'deletedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', }, From 51307c40c04e610e241ab55bef6cda8fa62c2967 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 15:29:58 -0700 Subject: [PATCH 17/24] =?UTF-8?q?improvement(tests):=20db-mock=20migration?= =?UTF-8?q?=20tranche=203=20=E2=80=94=20lib/workflows,=20lib/copilot=20rem?= =?UTF-8?q?ainder,=20ee/core/misc=20(#5864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(tests): db-mock migration tranche 3 — lib/workflows, lib/copilot remainder, ee/core/misc * improvement(tests): use the shared notLike operator in idempotency cleanup suite --- .../utils/permission-check.test.ts | 213 ++--- .../lib/copy/cleanup-failed.test.ts | 187 ++-- .../lib/lineage/unlink.test.ts | 41 +- .../lib/admin/external-collaborators.test.ts | 37 +- apps/sim/lib/api-key/byok.test.ts | 40 +- apps/sim/lib/auth/ban.test.ts | 45 +- apps/sim/lib/copilot/chat/post.test.ts | 40 +- .../lib/copilot/chat/stream-liveness.test.ts | 51 +- .../copilot/chat/workspace-context.test.ts | 15 +- .../copilot/request/lifecycle/start.test.ts | 23 +- apps/sim/lib/copilot/server/agent-url.test.ts | 77 +- .../server/knowledge/knowledge-base.test.ts | 53 +- .../tools/server/user/get-credentials.test.ts | 31 +- apps/sim/lib/core/idempotency/cleanup.test.ts | 49 +- apps/sim/lib/core/outbox/service.test.ts | 242 ++--- .../lib/organizations/settings-access.test.ts | 51 +- .../workspace-file-manager-errors.test.ts | 27 +- .../lib/workflows/deployment-outbox.test.ts | 113 ++- .../executor/execution-state.test.ts | 99 +- apps/sim/lib/workflows/lifecycle.test.ts | 76 +- .../workflows/orchestration/deploy.test.ts | 108 +-- .../workflows/persistence/duplicate.test.ts | 894 ++++++++---------- .../lib/workflows/schedules/deploy.test.ts | 122 +-- .../workflows/schedules/orchestration.test.ts | 85 +- .../lib/workflows/skills/operations.test.ts | 38 +- apps/sim/lib/workspaces/admin-move.test.ts | 127 +-- apps/sim/lib/workspaces/policy.test.ts | 73 +- 27 files changed, 1096 insertions(+), 1861 deletions(-) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 141c166dc3b..6ea563bcec9 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -1,7 +1,9 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { permissionGroup } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -10,8 +12,6 @@ const { mockGetWorkspaceWithOwner, mockGetProviderFromModel, mockGetBlock, - mockWorkspaceGroups, - mockDefaultGroup, } = vi.hoisted(() => ({ DEFAULT_PERMISSION_GROUP_CONFIG: { allowedIntegrations: null, @@ -44,59 +44,6 @@ const { mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), mockGetProviderFromModel: vi.fn<(model: string) => string>(), mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(), - // resolveWorkspaceGroup selects non-default groups targeting the workspace - // (FROM permissionGroup INNER JOIN permissionGroupWorkspace), awaiting the - // builder directly; each row carries `isMember`/`hasMembers` booleans. A row - // with neither flag set reads as an all-members group (hasMembers falsy). - // resolveDefaultGroup selects the org default directly with limit(1), no join. - // The db mock branches on whether an inner join was used. - mockWorkspaceGroups: { - value: [] as Array<{ - id?: string - name?: string - config: Record - isMember?: boolean - hasMembers?: boolean - }>, - }, - mockDefaultGroup: { value: [] as Array<{ config: Record }> }, -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - let usedInnerJoin = false - const resolveRows = () => (usedInnerJoin ? mockWorkspaceGroups.value : mockDefaultGroup.value) - const chain: Record = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockImplementation(() => { - usedInnerJoin = true - return chain - }) - chain.leftJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.orderBy = vi.fn().mockReturnValue(chain) - chain.limit = vi.fn().mockImplementation(() => Promise.resolve(resolveRows())) - // resolveWorkspaceGroup awaits the builder directly after `orderBy` (no - // limit), so the chain must be thenable. - chain.then = (onFulfilled: (rows: unknown) => unknown) => - Promise.resolve(resolveRows()).then(onFulfilled) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - permissionGroup: {}, - permissionGroupMember: {}, - permissionGroupWorkspace: {}, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - asc: vi.fn(), - sql: vi.fn(), })) vi.mock('@/lib/billing', () => ({ @@ -157,6 +104,34 @@ function setEnterpriseOrgWorkspace() { mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) } +interface WorkspaceGroupRow { + id?: string + name?: string + config: Record + isMember?: boolean + hasMembers?: boolean +} + +/** + * Queue one group-resolution pass. resolveWorkspaceGroup selects non-default + * groups targeting the workspace first (FROM permissionGroup INNER JOIN + * permissionGroupWorkspace, awaited at `orderBy`); each row carries + * `isMember`/`hasMembers` booleans, and a row with neither flag set reads as + * an all-members group. Only when no workspace group wins does + * resolveDefaultGroup select the org default (also FROM permissionGroup, with + * `limit(1)`). Both selects read the same table, so the queue holds the + * workspace-group set first and the default-group set second. + */ +function queueGroupResolution( + workspaceGroups: WorkspaceGroupRow[] = [], + defaultGroup: Array<{ config: Record }> = [] +) { + queueTableRows(permissionGroup, workspaceGroups) + queueTableRows(permissionGroup, defaultGroup) +} + +afterAll(resetDbChainMock) + /** * Default every block to non-legacy. `vi.clearAllMocks()` (used by the * describe-level hooks) keeps implementations, so reset here to stop a legacy @@ -185,8 +160,7 @@ describe('IntegrationNotAllowedError', () => { describe('getUserPermissionConfig (org + entitlement gating)', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) }) @@ -219,8 +193,7 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('falls back to the org default group when no workspace group governs the user', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { disableSkills: true } }] + queueGroupResolution([], [{ config: { disableSkills: true } }]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -229,8 +202,7 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('governs an external member via the org default group', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution([], [{ config: { disableCustomTools: true } }]) const config = await getUserPermissionConfig('external-user', 'workspace-1') @@ -239,9 +211,6 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { it('returns null when no workspace group and no default group apply', async () => { setEnterpriseOrgWorkspace() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] - const config = await getUserPermissionConfig('user-123', 'workspace-1') expect(config).toBeNull() @@ -251,16 +220,15 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { describe('getUserPermissionConfig (workspace-group precedence)', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('governs an explicit member via their workspace group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableMcpTools: true }, isMember: true, hasMembers: true }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -268,9 +236,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('governs all members (including non-listed) via an all-members group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableSkills: true }, isMember: false, hasMembers: false }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -278,9 +246,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('governs an external member via an all-members group', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'g', config: { disableCustomTools: true }, isMember: false, hasMembers: false }, - ] + ]) const config = await getUserPermissionConfig('external-user', 'workspace-1') @@ -288,10 +256,10 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('prefers an explicit-member group over an all-members group on the same workspace', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'all', config: { disableMcpTools: true }, isMember: false, hasMembers: false }, { id: 'explicit', config: { disableSkills: true }, isMember: true, hasMembers: true }, - ] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -300,10 +268,10 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('a narrowed group (has members) does not govern a non-member; falls back to default', async () => { - mockWorkspaceGroups.value = [ - { id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }, - ] - mockDefaultGroup.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution( + [{ id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }], + [{ config: { disableCustomTools: true } }] + ) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -312,10 +280,9 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { }) it('a narrowed group does not govern a non-member; unrestricted when no default', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true }, - ] - mockDefaultGroup.value = [] + ]) const config = await getUserPermissionConfig('user-123', 'workspace-1') @@ -326,8 +293,7 @@ describe('getUserPermissionConfig (workspace-group precedence)', () => { describe('validateBlockType', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() }) describe('when no env allowlist is configured', () => { @@ -416,8 +382,7 @@ describe('validateBlockType', () => { describe('validateModelProvider', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) @@ -428,7 +393,7 @@ describe('validateModelProvider', () => { }) it('throws ProviderNotAllowedError when provider is not in allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -437,14 +402,14 @@ describe('validateModelProvider', () => { }) it('allows when provider is on the allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic', 'openai'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic', 'openai'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await validateModelProvider('user-123', 'workspace-1', 'gpt-4') }) it('throws ModelNotAllowedError when the model is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -453,7 +418,7 @@ describe('validateModelProvider', () => { }) it('denylist match is case-insensitive', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }] + queueGroupResolution([{ config: { deniedModels: ['Ollama/Llama3'] } }]) mockGetProviderFromModel.mockReturnValue('ollama') await expect( @@ -462,9 +427,7 @@ describe('validateModelProvider', () => { }) it('enforces the denylist even when no provider allowlist is set', async () => { - mockWorkspaceGroups.value = [ - { config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }, - ] + queueGroupResolution([{ config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -473,15 +436,14 @@ describe('validateModelProvider', () => { }) it('allows a model that is not on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await validateModelProvider('user-123', 'workspace-1', 'gpt-4o') }) it('applies the org default group when no workspace group governs the user', async () => { - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([], [{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf( @@ -493,14 +455,13 @@ describe('validateModelProvider', () => { describe('validateMcpToolsAllowed', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => { - mockWorkspaceGroups.value = [{ config: { disableMcpTools: true } }] + queueGroupResolution([{ config: { disableMcpTools: true } }]) await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf( McpToolsNotAllowedError @@ -508,7 +469,7 @@ describe('validateMcpToolsAllowed', () => { }) it('no-ops when disableMcpTools is false', async () => { - mockWorkspaceGroups.value = [{ config: {} }] + queueGroupResolution([{ config: {} }]) await validateMcpToolsAllowed('user-123', 'workspace-1') }) @@ -517,38 +478,37 @@ describe('validateMcpToolsAllowed', () => { describe('validatePublicFileSharing', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws when public file sharing is fully disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disablePublicFileSharing: true } }] + queueGroupResolution([{ config: { disablePublicFileSharing: true } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'password') ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) }) it('throws when the auth type is not in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'public') ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) }) it('allows an auth type that is in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await validatePublicFileSharing('user-123', 'workspace-1', 'password') }) it('allows any auth type when the allow-list is null', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: null } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: null } }]) await validatePublicFileSharing('user-123', 'workspace-1', 'email') }) it('no-ops when no auth type is provided (master switch only)', async () => { - mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password'] } }] + queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password'] } }]) await validatePublicFileSharing('user-123', 'workspace-1') }) }) @@ -556,26 +516,25 @@ describe('validatePublicFileSharing', () => { describe('validateChatDeployAuth', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws when the auth type is not in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await expect( validateChatDeployAuth('user-123', 'workspace-1', 'public') ).rejects.toBeInstanceOf(ChatDeployAuthNotAllowedError) }) it('allows an auth type that is in the allow-list', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await validateChatDeployAuth('user-123', 'workspace-1', 'password') }) it('allows any auth type when the allow-list is null', async () => { - mockWorkspaceGroups.value = [{ config: { allowedChatDeployAuthTypes: null } }] + queueGroupResolution([{ config: { allowedChatDeployAuthTypes: null } }]) await validateChatDeployAuth('user-123', 'workspace-1', 'email') }) @@ -588,14 +547,13 @@ describe('validateChatDeployAuth', () => { describe('assertPermissionsAllowed', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] + resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) setEnterpriseOrgWorkspace() }) it('throws ProviderNotAllowedError when model provider is blocked', async () => { - mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }] + queueGroupResolution([{ config: { allowedModelProviders: ['anthropic'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect( @@ -608,7 +566,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws ModelNotAllowedError when the model is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }] + queueGroupResolution([{ config: { deniedModels: ['gpt-4'] } }]) mockGetProviderFromModel.mockReturnValue('openai') await expect( @@ -621,7 +579,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws IntegrationNotAllowedError when block type is blocked', async () => { - mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }] + queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) await expect( assertPermissionsAllowed({ @@ -633,7 +591,7 @@ describe('assertPermissionsAllowed', () => { }) it('exempts legacy blocks from the integration allowlist', async () => { - mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }] + queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) mockGetBlock.mockImplementation((type) => type === 'notion' ? { hideFromToolbar: true } : undefined ) @@ -646,7 +604,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws ToolNotAllowedError when the tool is on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) await expect( assertPermissionsAllowed({ @@ -658,7 +616,7 @@ describe('assertPermissionsAllowed', () => { }) it('allows a tool that is not on the denylist', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) await assertPermissionsAllowed({ userId: 'user-123', @@ -668,7 +626,7 @@ describe('assertPermissionsAllowed', () => { }) it('allows every tool when the denylist is empty', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }] + queueGroupResolution([{ config: { deniedTools: [] } }]) await assertPermissionsAllowed({ userId: 'user-123', @@ -678,9 +636,9 @@ describe('assertPermissionsAllowed', () => { }) it('denies a tool even when its block is allowed by the integration allowlist', async () => { - mockWorkspaceGroups.value = [ + queueGroupResolution([ { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } }, - ] + ]) await expect( assertPermissionsAllowed({ @@ -693,7 +651,7 @@ describe('assertPermissionsAllowed', () => { }) it('still enforces the tool denylist for an exempt block type', async () => { - mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + queueGroupResolution([{ config: { deniedTools: ['slack_canvas'] } }]) mockGetBlock.mockImplementation((type) => type === 'slack' ? { hideFromToolbar: true } : undefined ) @@ -709,7 +667,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }] + queueGroupResolution([{ config: { disableCustomTools: true } }]) await expect( assertPermissionsAllowed({ @@ -721,7 +679,7 @@ describe('assertPermissionsAllowed', () => { }) it('throws SkillsNotAllowedError when skills are disabled', async () => { - mockWorkspaceGroups.value = [{ config: { disableSkills: true } }] + queueGroupResolution([{ config: { disableSkills: true } }]) await expect( assertPermissionsAllowed({ @@ -733,9 +691,6 @@ describe('assertPermissionsAllowed', () => { }) it('passes when the workspace has no blocking config', async () => { - mockWorkspaceGroups.value = [] - mockDefaultGroup.value = [] - await assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index 0139b9c738c..907f08c9cf1 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -2,72 +2,14 @@ * @vitest-environment node */ import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const dbMock = vi.hoisted(() => { - const reads = new Map() - const updates: Array<{ table: unknown; values: Record }> = [] - const deletes: Array<{ table: unknown }> = [] - - const nextPage = (table: unknown): unknown[] => { - const pages = reads.get(table) - return pages && pages.length > 0 ? (pages.shift() as unknown[]) : [] - } - - // A drizzle-style read builder bound to one table: `.where`/`.orderBy`/`.limit` chain back to - // the same builder, and awaiting it (at `.where()` or `.limit()`) shifts that table's next page. - const makeReadBuilder = (table: unknown) => { - const builder = { - where: () => builder, - orderBy: () => builder, - limit: () => builder, - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (error: unknown) => unknown) => - Promise.resolve(nextPage(table)).then(onFulfilled, onRejected), - } - return builder - } - - const db = { - select: () => ({ from: (table: unknown) => makeReadBuilder(table) }), - update: (table: unknown) => ({ - set: (values: Record) => ({ - where: () => { - updates.push({ table, values }) - return Promise.resolve([]) - }, - }), - }), - delete: (table: unknown) => ({ - where: () => { - deletes.push({ table }) - return Promise.resolve([]) - }, - }), - } - - return { - db, - updates, - deletes, - queueRead: (table: unknown, ...pages: unknown[][]) => reads.set(table, pages), - reset: () => { - reads.clear() - updates.length = 0 - deletes.length = 0 - }, - } -}) +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({ mockInvalidateDeployedStateCache: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: dbMock.db, - dbReplica: dbMock.db, - runOutsideTransactionContext: (fn: () => T): T => fn(), - instrumentPoolClient: (client: T): T => client, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/persistence/utils', () => ({ invalidateDeployedStateCache: mockInvalidateDeployedStateCache, @@ -197,10 +139,22 @@ const kbValue = (state: unknown) => const docValue = (state: unknown) => (state as StateBlocks).blocks['block-1'].subBlocks.documentId.value +/** Every `update(table).set(values)` pair, in call order (one `set` per `update`). */ +const updates = () => + dbChainMockFns.update.mock.calls.map((call, index) => ({ + table: call[0], + values: dbChainMockFns.set.mock.calls[index]?.[0] as Record, + })) + +/** The table of every `delete(table)` call, in call order. */ +const deletes = () => dbChainMockFns.delete.mock.calls.map((call) => ({ table: call[0] })) + +afterAll(resetDbChainMock) + describe('cleanup-failed', () => { beforeEach(() => { vi.clearAllMocks() - dbMock.reset() + resetDbChainMock() vi.mocked(getBlock).mockReturnValue(kbBlockConfig()) }) @@ -248,82 +202,82 @@ describe('cleanup-failed', () => { describe('clearFailedReferencesInWorkflows', () => { it('sweeps the draft blocks and returns the affected workflow ids', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') expect([...affected]).toEqual(['wf-1']) - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowBlocks) - const cleared = dbMock.updates[0].values.subBlocks as Record + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowBlocks) + const cleared = updates()[0].values.subBlocks as Record expect(cleared.knowledgeBaseId.value).toBe('') expect(cleared.documentId.value).toBe('') }) it('returns an empty set and writes nothing when no block references a failed id', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') expect(affected.size).toBe(0) - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) }) }) describe('clearFailedReferencesInDeploymentVersions', () => { it('rewrites a version referencing a failed id and invalidates its deployed-state cache', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-1', version: 5, state: versionState('failed-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowDeploymentVersion) - expect(kbValue(dbMock.updates[0].values.state)).toBe('') - expect(docValue(dbMock.updates[0].values.state)).toBe('') + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowDeploymentVersion) + expect(kbValue(updates()[0].values.state)).toBe('') + expect(docValue(updates()[0].values.state)).toBe('') expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') }) it('leaves a version that does not reference a failed id unwritten and uncached', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-old', version: 3, state: versionState('other-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() }) it('writes only the changed version when a workflow mixes referencing and non-referencing versions', async () => { - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-active', version: 5, state: versionState('failed-kb') }, { id: 'dv-old', version: 4, state: versionState('other-kb') }, ]) await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(1) + expect(updates()).toHaveLength(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') }) it('does nothing when no workflows were affected', async () => { await clearFailedReferencesInDeploymentVersions(new Set(), failedByKind(), 'test') - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() }) }) describe('clearFailedForkResourceReferences', () => { it('threads the draft sweep into the deployed sweep, then drops the placeholder', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-active', version: 5, state: versionState('failed-kb') }, { id: 'dv-old', version: 4, state: versionState('other-kb') }, ]) @@ -336,18 +290,18 @@ describe('cleanup-failed', () => { expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) // One draft block update + one deployed version update (only the referencing version). - const updatedTables = dbMock.updates.map((u) => u.table) + const updatedTables = updates().map((u) => u.table) expect(updatedTables).toEqual([workflowBlocks, workflowDeploymentVersion]) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') // The orphaned KB placeholder is dropped after both sweeps. - expect(dbMock.deletes).toHaveLength(1) - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()).toHaveLength(1) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('still drops the placeholder when no workflow referenced the failed resource', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) const cleaned = await clearFailedForkResourceReferences({ childWorkspaceId: 'child-ws', @@ -358,18 +312,18 @@ describe('cleanup-failed', () => { expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) // No draft block referenced the failed id AND no deployed targets were threaded, so the // deployed sweep is skipped entirely. - expect(dbMock.updates).toHaveLength(0) + expect(updates()).toHaveLength(0) expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() - expect(dbMock.deletes).toHaveLength(1) - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()).toHaveLength(1) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('sweeps a deployed target version even when no draft referenced the failed id', async () => { // Draft is clean (other-kb), but a deployed target version still points at the dropped // placeholder - the deployed-target scope (not draft divergence) catches it. - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) - dbMock.queueRead(workflowDeploymentVersion, [ + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('other-kb')]) + queueTableRows(workflowDeploymentVersion, [ { id: 'dv-1', version: 5, state: versionState('failed-kb') }, ]) @@ -381,15 +335,15 @@ describe('cleanup-failed', () => { }) expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) - expect(dbMock.updates.map((u) => u.table)).toContain(workflowDeploymentVersion) + expect(updates().map((u) => u.table)).toContain(workflowDeploymentVersion) expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') // Clearing succeeded, so the placeholder is dropped. - expect(dbMock.deletes[0].table).toBe(knowledgeBase) + expect(deletes()[0].table).toBe(knowledgeBase) }) it('clears a file-upload reference to a failed copied blob and drops no row', async () => { - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [fileBlockRow('workspace/child/failed.png')]) + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [fileBlockRow('workspace/child/failed.png')]) const cleaned = await clearFailedForkResourceReferences({ childWorkspaceId: 'child-ws', @@ -398,36 +352,31 @@ describe('cleanup-failed', () => { }) expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) - expect(dbMock.updates).toHaveLength(1) - expect(dbMock.updates[0].table).toBe(workflowBlocks) - const cleared = dbMock.updates[0].values.subBlocks as Record + expect(updates()).toHaveLength(1) + expect(updates()[0].table).toBe(workflowBlocks) + const cleared = updates()[0].values.subBlocks as Record expect(cleared.file.value).toBe('') // A failed file has no placeholder row to drop (the metadata row stays re-uploadable). - expect(dbMock.deletes).toHaveLength(0) + expect(deletes()).toHaveLength(0) }) it('reports cleared:0 + clearingFailed and skips the placeholder drop when a clear phase throws', async () => { // A clear-phase failure must not drop the placeholder: that would turn an empty placeholder // into a dangling reference to a deleted row. Make the draft block UPDATE throw. - dbMock.queueRead(workflow, [{ id: 'wf-1' }]) - dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) - const originalUpdate = dbMock.db.update - dbMock.db.update = () => { + queueTableRows(workflow, [{ id: 'wf-1' }]) + queueTableRows(workflowBlocks, [draftBlockRow('failed-kb')]) + dbChainMockFns.update.mockImplementation(() => { throw new Error('update failed') - } - try { - const cleaned = await clearFailedForkResourceReferences({ - childWorkspaceId: 'child-ws', - failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], - requestId: 'test', - }) - // The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete. - expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) - } finally { - dbMock.db.update = originalUpdate - } + }) + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + // The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete. + expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) // The drop is skipped, so the placeholder row survives (no delete issued). - expect(dbMock.deletes).toHaveLength(0) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts index f2a34ce1c25..8ce5fc2f4d4 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts @@ -1,15 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({ - mockTransaction: vi.fn(), +const { mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({ mockSetForkLockTimeout: vi.fn(), mockAcquireForkEdgeLock: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: { transaction: mockTransaction } })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ setForkLockTimeout: mockSetForkLockTimeout, acquireForkEdgeLock: mockAcquireForkEdgeLock, @@ -17,49 +17,40 @@ vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' -/** A fake tx whose update returns `updatedRows` and whose deletes record their calls. */ -function fakeTx(updatedRows: Array<{ id: string }>) { - const updateWhere = vi.fn(() => ({ returning: vi.fn().mockResolvedValue(updatedRows) })) - const updateSet = vi.fn(() => ({ where: updateWhere })) - const update = vi.fn(() => ({ set: updateSet })) - const deleteWhere = vi.fn().mockResolvedValue(undefined) - const del = vi.fn(() => ({ where: deleteWhere })) - return { tx: { update, delete: del }, update, updateSet, del } -} - const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' } +afterAll(resetDbChainMock) + describe('unlinkForkEdge', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('nulls the child pointer and purges all four edge tables under the edge lock', async () => { - const { tx, update, updateSet, del } = fakeTx([{ id: 'child-ws' }]) - mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx)) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'child-ws' }]) const result = await unlinkForkEdge(EDGE, 'req-1') expect(result).toEqual({ unlinked: true }) expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1) - expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(tx, 'child-ws') - expect(update).toHaveBeenCalledTimes(1) - expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ forkedFromWorkspaceId: null })) - expect(del).toHaveBeenCalledTimes(4) + expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(dbChainMock.db, 'child-ws') + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ forkedFromWorkspaceId: null }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(4) }) it('is an idempotent no-op when the edge was already dissolved', async () => { - const { tx, del } = fakeTx([]) - mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx)) - const result = await unlinkForkEdge(EDGE) expect(result).toEqual({ unlinked: false }) - expect(del).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) it('propagates a transaction failure without swallowing it', async () => { - mockTransaction.mockRejectedValue(new Error('lock timeout')) + dbChainMockFns.transaction.mockRejectedValueOnce(new Error('lock timeout')) await expect(unlinkForkEdge(EDGE)).rejects.toThrow('lock timeout') }) }) diff --git a/apps/sim/lib/admin/external-collaborators.test.ts b/apps/sim/lib/admin/external-collaborators.test.ts index 61cc5cb6960..8cdff708d0b 100644 --- a/apps/sim/lib/admin/external-collaborators.test.ts +++ b/apps/sim/lib/admin/external-collaborators.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, permissions } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - rows: [] as unknown[][], setLimit: vi.fn(), acquireLock: vi.fn(), recordAudit: vi.fn(), @@ -16,22 +17,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit, })) -vi.mock('@sim/db', () => { - const makeSelectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.innerJoin = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve(mocks.rows.shift() ?? []) - return chain - } - const tx = { select: () => makeSelectChain() } - return { - db: { - transaction: async (operation: (executor: typeof tx) => Promise) => operation(tx), - }, - } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/member-limits', () => ({ setOrgMemberUsageLimit: mocks.setLimit, @@ -44,14 +30,17 @@ import { updateDashboardExternalCollaboratorUsageLimit } from '@/lib/admin/exter const actor = { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' } +afterAll(resetDbChainMock) + describe('updateDashboardExternalCollaboratorUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() - mocks.rows = [] + resetDbChainMock() }) it('sets a cap through the canonical organization usage-limit service', async () => { - mocks.rows = [[], [{ userId: 'external-1' }]] + queueTableRows(member, []) + queueTableRows(permissions, [{ userId: 'external-1' }]) await updateDashboardExternalCollaboratorUsageLimit('org-1', 'external-1', 30, actor) @@ -73,7 +62,8 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('clears an existing cap', async () => { - mocks.rows = [[], [{ userId: 'external-1' }]] + queueTableRows(member, []) + queueTableRows(permissions, [{ userId: 'external-1' }]) await updateDashboardExternalCollaboratorUsageLimit('org-1', 'external-1', null, actor) @@ -87,7 +77,7 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('rejects internal organization members', async () => { - mocks.rows = [[{ id: 'member-1' }]] + queueTableRows(member, [{ id: 'member-1' }]) await expect( updateDashboardExternalCollaboratorUsageLimit('org-1', 'user-1', 100, actor) @@ -97,7 +87,8 @@ describe('updateDashboardExternalCollaboratorUsageLimit', () => { }) it('rejects users without a current non-archived workspace permission', async () => { - mocks.rows = [[], []] + queueTableRows(member, []) + queueTableRows(permissions, []) await expect( updateDashboardExternalCollaboratorUsageLimit('org-1', 'user-1', 100, actor) diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 6b288b6213f..aec86b6e6b1 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -1,22 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({ - mockOrderBy: vi.fn(), +const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ orderBy: mockOrderBy })), - })), - })), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, @@ -62,10 +54,12 @@ const uniqueWorkspaceId = () => `workspace-${++testIndex}` const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` }) +afterAll(resetDbChainMock) + describe('getBYOKKey', () => { beforeEach(() => { vi.clearAllMocks() - mockOrderBy.mockResolvedValue([]) + resetDbChainMock() mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ decrypted: encrypted.replace('encrypted-', 'decrypted-'), })) @@ -82,7 +76,7 @@ describe('getBYOKKey', () => { it('returns the same key on every call when only one key is stored', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1')]) for (let call = 0; call < 3; call++) { expect(await getBYOKKey(workspaceId, 'openai')).toEqual({ @@ -94,7 +88,11 @@ describe('getBYOKKey', () => { it('round-robins across multiple keys in creation order', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')]) + dbChainMockFns.orderBy.mockResolvedValue([ + storedKey('key-1'), + storedKey('key-2'), + storedKey('key-3'), + ]) const apiKeys = [] for (let call = 0; call < 4; call++) { @@ -112,18 +110,18 @@ describe('getBYOKKey', () => { it('reads the key list fresh from the database on every call', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1')]) await getBYOKKey(workspaceId, 'openai') await getBYOKKey(workspaceId, 'openai') await getBYOKKey(workspaceId, 'openai') - expect(mockOrderBy).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(3) }) it('tracks rotation independently per provider within a workspace', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1') expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1') @@ -132,7 +130,7 @@ describe('getBYOKKey', () => { it('skips a key that fails to decrypt and returns the next one', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) mockDecryptSecret.mockImplementation(async (encrypted: string) => { if (encrypted === 'encrypted-key-1') { throw new Error('corrupt ciphertext') @@ -148,14 +146,14 @@ describe('getBYOKKey', () => { it('returns null when every key fails to decrypt', async () => { const workspaceId = uniqueWorkspaceId() - mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) + dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')]) mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext')) expect(await getBYOKKey(workspaceId, 'openai')).toBeNull() }) it('returns null when the keys query throws', async () => { - mockOrderBy.mockRejectedValue(new Error('database unavailable')) + dbChainMockFns.orderBy.mockRejectedValue(new Error('database unavailable')) expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull() }) diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index 7a38b914294..fffdfb634ae 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -1,21 +1,24 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockWhere, envRef } = vi.hoisted(() => ({ - mockWhere: vi.fn(), +import { user } from '@sim/db/schema' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { envRef } = vi.hoisted(() => ({ envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, BLOCKED_EMAILS: undefined as string | undefined, }, })) -vi.mock('@sim/db', () => ({ - db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) }, - user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' }, -})) -vi.mock('drizzle-orm', () => ({ inArray: vi.fn(), sql: vi.fn() })) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() })) vi.mock('@/lib/core/config/env', () => ({ get env() { @@ -26,6 +29,8 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false })) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' +afterAll(resetDbChainMock) + describe('isBanActive', () => { it('returns true for a permanent ban', () => { expect(isBanActive({ banned: true, banExpires: null })).toBe(true) @@ -48,24 +53,24 @@ describe('isBanActive', () => { describe('isEmailBlocked', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' envRef.BLOCKED_EMAILS = 'spam@evil.com' - mockWhere.mockResolvedValue([]) }) it('returns true for blocked domains and subdomains without querying users', async () => { expect(await isEmailBlocked('a@bad.com')).toBe(true) expect(await isEmailBlocked('a@mail.bad.com')).toBe(true) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns true for individually blocked emails without querying users', async () => { expect(await isEmailBlocked('spam@evil.com')).toBe(true) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns true when the email belongs to an actively banned account', async () => { - mockWhere.mockResolvedValue([{ banned: true, banExpires: null }]) + queueTableRows(user, [{ banned: true, banExpires: null }]) expect(await isEmailBlocked('a@good.com')).toBe(true) }) @@ -79,19 +84,19 @@ describe('isEmailBlocked', () => { describe('getActivelyBannedUserIds', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() envRef.BLOCKED_SIGNUP_DOMAINS = undefined envRef.BLOCKED_EMAILS = undefined - mockWhere.mockResolvedValue([]) }) it('short-circuits on empty input without querying', async () => { expect(await getActivelyBannedUserIds([])).toEqual([]) expect(await getActivelyBannedUserIds([''])).toEqual([]) - expect(mockWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() }) it('returns ids with an active db ban', async () => { - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@ok.com', banned: true, banExpires: null }, { id: 'u2', email: 'b@ok.com', banned: false, banExpires: null }, ]) @@ -99,7 +104,7 @@ describe('getActivelyBannedUserIds', () => { }) it('treats an expired ban as lifted', async () => { - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) }, ]) expect(await getActivelyBannedUserIds(['u1'])).toEqual([]) @@ -107,7 +112,7 @@ describe('getActivelyBannedUserIds', () => { it('returns ids whose email is individually blocked', async () => { envRef.BLOCKED_EMAILS = 'spam@evil.com' - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null }, { id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null }, ]) @@ -116,7 +121,7 @@ describe('getActivelyBannedUserIds', () => { it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => { envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' - mockWhere.mockResolvedValue([ + queueTableRows(user, [ { id: 'u1', email: 'a@bad.com', banned: false, banExpires: null }, { id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null }, { id: 'u3', email: 'c@good.com', banned: false, banExpires: null }, @@ -125,7 +130,7 @@ describe('getActivelyBannedUserIds', () => { }) it('propagates db failures so callers fail closed', async () => { - mockWhere.mockRejectedValue(new Error('db down')) + dbChainMockFns.where.mockImplementationOnce(() => Promise.reject(new Error('db down'))) await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down') }) }) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 92eea63c09f..bace6856104 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -4,13 +4,15 @@ import { authMockFns, + dbChainMock, permissionsMock, permissionsMockFns, + resetDbChainMock, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -115,42 +117,18 @@ vi.mock('@/lib/copilot/chat-status', () => ({ }, })) -vi.mock('@sim/db', () => { - const update = vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([]), - })), - })), - })) - const select = vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]), - })), - })), - })) - return { - db: { - update, - select, - transaction: async (cb: (tx: { update: typeof update; select: typeof select }) => unknown) => - cb({ update, select }), - }, - } -}) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), -})) +vi.mock('@sim/db', () => dbChainMock) import { handleUnifiedChatPost } from './post' describe('handleUnifiedChatPost', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() getSession.mockResolvedValue({ user: { id: 'user-1' } }) resolveWorkflowIdForUser.mockResolvedValue({ status: 'resolved', diff --git a/apps/sim/lib/copilot/chat/stream-liveness.test.ts b/apps/sim/lib/copilot/chat/stream-liveness.test.ts index f7294b4c6ec..27a17434878 100644 --- a/apps/sim/lib/copilot/chat/stream-liveness.test.ts +++ b/apps/sim/lib/copilot/chat/stream-liveness.test.ts @@ -1,33 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockAnd, mockEq, mockGetChatStreamLockOwners, mockSet, mockUpdate, mockWhere } = vi.hoisted( - () => ({ - mockAnd: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - mockEq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - mockGetChatStreamLockOwners: vi.fn(), - mockSet: vi.fn(), - mockUpdate: vi.fn(), - mockWhere: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { update: mockUpdate }, -})) +import { copilotChats } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - conversationId: 'copilotChats.conversationId', - }, -})) +vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - eq: mockEq, +const { mockGetChatStreamLockOwners } = vi.hoisted(() => ({ + mockGetChatStreamLockOwners: vi.fn(), })) vi.mock('@/lib/copilot/request/session', () => ({ @@ -39,15 +21,17 @@ import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' describe('reconcileChatStreamMarkers', () => { beforeEach(() => { vi.clearAllMocks() - mockSet.mockReturnValue({ where: mockWhere }) - mockUpdate.mockReturnValue({ set: mockSet }) - mockWhere.mockResolvedValue(undefined) + resetDbChainMock() mockGetChatStreamLockOwners.mockResolvedValue({ status: 'verified', ownersByChatId: new Map(), }) }) + afterAll(() => { + resetDbChainMock() + }) + it('clears a persisted stream marker when Redis verifies no lock owner exists', async () => { const markers = await reconcileChatStreamMarkers([ { chatId: 'chat-stuck', streamId: 'stream-orphaned' }, @@ -66,13 +50,10 @@ describe('reconcileChatStreamMarkers', () => { repairVerifiedStaleMarkers: true, }) - expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ conversationId: null }) - expect(mockWhere).toHaveBeenCalledWith( - mockAnd( - mockEq('copilotChats.id', 'chat-stuck'), - mockEq('copilotChats.conversationId', 'stream-orphaned') - ) + expect(dbChainMockFns.update).toHaveBeenCalledWith(copilotChats) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ conversationId: null }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + and(eq(copilotChats.id, 'chat-stuck'), eq(copilotChats.conversationId, 'stream-orphaned')) ) }) diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 44e36084b0d..3051019eda2 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -2,20 +2,7 @@ * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('@sim/db/schema', () => ({ - knowledgeBase: {}, - knowledgeConnector: {}, - mcpServers: {}, - userTableDefinitions: {}, - userTableRows: {}, - workflow: {}, - workflowFolder: {}, - workflowSchedule: {}, -})) - +import { describe, expect, it } from 'vitest' import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils' import { buildVfsSnapshot, buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index fa73ebd53b0..c3da25457c2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,8 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -117,15 +118,7 @@ vi.mock('@/lib/copilot/request/session/sse', () => ({ SSE_RESPONSE_HEADERS: {}, })) -vi.mock('@sim/db', () => ({ - db: { - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(), - })), - })), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/copilot/chat-status', () => ({ chatPubSub: null, @@ -160,8 +153,13 @@ async function drainStream(stream: ReadableStream) { } describe('createSSEStream terminal error handling', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() billingFlags.isHosted = false billingFlags.isCopilotBillingAttributionV1Enabled = false fetchGo.mockResolvedValue( @@ -342,8 +340,13 @@ describe('createSSEStream terminal error handling', () => { }) describe('requestChatTitle billing protocol', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() billingFlags.isHosted = true billingFlags.isCopilotBillingAttributionV1Enabled = true fetchGo.mockResolvedValue( diff --git a/apps/sim/lib/copilot/server/agent-url.test.ts b/apps/sim/lib/copilot/server/agent-url.test.ts index 6d6d1a14b8a..dae4021ad0a 100644 --- a/apps/sim/lib/copilot/server/agent-url.test.ts +++ b/apps/sim/lib/copilot/server/agent-url.test.ts @@ -1,47 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { user } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getMothershipBaseURL, getMothershipSourceEnvHeaders, MOTHERSHIP_SOURCE_ENV_HEADER, } from './agent-url' -const { dbMock, envMock, mockRows } = vi.hoisted(() => { - const mockRows: any[] = [] - const dbMock = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - leftJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(async () => mockRows), - })), - })), - })), - })), - } - const envMock = { +const { envMock } = vi.hoisted(() => ({ + envMock: { COPILOT_DEV_URL: 'https://dev.mothership.test', COPILOT_STAGING_URL: 'https://staging.mothership.test', COPILOT_PROD_URL: 'https://prod.mothership.test', COPILOT_SOURCE_ENV: undefined as string | undefined, - } - return { dbMock, envMock, mockRows } -}) - -vi.mock('@sim/db', () => ({ db: dbMock })) -vi.mock('@sim/db/schema', () => ({ - settings: { - userId: 'settings.userId', - superUserModeEnabled: 'settings.superUserModeEnabled', - mothershipEnvironment: 'settings.mothershipEnvironment', - }, - user: { - id: 'user.id', - role: 'user.role', }, })) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(() => ({})), -})) + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/api/contracts', () => ({ mothershipEnvironmentSchema: { safeParse: (value: unknown) => @@ -60,11 +35,15 @@ vi.mock('@/lib/core/config/env', () => ({ describe('getMothershipBaseURL', () => { beforeEach(() => { - mockRows.length = 0 - dbMock.select.mockClear() + vi.clearAllMocks() + resetDbChainMock() envMock.COPILOT_SOURCE_ENV = undefined }) + afterAll(() => { + resetDbChainMock() + }) + it('uses the default URL when there is no user context', async () => { await expect(getMothershipBaseURL()).resolves.toBe('https://default.mothership.test') await expect(getMothershipBaseURL({ environment: 'dev' })).resolves.toBe( @@ -73,11 +52,9 @@ describe('getMothershipBaseURL', () => { }) it('ignores stored and explicit environments for non-admin users', async () => { - mockRows.push({ - role: 'user', - superUserModeEnabled: true, - mothershipEnvironment: 'dev', - }) + queueTableRows(user, [ + { role: 'user', superUserModeEnabled: true, mothershipEnvironment: 'dev' }, + ]) await expect(getMothershipBaseURL({ userId: 'user-1', environment: 'staging' })).resolves.toBe( 'https://default.mothership.test' @@ -85,11 +62,9 @@ describe('getMothershipBaseURL', () => { }) it('ignores stored and explicit environments when super user mode is off', async () => { - mockRows.push({ - role: 'admin', - superUserModeEnabled: false, - mothershipEnvironment: 'dev', - }) + queueTableRows(user, [ + { role: 'admin', superUserModeEnabled: false, mothershipEnvironment: 'dev' }, + ]) await expect(getMothershipBaseURL({ userId: 'admin-1', environment: 'prod' })).resolves.toBe( 'https://default.mothership.test' @@ -97,11 +72,9 @@ describe('getMothershipBaseURL', () => { }) it('uses default for super admins until they select a concrete environment', async () => { - mockRows.push({ - role: 'admin', - superUserModeEnabled: true, - mothershipEnvironment: 'default', - }) + queueTableRows(user, [ + { role: 'admin', superUserModeEnabled: true, mothershipEnvironment: 'default' }, + ]) await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe( 'https://default.mothership.test' @@ -109,11 +82,13 @@ describe('getMothershipBaseURL', () => { }) it('allows effective super admins to use a selected environment', async () => { - mockRows.push({ + const superAdminRow = { role: 'admin', superUserModeEnabled: true, mothershipEnvironment: 'dev', - }) + } + queueTableRows(user, [superAdminRow]) + queueTableRows(user, [superAdminRow]) await expect(getMothershipBaseURL({ userId: 'admin-1' })).resolves.toBe( 'https://dev.mothership.test' diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 7055cee52f6..aa0dd96c9f6 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -1,46 +1,25 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { knowledgeConnector } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, mockCheckKnowledgeBaseWriteAccess, - mockDbChain, mockFetch, mockGenerateInternalToken, mockSerializeBillingAttributionHeader, -} = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), - } - return { - mockAssertBillingAttributionSnapshot: vi.fn(), - mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockDbChain: chain, - mockFetch: vi.fn(), - mockGenerateInternalToken: vi.fn(), - mockSerializeBillingAttributionHeader: vi.fn(), - } -}) - -vi.mock('@sim/db', () => ({ db: mockDbChain })) -vi.mock('@sim/db/schema', () => ({ - knowledgeConnector: { - id: 'knowledgeConnector.id', - knowledgeBaseId: 'knowledgeConnector.knowledgeBaseId', - archivedAt: 'knowledgeConnector.archivedAt', - deletedAt: 'knowledgeConnector.deletedAt', - }, -})) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), +} = vi.hoisted(() => ({ + mockAssertBillingAttributionSnapshot: vi.fn(), + mockCheckKnowledgeBaseWriteAccess: vi.fn(), + mockFetch: vi.fn(), + mockGenerateInternalToken: vi.fn(), + mockSerializeBillingAttributionHeader: vi.fn(), })) + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) @@ -119,13 +98,15 @@ const BILLING_ATTRIBUTION = { } describe('knowledge base connector Copilot operations', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', mockFetch) - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.limit.mockResolvedValue([{ knowledgeBaseId: 'knowledge-base-1' }]) + queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') mockGenerateInternalToken.mockResolvedValue('internal-token') diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index 07c0bfc2c3f..716612512a7 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -5,21 +5,21 @@ * never the connected account's OAuth access/refresh token. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { account, user } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const SECRET_ACCESS_TOKEN = 'ya29.a0SECRET_GOOGLE_BEARER_TOKEN_DO_NOT_LEAK' -const { selectMock, getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } = - vi.hoisted(() => ({ - selectMock: vi.fn(), +const { getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } = vi.hoisted( + () => ({ getAllOAuthServicesMock: vi.fn(), getPersonalAndWorkspaceEnvMock: vi.fn(), decodeJwtMock: vi.fn(), - })) + }) +) -vi.mock('@sim/db', () => ({ - db: { select: selectMock }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/oauth', () => ({ getAllOAuthServices: getAllOAuthServicesMock, @@ -41,17 +41,18 @@ import { getCredentialsServerTool } from './get-credentials' * 2. `select({...}).from(user).where().limit(1)` → user row */ function wireDb(accountRows: unknown[], userRows: Array<{ email: string }>) { - const whereThenable = { - then: (resolve: (rows: unknown[]) => unknown) => resolve(accountRows), - limit: () => Promise.resolve(userRows), - } - const builder = { from: () => builder, where: () => whereThenable } - selectMock.mockReturnValue(builder) + queueTableRows(account, accountRows) + queueTableRows(user, userRows) } describe('getCredentialsServerTool', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() wireDb( [ @@ -129,6 +130,6 @@ describe('getCredentialsServerTool', () => { await expect(getCredentialsServerTool.execute({}, undefined)).rejects.toThrow( 'Authentication required' ) - expect(selectMock).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/idempotency/cleanup.test.ts b/apps/sim/lib/core/idempotency/cleanup.test.ts index 08933a75422..3e460083168 100644 --- a/apps/sim/lib/core/idempotency/cleanup.test.ts +++ b/apps/sim/lib/core/idempotency/cleanup.test.ts @@ -1,65 +1,40 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { idempotencyKey } from '@sim/db/schema' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { like, notLike } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - notLike: vi.fn(() => 'not-like'), - like: vi.fn(() => 'like'), -})) - -vi.mock('@sim/db', () => { - const selectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve([]) - return chain - } - return { db: { select: () => selectChain() } } -}) -vi.mock('@sim/db/schema', () => ({ - idempotencyKey: { key: 'key', createdAt: 'createdAt' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), error: vi.fn() }), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn() })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...values: unknown[]) => values), - count: vi.fn(), - inArray: vi.fn(), - like: mocks.like, - lt: vi.fn(() => 'older-than'), - max: vi.fn(), - min: vi.fn(), - notLike: mocks.notLike, - sql: vi.fn(), -})) import { cleanupExpiredIdempotencyKeys } from '@/lib/core/idempotency/cleanup' +afterAll(resetDbChainMock) + describe('cleanupExpiredIdempotencyKeys', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('retains irreversible admin credit-grant keys during global cleanup', async () => { await cleanupExpiredIdempotencyKeys() - expect(mocks.notLike).toHaveBeenCalledWith('key', 'admin-credit-grant:%') + expect(notLike).toHaveBeenCalledWith(idempotencyKey.key, 'admin-credit-grant:%') }) it('retains permanent workflow execution ID claims during global cleanup', async () => { await cleanupExpiredIdempotencyKeys() - expect(mocks.notLike).toHaveBeenCalledWith('key', 'workflow-execution-id:%') + expect(notLike).toHaveBeenCalledWith(idempotencyKey.key, 'workflow-execution-id:%') }) it('keeps explicit namespace cleanup behavior unchanged', async () => { await cleanupExpiredIdempotencyKeys({ namespace: 'webhook' }) - expect(mocks.like).toHaveBeenCalledWith('key', 'webhook:%') - expect(mocks.notLike).not.toHaveBeenCalled() + expect(like).toHaveBeenCalledWith(idempotencyKey.key, 'webhook:%') + expect(notLike).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts index 3fd89d21f61..0c3390fbf95 100644 --- a/apps/sim/lib/core/outbox/service.test.ts +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { outboxEvent } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type OutboxRow = { id: string @@ -18,116 +20,7 @@ type OutboxRow = { processedAt: Date | null } -// Hoisted mock state — all tests manipulate these directly. -const { state, mockDb } = vi.hoisted(() => { - const state = { - // Rows returned from the FOR UPDATE SKIP LOCKED select in claimBatch. - claimedRows: [] as OutboxRow[], - // Whether the terminal update (lease CAS) should report a match. - leaseHeld: true, - // IDs the reaper's UPDATE should return (simulates stuck `processing` rows). - reapedRowIds: [] as string[], - // Everything written (for assertions). - inserts: [] as Array<{ values: unknown }>, - updates: [] as Array<{ set: Record; where?: unknown }>, - } - - const makeUpdateChain = () => { - const row: { set: Record; where?: unknown } = { set: {} } - const chain: Record = {} - chain.set = vi.fn((s: Record) => { - row.set = s - return chain - }) - chain.where = vi.fn((w: unknown) => { - row.where = w - state.updates.push(row) - return chain - }) - chain.returning = vi.fn(async () => { - // Terminal UPDATE (lease CAS): has `attempts` + `availableAt` - // on retry, or explicit completed/dead_letter. Reaper path sets - // status='pending' without attempts/availableAt. - const isReaperUpdate = - row.set.status === 'pending' && !('attempts' in row.set) && !('availableAt' in row.set) - - if (isReaperUpdate) { - return state.reapedRowIds.map((id) => ({ id })) - } - - if ( - row.set.status === 'completed' || - row.set.status === 'dead_letter' || - (row.set.status === 'pending' && 'attempts' in row.set && 'availableAt' in row.set) || - (!('status' in row.set) && 'attempts' in row.set && 'lockedAt' in row.set) || - 'payload' in row.set - ) { - return state.leaseHeld ? [{ id: 'evt-1' }] : [] - } - - return [] - }) - return chain - } - - const makeSelectChain = () => { - const chain: Record = {} - const self = () => chain - chain.from = vi.fn(self) - chain.where = vi.fn(self) - chain.orderBy = vi.fn(self) - chain.limit = vi.fn(self) - chain.for = vi.fn(async () => state.claimedRows.splice(0, 1)) - return chain - } - - const mockDb = { - insert: vi.fn(() => { - const chain: Record = {} - chain.values = vi.fn(async (v: unknown) => { - state.inserts.push({ values: v }) - }) - return chain - }), - update: vi.fn(() => makeUpdateChain()), - select: vi.fn(() => makeSelectChain()), - transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockDb)), - } - - return { state, mockDb } -}) - -vi.mock('@sim/db', () => ({ db: mockDb })) - -vi.mock('@sim/db/schema', () => ({ - outboxEvent: { - id: 'outbox_event.id', - eventType: 'outbox_event.event_type', - payload: 'outbox_event.payload', - status: 'outbox_event.status', - attempts: 'outbox_event.attempts', - maxAttempts: 'outbox_event.max_attempts', - availableAt: 'outbox_event.available_at', - lockedAt: 'outbox_event.locked_at', - lastError: 'outbox_event.last_error', - createdAt: 'outbox_event.created_at', - processedAt: 'outbox_event.processed_at', - $inferSelect: {} as OutboxRow, - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args) => ({ _op: 'and', args })), - asc: vi.fn((col) => ({ _op: 'asc', col })), - eq: vi.fn((col, val) => ({ _op: 'eq', col, val })), - inArray: vi.fn((col, vals) => ({ _op: 'inArray', col, vals })), - lte: vi.fn((col, val) => ({ _op: 'lte', col, val })), - sql: vi.fn(() => ({ _op: 'sql' })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-event-id'), @@ -152,24 +45,32 @@ function makePendingRow(overrides: Partial = {}): OutboxRow { } } -function resetState() { - state.claimedRows = [] - state.leaseHeld = true - state.reapedRowIds = [] - state.inserts.length = 0 - state.updates.length = 0 +/** The values object of every `set(...)` call, in call order. */ +const updateSets = (): Record[] => + dbChainMockFns.set.mock.calls.map((call) => call[0] as Record) + +/** + * Simulate a held processing lease: the reaper's `returning` (always the first + * `.returning()` of a run) reaps nothing, and every later terminal / + * checkpoint UPDATE's lease CAS reports a matched row. Without this priming, + * `returning` defaults to `[]` everywhere, which models a lost lease. + */ +function holdLease() { + dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValue([{ id: 'evt-1' }]) } +afterAll(resetDbChainMock) + describe('enqueueOutboxEvent', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('inserts a row with the given event type and payload', async () => { - const id = await enqueueOutboxEvent(mockDb, 'test.event', { foo: 'bar' }) + const id = await enqueueOutboxEvent(dbChainMock.db, 'test.event', { foo: 'bar' }) expect(id).toBe('test-event-id') - expect(state.inserts[0].values).toMatchObject({ + expect(dbChainMockFns.values.mock.calls[0][0]).toMatchObject({ id: 'test-event-id', eventType: 'test.event', payload: { foo: 'bar' }, @@ -178,21 +79,23 @@ describe('enqueueOutboxEvent', () => { }) it('respects maxAttempts override', async () => { - await enqueueOutboxEvent(mockDb, 'test.event', {}, { maxAttempts: 3 }) - expect(state.inserts[0].values).toMatchObject({ maxAttempts: 3 }) + await enqueueOutboxEvent(dbChainMock.db, 'test.event', {}, { maxAttempts: 3 }) + expect(dbChainMockFns.values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 }) }) it('respects availableAt override for delayed processing', async () => { const future = new Date(Date.now() + 60_000) - await enqueueOutboxEvent(mockDb, 'test.event', {}, { availableAt: future }) - expect((state.inserts[0].values as { availableAt: Date }).availableAt).toBe(future) + await enqueueOutboxEvent(dbChainMock.db, 'test.event', {}, { availableAt: future }) + expect((dbChainMockFns.values.mock.calls[0][0] as { availableAt: Date }).availableAt).toBe( + future + ) }) }) describe('processOutboxEvents — empty / no handler', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('returns zero counts when no events are due', async () => { @@ -207,21 +110,22 @@ describe('processOutboxEvents — empty / no handler', () => { }) it('dead-letters events with no registered handler', async () => { - state.claimedRows = [makePendingRow({ eventType: 'unknown.event' })] + queueTableRows(outboxEvent, [makePendingRow({ eventType: 'unknown.event' })]) + holdLease() const result = await processOutboxEvents({}) expect(result.deadLettered).toBe(1) - const terminal = state.updates.find((u) => u.set.status === 'dead_letter') + const terminal = updateSets().find((set) => set.status === 'dead_letter') expect(terminal).toBeDefined() - expect(terminal?.set.lastError).toMatch(/No handler registered/) + expect(terminal?.lastError).toMatch(/No handler registered/) }) }) describe('processOutboxEvents — handler success and retry', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('transitions to completed on handler success and passes context to handler', async () => { @@ -230,15 +134,16 @@ describe('processOutboxEvents — handler success and retry', () => { handlerCalls.push({ payload, eventId: ctx.eventId, attempts: ctx.attempts }) }) - state.claimedRows = [makePendingRow()] + queueTableRows(outboxEvent, [makePendingRow()]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.processed).toBe(1) expect(handlerCalls).toEqual([{ payload: { foo: 'bar' }, eventId: 'evt-1', attempts: 0 }]) - const completeUpdate = state.updates.find((u) => u.set.status === 'completed') + const completeUpdate = updateSets().find((set) => set.status === 'completed') expect(completeUpdate).toBeDefined() - expect(completeUpdate?.set.lastError).toBeNull() + expect(completeUpdate?.lastError).toBeNull() }) it('checkpoints payload fields only while the processing lease is held', async () => { @@ -250,12 +155,13 @@ describe('processOutboxEvents — handler success and retry', () => { await ctx.checkpointPayload({ stripeProgress: { customerId: 'cus_1' } }) } ) - state.claimedRows = [makePendingRow()] + queueTableRows(outboxEvent, [makePendingRow()]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.processed).toBe(1) - expect(state.updates.some((update) => 'payload' in update.set)).toBe(true) + expect(updateSets().some((set) => 'payload' in set)).toBe(true) }) it('stops a handler whose payload checkpoint loses the processing lease', async () => { @@ -267,8 +173,7 @@ describe('processOutboxEvents — handler success and retry', () => { await ctx.checkpointPayload({ stripeProgress: { customerId: 'cus_1' } }) } ) - state.claimedRows = [makePendingRow()] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow()]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -281,18 +186,19 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('transient failure') }) - state.claimedRows = [makePendingRow({ attempts: 2 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 2 })]) + holdLease() const before = Date.now() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.retried).toBe(1) - const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + const retryUpdate = updateSets().find((set) => set.status === 'pending' && 'attempts' in set) expect(retryUpdate).toBeDefined() - expect(retryUpdate?.set.attempts).toBe(3) - expect(retryUpdate?.set.lastError).toBe('transient failure') + expect(retryUpdate?.attempts).toBe(3) + expect(retryUpdate?.lastError).toBe('transient failure') // Backoff after nextAttempts=3: 1000 * 2^3 = 8000ms - const scheduledAt = retryUpdate?.set.availableAt as Date + const scheduledAt = retryUpdate?.availableAt as Date expect(scheduledAt.getTime()).toBeGreaterThan(before + 7500) expect(scheduledAt.getTime()).toBeLessThan(before + 10_000) }) @@ -302,15 +208,16 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('permanent failure') }) - state.claimedRows = [makePendingRow({ attempts: 9, maxAttempts: 10 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 9, maxAttempts: 10 })]) + holdLease() const result = await processOutboxEvents({ 'test.event': handler }) expect(result.deadLettered).toBe(1) - const deadUpdate = state.updates.find((u) => u.set.status === 'dead_letter') + const deadUpdate = updateSets().find((set) => set.status === 'dead_letter') expect(deadUpdate).toBeDefined() - expect(deadUpdate?.set.attempts).toBe(10) - expect(deadUpdate?.set.lastError).toBe('permanent failure') + expect(deadUpdate?.attempts).toBe(10) + expect(deadUpdate?.lastError).toBe('permanent failure') }) it('caps exponential backoff at 1 hour', async () => { @@ -318,14 +225,15 @@ describe('processOutboxEvents — handler success and retry', () => { throw new Error('transient') }) - state.claimedRows = [makePendingRow({ attempts: 20, maxAttempts: 100 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 20, maxAttempts: 100 })]) + holdLease() const before = Date.now() await processOutboxEvents({ 'test.event': handler }) - const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + const retryUpdate = updateSets().find((set) => set.status === 'pending' && 'attempts' in set) expect(retryUpdate).toBeDefined() - const scheduledAt = retryUpdate?.set.availableAt as Date + const scheduledAt = retryUpdate?.availableAt as Date // 1hr = 3,600,000ms expect(scheduledAt.getTime()).toBeLessThan(before + 3_600_000 + 1000) expect(scheduledAt.getTime()).toBeGreaterThan(before + 3_599_000) @@ -335,7 +243,7 @@ describe('processOutboxEvents — handler success and retry', () => { describe('processOutboxEvents — lease CAS / reaper race', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('reports leaseLost when completion UPDATE affects zero rows', async () => { @@ -343,8 +251,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { // "succeeds" but terminal write will fail the lease CAS }) - state.claimedRows = [makePendingRow()] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow()]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -357,8 +264,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { throw new Error('transient') }) - state.claimedRows = [makePendingRow({ attempts: 2 })] - state.leaseHeld = false + queueTableRows(outboxEvent, [makePendingRow({ attempts: 2 })]) const result = await processOutboxEvents({ 'test.event': handler }) @@ -370,7 +276,7 @@ describe('processOutboxEvents — lease CAS / reaper race', () => { describe('processOutboxEvents — handler timeout', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() vi.useFakeTimers() }) @@ -381,7 +287,8 @@ describe('processOutboxEvents — handler timeout', () => { it('times out a stuck handler without releasing it for overlapping retry', async () => { const neverResolves = vi.fn(() => new Promise(() => {})) - state.claimedRows = [makePendingRow({ attempts: 0 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 0 })]) + holdLease() const promise = processOutboxEvents({ 'test.event': neverResolves }) // Must exceed DEFAULT_HANDLER_TIMEOUT_MS (90s). @@ -389,11 +296,11 @@ describe('processOutboxEvents — handler timeout', () => { const result = await promise expect(result.leaseLost).toBe(1) - const timeoutUpdate = state.updates.find( - (u) => !('status' in u.set) && 'attempts' in u.set && 'lockedAt' in u.set + const timeoutUpdate = updateSets().find( + (set) => !('status' in set) && 'attempts' in set && 'lockedAt' in set ) - expect(timeoutUpdate?.set.attempts).toBe(1) - expect(timeoutUpdate?.set.lastError).toMatch(/timed out/) + expect(timeoutUpdate?.attempts).toBe(1) + expect(timeoutUpdate?.lastError).toMatch(/timed out/) }) it('aborts the handler signal when its execution window expires', async () => { @@ -410,7 +317,8 @@ describe('processOutboxEvents — handler timeout', () => { }) } ) - state.claimedRows = [makePendingRow({ attempts: 0 })] + queueTableRows(outboxEvent, [makePendingRow({ attempts: 0 })]) + holdLease() const promise = processOutboxEvents({ 'test.event': handler }) await vi.advanceTimersByTimeAsync(90 * 1000 + 1) @@ -424,11 +332,15 @@ describe('processOutboxEvents — handler timeout', () => { describe('processOutboxEvents — reaper recovery', () => { beforeEach(() => { vi.clearAllMocks() - resetState() + resetDbChainMock() }) it('reaps stuck processing rows back to pending and reports count', async () => { - state.reapedRowIds = ['stuck-1', 'stuck-2', 'stuck-3'] + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'stuck-1' }, + { id: 'stuck-2' }, + { id: 'stuck-3' }, + ]) const result = await processOutboxEvents({}) @@ -437,11 +349,11 @@ describe('processOutboxEvents — reaper recovery', () => { // The reaper's UPDATE sets status='pending' with NO attempts / availableAt // fields — that's how runHandler's retry update is distinguished from it. - const reaperUpdate = state.updates.find( - (u) => u.set.status === 'pending' && !('attempts' in u.set) && !('availableAt' in u.set) + const reaperUpdate = updateSets().find( + (set) => set.status === 'pending' && !('attempts' in set) && !('availableAt' in set) ) expect(reaperUpdate).toBeDefined() - expect(reaperUpdate?.set.lockedAt).toBeNull() + expect(reaperUpdate?.lockedAt).toBeNull() }) it('returns zero reaped when no rows are stuck', async () => { diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 51e6be13d96..616eaae5f00 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -1,46 +1,23 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAnd, mockEq, mockLimit, mockWhere } = vi.hoisted(() => ({ - mockAnd: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - mockEq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - mockLimit: vi.fn(), - mockWhere: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: mockWhere.mockImplementation(() => ({ limit: mockLimit })), - })), - })), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - eq: mockEq, -})) +vi.mock('@sim/db', () => dbChainMock) import { canOpenOrganizationSettingsSection, getOrganizationSettingsAccess, } from '@/lib/organizations/settings-access' +afterAll(resetDbChainMock) + describe('organization settings access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it.each([ @@ -48,25 +25,23 @@ describe('organization settings access', () => { { role: 'admin', isAdmin: true }, { role: 'member', isAdmin: false }, ])('derives $role access from the route organization membership', async ({ role, isAdmin }) => { - mockLimit.mockResolvedValueOnce([{ role }]) + queueTableRows(member, [{ role }]) await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).resolves.toEqual({ role, isMember: true, isAdmin, }) - expect(mockWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenCalledWith({ type: 'and', conditions: [ - { type: 'eq', left: 'member.organizationId', right: 'organization-route' }, - { type: 'eq', left: 'member.userId', right: 'viewer' }, + { type: 'eq', left: member.organizationId, right: 'organization-route' }, + { type: 'eq', left: member.userId, right: 'viewer' }, ], }) }) it('rejects users without membership in the route organization', async () => { - mockLimit.mockResolvedValueOnce([]) - await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).resolves.toEqual({ role: null, isMember: false, @@ -75,12 +50,12 @@ describe('organization settings access', () => { }) it('allows members to view the roster but reserves control-plane sections for admins', async () => { - mockLimit.mockResolvedValueOnce([{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) await expect( canOpenOrganizationSettingsSection('organization-route', 'viewer', 'members') ).resolves.toBe(true) - mockLimit.mockResolvedValueOnce([{ role: 'member' }]) + queueTableRows(member, [{ role: 'member' }]) await expect( canOpenOrganizationSettingsSection('organization-route', 'viewer', 'sso') ).resolves.toBe(false) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index 6b032412bd9..b106ce0aa54 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -1,33 +1,20 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => { - const chain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - } - chain.from.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - return { - chain, - select: vi.fn(() => chain), - } -}) - -vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) +vi.mock('@sim/db', () => dbChainMock) import { listWorkspaceFiles } from './workspace-file-manager' +afterAll(resetDbChainMock) + describe('listWorkspaceFiles error handling', () => { beforeEach(() => { vi.clearAllMocks() - mocks.chain.from.mockReturnValue(mocks.chain) - mocks.chain.where.mockReturnValue(mocks.chain) - mocks.chain.orderBy.mockRejectedValue(new Error('database unavailable')) - mocks.select.mockReturnValue(mocks.chain) + resetDbChainMock() + dbChainMockFns.orderBy.mockRejectedValue(new Error('database unavailable')) }) it('keeps the established best-effort behavior by default', async () => { diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts index 72881739136..164538fb65d 100644 --- a/apps/sim/lib/workflows/deployment-outbox.test.ts +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -1,10 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockLimit, mockPrepareWebhooks, mockGetDeploymentOperation, mockMarkDeploymentComponentReadiness, @@ -26,7 +32,6 @@ const { mockCaptureServerEvent, mockTx, } = vi.hoisted(() => ({ - mockLimit: vi.fn(), mockPrepareWebhooks: vi.fn(), mockGetDeploymentOperation: vi.fn(), mockMarkDeploymentComponentReadiness: vi.fn(), @@ -58,41 +63,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockLimit, - })), - })), - })), - insert: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - transaction: vi.fn(), - }, - workflow: { - id: 'workflow.id', - isDeployed: 'workflow.isDeployed', - }, - workflowDeploymentVersion: { - id: 'workflowDeploymentVersion.id', - workflowId: 'workflowDeploymentVersion.workflowId', - state: 'workflowDeploymentVersion.state', - isActive: 'workflowDeploymentVersion.isActive', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args) => ({ type: 'and', args })), - eq: vi.fn((column, value) => ({ type: 'eq', column, value })), - ne: vi.fn((column, value) => ({ type: 'ne', column, value })), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'secret' }, @@ -231,9 +202,21 @@ function handler() { })[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2] } +afterAll(() => { + resetDbChainMock() +}) + describe('versioned deployment preparation outbox', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + /** + * These handlers only reach db.transaction in deferred cleanup helpers the + * suite intentionally keeps inert (the previous private factory returned + * undefined without running the callback); the default chain-mock + * transaction would execute the callback and consume queued select rows. + */ + dbChainMockFns.transaction.mockResolvedValue(undefined) vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) mockPrepareWebhooks.mockResolvedValue(undefined) mockActivateWebhookRegistrations.mockResolvedValue(undefined) @@ -280,9 +263,12 @@ describe('versioned deployment preparation outbox', () => { completedAt: NOW, }) mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockMarkDeploymentComponentReadiness .mockResolvedValueOnce({ success: true, operation: webhooksReady }) .mockResolvedValueOnce({ success: true, operation: schedulesReady }) @@ -366,9 +352,12 @@ describe('versioned deployment preparation outbox', () => { it('generation-guards failure on the final outbox attempt', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue(new Error('provider unavailable')) await expect(handler()(payload(), context(new AbortController(), 3))).rejects.toThrow( @@ -388,9 +377,12 @@ describe('versioned deployment preparation outbox', () => { it('retries transient mid-attempt failures without failing the operation', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue(new Error('provider briefly unavailable')) await expect(handler()(payload(), context(new AbortController(), 0))).rejects.toThrow( @@ -428,9 +420,12 @@ describe('versioned deployment preparation outbox', () => { }, }) mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockMarkDeploymentComponentReadiness .mockResolvedValueOnce({ success: true, operation: webhooksReady }) .mockResolvedValueOnce({ success: true, operation: schedulesReady }) @@ -464,9 +459,12 @@ describe('versioned deployment preparation outbox', () => { it('fails the operation immediately on a non-retryable preparation error', async () => { const preparing = operation() mockGetDeploymentOperation.mockResolvedValue(preparing) - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'version-2', state: { blocks: {} } }, + ]) mockPrepareWebhooks.mockRejectedValue( new NonRetryableDeploymentError( 'Webhook path "/leads" is already in use. Choose a different path.', @@ -489,10 +487,11 @@ describe('versioned deployment preparation outbox', () => { }) it('keeps v1 cleanup from deleting a candidate owned by the current v2 operation', async () => { - mockLimit - .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) - .mockResolvedValueOnce([{ isActive: false }]) - .mockResolvedValueOnce([{ isDeployed: true }]) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + queueTableRows(schemaMock.workflowDeploymentVersion, [{ isActive: false }]) + queueTableRows(schemaMock.workflow, [{ isDeployed: true }]) mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(true) const cleanupHandler = createWorkflowDeploymentOutboxHandlers()[ diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 689371c0ffd..49101daa652 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -1,16 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockMaterializeExecutionData, mockSelect } = vi.hoisted(() => ({ +const { mockMaterializeExecutionData } = vi.hoisted(() => ({ mockMaterializeExecutionData: vi.fn(), - mockSelect: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { select: mockSelect }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionData: mockMaterializeExecutionData, @@ -32,24 +30,15 @@ const EXECUTION_STATE = { activeExecutionPath: [], } -function createSelectChain(rows: unknown[]) { - const chain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - limit: vi.fn().mockResolvedValue(rows), - } - chain.from.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - chain.orderBy.mockReturnValue(chain) - return chain -} - describe('execution state lookup', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockMaterializeExecutionData.mockReset() - mockSelect.mockReset() + }) + + afterAll(() => { + resetDbChainMock() }) it('materializes externalized execution data for a specific execution', async () => { @@ -64,16 +53,14 @@ describe('execution state lookup', () => { executionId: 'execution-1', }, } - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionData: slimExecutionData, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) mockMaterializeExecutionData.mockResolvedValueOnce({ executionState: EXECUTION_STATE, }) @@ -100,16 +87,14 @@ describe('execution state lookup', () => { executionId: 'execution-1', }, } - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionData: slimExecutionData, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) mockMaterializeExecutionData.mockResolvedValueOnce({ workflowInput: { leadId: 'lead-1' }, }) @@ -128,24 +113,22 @@ describe('execution state lookup', () => { }) it('checks older pointer-backed candidates when the latest has no execution state', async () => { - mockSelect.mockReturnValueOnce( - createSelectChain([ - { - executionId: 'execution-2', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionState: null, - traceStoreRef: { id: 'value-2' }, - }, - { - executionId: 'execution-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionState: null, - traceStoreRef: { id: 'value-1' }, - }, - ]) - ) + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-2' }, + }, + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-1' }, + }, + ]) mockMaterializeExecutionData .mockResolvedValueOnce({}) .mockImplementationOnce(async (executionData: Record) => { diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts index 2a6d3f03182..5749f6408c6 100644 --- a/apps/sim/lib/workflows/lifecycle.test.ts +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -3,33 +3,25 @@ */ import { createEnvMock, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + schemaMock, urlsMock, urlsMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelect, mockTransaction, mockCleanupExternalWebhook, mockWorkflowDeleted } = vi.hoisted( - () => ({ - mockSelect: vi.fn(), - mockTransaction: vi.fn(), - mockCleanupExternalWebhook: vi.fn(), - mockWorkflowDeleted: vi.fn(), - }) -) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCleanupExternalWebhook, mockWorkflowDeleted } = vi.hoisted(() => ({ + mockCleanupExternalWebhook: vi.fn(), + mockWorkflowDeleted: vi.fn(), +})) const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - transaction: mockTransaction, - }, - workflow: { id: 'id' }, - workflowDeploymentOperation: { workflowId: 'workflowId', status: 'status' }, - workflowDeploymentVersion: { workflowId: 'workflowId', isActive: 'isActive' }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -51,31 +43,18 @@ vi.mock('@/lib/core/telemetry', () => ({ import { archiveWorkflow } from '@/lib/workflows/lifecycle' -function createSelectChain(result: T) { - const chain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue(result), - } - - return chain -} - -function createUpdateChain() { - return { - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } -} - describe('workflow lifecycle', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() urlsMockFns.mockGetSocketServerUrl.mockReturnValue('http://socket.test') vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) }) + afterAll(() => { + resetDbChainMock() + }) + it('archives workflow and disables live surfaces', async () => { mockGetWorkflowById .mockResolvedValueOnce({ @@ -93,25 +72,14 @@ describe('workflow lifecycle', () => { archivedAt: new Date(), }) - mockSelect.mockReturnValue(createSelectChain([])) - - const tx = { - update: vi.fn().mockImplementation(() => createUpdateChain()), - delete: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - } - mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => - callback(tx) - ) - const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) expect(result.archived).toBe(true) - expect(tx.update).toHaveBeenCalledTimes(7) - const supersedeSet = tx.update.mock.results[0]?.value.set - expect(supersedeSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'superseded' })) - expect(tx.delete).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(7) + expect(dbChainMockFns.set.mock.calls[0][0]).toEqual( + expect.objectContaining({ status: 'superseded' }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) expect(mockWorkflowDeleted).toHaveBeenCalledWith({ workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -134,7 +102,7 @@ describe('workflow lifecycle', () => { const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) expect(result.archived).toBe(false) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(fetch).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index 8df4a4fed44..b9afcdd9fa7 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockLimit, - mockUpdateSet, mockSaveWorkflowToNormalizedTables, mockRecordAudit, mockCaptureServerEvent, - mockTransaction, mockValidateWorkflowSchedules, mockValidateTriggerWebhookConfigForDeploy, mockEmitWorkflowDeployedEvent, @@ -22,12 +26,9 @@ const { mockLoadWorkflowDeploymentSnapshot, mockTx, } = vi.hoisted(() => ({ - mockLimit: vi.fn(), - mockUpdateSet: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockTransaction: vi.fn(), mockValidateWorkflowSchedules: vi.fn(), mockValidateTriggerWebhookConfigForDeploy: vi.fn(), mockEmitWorkflowDeployedEvent: vi.fn(), @@ -38,49 +39,15 @@ const { mockProcessWorkflowDeploymentOutboxEvent: vi.fn(), mockNotifySocketDeploymentChanged: vi.fn(), mockLoadWorkflowDeploymentSnapshot: vi.fn(), - mockTx: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => ({ - for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]), - })), - })), - })), - })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), - })), - execute: vi.fn().mockResolvedValue(undefined), - }, + /** + * Sentinel transaction handle the mocked prepare functions hand to the real + * onPrepareTransaction callback, which only forwards it into the (mocked) + * outbox enqueue — identity is asserted, never chained on. + */ + mockTx: { sentinel: 'tx' }, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockLimit, - })), - })), - })), - update: vi.fn(() => ({ - set: mockUpdateSet, - })), - transaction: mockTransaction, - }, - workflow: { - id: 'workflow.id', - deployedAt: 'workflow.deployedAt', - workspaceId: 'workflow.workspaceId', - }, - workflowDeploymentVersion: { - workflowId: 'workflowDeploymentVersion.workflowId', - version: 'workflowDeploymentVersion.version', - isActive: 'workflowDeploymentVersion.isActive', - state: 'workflowDeploymentVersion.state', - }, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/audit', () => ({ AuditAction: { @@ -145,30 +112,20 @@ import { performRevertToVersion, } from '@/lib/workflows/orchestration/deploy' +afterAll(() => { + resetDbChainMock() +}) + describe('performRevertToVersion', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) - mockTransaction.mockImplementation(async (callback) => callback(mockTx)) - mockTx.select.mockImplementation((selection?: Record) => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: - selection && Object.hasOwn(selection, 'state') - ? mockLimit - : vi.fn(() => ({ - for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]), - })), - })), - })), - })) - mockTx.update.mockReturnValue({ set: mockUpdateSet }) - mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) }) it('restores variables when the deployment snapshot includes them', async () => { - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflowDeploymentVersion, [ { state: { blocks: {}, @@ -207,9 +164,9 @@ describe('performRevertToVersion', () => { }, }, }), - mockTx + dbChainMock.db ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ variables: { variableA: { @@ -224,7 +181,7 @@ describe('performRevertToVersion', () => { }) it('preserves existing variables when reverting a legacy snapshot without variables', async () => { - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflowDeploymentVersion, [ { state: { blocks: {}, @@ -245,7 +202,7 @@ describe('performRevertToVersion', () => { expect(result.success).toBe(true) const savedState = mockSaveWorkflowToNormalizedTables.mock.calls[0][1] expect(Object.hasOwn(savedState, 'variables')).toBe(false) - const workflowUpdate = mockUpdateSet.mock.calls[0][0] + const workflowUpdate = dbChainMockFns.set.mock.calls[0][0] expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false) }) }) @@ -253,6 +210,7 @@ describe('performRevertToVersion', () => { describe('performFullDeploy workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) const now = new Date('2026-07-14T08:00:00.000Z') const operation = { @@ -281,7 +239,7 @@ describe('performFullDeploy workspace event emission', () => { } mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed') mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) - mockLimit.mockResolvedValue([ + queueTableRows(schemaMock.workflow, [ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, ]) mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ @@ -544,6 +502,7 @@ describe('performFullDeploy workspace event emission', () => { describe('performActivateVersion workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) const now = new Date('2026-07-14T08:00:00.000Z') const operation = { @@ -574,7 +533,9 @@ describe('performActivateVersion workspace event emission', () => { mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) - mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }]) + queueTableRows(schemaMock.workflowDeploymentVersion, [ + { id: 'dv-2', state: { blocks: {} }, isActive: false }, + ]) mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate-default') mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => { await input.onPrepareTransaction?.(mockTx, operation) @@ -671,7 +632,12 @@ describe('performActivateVersion workspace event emission', () => { }) it('does not emit when the version is already active (no-op activation)', async () => { - mockLimit + /** + * Per-chain overrides answer the two selects directly (version row, then + * workflow deployedAt); the default row queued in beforeEach stays + * unconsumed and is cleared by the next reset. + */ + dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }]) .mockResolvedValueOnce([{ deployedAt: new Date() }]) diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index 5d7be01ed4f..b8328c900e2 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -1,76 +1,63 @@ /** * @vitest-environment node */ -import { workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const { mockDb } = vi.hoisted(() => ({ - mockDb: { - transaction: vi.fn(), - }, -})) - -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/db', () => ({ - db: mockDb, -})) - -import { duplicateWorkflow } from './duplicate' - -function createMockTx( - selectResults: unknown[], - onWorkflowInsert?: (values: Record) => void, - onInsert?: (values: unknown) => void -) { - let selectCallCount = 0 - - const select = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - const result = selectResults[selectCallCount++] ?? [] - if (selectCallCount === 1) { - return { - limit: vi.fn().mockResolvedValue(result), - } - } - return Promise.resolve(result) - }), - }), - })) - - const insert = vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - onWorkflowInsert?.(values) - onInsert?.(values) - return Promise.resolve(undefined) - }), - }) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) - const update = vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - }) +import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' - return { - select, - insert, - update, - } +/** + * Queues the table-routed result sets consumed by `duplicateWorkflow`, in + * chain order: source workflow lookup, sibling/folder minimum sort-order + * aggregates, then the source blocks/edges/subflows reads. + */ +function queueDuplicateFixtures(options: { + sourceWorkflow: Record + workflowMin?: unknown[] + folderMin?: unknown[] + blocks?: unknown[] + edges?: unknown[] + subflows?: unknown[] +}) { + queueTableRows(schemaMock.workflow, [options.sourceWorkflow]) + queueTableRows(schemaMock.workflow, options.workflowMin ?? []) + queueTableRows(schemaMock.workflowFolder, options.folderMin ?? []) + queueTableRows(schemaMock.workflowBlocks, options.blocks ?? []) + queueTableRows(schemaMock.workflowEdges, options.edges ?? []) + queueTableRows(schemaMock.workflowSubflows, options.subflows ?? []) +} + +/** + * Returns each payload passed to `insert(table).values(payload)` for the given + * schema table. Insert/values chains run sequentially in the code under test, + * so the two spies' call lists stay index-aligned. + */ +function insertedValuesFor(table: unknown): unknown[] { + return dbChainMockFns.insert.mock.calls.flatMap(([calledTable], index) => + calledTable === table ? [dbChainMockFns.values.mock.calls[index]?.[0]] : [] + ) } describe('duplicateWorkflow ordering', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('new-workflow-id'), @@ -87,33 +74,22 @@ describe('duplicateWorkflow ordering', () => { ) }) - it('uses mixed-sibling top insertion sort order', async () => { - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [{ minOrder: 5 }], - [{ minOrder: 2 }], - [], - [], - [], - ], - (values) => { - insertedWorkflowValues = values - } - ) + afterAll(() => { + resetDbChainMock() + }) - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + it('uses mixed-sibling top insertion sort order', async () => { + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + workflowMin: [{ minOrder: 5 }], + folderMin: [{ minOrder: 2 }], + }) const result = await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -125,36 +101,23 @@ describe('duplicateWorkflow ordering', () => { }) expect(result.sortOrder).toBe(1) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > expect(insertedWorkflowValues?.sortOrder).toBe(1) }) it('defaults to sortOrder 0 when target has no siblings', async () => { - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [], - [], - [], - ], - (values) => { - insertedWorkflowValues = values - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + }) const result = await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -166,91 +129,76 @@ describe('duplicateWorkflow ordering', () => { }) expect(result.sortOrder).toBe(0) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > expect(insertedWorkflowValues?.sortOrder).toBe(0) }) it('strips copied webhook runtime subblocks and remaps variable assignments', async () => { - let insertedBlocks: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', - variables: { - 'old-var-id': { - id: 'old-var-id', - workflowId: 'source-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'source-block-id', + variables: { + 'old-var-id': { + id: 'old-var-id', workflowId: 'source-workflow-id', - type: 'generic_webhook', - name: 'Webhook', - parentId: null, - extent: null, - data: {}, - subBlocks: { - triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' }, - webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' }, - webhookUrlDisplay: { - id: 'webhookUrlDisplay', - type: 'short-input', - value: 'https://example.test/api/webhooks/trigger/old-webhook-path', - }, - variables: { - id: 'variables', - type: 'variables-input', - value: JSON.stringify([ - { - id: 'assignment-1', - variableId: 'old-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ]), - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'source-block-id', + workflowId: 'source-workflow-id', + type: 'generic_webhook', + name: 'Webhook', + parentId: null, + extent: null, + data: {}, + subBlocks: { + triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' }, + webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' }, + webhookUrlDisplay: { + id: 'webhookUrlDisplay', + type: 'short-input', + value: 'https://example.test/api/webhooks/trigger/old-webhook-path', + }, + variables: { + id: 'variables', + type: 'variables-input', + value: JSON.stringify([ + { + id: 'assignment-1', + variableId: 'old-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ]), }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: true, - locked: true, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: true, + locked: true, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await duplicateWorkflow({ sourceWorkflowId: 'source-workflow-id', @@ -261,6 +209,9 @@ describe('duplicateWorkflow ordering', () => { requestId: 'req-3', }) + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(copiedSubBlocks.triggerPath).toBeUndefined() @@ -272,85 +223,61 @@ describe('duplicateWorkflow ordering', () => { }) it('remaps variable assignments when duplicating an already-duplicated source (array value)', async () => { - let insertedBlocks: Array> | null = null - let insertedWorkflowValues: Record | null = null - const tx = createMockTx( - [ - [ - { - id: 'first-copy-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'first copy', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'first-copy-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'first copy', - variables: { - 'first-copy-var-id': { - id: 'first-copy-var-id', - workflowId: 'first-copy-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'first-copy-block-id', + variables: { + 'first-copy-var-id': { + id: 'first-copy-var-id', workflowId: 'first-copy-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: { - variables: { - id: 'variables', - type: 'variables-input', - value: [ - { - id: 'assignment-1', - variableId: 'first-copy-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ], - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'first-copy-block-id', + workflowId: 'first-copy-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: { + variables: { + id: 'variables', + type: 'variables-input', + value: [ + { + id: 'assignment-1', + variableId: 'first-copy-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ], }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - (values) => { - if (!insertedWorkflowValues && !Array.isArray(values)) { - insertedWorkflowValues = values - } - }, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await duplicateWorkflow({ sourceWorkflowId: 'first-copy-workflow-id', @@ -361,11 +288,18 @@ describe('duplicateWorkflow ordering', () => { requestId: 'req-second-copy', }) + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(Array.isArray(copiedSubBlocks.variables.value)).toBe(true) expect(copiedSubBlocks.variables.value).toHaveLength(1) + const insertedWorkflowValues = insertedValuesFor(schemaMock.workflow)[0] as Record< + string, + unknown + > const newVarIds = Object.keys( (insertedWorkflowValues?.variables as Record) || {} ) @@ -377,101 +311,79 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves remap when an edge references an unknown source block (drops the edge with a warning)', async () => { - let insertedEdges: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [ - { - id: 'block-a', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent A', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'block-b', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent B', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [ - { - id: 'edge-valid', - workflowId: 'source-workflow-id', - sourceBlockId: 'block-a', - targetBlockId: 'block-b', - sourceHandle: null, - targetHandle: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'edge-orphan', - workflowId: 'source-workflow-id', - sourceBlockId: 'unknown-source-block', - targetBlockId: 'block-b', - sourceHandle: null, - targetHandle: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [], + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + blocks: [ + { + id: 'block-a', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent A', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'block-b', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent B', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if ( - Array.isArray(values) && - values.length > 0 && - (values[0] as Record)?.sourceBlockId !== undefined - ) { - insertedEdges = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + edges: [ + { + id: 'edge-valid', + workflowId: 'source-workflow-id', + sourceBlockId: 'block-a', + targetBlockId: 'block-b', + sourceHandle: null, + targetHandle: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'edge-orphan', + workflowId: 'source-workflow-id', + sourceBlockId: 'unknown-source-block', + targetBlockId: 'block-b', + sourceHandle: null, + targetHandle: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }) await expect( duplicateWorkflow({ @@ -484,6 +396,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedEdges = insertedValuesFor(schemaMock.workflowEdges)[0] as Array< + Record + > expect(insertedEdges).toHaveLength(1) const onlyEdge = insertedEdges?.[0] expect(onlyEdge?.sourceBlockId).not.toBe('unknown-source-block') @@ -492,94 +407,72 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves remap when a subflow references an unknown node (drops the node with a warning)', async () => { - let insertedSubflows: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', - variables: {}, - }, - ], - [], - [], - [ - { - id: 'loop-block', - workflowId: 'source-workflow-id', - type: 'loop', - name: 'Loop', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'known-node', - workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: {}, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [], - [ - { + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + variables: {}, + }, + blocks: [ + { + id: 'loop-block', + workflowId: 'source-workflow-id', + type: 'loop', + name: 'Loop', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'known-node', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: {}, + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + subflows: [ + { + id: 'loop-block', + workflowId: 'source-workflow-id', + type: 'loop', + config: { id: 'loop-block', - workflowId: 'source-workflow-id', - type: 'loop', - config: { - id: 'loop-block', - nodes: ['known-node', 'unknown-node'], - iterations: 1, - loopType: 'for', - }, - createdAt: new Date(), - updatedAt: new Date(), + nodes: ['known-node', 'unknown-node'], + iterations: 1, + loopType: 'for', }, - ], + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if ( - Array.isArray(values) && - values.length > 0 && - (values[0] as Record)?.config !== undefined - ) { - insertedSubflows = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await expect( duplicateWorkflow({ @@ -592,6 +485,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedSubflows = insertedValuesFor(schemaMock.workflowSubflows)[0] as Array< + Record + > expect(insertedSubflows).toHaveLength(1) const remappedConfig = insertedSubflows?.[0].config as { nodes: string[] } expect(Array.isArray(remappedConfig.nodes)).toBe(true) @@ -601,80 +497,61 @@ describe('duplicateWorkflow ordering', () => { }) it('preserves stale variable references instead of failing the duplicate', async () => { - let insertedBlocks: Array> | null = null - const tx = createMockTx( - [ - [ - { - id: 'source-workflow-id', - workspaceId: 'workspace-123', - folderId: null, - description: 'source', + queueDuplicateFixtures({ + sourceWorkflow: { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', - variables: { - 'live-var-id': { - id: 'live-var-id', - workflowId: 'source-workflow-id', - name: 'customerName', - type: 'string', - value: 'Ada', - }, - }, - }, - ], - [], - [], - [ - { - id: 'source-block-id', + variables: { + 'live-var-id': { + id: 'live-var-id', workflowId: 'source-workflow-id', - type: 'agent', - name: 'Agent', - parentId: null, - extent: null, - data: {}, - subBlocks: { - variables: { - id: 'variables', - type: 'variables-input', - value: [ - { - id: 'assignment-1', - variableId: 'deleted-var-id', - variableName: 'customerName', - type: 'string', - value: 'Grace', - isExisting: true, - }, - ], - }, + name: 'customerName', + type: 'string', + value: 'Ada', + }, + }, + }, + blocks: [ + { + id: 'source-block-id', + workflowId: 'source-workflow-id', + type: 'agent', + name: 'Agent', + parentId: null, + extent: null, + data: {}, + subBlocks: { + variables: { + id: 'variables', + type: 'variables-input', + value: [ + { + id: 'assignment-1', + variableId: 'deleted-var-id', + variableName: 'customerName', + type: 'string', + value: 'Grace', + isExisting: true, + }, + ], }, - position: { x: 0, y: 0 }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - advancedMode: false, - triggerMode: false, - locked: false, - createdAt: new Date(), - updatedAt: new Date(), }, - ], - [], - [], + position: { x: 0, y: 0 }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + advancedMode: false, + triggerMode: false, + locked: false, + createdAt: new Date(), + updatedAt: new Date(), + }, ], - undefined, - (values) => { - if (Array.isArray(values)) { - insertedBlocks = values as Array> - } - } - ) - - mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => - callback(tx) - ) + }) await expect( duplicateWorkflow({ @@ -687,6 +564,9 @@ describe('duplicateWorkflow ordering', () => { }) ).resolves.toBeDefined() + const insertedBlocks = insertedValuesFor(schemaMock.workflowBlocks)[0] as Array< + Record + > expect(insertedBlocks).toHaveLength(1) const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record expect(copiedSubBlocks.variables.value[0].variableId).toBe('deleted-var-id') diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index f38093ca6fd..afb5dc2abdc 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -3,51 +3,14 @@ * * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockInsert, - mockDelete, - mockOnConflictDoUpdate, - mockValues, - mockWhere, - mockRandomUUID, - mockTransaction, - mockSelect, - mockFrom, -} = vi.hoisted(() => ({ - mockInsert: vi.fn(), - mockDelete: vi.fn(), - mockOnConflictDoUpdate: vi.fn(), - mockValues: vi.fn(), - mockWhere: vi.fn(), +const { mockRandomUUID } = vi.hoisted(() => ({ mockRandomUUID: vi.fn(), - mockTransaction: vi.fn(), - mockSelect: vi.fn(), - mockFrom: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - transaction: mockTransaction, - }, - workflowSchedule: { - workflowId: 'workflow_id', - blockId: 'block_id', - deploymentVersionId: 'deployment_version_id', - deploymentOperationId: 'deployment_operation_id', - id: 'id', - archivedAt: 'archived_at', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((...args) => ({ type: 'eq', args })), - and: vi.fn((...args) => ({ type: 'and', args })), - inArray: vi.fn((...args) => ({ type: 'inArray', args })), - isNull: vi.fn((...args) => ({ type: 'isNull', args })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/webhooks/deploy', () => ({ cleanupWebhooksForWorkflow: vi.fn().mockResolvedValue(undefined), @@ -75,11 +38,13 @@ afterAll(() => { mockCalculateNextRunTime.mockRestore() mockValidateCronExpression.mockRestore() mockGetScheduleTimeValues.mockRestore() + resetDbChainMock() }) describe('Schedule Deploy Utilities', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() /** * Re-stub per test: `unstubGlobals: true` unstubs all globals before each @@ -104,29 +69,6 @@ describe('Schedule Deploy Utilities', () => { monthlyTime: [9, 0], cronExpression: null, }) - - // Setup mock chain for insert - mockOnConflictDoUpdate.mockResolvedValue({}) - mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) - mockInsert.mockReturnValue({ values: mockValues }) - - // Setup mock chain for delete - mockWhere.mockResolvedValue({}) - mockDelete.mockReturnValue({ where: mockWhere }) - - // Setup mock chain for select - mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) - mockSelect.mockReturnValue({ from: mockFrom }) - - // Setup transaction mock to execute callback with mock tx - mockTransaction.mockImplementation(async (callback) => { - const mockTx = { - insert: mockInsert, - delete: mockDelete, - select: mockSelect, - } - return callback(mockTx) - }) }) afterEach(() => { @@ -712,24 +654,15 @@ describe('Schedule Deploy Utilities', () => { }) describe('createSchedulesForDeploy', () => { - const setupMockTransaction = ( - existingSchedules: Array<{ id: string; blockId: string }> = [] - ) => { - mockFrom.mockReturnValue({ where: vi.fn().mockResolvedValue(existingSchedules) }) - mockSelect.mockReturnValue({ from: mockFrom }) - } - it('should return success with no schedule blocks', async () => { const blocks: Record = { 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(true) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('should create schedule for valid schedule block', async () => { @@ -745,17 +678,15 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(true) expect(result.scheduleId).toBe('test-uuid') expect(result.cronExpression).toBe('0 9 * * *') expect(result.nextRunAt).toEqual(new Date('2025-04-15T09:00:00Z')) - expect(mockTransaction).toHaveBeenCalled() - expect(mockInsert).toHaveBeenCalled() - expect(mockOnConflictDoUpdate).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) it('should return error for invalid schedule block', async () => { @@ -770,13 +701,11 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const result = await createSchedulesForDeploy('workflow-1', blocks) expect(result.success).toBe(false) expect(result.error).toBe('Time is required for daily schedules') - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('should write through a provided transaction without opening a new one', async () => { @@ -792,15 +721,19 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - const callerTx = { insert: mockInsert, delete: mockDelete, select: mockSelect } as any + /** + * A distinct object identity from `db` (the code under test treats + * `tx === db` as "no caller transaction"), but backed by the same chain + * spies so writes are still observable. + */ + const callerTx = { ...dbChainMock.db } as any const result = await createSchedulesForDeploy('workflow-1', blocks, callerTx) expect(result.success).toBe(true) - expect(mockTransaction).not.toHaveBeenCalled() - expect(mockInsert).toHaveBeenCalled() - expect(mockOnConflictDoUpdate).toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) it('should use onConflictDoUpdate for existing schedules', async () => { @@ -816,11 +749,9 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - setupMockTransaction() - await createSchedulesForDeploy('workflow-1', blocks, undefined, 'version-1', 'operation-1') - expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({ + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith({ target: expect.any(Array), targetWhere: expect.objectContaining({ type: 'isNull' }), set: expect.objectContaining({ @@ -846,7 +777,7 @@ describe('Schedule Deploy Utilities', () => { } as BlockState, } - mockTransaction.mockRejectedValueOnce(new Error('Database error')) + dbChainMockFns.transaction.mockRejectedValueOnce(new Error('Database error')) const result = await createSchedulesForDeploy('workflow-1', blocks) @@ -857,15 +788,10 @@ describe('Schedule Deploy Utilities', () => { describe('deleteSchedulesForWorkflow', () => { it('should delete all schedules for a workflow', async () => { - const mockTx = { - insert: mockInsert, - delete: mockDelete, - } - - await deleteSchedulesForWorkflow('workflow-1', mockTx as any) + await deleteSchedulesForWorkflow('workflow-1', dbChainMock.db as any) - expect(mockDelete).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index 145341655b3..b656490043d 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -1,20 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockUpdate, - mockUpdateSet, - mockUpdateWhere, - mockRecordAudit, - mockCaptureServerEvent, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockUpdate: vi.fn(), - mockUpdateSet: vi.fn(), - mockUpdateWhere: vi.fn(), +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRecordAudit, mockCaptureServerEvent } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), })) @@ -25,36 +21,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - sourceWorkspaceId: 'sourceWorkspaceId', - sourceType: 'sourceType', - archivedAt: 'archivedAt', - timezone: 'timezone', - status: 'status', - cronExpression: 'cronExpression', - jobTitle: 'jobTitle', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - }), -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, @@ -73,26 +40,18 @@ const BASE_JOB = { status: 'disabled', } -function mockExistingJob(job: typeof BASE_JOB) { - mockSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([job]), - }), - }), - }) -} - describe('performUpdateJob', () => { beforeEach(() => { vi.clearAllMocks() - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockUpdateWhere.mockResolvedValue(undefined) + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('does not schedule a next run when editing time on a disabled job', async () => { - mockExistingJob({ ...BASE_JOB, status: 'disabled' }) + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'disabled' }]) const result = await performUpdateJob({ jobId: 'job-1', @@ -102,12 +61,12 @@ describe('performUpdateJob', () => { }) expect(result.success).toBe(true) - expect(mockUpdateSet).toHaveBeenCalledTimes(1) - expect(mockUpdateSet.mock.calls[0][0]).not.toHaveProperty('nextRunAt') + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('nextRunAt') }) it('schedules the next run when editing time on an active job', async () => { - mockExistingJob({ ...BASE_JOB, status: 'active' }) + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) const result = await performUpdateJob({ jobId: 'job-1', @@ -117,8 +76,8 @@ describe('performUpdateJob', () => { }) expect(result.success).toBe(true) - expect(mockUpdateSet).toHaveBeenCalledTimes(1) - expect(mockUpdateSet.mock.calls[0][0]).toMatchObject({ + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index 93d0c552267..9c5ebc167e1 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -1,46 +1,36 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { orderByMock } = vi.hoisted(() => ({ orderByMock: vi.fn() })) - -vi.mock('@sim/db', () => ({ - db: { select: () => ({ from: () => ({ where: () => ({ orderBy: orderByMock }) }) }) }, -})) -vi.mock('@sim/db/schema', () => ({ - skill: { workspaceId: 'workspaceId', name: 'name', createdAt: 'createdAt' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/utils/id', () => ({ generateShortId: () => 'gen-id' })) -vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'req-id' })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - desc: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - ne: vi.fn(() => ({})), -})) -import { listSkills } from './operations' +import { listSkills } from '@/lib/workflows/skills/operations' describe('listSkills includeBuiltins', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('prepends builtin template skills by default', async () => { - orderByMock.mockResolvedValue([]) const result = await listSkills({ workspaceId: 'ws-1' }) expect(result.length).toBeGreaterThan(0) expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) }) - // The mothership skill inventory passes includeBuiltins: false so it never sees - // the code-only template skills. + /** + * The mothership skill inventory passes includeBuiltins: false so it never + * sees the code-only template skills. + */ it('excludes builtin template skills when includeBuiltins is false', async () => { - orderByMock.mockResolvedValue([ + queueTableRows(schemaMock.skill, [ { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, ]) const result = await listSkills({ workspaceId: 'ws-1', includeBuiltins: false }) diff --git a/apps/sim/lib/workspaces/admin-move.test.ts b/apps/sim/lib/workspaces/admin-move.test.ts index 04873c40f77..e77473ffa3a 100644 --- a/apps/sim/lib/workspaces/admin-move.test.ts +++ b/apps/sim/lib/workspaces/admin-move.test.ts @@ -1,14 +1,9 @@ /** @vitest-environment node */ -import { - invitation, - invitationWorkspaceGrant, - organization, - permissions, - workspace, -} from '@sim/db/schema' +import { organization, workspace } from '@sim/db/schema' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { PgDialect } from 'drizzle-orm/pg-core' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceMoveError } from '@/lib/workspaces/admin-move' import { buildPendingInvitationMergeScopeCondition, @@ -20,25 +15,18 @@ import { WORKSPACE_MODE } from '@/lib/workspaces/policy' vi.unmock('drizzle-orm') const { - mockDb, recordAudit, enqueueOutboxEvent, invalidateWorkspaceTableLimitsCache, changeWorkspaceStoragePayerInTx, } = vi.hoisted(() => ({ - mockDb: { - select: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - transaction: vi.fn(), - }, recordAudit: vi.fn(), enqueueOutboxEvent: vi.fn(), invalidateWorkspaceTableLimitsCache: vi.fn(), changeWorkspaceStoragePayerInTx: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: mockDb })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => ({ AuditAction: { WORKSPACE_UPDATED: 'workspace.updated', INVITATION_UPDATED: 'invitation.updated' }, AuditResourceType: { WORKSPACE: 'workspace' }, @@ -86,79 +74,29 @@ const destination = { ownerEmail: 'org-owner@example.com', } -let selectedWorkspace = movedWorkspace -const operationOrder: string[] = [] - -function createSelectChain() { - let source: unknown - const rows = () => { - if (source === workspace) return [selectedWorkspace] - if (source === organization) return [destination] - if (source === invitation || source === invitationWorkspaceGrant || source === permissions) { - return [] - } - return [] - } - const chain = { - from(table: unknown) { - source = table - return chain - }, - innerJoin() { - return chain - }, - leftJoin() { - return chain - }, - where() { - return chain - }, - orderBy() { - return chain - }, - for() { - if (source === workspace) operationOrder.push('workspace-lock') - return chain - }, - groupBy() { - return chain - }, - async limit() { - return rows() - }, - then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) { - return Promise.resolve(rows()).then(resolve, reject) - }, - } - return chain +/** + * The move flow reads the workspace three times in order — the pre-lock + * `FOR UPDATE` select (rows ignored), the classification row, and the final + * summary reload — so the workspace queue gets one set per read. All + * invitation/grant/permission selects resolve the queue-less empty default. + */ +function queueMoveSelects(workspaceRow: Record) { + queueTableRows(workspace, [workspaceRow]) + queueTableRows(workspace, [workspaceRow]) + queueTableRows(workspace, [workspaceRow]) + queueTableRows(organization, [destination]) } +afterAll(resetDbChainMock) + beforeEach(() => { vi.clearAllMocks() - operationOrder.length = 0 - selectedWorkspace = movedWorkspace - mockDb.select.mockImplementation(() => createSelectChain()) - mockDb.transaction.mockImplementation(async (callback: (tx: typeof mockDb) => unknown) => - callback(mockDb) - ) - mockDb.update.mockReturnValue({ - set: () => ({ - where: vi.fn().mockResolvedValue([]), - }), - }) - mockDb.insert.mockReturnValue({ - values: () => ({ - onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), - }), - }) - changeWorkspaceStoragePayerInTx.mockImplementation(async () => { - operationOrder.push('payer-mutation') - return { - billableBytes: 128, - newPayer: { type: 'organization', id: destination.id }, - oldPayer: { type: 'user', id: personalWorkspace.billedAccountUserId }, - repairedWorkspaceLedger: false, - } + resetDbChainMock() + changeWorkspaceStoragePayerInTx.mockResolvedValue({ + billableBytes: 128, + newPayer: { type: 'organization', id: destination.id }, + oldPayer: { type: 'user', id: personalWorkspace.billedAccountUserId }, + repairedWorkspaceLedger: false, }) }) @@ -226,6 +164,8 @@ describe('pending invitation destination identity', () => { describe('moveWorkspaceToOrganization retries', () => { it('returns the existing destination summary without repeating side effects', async () => { + queueMoveSelects(movedWorkspace) + const result = await moveWorkspaceToOrganization({ workspaceId: movedWorkspace.id, destinationOrganizationId: destination.id, @@ -240,13 +180,13 @@ describe('moveWorkspaceToOrganization retries', () => { expect(enqueueOutboxEvent).not.toHaveBeenCalled() expect(recordAudit).not.toHaveBeenCalled() expect(invalidateWorkspaceTableLimitsCache).not.toHaveBeenCalled() - expect(mockDb.insert).not.toHaveBeenCalled() - expect(mockDb.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled() }) it('pre-locks a nonzero workspace before changing its storage payer', async () => { - selectedWorkspace = personalWorkspace + queueMoveSelects(personalWorkspace) await moveWorkspaceToOrganization({ workspaceId: personalWorkspace.id, @@ -254,9 +194,12 @@ describe('moveWorkspaceToOrganization retries', () => { adminEmail: 'admin@sim.ai', }) - const firstWorkspaceLock = operationOrder.indexOf('workspace-lock') - const payerMutation = operationOrder.indexOf('payer-mutation') - expect(firstWorkspaceLock).toBeGreaterThanOrEqual(0) - expect(payerMutation).toBeGreaterThan(firstWorkspaceLock) + // The first `.for('update')` in the move path is the workspace pre-lock + // select (the earlier invitation-scan selects carry no row lock), so its + // invocation order against the payer mutation proves lock-before-payer. + const firstForUpdate = dbChainMockFns.for.mock.invocationCallOrder[0] + const payerMutation = changeWorkspaceStoragePayerInTx.mock.invocationCallOrder[0] + expect(firstForUpdate).toBeGreaterThan(0) + expect(payerMutation).toBeGreaterThan(firstForUpdate) }) }) diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 31ed0ed7e26..0fc262b6036 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -1,50 +1,23 @@ /** * @vitest-environment node */ -import { schemaMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, workspace } from '@sim/db/schema' +import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, - mockDbResults, mockFeatureFlags, -} = vi.hoisted(() => { - const mockGetUserOrganization = vi.fn() - const mockGetOrganizationSubscription = vi.fn() - const mockGetHighestPrioritySubscription = vi.fn() - const mockDbResults: { value: any[] } = { value: [] } - const mockFeatureFlags = { isBillingEnabled: true } - - return { - mockGetUserOrganization, - mockGetOrganizationSubscription, - mockGetHighestPrioritySubscription, - mockDbResults, - mockFeatureFlags, - } -}) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const result = mockDbResults.value.shift() || [] - return Promise.resolve(callback ? callback(result) : result) - }) - return chain - }), - }, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockFeatureFlags: { isBillingEnabled: true }, })) -vi.mock('@sim/db/schema', () => schemaMock) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/membership', () => ({ getUserOrganization: mockGetUserOrganization, @@ -71,10 +44,12 @@ import { } from '@/lib/workspaces/policy' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' +afterAll(resetDbChainMock) + describe('getWorkspaceCreationPolicy', () => { beforeEach(() => { vi.clearAllMocks() - mockDbResults.value = [] + resetDbChainMock() mockFeatureFlags.isBillingEnabled = true mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) @@ -82,7 +57,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('blocks free users once they already own one non-organization workspace', async () => { - mockDbResults.value = [[{ value: 1 }]] + queueTableRows(workspace, [{ value: 1 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -98,7 +73,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_6000', status: 'active', }) - mockDbResults.value = [[{ value: 2 }]] + queueTableRows(workspace, [{ value: 2 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -114,7 +89,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_25000', status: 'active', }) - mockDbResults.value = [[{ value: 5 }]] + queueTableRows(workspace, [{ value: 5 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -130,7 +105,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'pro_25000', status: 'active', }) - mockDbResults.value = [[{ value: 10 }]] + queueTableRows(workspace, [{ value: 10 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -141,7 +116,7 @@ describe('getWorkspaceCreationPolicy', () => { it('allows unlimited personal workspaces when billing is disabled', async () => { mockFeatureFlags.isBillingEnabled = false - mockDbResults.value = [[{ value: 9 }]] + queueTableRows(workspace, [{ value: 9 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -159,7 +134,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -177,7 +152,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ value: 0 }]] + queueTableRows(workspace, [{ value: 0 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -202,7 +177,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'team_6000', status: 'active', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -222,7 +197,7 @@ describe('getWorkspaceCreationPolicy', () => { role: 'admin', memberId: 'member-1', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -247,7 +222,7 @@ describe('getWorkspaceCreationPolicy', () => { plan: 'enterprise', status: 'active', }) - mockDbResults.value = [[{ userId: 'owner-1' }]] + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1', @@ -260,7 +235,8 @@ describe('getWorkspaceCreationPolicy', () => { }) it('blocks users without org membership from creating workspaces in the active org context', async () => { - mockDbResults.value = [[], [{ userId: 'owner-1' }]] + queueTableRows(member, []) + queueTableRows(member, [{ userId: 'owner-1' }]) const result = await getWorkspaceCreationPolicy({ userId: 'external-user-1', @@ -280,6 +256,7 @@ describe('getWorkspaceCreationPolicy', () => { describe('getWorkspaceInvitePolicy', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFeatureFlags.isBillingEnabled = true mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) From 21ac7b1bbacc13182a84ed64544851ddf1564e54 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 15:51:18 -0700 Subject: [PATCH 18/24] =?UTF-8?q?improvement(tests):=20db-mock=20migration?= =?UTF-8?q?=20tranche=204=20=E2=80=94=20billing,=20webhooks/execution/logs?= =?UTF-8?q?,=20routes/misc=20(final)=20(#5866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(tests): db-mock migration tranche 4 — billing, webhooks/execution/logs, routes/misc (final) * fix(tests): route agent-handler MCP server rows through queueTableRows --- .../app/api/auth/sso/register/route.test.ts | 129 ++--- .../api/chat/[identifier]/otp/route.test.ts | 344 +++--------- apps/sim/app/api/resume/poll/route.test.ts | 36 +- apps/sim/app/api/speech/token/route.test.ts | 35 +- apps/sim/app/api/tools/custom/route.test.ts | 179 +----- apps/sim/app/api/v1/audit-logs/auth.test.ts | 72 +-- .../handlers/agent/agent-handler.test.ts | 32 +- .../handlers/agent/skills-resolver.test.ts | 36 +- apps/sim/lib/billing/authorization.test.ts | 13 +- .../calculations/usage-monitor.test.ts | 34 +- .../lib/billing/cleanup-dispatcher.test.ts | 15 +- .../billing/core/billing-attribution.test.ts | 49 +- .../billing/core/limit-notifications.test.ts | 70 +-- .../billing/enterprise-provisioning.test.ts | 188 +++---- .../organizations/member-limits.test.ts | 125 ++--- .../organizations/provision-seat.test.ts | 32 +- .../billing/organizations/seat-drift.test.ts | 50 +- .../lib/billing/organizations/seats.test.ts | 96 ++-- apps/sim/lib/billing/storage/limits.test.ts | 47 +- .../sim/lib/billing/threshold-billing.test.ts | 519 +++++------------- .../enterprise-reconciliation-lease.test.ts | 15 +- .../billing/webhooks/outbox-handlers.test.ts | 68 +-- apps/sim/lib/credentials/access.test.ts | 59 +- .../payloads/large-value-metadata.test.ts | 208 ++----- apps/sim/lib/execution/preprocessing.test.ts | 2 - .../preprocessing.webhook-correlation.test.ts | 2 - apps/sim/lib/global-work/summary.test.ts | 15 +- .../lib/knowledge/connectors/queue.test.ts | 41 +- .../knowledge/connectors/sync-engine.test.ts | 4 +- apps/sim/lib/logs/execution/logger.test.ts | 65 +-- .../logs/execution/logging-session.test.ts | 109 ++-- apps/sim/lib/logs/list-logs.test.ts | 49 +- apps/sim/lib/mcp/oauth/revoke.test.ts | 21 +- apps/sim/lib/table/cell-write.test.ts | 29 +- apps/sim/lib/table/snapshot-cache.test.ts | 29 +- apps/sim/lib/webhooks/deploy.test.ts | 38 +- apps/sim/lib/webhooks/polling/utils.test.ts | 48 +- apps/sim/lib/webhooks/processor.test.ts | 63 +-- .../lib/webhooks/providers/whatsapp.test.ts | 6 +- .../lib/webhooks/registration-store.test.ts | 28 +- apps/sim/lib/webhooks/utils.server.test.ts | 75 +-- apps/sim/lib/workspaces/lifecycle.test.ts | 40 +- .../organization-workspaces.test.ts | 232 +++----- 43 files changed, 1091 insertions(+), 2256 deletions(-) diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index cdacc7c6c88..e3539b969e5 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -1,8 +1,16 @@ /** * @vitest-environment node */ -import { createEnvMock, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createEnvMock, + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, @@ -10,58 +18,29 @@ const { mockHasSSOAccess, mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP, - dbState, - memberTable, - ssoProviderTable, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockRegisterSSOProvider: vi.fn(), mockHasSSOAccess: vi.fn(), mockValidateUrlWithDNS: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), - dbState: { members: [] as any[], providers: [] as any[] }, - memberTable: { - userId: 'member.userId', - organizationId: 'member.organizationId', - role: 'member.role', - }, - ssoProviderTable: { - id: 'sso.id', - providerId: 'sso.providerId', - domain: 'sso.domain', - issuer: 'sso.issuer', - userId: 'sso.userId', - organizationId: 'sso.organizationId', - oidcConfig: 'sso.oidcConfig', - samlConfig: 'sso.samlConfig', - }, })) -function makeBuilder(rows: any[]): any { - const thenable: any = Promise.resolve(rows) - thenable.where = (condition: any) => { - const values = condition?.values - if (Array.isArray(values) && values.length > 0) { - const target = String(values[values.length - 1]).toLowerCase() - return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target)) - } - return makeBuilder(rows) - } - thenable.limit = () => Promise.resolve(rows) - thenable.orderBy = () => Promise.resolve(rows) - return thenable +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +/** Queues the caller's org membership row(s) for the admin/owner check. */ +function queueMembers(rows: Array>) { + queueTableRows(schemaMock.member, rows) } -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: (table: unknown) => - makeBuilder(table === memberTable ? dbState.members : dbState.providers), - }), - }, - member: memberTable, - ssoProvider: ssoProviderTable, -})) +/** + * Queues existing SSO provider rows for BOTH domain-conflict lookups (the + * pre-registration check and the post-registration re-check). + */ +function queueProviders(rows: Array>) { + queueTableRows(schemaMock.ssoProvider, rows) + queueTableRows(schemaMock.ssoProvider, rows) +} vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -109,8 +88,7 @@ function request(body: Record) { describe('POST /api/auth/sso/register', () => { beforeEach(() => { vi.clearAllMocks() - dbState.members = [] - dbState.providers = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) @@ -118,6 +96,10 @@ describe('POST /api/auth/sso/register', () => { mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) }) + afterAll(() => { + resetDbChainMock() + }) + it('rejects callers without an Enterprise plan', async () => { mockHasSSOAccess.mockResolvedValue(false) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) @@ -126,22 +108,22 @@ describe('POST /api/auth/sso/register', () => { }) it('rejects callers who are not an admin/owner of the target org', async () => { - dbState.members = [{ organizationId: 'org1', role: 'member' }] + queueMembers([{ organizationId: 'org1', role: 'member' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(403) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects an invalid domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' })) expect(res.status).toBe(400) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects a domain already registered by another organization', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }] + queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) const json = await res.json() expect(res.status).toBe(409) @@ -150,46 +132,51 @@ describe('POST /api/auth/sso/register', () => { }) it('matches conflicts across casing variants', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }] + queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) + queueProviders([{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + // The conflict lookup itself must be case-insensitive: lower(domain) = . + const conflictWhere = dbChainMockFns.where.mock.calls.find(([condition]) => + condition?.strings?.join('?').includes('lower(') + ) + expect(conflictWhere?.[0]?.values).toContain('acme.com') }) it('registers when the domain is unclaimed', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('allows the owning tenant to update its own provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('lets an org admin adopt their own user-scoped provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: null }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it("still blocks an org admin from claiming another user's user-scoped domain", async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('normalizes the domain before persisting it', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) @@ -198,7 +185,7 @@ describe('POST /api/auth/sso/register', () => { }) it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -206,7 +193,7 @@ describe('POST /api/auth/sso/register', () => { }) it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -214,7 +201,7 @@ describe('POST /api/auth/sso/register', () => { }) it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userInfoEndpoint') { return { isValid: false, error: 'resolves to a private IP address' } @@ -228,7 +215,7 @@ describe('POST /api/auth/sso/register', () => { }) it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userinfo_endpoint') { return { isValid: false, error: 'resolves to a private IP address' } @@ -258,7 +245,7 @@ describe('POST /api/auth/sso/register', () => { }) it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -266,7 +253,7 @@ describe('POST /api/auth/sso/register', () => { }) it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -290,7 +277,7 @@ describe('POST /api/auth/sso/register', () => { }) it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -305,7 +292,7 @@ describe('POST /api/auth/sso/register', () => { }) it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED')) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) @@ -316,7 +303,7 @@ describe('POST /api/auth/sso/register', () => { }) it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -330,7 +317,7 @@ describe('POST /api/auth/sso/register', () => { }) it('defaults to client_secret_post when discovery advertises no auth methods', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({}), @@ -342,7 +329,7 @@ describe('POST /api/auth/sso/register', () => { }) it('surfaces the specific discovery failure reason when endpoints are missing', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC discovery URL') { return { isValid: false, error: 'resolves to a private IP address' } diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 6d32eb61f6b..b67e0942c4c 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -4,14 +4,19 @@ * @vitest-environment node */ import { + dbChainMock, + dbChainMockFns, + queueTableRows, redisConfigMock, redisConfigMockFns, requestUtilsMockFns, + resetDbChainMock, + schemaMock, workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRedisSet, @@ -20,10 +25,6 @@ const { mockRedisTtl, mockRedisEval, mockRedisClient, - mockDbSelect, - mockDbInsert, - mockDbDelete, - mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, @@ -43,10 +44,6 @@ const { ttl: mockRedisTtl, eval: mockRedisEval, } - const mockDbSelect = vi.fn() - const mockDbInsert = vi.fn() - const mockDbDelete = vi.fn() - const mockDbUpdate = vi.fn() const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() const mockSetChatAuthCookie = vi.fn() @@ -61,10 +58,6 @@ const { mockRedisTtl, mockRedisEval, mockRedisClient, - mockDbSelect, - mockDbInsert, - mockDbDelete, - mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, @@ -80,30 +73,7 @@ const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse vi.mock('@/lib/core/config/redis', () => redisConfigMock) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - update: mockDbUpdate, - transaction: vi.fn(async (callback: (tx: Record) => unknown) => { - return callback({ - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - update: mockDbUpdate, - }) - }), - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })), - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), - lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, @@ -201,8 +171,21 @@ describe('Chat OTP API Route', () => { const mockIdentifier = 'test-chat' const mockOTP = '123456' + /** Queues the chat-deployment row the route reads before touching OTP storage. */ + const queueDeployment = (row: Record) => { + queueTableRows(schemaMock.chat, [row]) + } + + const emailDeployment = { + id: mockChatId, + authType: 'email', + allowedEmails: [mockEmail], + title: 'Test Chat', + } + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.spyOn(Math, 'random').mockReturnValue(0.123456) vi.spyOn(Date, 'now').mockReturnValue(1640995200000) @@ -218,27 +201,6 @@ describe('Chat OTP API Route', () => { mockRedisDel.mockResolvedValue(1) mockRedisTtl.mockResolvedValue(600) - const createDbChain = (result: unknown) => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(result), - }), - }), - }) - - mockDbSelect.mockImplementation(() => createDbChain([])) - mockDbInsert.mockImplementation(() => ({ - values: vi.fn().mockResolvedValue(undefined), - })) - mockDbDelete.mockImplementation(() => ({ - where: vi.fn().mockResolvedValue(undefined), - })) - mockDbUpdate.mockImplementation(() => ({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - })) - mockGetStorageMethod.mockReturnValue('redis') mockSendEmail.mockResolvedValue({ success: true }) @@ -271,26 +233,17 @@ describe('Chat OTP API Route', () => { vi.restoreAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('POST - Store OTP (Redis path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') }) it('should store OTP in Redis when storage method is redis', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -306,27 +259,11 @@ describe('Chat OTP API Route', () => { 900 // 15 minutes ) - expect(mockDbInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) describe('POST - Rate limiting', () => { - const buildDeploymentSelect = () => - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) - it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, @@ -354,7 +291,7 @@ describe('Chat OTP API Route', () => { expect(response.status).toBe(429) expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') expect(mockSendEmail).not.toHaveBeenCalled() - expect(mockDbSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) it('returns 429 with Retry-After when email rate limit is exceeded', async () => { @@ -378,7 +315,7 @@ describe('Chat OTP API Route', () => { headers: { set: headerSet }, })) - buildDeploymentSelect() + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -420,7 +357,7 @@ describe('Chat OTP API Route', () => { it('folds spoofed `unknown` client IPs into a single shared bucket', async () => { requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') - buildDeploymentSelect() + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -448,30 +385,7 @@ describe('Chat OTP API Route', () => { }) it('should store OTP in database when storage method is database', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) - - const mockInsertValues = vi.fn().mockResolvedValue(undefined) - mockDbInsert.mockImplementationOnce(() => ({ - values: mockInsertValues, - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -480,10 +394,10 @@ describe('Chat OTP API Route', () => { await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() - expect(mockDbInsert).toHaveBeenCalled() - expect(mockInsertValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith({ id: expect.any(String), identifier: `chat-otp:${mockChatId}:${mockEmail}`, value: expect.any(String), @@ -503,18 +417,7 @@ describe('Chat OTP API Route', () => { }) it('should retrieve OTP from Redis and verify successfully', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - }, - ]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -525,7 +428,7 @@ describe('Chat OTP API Route', () => { expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) - expect(mockDbSelect).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) }) }) @@ -536,19 +439,11 @@ describe('Chat OTP API Route', () => { }) it('rejects verification when the chat has switched away from email auth', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'password', - password: 'encrypted-password', - }, - ]), - }), - }), - })) + queueDeployment({ + id: mockChatId, + authType: 'password', + password: 'encrypted-password', + }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -573,36 +468,13 @@ describe('Chat OTP API Route', () => { }) it('should retrieve OTP from database and verify successfully', async () => { - let selectCallCount = 0 - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([ - { - id: mockChatId, - authType: 'email', - }, - ]) - } - return Promise.resolve([ - { - value: `${mockOTP}:0`, - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - }, - ]) - }), - }), - }), - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, [ + { + value: `${mockOTP}:0`, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }, + ]) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -611,34 +483,16 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbSelect).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockRedisGet).not.toHaveBeenCalled() }) it('should reject expired OTP from database', async () => { - let selectCallCount = 0 - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([ - { - id: mockChatId, - authType: 'email', - }, - ]) - } - return Promise.resolve([]) - }), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, []) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -662,18 +516,7 @@ describe('Chat OTP API Route', () => { it('should delete OTP from Redis after verification', async () => { mockRedisGet.mockResolvedValue(`${mockOTP}:0`) - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - }, - ]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -683,7 +526,7 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) - expect(mockDbDelete).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) @@ -694,27 +537,10 @@ describe('Chat OTP API Route', () => { }) it('should delete OTP from database after verification', async () => { - let selectCallCount = 0 - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([{ id: mockChatId, authType: 'email' }]) - } - return Promise.resolve([ - { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) }, - ]) - }), - }), - }), - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, [ + { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) }, + ]) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -723,7 +549,7 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockRedisDel).not.toHaveBeenCalled() }) }) @@ -737,13 +563,7 @@ describe('Chat OTP API Route', () => { mockRedisGet.mockResolvedValue('654321:0') mockRedisEval.mockResolvedValue('654321:1') - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -765,13 +585,7 @@ describe('Chat OTP API Route', () => { mockRedisGet.mockResolvedValue('654321:4') mockRedisEval.mockResolvedValue('LOCKED') - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -788,20 +602,7 @@ describe('Chat OTP API Route', () => { }) it('should store OTP with zero attempts on generation', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -824,13 +625,7 @@ describe('Chat OTP API Route', () => { mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(null) - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -850,20 +645,7 @@ describe('Chat OTP API Route', () => { mockGetStorageMethod.mockReturnValue('redis') - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', diff --git a/apps/sim/app/api/resume/poll/route.test.ts b/apps/sim/app/api/resume/poll/route.test.ts index aee6f33055b..19808c6f2eb 100644 --- a/apps/sim/app/api/resume/poll/route.test.ts +++ b/apps/sim/app/api/resume/poll/route.test.ts @@ -1,13 +1,13 @@ /** * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { acquireLockMock, assertBillingAttributionSnapshotMock, - dbSelectMock, dueRowsLimitMock, enqueueOrStartResumeMock, executionSnapshotFromJsonMock, @@ -24,7 +24,6 @@ const { } = vi.hoisted(() => ({ acquireLockMock: vi.fn(), assertBillingAttributionSnapshotMock: vi.fn((value: unknown) => value), - dbSelectMock: vi.fn(), dueRowsLimitMock: vi.fn(), enqueueOrStartResumeMock: vi.fn(), executionSnapshotFromJsonMock: vi.fn(), @@ -42,11 +41,7 @@ const { ), })) -vi.mock('@sim/db', () => ({ - db: { - select: dbSelectMock, - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/db/schema', () => ({ pausedExecutions: { @@ -173,7 +168,8 @@ function makeSerializedSnapshot(index: number) { describe('time-pause resume admission', () => { beforeEach(() => { vi.clearAllMocks() - dbSelectMock.mockImplementation((selection: Record) => { + resetDbChainMock() + dbChainMockFns.select.mockImplementation((selection: Record) => { if ('snapshotBytes' in selection) { return { from: vi.fn(() => ({ @@ -219,6 +215,10 @@ describe('time-pause resume admission', () => { executionSnapshotFromJsonMock.mockImplementation((value: string) => JSON.parse(value)) }) + afterAll(() => { + resetDbChainMock() + }) + it('keeps a timed pause unclaimed, records why, and schedules automatic retry', async () => { dueRowsLimitMock.mockResolvedValueOnce([makeDueRow(1)]) preprocessExecutionMock.mockResolvedValueOnce({ @@ -385,14 +385,14 @@ describe('time-pause resume admission', () => { expect(dueRowsLimitMock).toHaveBeenCalledWith(200) expect(legacySizeRowsLimitMock).toHaveBeenCalledWith(200) expect(fallbackRowsLimitMock).toHaveBeenCalledWith(LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE) - expect(dbSelectMock).toHaveBeenCalledTimes(3) - expect(dbSelectMock.mock.calls[0]?.[0]).not.toHaveProperty('executionSnapshot') - expect(dbSelectMock.mock.calls[0]?.[0]).toEqual( + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.select.mock.calls[0]?.[0]).not.toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ metadata: 'boundedMetadata' }) ) - expect(dbSelectMock.mock.calls[1]?.[0]).toHaveProperty('snapshotBytes') - expect(dbSelectMock.mock.calls[1]?.[0]).not.toHaveProperty('executionSnapshot') - expect(dbSelectMock.mock.calls[2]?.[0]).toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[1]?.[0]).toHaveProperty('snapshotBytes') + expect(dbChainMockFns.select.mock.calls[1]?.[0]).not.toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[2]?.[0]).toHaveProperty('executionSnapshot') expect( sqlMock.mock.calls.some(([strings]) => (strings as TemplateStringsArray).join('').includes('octet_length(') @@ -455,9 +455,9 @@ describe('time-pause resume admission', () => { expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(1, 200) expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(2, 200) expect(fallbackRowsLimitMock).not.toHaveBeenCalled() - expect(dbSelectMock.mock.calls.some(([selection]) => 'executionSnapshot' in selection)).toBe( - false - ) + expect( + dbChainMockFns.select.mock.calls.some(([selection]) => 'executionSnapshot' in selection) + ).toBe(false) expect(executionSnapshotFromJsonMock).not.toHaveBeenCalled() expect(preprocessExecutionMock).not.toHaveBeenCalled() expect(enqueueOrStartResumeMock).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index ca31a3cd609..5f42626ba41 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -1,15 +1,20 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + dbChainMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockRecordUsage, mockCheckActorUsageLimits, mockVerifyWorkspaceMembership, - mockChatRows, mockResolveBillingAttribution, mockResolveSystemBillingAttribution, mockCheckAttributedUsageLimits, @@ -20,7 +25,6 @@ const { mockRecordUsage: vi.fn(), mockCheckActorUsageLimits: vi.fn(), mockVerifyWorkspaceMembership: vi.fn(), - mockChatRows: { value: [] as Array> }, mockResolveBillingAttribution: vi.fn(), mockResolveSystemBillingAttribution: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -41,18 +45,7 @@ const SYSTEM_BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@sim/db', () => ({ - db: { - select: () => { - const chain: Record = {} - chain.from = () => chain - chain.leftJoin = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve(mockChatRows.value) - return chain - }, - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) @@ -105,7 +98,7 @@ const publicChatRow = { beforeEach(() => { vi.clearAllMocks() - mockChatRows.value = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'member-1' } }) mockRecordUsage.mockResolvedValue(undefined) mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) @@ -135,6 +128,10 @@ beforeEach(() => { }) as unknown as typeof fetch }) +afterAll(() => { + resetDbChainMock() +}) + describe('POST /api/speech/token — usage attribution', () => { it('editor voice: bills the session user and stamps the verified workspace', async () => { const res = await POST(createMockRequest('POST', { workspaceId: 'ws-1' })) @@ -167,7 +164,7 @@ describe('POST /api/speech/token — usage attribution', () => { }) it('deployed chat: uses one atomic system actor and payer snapshot', async () => { - mockChatRows.value = [publicChatRow] + queueTableRows(schemaMock.chat, [publicChatRow]) const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) @@ -189,7 +186,7 @@ describe('POST /api/speech/token — usage attribution', () => { }) it('deployed chat: uses the chat owner only when no workspace exists', async () => { - mockChatRows.value = [{ ...publicChatRow, workspaceId: null }] + queueTableRows(schemaMock.chat, [{ ...publicChatRow, workspaceId: null }]) const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 67e1a186e2e..d84994ab522 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -6,42 +6,23 @@ import { authMockFns, createMockRequest, + dbChainMock, + dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockOrderBy, - mockInsert, - mockValues, - mockUpdate, - mockSet, - mockDelete, - mockLimit, - mockUpsertCustomTools, -} = vi.hoisted(() => { - return { - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), - mockInsert: vi.fn(), - mockValues: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockDelete: vi.fn(), - mockLimit: vi.fn(), - mockUpsertCustomTools: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUpsertCustomTools } = vi.hoisted(() => ({ + mockUpsertCustomTools: vi.fn(), +})) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -102,83 +83,10 @@ const sampleTools = [ }, ] -vi.mock('@sim/db', () => ({ - db: { - select: (...args: unknown[]) => mockSelect(...args), - insert: (...args: unknown[]) => mockInsert(...args), - update: (...args: unknown[]) => mockUpdate(...args), - delete: (...args: unknown[]) => mockDelete(...args), - transaction: vi - .fn() - .mockImplementation(async (callback: (tx: Record) => unknown) => { - const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom }) - const txMockInsert = vi.fn().mockReturnValue({ values: mockValues }) - const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere }) - - const txMockOrderBy = vi.fn().mockImplementation(() => { - const queryBuilder = { - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockWhere = vi.fn().mockImplementation(() => { - const queryBuilder = { - orderBy: txMockOrderBy, - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere }) - txMockSelect.mockReturnValue({ from: txMockFrom }) - - return await callback({ - select: txMockSelect, - insert: txMockInsert, - update: txMockUpdate, - delete: txMockDelete, - }) - }), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ - field, - value, - operator: 'eq', - })), - and: vi.fn().mockImplementation((...conditions: unknown[]) => ({ - operator: 'and', - conditions, - })), - or: vi.fn().mockImplementation((...conditions: unknown[]) => ({ - operator: 'or', - conditions, - })), - isNull: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'isNull' })), - ne: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ - field, - value, - operator: 'ne', - })), - desc: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'desc' })), -})) - vi.mock('@/lib/workflows/custom-tools/operations', () => ({ upsertCustomTools: (...args: unknown[]) => mockUpsertCustomTools(...args), })) @@ -192,38 +100,7 @@ describe('Custom Tools API Routes', () => { beforeEach(() => { vi.clearAllMocks() - - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockImplementation(() => { - const queryBuilder = { - orderBy: mockOrderBy, - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - mockOrderBy.mockImplementation(() => { - const queryBuilder = { - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - mockLimit.mockResolvedValue(sampleTools) - mockInsert.mockReturnValue({ values: mockValues }) - mockValues.mockResolvedValue({ id: 'new-tool-id' }) - mockUpdate.mockReturnValue({ set: mockSet }) - mockSet.mockReturnValue({ where: mockWhere }) - mockDelete.mockReturnValue({ where: mockWhere }) + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(mockSession) hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ @@ -240,6 +117,10 @@ describe('Custom Tools API Routes', () => { }) }) + afterAll(() => { + resetDbChainMock() + }) + /** * Test GET endpoint */ @@ -249,9 +130,7 @@ describe('Custom Tools API Routes', () => { 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - mockWhere.mockReturnValueOnce({ - orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)), - }) + queueTableRows(schemaMock.customTools, sampleTools) const response = await GET(req) const data = await response.json() @@ -260,10 +139,10 @@ describe('Custom Tools API Routes', () => { expect(data).toHaveProperty('data') expect(data.data).toEqual(sampleTools) - expect(mockSelect).toHaveBeenCalled() - expect(mockFrom).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() - expect(mockOrderBy).toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.from).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() }) it('should handle unauthorized access', async () => { @@ -286,13 +165,15 @@ describe('Custom Tools API Routes', () => { it('should handle workflowId parameter', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123') + queueTableRows(schemaMock.customTools, sampleTools) + const response = await GET(req) const data = await response.json() expect(response.status).toBe(200) expect(data).toHaveProperty('data') - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) }) @@ -336,7 +217,7 @@ describe('Custom Tools API Routes', () => { */ describe('DELETE /api/tools/custom', () => { it('should delete a workspace-scoped tool by ID', async () => { - mockLimit.mockResolvedValueOnce([sampleTools[0]]) + queueTableRows(schemaMock.customTools, [sampleTools[0]]) const req = new NextRequest( 'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123' @@ -348,8 +229,8 @@ describe('Custom Tools API Routes', () => { expect(response.status).toBe(200) expect(data).toHaveProperty('success', true) - expect(mockDelete).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('should reject requests missing tool ID', async () => { @@ -363,8 +244,7 @@ describe('Custom Tools API Routes', () => { }) it('should handle tool not found', async () => { - const mockLimitNotFound = vi.fn().mockResolvedValue([]) - mockWhere.mockReturnValueOnce({ limit: mockLimitNotFound }) + queueTableRows(schemaMock.customTools, []) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent') @@ -383,8 +263,7 @@ describe('Custom Tools API Routes', () => { }) const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' } - const mockLimitUserScoped = vi.fn().mockResolvedValue([userScopedTool]) - mockWhere.mockReturnValueOnce({ limit: mockLimitUserScoped }) + queueTableRows(schemaMock.customTools, [userScopedTool]) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index 119c7e9c643..d7130879a95 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -1,45 +1,20 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsOrganizationBillingBlocked, - mockMemberWhere, - mockMembersWhere, - mockSelect, - mockSubscriptionWhere, -} = vi.hoisted(() => ({ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ mockIsOrganizationBillingBlocked: vi.fn(), - mockMemberWhere: vi.fn(), - mockMembersWhere: vi.fn(), - mockSelect: vi.fn(), - mockSubscriptionWhere: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { select: mockSelect }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, - subscription: { - id: 'subscription.id', - plan: 'subscription.plan', - referenceId: 'subscription.referenceId', - status: 'subscription.status', - }, })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - inArray: vi.fn((left: unknown, right: unknown) => ({ type: 'inArray', left, right })), -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, @@ -50,18 +25,15 @@ import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' describe('enterprise audit access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockMemberWhere.mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ organizationId: 'organization-route', role: 'admin' }]), - }) - mockSubscriptionWhere.mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: 'subscription-1' }]), - }) - mockMembersWhere.mockResolvedValue([{ userId: 'viewer' }, { userId: 'member-2' }]) - mockSelect - .mockReturnValueOnce({ from: () => ({ where: mockMemberWhere }) }) - .mockReturnValueOnce({ from: () => ({ where: mockSubscriptionWhere }) }) - .mockReturnValueOnce({ from: () => ({ where: mockMembersWhere }) }) + queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }]) + queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }]) + }) + + afterAll(() => { + resetDbChainMock() }) it('authorizes and bills against the organization named by the route', async () => { @@ -72,11 +44,11 @@ describe('enterprise audit access', () => { orgMemberIds: ['viewer', 'member-2'], }, }) - expect(mockMemberWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, { type: 'and', conditions: [ - { type: 'eq', left: 'member.userId', right: 'viewer' }, - { type: 'eq', left: 'member.organizationId', right: 'organization-route' }, + { type: 'eq', left: schemaMock.member.userId, right: 'viewer' }, + { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' }, ], }) expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route') diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index f733c885944..a5a5b36b410 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getAllBlocks } from '@/blocks' import { BlockType, isMcpTool } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' @@ -87,19 +88,14 @@ vi.mock('@/executor/utils/http', () => ({ }), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([ - { id: 'mcp-search-server', connectionStatus: 'connected' }, - { id: 'same-server', connectionStatus: 'connected' }, - { id: 'mcp-legacy-server', connectionStatus: 'connected' }, - ]), - }), - }), - }, -})) +vi.mock('@sim/db', () => dbChainMock) + +/** Connected MCP servers every workspace-server lookup in this suite resolves. */ +const MCP_SERVER_ROWS = [ + { id: 'mcp-search-server', connectionStatus: 'connected' }, + { id: 'same-server', connectionStatus: 'connected' }, + { id: 'mcp-legacy-server', connectionStatus: 'connected' }, +] const mockGetCustomToolById = vi.fn() @@ -122,6 +118,10 @@ describe('AgentBlockHandler', () => { beforeEach(() => { handler = new AgentBlockHandler() vi.clearAllMocks() + resetDbChainMock() + // The MCP server lookup awaits select().from(mcpServers).where(...) directly; + // queue a set per lookup so the structural where spy keeps its default wiring. + queueTableRows(schemaMock.mcpServers, MCP_SERVER_ROWS) // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here vi.stubGlobal('fetch', mockFetch) @@ -213,6 +213,10 @@ describe('AgentBlockHandler', () => { } catch (e) {} }) + afterAll(() => { + resetDbChainMock() + }) + describe('canHandle', () => { it('should return true for blocks with metadata id "agent"', () => { expect(handler.canHandle(mockBlock)).toBe(true) diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index e5e74ba4222..e562b63b7ec 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -1,22 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { limitMock } = vi.hoisted(() => ({ limitMock: vi.fn() })) - -vi.mock('@sim/db', () => ({ - db: { select: () => ({ from: () => ({ where: () => ({ limit: limitMock }) }) }) }, - skill: { workspaceId: 'workspaceId', name: 'name', content: 'content' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), -})) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - inArray: vi.fn(() => ({})), -})) +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) import { resolveSkillContent } from './skills-resolver' @@ -25,6 +19,11 @@ import { resolveSkillContent } from './skills-resolver' describe('resolveSkillContent', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('returns null without a skill name or workspace', async () => { @@ -35,16 +34,15 @@ describe('resolveSkillContent', () => { it('resolves builtin skills without touching the database', async () => { const content = await resolveSkillContent('research', 'ws-1') expect(content).toBeTruthy() - expect(limitMock).not.toHaveBeenCalled() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() }) it('resolves a workspace user skill by name', async () => { - limitMock.mockResolvedValue([{ content: '# Playbook', name: 'posthog-playbook' }]) + queueTableRows(schemaMock.skill, [{ content: '# Playbook', name: 'posthog-playbook' }]) expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook') }) it('returns null when the user skill is not found', async () => { - limitMock.mockResolvedValue([]) expect(await resolveSkillContent('missing', 'ws-1')).toBeNull() }) }) diff --git a/apps/sim/lib/billing/authorization.test.ts b/apps/sim/lib/billing/authorization.test.ts index 002bb2a4537..457564f13a8 100644 --- a/apps/sim/lib/billing/authorization.test.ts +++ b/apps/sim/lib/billing/authorization.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockHasPaidSubscription, @@ -15,7 +16,7 @@ const { mockGetOrganizationCoverageForMember: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: {} })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing', () => ({ hasPaidSubscription: mockHasPaidSubscription })) vi.mock('@/lib/billing/core/organization', () => ({ isOrganizationOwnerOrAdmin: mockIsOwnerOrAdmin, @@ -42,6 +43,14 @@ import { } from '@/lib/billing/authorization' import { EnterpriseIssuanceInProgressError } from '@/lib/billing/enterprise-outbox' +beforeEach(() => { + resetDbChainMock() +}) + +afterAll(() => { + resetDbChainMock() +}) + describe('isPersonalCheckoutRequest', () => { it('classifies an explicit self reference as personal regardless of customerType', () => { expect(isPersonalCheckoutRequest({ referenceId: 'user-1' }, 'user-1')).toBe(true) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index 4f57be3b627..b1a498cd455 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -1,17 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFlags, - mockDbLimit, mockGetOrgMemberUsageForBillingPeriod, mockGetOrgMemberUsageLimit, mockIsOrganizationBillingBlocked, } = vi.hoisted(() => ({ mockFlags: { isHosted: true, isBillingEnabled: true }, - mockDbLimit: vi.fn(), mockGetOrgMemberUsageForBillingPeriod: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), @@ -26,17 +25,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ }, })) -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ - limit: mockDbLimit, - }), - }), - }), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/member-limits', () => ({ getOrgMemberUsageForBillingPeriod: mockGetOrgMemberUsageForBillingPeriod, @@ -60,12 +49,17 @@ import { checkOrganizationMemberUsageLimit, } from '@/lib/billing/calculations/usage-monitor' +afterAll(() => { + resetDbChainMock() +}) + describe('checkBillingBlocked', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isHosted = true mockFlags.isBillingEnabled = true - mockDbLimit.mockResolvedValue([{ blocked: false, blockedReason: null }]) + dbChainMockFns.limit.mockResolvedValue([{ blocked: false, blockedReason: null }]) }) it("checks only the actor's own user account without inspecting organization memberships", async () => { @@ -73,7 +67,7 @@ describe('checkBillingBlocked', () => { await expect(checkBillingBlocked('actor-1')).resolves.toEqual({ blocked: false }) - expect(mockDbLimit).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1) expect(mockIsOrganizationBillingBlocked).not.toHaveBeenCalled() }) }) @@ -81,10 +75,11 @@ describe('checkBillingBlocked', () => { describe('checkBillingEntityBlocked', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isHosted = true mockFlags.isBillingEnabled = true mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockDbLimit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) }) it('checks only the exact organization payer', async () => { @@ -95,11 +90,11 @@ describe('checkBillingEntityBlocked', () => { ).resolves.toMatchObject({ blocked: true }) expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('workspace-org') - expect(mockDbLimit).not.toHaveBeenCalled() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() }) it('checks the exact personal payer directly', async () => { - mockDbLimit.mockResolvedValue([{ blocked: true, blockedReason: 'dispute' }]) + dbChainMockFns.limit.mockResolvedValue([{ blocked: true, blockedReason: 'dispute' }]) await expect( checkBillingEntityBlocked({ type: 'user', id: 'personal-payer' }) @@ -120,6 +115,7 @@ describe('checkOrganizationMemberUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isHosted = true mockFlags.isBillingEnabled = true mockGetOrgMemberUsageLimit.mockResolvedValue(2) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts index e696d221895..ed126c26535 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts @@ -1,15 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFlags, mockIsTriggerAvailable, mockSelect } = vi.hoisted(() => ({ +const { mockFlags, mockIsTriggerAvailable } = vi.hoisted(() => ({ mockFlags: { isBillingEnabled: false }, mockIsTriggerAvailable: vi.fn(), - mockSelect: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: { select: mockSelect } })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() })) vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPriorityPersonalSubscription: vi.fn(), @@ -36,14 +36,19 @@ import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' describe('dispatchCleanupJobs billing gate', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isBillingEnabled = false }) + afterAll(() => { + resetDbChainMock() + }) + it('never dispatches plan-based retention deletion when billing is disabled', async () => { const result = await dispatchCleanupJobs('cleanup-logs') expect(result).toEqual({ jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 }) expect(mockIsTriggerAvailable).not.toHaveBeenCalled() - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts index fb6105a6bc8..05f42edf884 100644 --- a/apps/sim/lib/billing/core/billing-attribution.test.ts +++ b/apps/sim/lib/billing/core/billing-attribution.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFlags, @@ -11,7 +12,6 @@ const { mockCheckUsageStatus, mockGetHighestPriorityPersonalSubscription, mockGetOrganizationSubscription, - mockLimit, } = vi.hoisted(() => ({ mockFlags: { isBillingEnabled: true, isHosted: true }, mockCheckBillingBlocked: vi.fn(), @@ -20,7 +20,6 @@ const { mockCheckUsageStatus: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetOrganizationSubscription: vi.fn(), - mockLimit: vi.fn(), })) vi.mock('@/lib/core/config/env-flags', () => ({ @@ -32,17 +31,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ }, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockLimit, - })), - })), - })), - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkBillingBlocked: mockCheckBillingBlocked, @@ -76,6 +65,10 @@ import { toBillingContext, } from '@/lib/billing/core/billing-attribution' +afterAll(() => { + resetDbChainMock() +}) + const ORG_SUBSCRIPTION = { id: 'sub-org-b', plan: 'team_25000', @@ -89,6 +82,7 @@ const ORG_SUBSCRIPTION = { describe('resolveBillingAttribution', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: false }) mockCheckUsageStatus.mockResolvedValue({ @@ -108,7 +102,7 @@ describe('resolveBillingAttribution', () => { }) it('bills the workspace organization while retaining an external session actor', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -150,7 +144,7 @@ describe('resolveBillingAttribution', () => { }) it('resolves the system actor and payer from one workspace row', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -167,11 +161,11 @@ describe('resolveBillingAttribution', () => { organizationId: 'org-b', workspaceId: 'workspace-b', }) - expect(mockLimit).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(1) }) it('uses the workspace organization reference even when its billed owner has other memberships', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'multi-org-owner', organizationId: 'org-b', @@ -192,7 +186,7 @@ describe('resolveBillingAttribution', () => { }) it('bills a personal workspace billed account without changing the API-key actor', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'personal-owner', organizationId: null, @@ -222,7 +216,7 @@ describe('resolveBillingAttribution', () => { }) it('retains the exact personal payer when it has no subscription', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'personal-owner', organizationId: null, @@ -245,7 +239,7 @@ describe('resolveBillingAttribution', () => { }) it('serializes only the payer fields needed by later billing gates', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -276,7 +270,7 @@ describe('resolveBillingAttribution', () => { }) it('carries only the normalized Enterprise concurrency metadata needed by admission', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -301,7 +295,7 @@ describe('resolveBillingAttribution', () => { }) it('rejects a subscription that does not belong to the exact workspace payer', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -321,7 +315,7 @@ describe('resolveBillingAttribution', () => { }) it('fails closed when the workspace payer cannot be resolved', async () => { - mockLimit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) await expect( resolveBillingAttribution({ @@ -332,7 +326,7 @@ describe('resolveBillingAttribution', () => { }) it('resolves markerless legacy-v0 from the current workspace payer', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -354,7 +348,7 @@ describe('resolveBillingAttribution', () => { }) it('returns no workspace payer for an opaque markerless legacy-v0 workspace', async () => { - mockLimit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) await expect( resolveLegacyV0BillingAttribution({ @@ -367,7 +361,7 @@ describe('resolveBillingAttribution', () => { }) it('converts the serialized period back to the exact runtime billing context', async () => { - mockLimit.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { billedAccountUserId: 'owner-b', organizationId: 'org-b', @@ -485,6 +479,7 @@ describe('serialized attribution boundaries', () => { describe('checkAttributedUsageLimits', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isBillingEnabled = true mockFlags.isHosted = true mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index f3486fdd9f0..5c82b418b72 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -1,13 +1,17 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { billingFlag, - mockClaim, - mockSelectRows, - dbUpdateSpy, sendEmailSpy, getEmailPreferencesMock, renderMock, @@ -15,9 +19,6 @@ const { isOrgAdminRoleMock, } = vi.hoisted(() => ({ billingFlag: { enabled: true }, - mockClaim: vi.fn<[], unknown[]>(() => [{ id: 'u1' }]), - mockSelectRows: vi.fn<[], unknown[]>(() => []), - dbUpdateSpy: vi.fn(), sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })), getEmailPreferencesMock: vi.fn(() => Promise.resolve(null as unknown)), renderMock: vi.fn(() => Promise.resolve('')), @@ -25,26 +26,7 @@ const { isOrgAdminRoleMock: vi.fn(() => true), })) -vi.mock('@sim/db', () => { - const updateBuilder: Record = { - set: () => updateBuilder, - where: () => updateBuilder, - returning: () => Promise.resolve(mockClaim()), - then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) => - Promise.resolve(undefined).then(f, r), - } - const selectBuilder: Record = { - from: () => selectBuilder, - where: () => selectBuilder, - innerJoin: () => selectBuilder, - leftJoin: () => selectBuilder, - limit: () => Promise.resolve(mockSelectRows()), - then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) => - Promise.resolve(mockSelectRows()).then(f, r), - } - dbUpdateSpy.mockImplementation(() => updateBuilder) - return { db: { update: dbUpdateSpy, select: () => selectBuilder } } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/config/env-flags', () => ({ get isBillingEnabled() { @@ -78,12 +60,16 @@ const baseUserParams = { describe('maybeSendLimitThresholdEmail', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() billingFlag.enabled = true - mockClaim.mockReturnValue([{ id: 'u1' }]) - mockSelectRows.mockReturnValue([]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'u1' }]) getEmailPreferencesMock.mockResolvedValue(null) }) + afterAll(() => { + resetDbChainMock() + }) + it('sends a warning email when crossing 80% and the claim wins', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 }) expect(sendEmailSpy).toHaveBeenCalledTimes(1) @@ -104,66 +90,66 @@ describe('maybeSendLimitThresholdEmail', () => { limit: 5, rearmOnly: true, }) - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) it('does not send when the atomic claim is lost (already notified)', async () => { - mockClaim.mockReturnValue([]) + dbChainMockFns.returning.mockResolvedValue([]) await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 }) expect(sendEmailSpy).not.toHaveBeenCalled() }) it('claims without re-arming on a crossing (re-arm and claim are mutually exclusive)', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 }) - expect(dbUpdateSpy).toHaveBeenCalledTimes(1) - expect(mockClaim).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.returning).toHaveBeenCalledTimes(1) expect(sendEmailSpy).toHaveBeenCalledTimes(1) }) it('does not send in the dead band (70%–80%)', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 3.75, limit: 5 }) - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) it('re-arms below the band without claiming or sending', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 1, limit: 5 }) - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) it('does not send OR burn the claim when the per-user toggle is off', async () => { - mockSelectRows.mockReturnValue([{ enabled: false }]) + queueTableRows(schemaMock.settings, [{ enabled: false }]) await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 }) expect(sendEmailSpy).not.toHaveBeenCalled() - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() }) it('does not send OR burn the claim when the recipient unsubscribed', async () => { getEmailPreferencesMock.mockResolvedValue({ unsubscribeNotifications: true }) await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 }) expect(sendEmailSpy).not.toHaveBeenCalled() - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() }) it('skips entirely when billing is disabled', async () => { billingFlag.enabled = false await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 5, limit: 5 }) - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) it('re-arms but does not send when usage is fully cleared (zero usage)', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 0, limit: 5 }) - expect(dbUpdateSpy).toHaveBeenCalledTimes(1) - expect(mockClaim).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) it('skips when the limit is non-positive', async () => { await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4, limit: 0 }) - expect(dbUpdateSpy).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts index d01e15d1964..d50dd0a8088 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.test.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts @@ -1,11 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - select: vi.fn(), - update: vi.fn(), subscriptionsCreate: vi.fn(), subscriptionsList: vi.fn(), subscriptionsUpdate: vi.fn(), @@ -27,49 +32,9 @@ vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: mocks.select, - update: mocks.update, - transaction: vi.fn(), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { userId: 'userId', organizationId: 'organizationId', role: 'role' }, - organization: { id: 'id', name: 'name' }, - outboxEvent: { - id: 'id', - eventType: 'eventType', - payload: 'payload', - status: 'status', - createdAt: 'createdAt', - }, - subscription: { - id: 'id', - referenceId: 'referenceId', - status: 'status', - stripeSubscriptionId: 'stripeSubscriptionId', - metadata: 'metadata', - }, - user: { - id: 'id', - name: 'name', - email: 'email', - stripeCustomerId: 'stripeCustomerId', - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'generated-id') })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => 'and'), - count: vi.fn(() => 'count'), - desc: vi.fn(() => 'desc'), - eq: vi.fn(() => 'eq'), - inArray: vi.fn(() => 'inArray'), - isNull: vi.fn(() => 'isNull'), - sql: vi.fn(() => 'sql'), -})) vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: vi.fn(), })) @@ -107,26 +72,9 @@ import { syncEnterpriseMetadataInStripe, } from '@/lib/billing/enterprise-provisioning' -function selectChain(rows: unknown[]) { - const chain = { - from: vi.fn(), - innerJoin: vi.fn(), - leftJoin: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - for: vi.fn(), - limit: vi.fn().mockResolvedValue(rows), - then: (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) => - Promise.resolve(rows).then(resolve, reject), - } - chain.from.mockReturnValue(chain) - chain.innerJoin.mockReturnValue(chain) - chain.leftJoin.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - chain.orderBy.mockReturnValue(chain) - chain.for.mockReturnValue(chain) - return chain -} +afterAll(() => { + resetDbChainMock() +}) function operationPayload(overrides: Record = {}) { return { @@ -256,29 +204,26 @@ function arrangeWorkerReads( finalLocalSubscriptions: unknown[] = localSubscriptions, finalMemberCount = 1 ) { - mocks.select - .mockReturnValueOnce( - selectChain([ - { - ownerId: 'owner-1', - ownerName: 'Owner', - ownerEmail: 'owner@example.com', - ownerStripeCustomerId: 'cus_1', - organizationName: 'Acme', - ownerRole: 'owner', - }, - ]) - ) - .mockReturnValueOnce(selectChain([{ value: 1 }])) - .mockReturnValueOnce(selectChain(localSubscriptions)) - .mockReturnValueOnce(selectChain(finalLocalSubscriptions)) - .mockReturnValueOnce(selectChain([{ value: finalMemberCount }])) + queueTableRows(schemaMock.user, [ + { + ownerId: 'owner-1', + ownerName: 'Owner', + ownerEmail: 'owner@example.com', + ownerStripeCustomerId: 'cus_1', + organizationName: 'Acme', + ownerRole: 'owner', + }, + ]) + queueTableRows(schemaMock.member, [{ value: 1 }]) + queueTableRows(schemaMock.subscription, localSubscriptions) + queueTableRows(schemaMock.subscription, finalLocalSubscriptions) + queueTableRows(schemaMock.member, [{ value: finalMemberCount }]) } describe('Enterprise issuance outbox handler', () => { beforeEach(() => { vi.clearAllMocks() - mocks.select.mockReset() + resetDbChainMock() mocks.subscriptionsList.mockResolvedValue({ data: [], has_more: false }) mocks.customersList.mockResolvedValue({ data: [], has_more: false }) mocks.pricesList.mockResolvedValue({ data: [], has_more: false }) @@ -468,7 +413,7 @@ describe('Enterprise issuance outbox handler', () => { context() ) - expect(mocks.select).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mocks.subscriptionsCreate).not.toHaveBeenCalled() }) @@ -482,7 +427,7 @@ describe('Enterprise issuance outbox handler', () => { describe('Enterprise metadata outbox handler', () => { beforeEach(() => { vi.clearAllMocks() - mocks.select.mockReset() + resetDbChainMock() }) it('pushes only the latest full desired metadata under an operation-stable key', async () => { @@ -498,13 +443,12 @@ describe('Enterprise metadata outbox handler', () => { concurrencyLimit: 1250, }, } - mocks.select - .mockReturnValueOnce( - selectChain([{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }]) - ) - .mockReturnValueOnce(selectChain([{ metadata: {} }])) - .mockReturnValueOnce(selectChain([{ id: 'metadata-event-1', payload }])) - .mockReturnValueOnce(selectChain([{ value: 10 }])) + queueTableRows(schemaMock.subscription, [ + { stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }, + ]) + queueTableRows(schemaMock.subscription, [{ metadata: {} }]) + queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-1', payload }]) + queueTableRows(schemaMock.member, [{ value: 10 }]) mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' }) await expect( @@ -546,13 +490,12 @@ describe('Enterprise metadata outbox handler', () => { concurrencyLimit: null, }, } - mocks.select - .mockReturnValueOnce( - selectChain([{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }]) - ) - .mockReturnValueOnce(selectChain([{ metadata: {} }])) - .mockReturnValueOnce(selectChain([{ id: 'metadata-event-2', payload }])) - .mockReturnValueOnce(selectChain([{ value: 10 }])) + queueTableRows(schemaMock.subscription, [ + { stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }, + ]) + queueTableRows(schemaMock.subscription, [{ metadata: {} }]) + queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-2', payload }]) + queueTableRows(schemaMock.member, [{ value: 10 }]) mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' }) await expect( @@ -583,24 +526,21 @@ describe('Enterprise metadata outbox handler', () => { deliveryRevision: 0, metadata: { seats: 12 }, } - mocks.select - .mockReturnValueOnce( - selectChain([{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }]) - ) - .mockReturnValueOnce(selectChain([{ metadata: {} }])) - .mockReturnValueOnce( - selectChain([ - { - id: 'newer-event', - payload: { - subscriptionId: 'local-sub-1', - revision: 4, - deliveryRevision: 0, - metadata: { seats: 15 }, - }, - }, - ]) - ) + queueTableRows(schemaMock.subscription, [ + { stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }, + ]) + queueTableRows(schemaMock.subscription, [{ metadata: {} }]) + queueTableRows(schemaMock.outboxEvent, [ + { + id: 'newer-event', + payload: { + subscriptionId: 'local-sub-1', + revision: 4, + deliveryRevision: 0, + metadata: { seats: 15 }, + }, + }, + ]) await syncEnterpriseMetadataInStripe(payload, { eventId: 'older-event', @@ -619,15 +559,13 @@ describe('Enterprise metadata outbox handler', () => { deliveryRevision: 0, metadata: { seats: 15 }, } - mocks.select.mockReturnValueOnce( - selectChain([ - { - stripeSubscriptionId: 'sub_1', - referenceId: 'org-1', - metadata: { simConfigOperationId: 'metadata-event-1' }, - }, - ]) - ) + queueTableRows(schemaMock.subscription, [ + { + stripeSubscriptionId: 'sub_1', + referenceId: 'org-1', + metadata: { simConfigOperationId: 'metadata-event-1' }, + }, + ]) await syncEnterpriseMetadataInStripe(payload, { eventId: 'metadata-event-1', diff --git a/apps/sim/lib/billing/organizations/member-limits.test.ts b/apps/sim/lib/billing/organizations/member-limits.test.ts index 51fb47aa021..3558a65337f 100644 --- a/apps/sim/lib/billing/organizations/member-limits.test.ts +++ b/apps/sim/lib/billing/organizations/member-limits.test.ts @@ -1,82 +1,56 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, - mockInsert, - mockInsertValues, - mockOnConflictDoUpdate, - mockDelete, - mockDeleteWhere, + schemaTables, mockAnd, mockEq, mockGte, mockIsNull, - mockLeftJoin, mockLt, mockOr, mockGetOrganizationSubscription, } = vi.hoisted(() => ({ - mockDbState: { selectResults: [] as unknown[] }, - mockInsert: vi.fn(), - mockInsertValues: vi.fn(), - mockOnConflictDoUpdate: vi.fn(), - mockDelete: vi.fn(), - mockDeleteWhere: vi.fn(), + schemaTables: { + organizationMemberUsageLimit: { + id: 'oml.id', + organizationId: 'oml.organizationId', + userId: 'oml.userId', + usageLimit: 'oml.usageLimit', + setBy: 'oml.setBy', + createdAt: 'oml.createdAt', + updatedAt: 'oml.updatedAt', + }, + usageLog: { + billingEntityType: 'usageLog.billingEntityType', + billingEntityId: 'usageLog.billingEntityId', + billingPeriodStart: 'usageLog.billingPeriodStart', + billingPeriodEnd: 'usageLog.billingPeriodEnd', + createdAt: 'usageLog.createdAt', + cost: 'usageLog.cost', + userId: 'usageLog.userId', + }, + workspace: { + id: 'workspace.id', + organizationAssignedAt: 'workspace.organizationAssignedAt', + organizationId: 'workspace.organizationId', + }, + }, mockAnd: vi.fn((...conditions: unknown[]) => ({ operator: 'and', conditions })), mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value })), mockGte: vi.fn((field: unknown, value: unknown) => ({ operator: 'gte', field, value })), mockIsNull: vi.fn((field: unknown) => ({ operator: 'isNull', field })), - mockLeftJoin: vi.fn(), mockLt: vi.fn((field: unknown, value: unknown) => ({ operator: 'lt', field, value })), mockOr: vi.fn((...conditions: unknown[]) => ({ operator: 'or', conditions })), mockGetOrganizationSubscription: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => { - const chain: Record = {} - chain.from = vi.fn(() => chain) - chain.leftJoin = mockLeftJoin.mockImplementation(() => chain) - chain.where = vi.fn(() => chain) - chain.limit = vi.fn(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then = (cb: (rows: unknown) => unknown) => - Promise.resolve(cb(mockDbState.selectResults.shift() ?? [])) - return chain - }), - insert: mockInsert, - delete: mockDelete, - }, -})) +vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => ({ - organizationMemberUsageLimit: { - id: 'oml.id', - organizationId: 'oml.organizationId', - userId: 'oml.userId', - usageLimit: 'oml.usageLimit', - setBy: 'oml.setBy', - createdAt: 'oml.createdAt', - updatedAt: 'oml.updatedAt', - }, - usageLog: { - billingEntityType: 'usageLog.billingEntityType', - billingEntityId: 'usageLog.billingEntityId', - billingPeriodStart: 'usageLog.billingPeriodStart', - billingPeriodEnd: 'usageLog.billingPeriodEnd', - createdAt: 'usageLog.createdAt', - cost: 'usageLog.cost', - userId: 'usageLog.userId', - }, - workspace: { - id: 'workspace.id', - organizationAssignedAt: 'workspace.organizationAssignedAt', - organizationId: 'workspace.organizationId', - }, -})) +vi.mock('@sim/db/schema', () => schemaTables) vi.mock('drizzle-orm', () => ({ and: mockAnd, @@ -102,22 +76,21 @@ import { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] - mockInsert.mockReturnValue({ values: mockInsertValues }) - mockInsertValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) - mockOnConflictDoUpdate.mockResolvedValue(undefined) - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) + resetDbChainMock() +}) + +afterAll(() => { + resetDbChainMock() }) describe('getOrgMemberUsageLimit', () => { it('returns null when no row exists', async () => { - mockDbState.selectResults = [[]] + queueTableRows(schemaTables.organizationMemberUsageLimit, []) await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBeNull() }) it('returns the stored dollar limit as a number', async () => { - mockDbState.selectResults = [[{ usageLimit: '2' }]] + queueTableRows(schemaTables.organizationMemberUsageLimit, [{ usageLimit: '2' }]) await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBe(2) }) }) @@ -128,7 +101,7 @@ describe('getOrgMemberUsageForBillingPeriod', () => { start: new Date('2026-06-01T00:00:00.000Z'), end: new Date('2026-07-01T00:00:00.000Z'), } - mockDbState.selectResults = [[{ cost: '4.5' }]] + queueTableRows(schemaTables.usageLog, [{ cost: '4.5' }]) mockGetOrganizationSubscription.mockResolvedValue({ periodStart: new Date('2026-07-01T00:00:00.000Z'), periodEnd: new Date('2026-08-01T00:00:00.000Z'), @@ -150,7 +123,7 @@ describe('getOrgMemberUsageForBillingPeriod', () => { expect(mockGte).toHaveBeenCalledWith('usageLog.createdAt', 'workspace.organizationAssignedAt') expect(mockGte).toHaveBeenCalledWith('usageLog.createdAt', billingPeriod.start) expect(mockLt).toHaveBeenCalledWith('usageLog.createdAt', billingPeriod.end) - expect(mockLeftJoin).toHaveBeenCalledWith( + expect(dbChainMockFns.leftJoin).toHaveBeenCalledWith( expect.objectContaining({ id: 'workspace.id' }), expect.anything() ) @@ -174,23 +147,23 @@ describe('getOrgMemberUsageForBillingPeriod', () => { describe('setOrgMemberUsageLimit', () => { it('upserts when given a dollar amount', async () => { await setOrgMemberUsageLimit('org-1', 'user-2', 2, 'admin-1') - expect(mockInsert).toHaveBeenCalledTimes(1) - expect(mockDelete).not.toHaveBeenCalled() - const values = mockInsertValues.mock.calls[0][0] + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + const values = dbChainMockFns.values.mock.calls[0][0] expect(values).toMatchObject({ organizationId: 'org-1', userId: 'user-2', usageLimit: '2', setBy: 'admin-1', }) - expect(mockOnConflictDoUpdate).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledTimes(1) }) it('deletes the row when limit is null', async () => { await setOrgMemberUsageLimit('org-1', 'user-2', null, 'admin-1') - expect(mockDelete).toHaveBeenCalledTimes(1) - expect(mockDeleteWhere).toHaveBeenCalledTimes(1) - expect(mockInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) @@ -199,7 +172,7 @@ describe('getOrgMemberUsageForCurrentPeriod', () => { const periodStart = new Date('2026-06-01T00:00:00.000Z') const periodEnd = new Date('2026-07-01T00:00:00.000Z') mockGetOrganizationSubscription.mockResolvedValue({ periodStart, periodEnd }) - mockDbState.selectResults = [[{ cost: '5' }]] + queueTableRows(schemaTables.usageLog, [{ cost: '5' }]) const result = await getOrgMemberUsageForCurrentPeriod('org-1', 'user-2') @@ -213,7 +186,7 @@ describe('getOrgMemberUsageForCurrentPeriod', () => { it('uses a prefetched subscription without a second lookup', async () => { const periodStart = new Date('2026-06-01T00:00:00.000Z') const periodEnd = new Date('2026-07-01T00:00:00.000Z') - mockDbState.selectResults = [[{ cost: '5' }]] + queueTableRows(schemaTables.usageLog, [{ cost: '5' }]) const result = await getOrgMemberUsageForCurrentPeriod('org-1', 'user-2', { periodStart, @@ -226,7 +199,7 @@ describe('getOrgMemberUsageForCurrentPeriod', () => { }) it('falls back to the all-time window when the org has no subscription period', async () => { - mockDbState.selectResults = [[{ cost: '7' }]] + queueTableRows(schemaTables.usageLog, [{ cost: '7' }]) const result = await getOrgMemberUsageForCurrentPeriod('org-1', 'user-2', null) diff --git a/apps/sim/lib/billing/organizations/provision-seat.test.ts b/apps/sim/lib/billing/organizations/provision-seat.test.ts index 126f1c645c6..638d84897d4 100644 --- a/apps/sim/lib/billing/organizations/provision-seat.test.ts +++ b/apps/sim/lib/billing/organizations/provision-seat.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetOrganizationSubscription, @@ -12,7 +13,6 @@ const { mockGetPlanByName, enqueueMock, updateCalls, - globalTransactionMock, } = vi.hoisted(() => ({ mockGetOrganizationSubscription: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), @@ -22,26 +22,9 @@ const { mockGetPlanByName: vi.fn(), enqueueMock: vi.fn(), updateCalls: { value: [] as Array> }, - globalTransactionMock: vi.fn(), })) -vi.mock('@sim/db', () => { - const update = () => ({ - set: (values: Record) => { - updateCalls.value.push(values) - return { where: () => Promise.resolve([]) } - }, - }) - const txMock = { update } - globalTransactionMock.mockImplementation(async (cb: (tx: typeof txMock) => Promise) => - cb(txMock) - ) - const dbMock = { - update, - transaction: globalTransactionMock, - } - return { db: dbMock } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, @@ -95,6 +78,7 @@ function testExecutor(onUpdate: () => void = () => {}) { describe('ensureTeamOrganizationForAcceptance', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() updateCalls.value = [] mockGetPlanByName.mockReturnValue({ priceId: 'price_team_month', @@ -103,6 +87,10 @@ describe('ensureTeamOrganizationForAcceptance', () => { mockAssertNoUnresolvedEnterpriseIssuance.mockResolvedValue(undefined) }) + afterAll(() => { + resetDbChainMock() + }) + it('is a no-op for enterprise organizations (fixed seats)', async () => { mockGetOrganizationSubscription.mockResolvedValue({ id: 'sub-ent', @@ -187,7 +175,7 @@ describe('ensureTeamOrganizationForAcceptance', () => { 'org-1', expect.objectContaining({ executor }) ) - expect(globalTransactionMock).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('blocks an org-scoped Pro conversion while Enterprise issuance is unresolved', async () => { @@ -273,7 +261,7 @@ describe('ensureTeamOrganizationForAcceptance', () => { 'stripe.sync-subscription-seats', expect.objectContaining({ subscriptionId: 'sub-pro' }) ) - expect(globalTransactionMock).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(lockOrder).toEqual(['organization', 'subscription']) // ...but with no scheduled cancellation there is no cancel-sync event. expect(enqueueMock).not.toHaveBeenCalledWith( diff --git a/apps/sim/lib/billing/organizations/seat-drift.test.ts b/apps/sim/lib/billing/organizations/seat-drift.test.ts index 43338b0c615..a95ffa0d2f9 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.test.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.test.ts @@ -1,27 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockReconcileOrganizationSeats, selectRows, mockFeatureFlags } = vi.hoisted(() => ({ +const { mockReconcileOrganizationSeats, mockFeatureFlags } = vi.hoisted(() => ({ mockReconcileOrganizationSeats: vi.fn(), - selectRows: { value: [] as unknown[] }, mockFeatureFlags: { isBillingEnabled: true }, })) -vi.mock('@sim/db', () => { - const makeChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.innerJoin = () => chain - chain.where = () => chain - chain.groupBy = () => chain - chain.having = () => chain - chain.orderBy = () => Promise.resolve(selectRows.value) - return chain - } - return { db: { select: () => makeChain() } } -}) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mockReconcileOrganizationSeats, @@ -38,15 +26,22 @@ import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift' describe('reconcileTeamSeatDrift', () => { beforeEach(() => { vi.clearAllMocks() - selectRows.value = [] + resetDbChainMock() mockFeatureFlags.isBillingEnabled = true mockReconcileOrganizationSeats.mockResolvedValue({ changed: true, previousSeats: 1, seats: 2 }) }) + afterAll(() => { + resetDbChainMock() + }) + it('reconciles each drifted Team org returned by the query', async () => { // The SQL WHERE (Team-only) + HAVING (seats != member count) already // restrict the result to drifted Team orgs; the function reconciles each. - selectRows.value = [{ organizationId: 'org-1' }, { organizationId: 'org-2' }] + queueTableRows(schemaMock.subscription, [ + { organizationId: 'org-1' }, + { organizationId: 'org-2' }, + ]) const result = await reconcileTeamSeatDrift() @@ -63,7 +58,7 @@ describe('reconcileTeamSeatDrift', () => { }) it('reconciles a past-due Team candidate returned by the entitlement query', async () => { - selectRows.value = [{ organizationId: 'org-past-due' }] + queueTableRows(schemaMock.subscription, [{ organizationId: 'org-past-due' }]) const result = await reconcileTeamSeatDrift() @@ -75,7 +70,10 @@ describe('reconcileTeamSeatDrift', () => { }) it('counts only reconciles that changed the seat count', async () => { - selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }] + queueTableRows(schemaMock.subscription, [ + { organizationId: 'org-a' }, + { organizationId: 'org-b' }, + ]) mockReconcileOrganizationSeats .mockResolvedValueOnce({ changed: true, seats: 2 }) .mockResolvedValueOnce({ changed: false }) @@ -86,7 +84,10 @@ describe('reconcileTeamSeatDrift', () => { }) it('continues past a reconcile failure', async () => { - selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }] + queueTableRows(schemaMock.subscription, [ + { organizationId: 'org-a' }, + { organizationId: 'org-b' }, + ]) mockReconcileOrganizationSeats .mockRejectedValueOnce(new Error('db error')) .mockResolvedValueOnce({ changed: true, seats: 3 }) @@ -107,9 +108,10 @@ describe('reconcileTeamSeatDrift', () => { }) it('caps reconciles per run while still reporting the full drift count', async () => { - selectRows.value = Array.from({ length: 150 }, (_, i) => ({ - organizationId: `org-${i}`, - })) + queueTableRows( + schemaMock.subscription, + Array.from({ length: 150 }, (_, i) => ({ organizationId: `org-${i}` })) + ) const result = await reconcileTeamSeatDrift() diff --git a/apps/sim/lib/billing/organizations/seats.test.ts b/apps/sim/lib/billing/organizations/seats.test.ts index 1a98033586a..26154aa1987 100644 --- a/apps/sim/lib/billing/organizations/seats.test.ts +++ b/apps/sim/lib/billing/organizations/seats.test.ts @@ -1,43 +1,23 @@ /** * @vitest-environment node */ -import { auditMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSyncSubscriptionUsageLimits, enqueueMock, setMock, queryQueue, mockFeatureFlags } = - vi.hoisted(() => ({ - mockSyncSubscriptionUsageLimits: vi.fn(), - enqueueMock: vi.fn(), - setMock: vi.fn(), - queryQueue: { value: [] as unknown[][] }, - mockFeatureFlags: { isBillingEnabled: true }, - })) - -vi.mock('@sim/db', () => { - const makeSelectChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.where = () => chain - chain.for = () => chain - chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? []) - chain.then = (resolve: (rows: unknown[]) => unknown, reject?: (e: unknown) => unknown) => - Promise.resolve(queryQueue.value.shift() ?? []).then(resolve, reject) - return chain - } - const update = () => ({ - set: (values: Record) => { - setMock(values) - return { where: () => Promise.resolve([]) } - }, - }) - const txMock = { select: () => makeSelectChain(), update } - const dbMock = { - select: () => makeSelectChain(), - update, - transaction: async (cb: (tx: typeof txMock) => Promise) => cb(txMock), - } - return { db: dbMock } -}) +import { + auditMock, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSyncSubscriptionUsageLimits, enqueueMock, mockFeatureFlags } = vi.hoisted(() => ({ + mockSyncSubscriptionUsageLimits: vi.fn(), + enqueueMock: vi.fn(), + mockFeatureFlags: { isBillingEnabled: true }, +})) + +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organization', () => ({ syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits, @@ -71,16 +51,26 @@ const teamSub = { stripeSubscriptionId: 'stripe_sub', } +/** Queues the two in-transaction reads: locked subscription, then member count. */ +function queueReconcileReads(subscriptionRows: unknown[], memberCountRows: unknown[] = []) { + queueTableRows(schemaMock.subscription, subscriptionRows) + queueTableRows(schemaMock.member, memberCountRows) +} + describe('reconcileOrganizationSeats', () => { beforeEach(() => { vi.clearAllMocks() - queryQueue.value = [] + resetDbChainMock() enqueueMock.mockResolvedValue('evt-1') mockFeatureFlags.isBillingEnabled = true }) + afterAll(() => { + resetDbChainMock() + }) + it('grows seats to the member count and enqueues a Stripe sync', async () => { - queryQueue.value = [[teamSub], [{ value: 2 }]] + queueReconcileReads([teamSub], [{ value: 2 }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -94,7 +84,7 @@ describe('reconcileOrganizationSeats', () => { reason: undefined, outboxEventId: 'evt-1', }) - expect(setMock).toHaveBeenCalledWith({ seats: 2 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 }) expect(enqueueMock).toHaveBeenCalledWith(expect.anything(), 'stripe.sync-subscription-seats', { subscriptionId: 'sub-1', reason: 'member-accepted-invite', @@ -105,7 +95,7 @@ describe('reconcileOrganizationSeats', () => { }) it('reconciles a past-due Team subscription because it remains entitled', async () => { - queryQueue.value = [[{ ...teamSub, status: 'past_due' }], [{ value: 2 }]] + queueReconcileReads([{ ...teamSub, status: 'past_due' }], [{ value: 2 }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -113,12 +103,12 @@ describe('reconcileOrganizationSeats', () => { }) expect(result.changed).toBe(true) - expect(setMock).toHaveBeenCalledWith({ seats: 2 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 }) expect(enqueueMock).toHaveBeenCalledOnce() }) it('still records the seat audit when the post-commit usage-limit sync fails', async () => { - queryQueue.value = [[teamSub], [{ value: 2 }]] + queueReconcileReads([teamSub], [{ value: 2 }]) mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('sync unavailable')) const result = await reconcileOrganizationSeats({ @@ -128,7 +118,7 @@ describe('reconcileOrganizationSeats', () => { }) expect(result.changed).toBe(true) - expect(setMock).toHaveBeenCalledWith({ seats: 2 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 }) expect(auditMock.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ actorId: 'user-1', @@ -139,7 +129,7 @@ describe('reconcileOrganizationSeats', () => { }) it('reduces seats to the member count on removal', async () => { - queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 2 }]] + queueReconcileReads([{ ...teamSub, seats: 3 }], [{ value: 2 }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -148,12 +138,12 @@ describe('reconcileOrganizationSeats', () => { expect(result.changed).toBe(true) expect(result.seats).toBe(2) - expect(setMock).toHaveBeenCalledWith({ seats: 2 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 }) expect(enqueueMock).toHaveBeenCalled() }) it('is a no-op when seats already match the member count', async () => { - queryQueue.value = [[{ ...teamSub, seats: 2 }], [{ value: 2 }]] + queueReconcileReads([{ ...teamSub, seats: 2 }], [{ value: 2 }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -167,13 +157,13 @@ describe('reconcileOrganizationSeats', () => { reason: undefined, outboxEventId: undefined, }) - expect(setMock).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(enqueueMock).not.toHaveBeenCalled() expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled() }) it('never drops below one seat', async () => { - queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 0 }]] + queueReconcileReads([{ ...teamSub, seats: 3 }], [{ value: 0 }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -181,11 +171,11 @@ describe('reconcileOrganizationSeats', () => { }) expect(result.seats).toBe(1) - expect(setMock).toHaveBeenCalledWith({ seats: 1 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 1 }) }) it('skips non-Team subscriptions', async () => { - queryQueue.value = [[{ ...teamSub, plan: 'pro_6000' }]] + queueReconcileReads([{ ...teamSub, plan: 'pro_6000' }]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', @@ -194,12 +184,12 @@ describe('reconcileOrganizationSeats', () => { expect(result.changed).toBe(false) expect(result.reason).toMatch(/Team/) - expect(setMock).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(enqueueMock).not.toHaveBeenCalled() }) it('skips when the organization has no usable subscription', async () => { - queryQueue.value = [[]] + queueReconcileReads([]) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', diff --git a/apps/sim/lib/billing/storage/limits.test.ts b/apps/sim/lib/billing/storage/limits.test.ts index c0850a29e97..c690221cdfb 100644 --- a/apps/sim/lib/billing/storage/limits.test.ts +++ b/apps/sim/lib/billing/storage/limits.test.ts @@ -1,31 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockEq, - mockFlags, - mockFrom, - mockGetHighestPrioritySubscription, - mockLimit, - mockSelect, - mockWhere, -} = vi.hoisted(() => ({ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEq, mockFlags, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value })), mockFlags: { isBillingEnabled: true }, - mockFrom: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), - mockLimit: vi.fn(), - mockSelect: vi.fn(), - mockWhere: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/db/schema', () => ({ organization: { @@ -91,17 +76,19 @@ const GIB = 1024 ** 3 describe('storage limits and quota', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockFlags.isBillingEnabled = true mockGetEnv.mockReturnValue(undefined) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([{ storageUsedBytes: 1024 }]) + dbChainMockFns.limit.mockResolvedValue([{ storageUsedBytes: 1024 }]) mockGetHighestPrioritySubscription.mockResolvedValue(null) }) + afterAll(() => { + resetDbChainMock() + }) + it('reads user and organization counters through the same entity-aware path', async () => { - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ storageUsedBytes: 11 }]) .mockResolvedValueOnce([{ storageUsedBytes: 22 }]) .mockResolvedValueOnce([{ storageUsedBytes: 33 }]) @@ -138,7 +125,7 @@ describe('storage limits and quota', () => { }) it('returns the exact same quota result for legacy and workspace organization payers', async () => { - mockLimit.mockResolvedValue([{ storageUsedBytes: GIB }]) + dbChainMockFns.limit.mockResolvedValue([{ storageUsedBytes: GIB }]) mockGetHighestPrioritySubscription.mockResolvedValue({ metadata: { customStorageLimitGB: 1 }, plan: 'team_25000', @@ -170,7 +157,7 @@ describe('storage limits and quota', () => { await expect(checkStorageQuota('workspace-owner', GIB)).resolves.toEqual(expected) await expect(checkStorageQuotaForBillingContext(ORG_CONTEXT, GIB)).resolves.toEqual(expected) expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) it('opts into free-tier enforcement when FREE_STORAGE_LIMIT_GB is explicitly set', async () => { @@ -178,7 +165,7 @@ describe('storage limits and quota', () => { mockGetEnv.mockImplementation((variable: string) => variable === 'FREE_STORAGE_LIMIT_GB' ? '1' : undefined ) - mockLimit.mockResolvedValue([{ storageUsedBytes: GIB }]) + dbChainMockFns.limit.mockResolvedValue([{ storageUsedBytes: GIB }]) await expect(checkStorageQuota('workspace-owner', GIB / 2)).resolves.toEqual({ allowed: false, @@ -199,12 +186,12 @@ describe('storage limits and quota', () => { mockGetHighestPrioritySubscription.mockRejectedValueOnce(new Error('subscription unavailable')) await expect(checkStorageQuota('workspace-owner', GIB)).resolves.toEqual(expected) - mockLimit.mockRejectedValueOnce(new Error('counter unavailable')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('counter unavailable')) await expect(checkStorageQuotaForBillingContext(ORG_CONTEXT, GIB)).resolves.toEqual(expected) }) it('retains zero fallback for direct usage readers', async () => { - mockLimit.mockRejectedValueOnce(new Error('counter unavailable')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('counter unavailable')) await expect(getStorageUsageForBillingContext(ORG_CONTEXT)).resolves.toBe(0) }) diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index 09f88c9ae09..63838978487 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -1,13 +1,18 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCalculateSubscriptionOverage, mockComputeOrgOverageAmount, - mockDbSelect, - mockDbTransaction, mockEnqueueOutboxEvent, mockGetEffectiveBillingStatus, mockGetHighestPrioritySubscription, @@ -20,15 +25,9 @@ const { mockIsOrganizationBillingBlocked, mockRecordAudit, mockCaptureServerEvent, - mockTxExecute, - mockTxSelect, - mockTxStatsLimit, - mockTxUpdate, } = vi.hoisted(() => ({ mockCalculateSubscriptionOverage: vi.fn(), mockComputeOrgOverageAmount: vi.fn(), - mockDbSelect: vi.fn(), - mockDbTransaction: vi.fn(), mockEnqueueOutboxEvent: vi.fn(), mockGetEffectiveBillingStatus: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), @@ -41,10 +40,6 @@ const { mockIsOrganizationBillingBlocked: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockTxExecute: vi.fn(), - mockTxSelect: vi.fn(), - mockTxStatsLimit: vi.fn(), - mockTxUpdate: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -53,38 +48,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - transaction: mockDbTransaction, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, - organization: { - creditBalance: 'organization.creditBalance', - departedMemberUsage: 'organization.departedMemberUsage', - id: 'organization.id', - }, - subscription: { - id: 'subscription.id', - stripeCustomerId: 'subscription.stripeCustomerId', - }, - userStats: { - billedOverageThisPeriod: 'userStats.billedOverageThisPeriod', - creditBalance: 'userStats.creditBalance', - currentPeriodCost: 'userStats.currentPeriodCost', - lastPeriodCost: 'userStats.lastPeriodCost', - proPeriodCostSnapshot: 'userStats.proPeriodCostSnapshot', - proPeriodCostSnapshotAt: 'userStats.proPeriodCostSnapshotAt', - userId: 'userStats.userId', - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/access', () => ({ getEffectiveBillingStatus: mockGetEffectiveBillingStatus, @@ -140,12 +104,6 @@ import { ThresholdSettlementError, } from '@/lib/billing/threshold-billing' -interface MockTx { - execute: typeof mockTxExecute - select: typeof mockTxSelect - update: typeof mockTxUpdate -} - const userSubscription = { id: 'sub-db-1', plan: 'pro', @@ -162,88 +120,85 @@ const expectedBillingPeriod = { end: new Date('2026-06-01T00:00:00.000Z'), } -function buildSelectChain(rows: T[]) { - const chain = { - from: vi.fn(() => chain), - leftJoin: vi.fn(() => chain), - innerJoin: vi.fn(() => chain), - where: vi.fn(() => result), - } - const result = { - limit: vi.fn(async () => rows), - then: (resolve: (value: T[]) => unknown, reject?: (reason: unknown) => unknown) => - Promise.resolve(rows).then(resolve, reject), - } +const defaultUsageSnapshotRow = { + currentPeriodCost: '0', + proPeriodCostSnapshot: '0', + proPeriodCostSnapshotAt: null as Date | null, + lastPeriodCost: '0', +} + +/** + * Queues the two pre-transaction personal reads: the user_stats usage snapshot + * and the subscription's Stripe customer row. + */ +function queuePersonalReads( + snapshot: Record = defaultUsageSnapshotRow, + customerId = 'cus_1' +) { + queueTableRows(schemaMock.userStats, [snapshot]) + queueTableRows(schemaMock.subscription, [{ stripeCustomerId: customerId }]) +} +/** Builds the locked in-transaction user_stats row. */ +function lockedStatsRow(overrides: Record = {}) { return { - from: chain.from, + ...defaultUsageSnapshotRow, + billedOverageThisPeriod: '0', + creditBalance: '0', + ...overrides, } } -function buildPersonalSelectChain(customerId = 'cus_1') { - return buildSelectChain([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - stripeCustomerId: customerId, - }, - ]) +/** Queues the locked user_stats read taken inside the settlement transaction. */ +function queueLockedStats(row: Record) { + queueTableRows(schemaMock.userStats, [row]) } -function buildPersonalSnapshotSelectChain({ - currentPeriodCost = '0', - proPeriodCostSnapshot = '0', - proPeriodCostSnapshotAt = null, - lastPeriodCost = '0', -}: { - currentPeriodCost?: string - proPeriodCostSnapshot?: string - proPeriodCostSnapshotAt?: Date | null - lastPeriodCost?: string -}) { - return buildSelectChain([ - { - currentPeriodCost, - proPeriodCostSnapshot, - proPeriodCostSnapshotAt, - lastPeriodCost, - }, - ]) +const orgMemberUsageRow = { + userId: 'owner-1', + role: 'owner', + currentPeriodCost: '350', + departedMemberUsage: '25', } -function buildStatsSelectChain() { - const result = { - for: vi.fn(() => result), - limit: mockTxStatsLimit, - then: (resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => - Promise.resolve(mockTxStatsLimit()).then(resolve, reject), - } - - return { - from: vi.fn(() => ({ - leftJoin: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn(() => result), - })), - })), - where: vi.fn(() => result), - })), - } +/** + * Queues the organization settlement reads in table order: the pre-transaction + * member usage join, then the locked owner row, owner stats, organization row, + * and locked member usage join inside the transaction. + */ +function queueOrgReads({ + memberUsageRows = [orgMemberUsageRow], + lockedOwnerRows = [{ userId: 'owner-1' }], + ownerStatsRows = [{ billedOverageThisPeriod: '0' }], + organizationRows = [{ creditBalance: '0', departedMemberUsage: '25' }], + lockedMemberUsageRows = memberUsageRows, +}: { + memberUsageRows?: unknown[] + lockedOwnerRows?: unknown[] + ownerStatsRows?: unknown[] + organizationRows?: unknown[] + lockedMemberUsageRows?: unknown[] +} = {}) { + queueTableRows(schemaMock.member, memberUsageRows) + queueTableRows(schemaMock.member, lockedOwnerRows) + queueTableRows(schemaMock.userStats, ownerStatsRows) + queueTableRows(schemaMock.organization, organizationRows) + queueTableRows(schemaMock.member, lockedMemberUsageRows) } -function buildUpdateChain() { - return { - set: vi.fn(() => ({ - where: vi.fn(async () => []), - })), - } +const usableOrgSubscription = { + plan: 'team', + seats: 2, + periodStart: new Date('2026-05-01T00:00:00.000Z'), + periodEnd: new Date('2026-06-01T00:00:00.000Z'), + stripeSubscriptionId: 'sub_team_1', + stripeCustomerId: 'cus_team_1', } describe('checkAndBillOverageThreshold', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetHighestPrioritySubscription.mockResolvedValue(userSubscription) mockGetEffectiveBillingStatus.mockResolvedValue({ billingBlocked: false }) @@ -253,16 +208,14 @@ describe('checkAndBillOverageThreshold', () => { mockIsEnterprise.mockReturnValue(false) mockIsOrgScopedSubscription.mockReturnValue(false) mockGetBillingPeriodUsageCost.mockResolvedValue(0) - mockDbSelect.mockImplementation(() => buildPersonalSelectChain()) - mockTxSelect.mockImplementation(() => buildStatsSelectChain()) - mockTxUpdate.mockImplementation(() => buildUpdateChain()) - mockTxExecute.mockResolvedValue(undefined) - mockDbTransaction.mockImplementation(async (callback: (tx: MockTx) => Promise) => - callback({ execute: mockTxExecute, select: mockTxSelect, update: mockTxUpdate }) - ) + }) + + afterAll(() => { + resetDbChainMock() }) it('does not lock user_stats when calculated overage is below threshold', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(99) await checkAndBillOverageThreshold('user-1') @@ -275,18 +228,20 @@ describe('checkAndBillOverageThreshold', () => { periodStart: userSubscription.periodStart, periodEnd: userSubscription.periodEnd, }) - expect(mockDbTransaction).not.toHaveBeenCalled() - expect(mockDbSelect).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('preserves best-effort error handling for existing callers', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockRejectedValue(new Error('Overage lookup unavailable')) await expect(checkAndBillOverageThreshold('user-1')).resolves.toBeUndefined() }) it('wraps provider failures when strict settlement has no expected billing period', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockRejectedValue(new Error('Overage lookup unavailable')) await expect( @@ -299,6 +254,7 @@ describe('checkAndBillOverageThreshold', () => { }) it('wraps provider failures as retryable errors for a frozen modern period', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockRejectedValue(new Error('Overage lookup unavailable')) await expect( @@ -329,7 +285,7 @@ describe('checkAndBillOverageThreshold', () => { }) expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled() - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) @@ -349,6 +305,7 @@ describe('checkAndBillOverageThreshold', () => { }) it('fails retryably when an above-threshold modern settlement lacks payment state', async () => { + queuePersonalReads() mockGetHighestPrioritySubscription.mockResolvedValue({ ...userSubscription, stripeSubscriptionId: null, @@ -366,11 +323,12 @@ describe('checkAndBillOverageThreshold', () => { retryable: true, }) - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('throws retryably for markerless strict settlement when payment state is missing', async () => { + queuePersonalReads() mockGetHighestPrioritySubscription.mockResolvedValue({ ...userSubscription, stripeSubscriptionId: null, @@ -385,7 +343,7 @@ describe('checkAndBillOverageThreshold', () => { retryable: true, }) - expect(mockDbTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) @@ -410,19 +368,11 @@ describe('checkAndBillOverageThreshold', () => { name: 'already settled', prepare: () => { mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '250', - creditBalance: '0', - }, - ]) + queueLockedStats(lockedStatsRow({ billedOverageThisPeriod: '250' })) }, }, ])('keeps the $name terminal no-op successful in markerless strict mode', async ({ prepare }) => { + queuePersonalReads() prepare() await expect( @@ -431,6 +381,7 @@ describe('checkAndBillOverageThreshold', () => { }) it('returns a distinct modern no-op when overage is below threshold', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(99) await expect( @@ -455,14 +406,7 @@ describe('checkAndBillOverageThreshold', () => { }) it('wraps organization provider failures through the strict payer helper', async () => { - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: expectedBillingPeriod.start, - periodEnd: expectedBillingPeriod.end, - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', - }) + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) mockIsOrganizationBillingBlocked.mockRejectedValue(new Error('Organization lookup unavailable')) await expect( @@ -479,14 +423,7 @@ describe('checkAndBillOverageThreshold', () => { it('keeps billing-blocked organizations as terminal no-ops in markerless strict mode', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: expectedBillingPeriod.start, - periodEnd: expectedBillingPeriod.end, - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', - }) + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) mockIsOrganizationBillingBlocked.mockResolvedValue(true) await expect( @@ -516,52 +453,27 @@ describe('checkAndBillOverageThreshold', () => { }) it('calculates overage before opening the short user_stats transaction', async () => { + queuePersonalReads() + queueLockedStats(lockedStatsRow()) mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '0', - creditBalance: '0', - }, - ]) await checkAndBillOverageThreshold('user-1') expect(mockCalculateSubscriptionOverage).toHaveBeenCalled() - expect(mockDbTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockCalculateSubscriptionOverage.mock.invocationCallOrder[0]).toBeLessThan( - mockDbTransaction.mock.invocationCallOrder[0] + dbChainMockFns.transaction.mock.invocationCallOrder[0] ) - expect(mockTxExecute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) }) it('emits audit and analytics once when a retry finds overage already settled', async () => { mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockTxStatsLimit - .mockResolvedValueOnce([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '0', - creditBalance: '0', - }, - ]) - .mockResolvedValueOnce([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '250', - creditBalance: '0', - }, - ]) + queuePersonalReads() + queueLockedStats(lockedStatsRow()) + queuePersonalReads() + queueLockedStats(lockedStatsRow({ billedOverageThisPeriod: '250' })) await checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) await checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) @@ -572,17 +484,9 @@ describe('checkAndBillOverageThreshold', () => { }) it('distinguishes an already-settled modern period without duplicating side effects', async () => { + queuePersonalReads() + queueLockedStats(lockedStatsRow({ billedOverageThisPeriod: '250' })) mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '250', - creditBalance: '0', - }, - ]) await expect( checkAndBillOverageThreshold('user-1', undefined, { @@ -597,64 +501,34 @@ describe('checkAndBillOverageThreshold', () => { }) it('rechecks billed overage while locked before enqueueing an invoice', async () => { + queuePersonalReads() + queueLockedStats(lockedStatsRow({ billedOverageThisPeriod: '200' })) mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '0', - billedOverageThisPeriod: '200', - creditBalance: '0', - }, - ]) await checkAndBillOverageThreshold('user-1') - expect(mockDbTransaction).toHaveBeenCalled() - expect(mockTxExecute).toHaveBeenCalledTimes(1) - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('skips personal threshold billing when locked usage inputs changed', async () => { + queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' }) + queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' })) mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockDbSelect - .mockImplementationOnce(() => buildPersonalSnapshotSelectChain({ currentPeriodCost: '250' })) - .mockImplementationOnce(() => buildPersonalSelectChain()) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '250', - billedOverageThisPeriod: '0', - creditBalance: '0', - }, - ]) await checkAndBillOverageThreshold('user-1') - expect(mockDbTransaction).toHaveBeenCalled() - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('throws retryably in markerless strict mode when locked personal usage changes', async () => { + queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' }) + queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' })) mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockDbSelect - .mockImplementationOnce(() => buildPersonalSnapshotSelectChain({ currentPeriodCost: '250' })) - .mockImplementationOnce(() => buildPersonalSelectChain()) - mockTxStatsLimit.mockResolvedValue([ - { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - lastPeriodCost: '250', - billedOverageThisPeriod: '0', - creditBalance: '0', - }, - ]) await expect( checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) @@ -663,13 +537,16 @@ describe('checkAndBillOverageThreshold', () => { code: 'concurrent_state_change', retryable: true, }) - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('wraps lock timeouts in markerless strict mode', async () => { + queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(250) - mockDbTransaction.mockRejectedValueOnce(new Error('canceling statement due to lock timeout')) + dbChainMockFns.transaction.mockRejectedValueOnce( + new Error('canceling statement due to lock timeout') + ) await expect( checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) @@ -683,41 +560,13 @@ describe('checkAndBillOverageThreshold', () => { it('computes organization overage before opening the locked transaction', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: new Date('2026-05-01T00:00:00.000Z'), - periodEnd: new Date('2026-06-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', - }) - mockDbSelect.mockImplementationOnce(() => - buildSelectChain([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - ]) - ) + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + queueOrgReads() mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, baseSubscriptionAmount: 100, effectiveUsage: 350, }) - mockTxStatsLimit - .mockResolvedValueOnce([{ userId: 'owner-1' }]) - .mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }]) - .mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }]) - .mockResolvedValueOnce([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - ]) await checkAndBillOverageThreshold('user-1') @@ -731,143 +580,69 @@ describe('checkAndBillOverageThreshold', () => { departedMemberUsage: 25, memberIds: ['owner-1'], }) - expect(mockDbTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan( - mockDbTransaction.mock.invocationCallOrder[0] + dbChainMockFns.transaction.mock.invocationCallOrder[0] ) - expect(mockTxExecute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) }) it('skips stale organization overage when locked usage inputs changed', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: new Date('2026-05-01T00:00:00.000Z'), - periodEnd: new Date('2026-06-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + queueOrgReads({ + organizationRows: [{ creditBalance: '0', departedMemberUsage: '75' }], + lockedMemberUsageRows: [{ ...orgMemberUsageRow, departedMemberUsage: '75' }], }) - mockDbSelect.mockImplementationOnce(() => - buildSelectChain([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - ]) - ) mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, baseSubscriptionAmount: 100, effectiveUsage: 350, }) - mockTxStatsLimit - .mockResolvedValueOnce([{ userId: 'owner-1' }]) - .mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }]) - .mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '75' }]) - .mockResolvedValueOnce([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '75', - }, - ]) await checkAndBillOverageThreshold('user-1') - expect(mockDbTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('rechecks organization billed overage on the locked owner tracker', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: new Date('2026-05-01T00:00:00.000Z'), - periodEnd: new Date('2026-06-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', - }) - mockDbSelect.mockImplementationOnce(() => - buildSelectChain([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - ]) - ) + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + queueOrgReads({ ownerStatsRows: [{ billedOverageThisPeriod: '200' }] }) mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, baseSubscriptionAmount: 100, effectiveUsage: 350, }) - mockTxStatsLimit - .mockResolvedValueOnce([{ userId: 'owner-1' }]) - .mockResolvedValueOnce([{ billedOverageThisPeriod: '200' }]) - .mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }]) - .mockResolvedValueOnce([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - ]) await checkAndBillOverageThreshold('user-1') - expect(mockDbTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('skips stale organization overage when owner identity changed', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockGetOrganizationSubscriptionUsable.mockResolvedValue({ - plan: 'team', - seats: 2, - periodStart: new Date('2026-05-01T00:00:00.000Z'), - periodEnd: new Date('2026-06-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_team_1', - stripeCustomerId: 'cus_team_1', - }) - mockDbSelect.mockImplementationOnce(() => - buildSelectChain([ - { - userId: 'owner-1', - role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + queueOrgReads({ + memberUsageRows: [ + orgMemberUsageRow, { userId: 'member-1', role: 'member', currentPeriodCost: '25', departedMemberUsage: '25', }, - ]) - ) - mockComputeOrgOverageAmount.mockResolvedValue({ - totalOverage: 250, - baseSubscriptionAmount: 100, - effectiveUsage: 350, - }) - mockTxStatsLimit - .mockResolvedValueOnce([{ userId: 'member-1' }]) - .mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }]) - .mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }]) - .mockResolvedValueOnce([ + ], + lockedOwnerRows: [{ userId: 'member-1' }], + lockedMemberUsageRows: [ { userId: 'owner-1', role: 'member', @@ -880,12 +655,18 @@ describe('checkAndBillOverageThreshold', () => { currentPeriodCost: '25', departedMemberUsage: '25', }, - ]) + ], + }) + mockComputeOrgOverageAmount.mockResolvedValue({ + totalOverage: 250, + baseSubscriptionAmount: 100, + effectiveUsage: 350, + }) await checkAndBillOverageThreshold('user-1') - expect(mockDbTransaction).toHaveBeenCalled() + expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - expect(mockTxUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts b/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts index 2b9dcb08069..8fcff1ee94e 100644 --- a/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts +++ b/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('@sim/db/schema', () => ({ idempotencyKey: {} })) +vi.mock('@sim/db', () => dbChainMock) import { assertEnterpriseReconciliationLeaseHeld, @@ -15,6 +15,15 @@ import { } from '@/lib/billing/webhooks/enterprise-reconciliation-lease' import type { DbOrTx } from '@/lib/db/types' +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) + +afterAll(() => { + resetDbChainMock() +}) + function inMemoryLeaseStore(): EnterpriseReconciliationLeaseStore { let held: EnterpriseReconciliationLease | null = null let nextToken = 0 diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts index d1f9a9748e7..5cf73d5cb85 100644 --- a/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts @@ -1,36 +1,25 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetPlanByName, mockResolveDefaultPaymentMethod, queryQueue, stripeMock } = vi.hoisted( - () => { - const stripeMock = { - subscriptions: { - retrieve: vi.fn(), - update: vi.fn(), - }, - } - return { - mockGetPlanByName: vi.fn(), - mockResolveDefaultPaymentMethod: vi.fn(), - queryQueue: { value: [] as unknown[][] }, - stripeMock, - } +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetPlanByName, mockResolveDefaultPaymentMethod, stripeMock } = vi.hoisted(() => { + const stripeMock = { + subscriptions: { + retrieve: vi.fn(), + update: vi.fn(), + }, } -) - -vi.mock('@sim/db', () => { - const makeChain = () => { - const chain: Record = {} - chain.from = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? []) - return chain + return { + mockGetPlanByName: vi.fn(), + mockResolveDefaultPaymentMethod: vi.fn(), + stripeMock, } - return { db: { select: () => makeChain() } } }) +vi.mock('@sim/db', () => dbChainMock) + vi.mock('@/lib/billing/stripe-client', () => ({ requireStripeClient: () => stripeMock, })) @@ -76,10 +65,17 @@ function stripeItem(overrides: { } } +/** Queues the handler's pre-Stripe and re-verification subscription reads. */ +function queueSubscriptionReads(rowSets: unknown[][]) { + for (const rows of rowSets) { + queueTableRows(schemaMock.subscription, rows) + } +} + describe('stripeSyncSubscriptionSeats outbox handler', () => { beforeEach(() => { vi.clearAllMocks() - queryQueue.value = [] + resetDbChainMock() mockGetPlanByName.mockReturnValue({ priceId: 'price_team_month', annualDiscountPriceId: 'price_team_year', @@ -87,6 +83,10 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { stripeMock.subscriptions.update.mockResolvedValue({}) }) + afterAll(() => { + resetDbChainMock() + }) + it('reconciles both price and quantity for a Pro→Team conversion', async () => { const row = { plan: 'team_6000', @@ -94,7 +94,7 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { status: 'active', stripeSubscriptionId: 'stripe_sub', } - queryQueue.value = [[row], [row]] + queueSubscriptionReads([[row], [row]]) stripeMock.subscriptions.retrieve.mockResolvedValue( stripeItem({ quantity: 1, priceId: 'price_pro_month' }) ) @@ -118,7 +118,7 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { status: 'past_due', stripeSubscriptionId: 'stripe_sub', } - queryQueue.value = [[row], [row]] + queueSubscriptionReads([[row], [row]]) stripeMock.subscriptions.retrieve.mockResolvedValue( stripeItem({ quantity: 1, priceId: 'price_team_month', status: 'past_due' }) ) @@ -139,7 +139,7 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { status: 'active', stripeSubscriptionId: 'stripe_sub', } - queryQueue.value = [[row], [row]] + queueSubscriptionReads([[row], [row]]) stripeMock.subscriptions.retrieve.mockResolvedValue( stripeItem({ quantity: 1, priceId: 'price_pro_year', interval: 'year' }) ) @@ -162,7 +162,7 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { status: 'active', stripeSubscriptionId: 'stripe_sub', } - queryQueue.value = [[row], [row]] + queueSubscriptionReads([[row], [row]]) stripeMock.subscriptions.retrieve.mockResolvedValue( stripeItem({ quantity: 2, priceId: 'price_team_month' }) ) @@ -183,7 +183,7 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { status: 'active', stripeSubscriptionId: 'stripe_sub', } - queryQueue.value = [[row], [row]] + queueSubscriptionReads([[row], [row]]) stripeMock.subscriptions.retrieve.mockResolvedValue( stripeItem({ quantity: 2, priceId: 'price_team_month' }) ) @@ -194,9 +194,9 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => { }) it('skips non-Team subscriptions', async () => { - queryQueue.value = [ + queueSubscriptionReads([ [{ plan: 'pro_6000', seats: 1, status: 'active', stripeSubscriptionId: 's' }], - ] + ]) await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx) diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index fcba37053f8..0fc2c8dc2f8 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -1,43 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { credential, credentialMember } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkspaceAccess, dbState } = vi.hoisted(() => ({ +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), - dbState: { results: [] as any[][] }, -})) - -function makeChain() { - const chain: any = {} - chain.from = vi.fn(() => chain) - chain.where = vi.fn(() => chain) - chain.limit = vi.fn(() => Promise.resolve(dbState.results.shift() ?? [])) - return chain -} - -vi.mock('@sim/db', () => ({ - db: { select: vi.fn(() => makeChain()) }, -})) - -vi.mock('@sim/db/schema', () => ({ - credentialTypeEnum: { - enumValues: ['oauth', 'env_workspace', 'env_personal', 'service_account'], - }, - credential: { - id: 'credential.id', - workspaceId: 'credential.workspaceId', - type: 'credential.type', - }, - credentialMember: { - credentialId: 'credentialMember.credentialId', - userId: 'credentialMember.userId', - status: 'credentialMember.status', - role: 'credentialMember.role', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => ({ and: args })), - eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), - inArray: vi.fn((a: unknown, b: unknown) => ({ inArray: [a, b] })), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -46,17 +12,20 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getCredentialActorContext } from '@/lib/credentials/access' +afterAll(resetDbChainMock) + const workspaceAdminAccess = { hasAccess: true, canWrite: true, canAdmin: true } const noWorkspaceAccess = { hasAccess: false, canWrite: false, canAdmin: false } describe('getCredentialActorContext', () => { beforeEach(() => { vi.clearAllMocks() - dbState.results = [] + resetDbChainMock() }) it('treats an explicit credential admin membership as admin', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], [{ role: 'admin' }]] + queueTableRows(credential, [{ id: 'c1', workspaceId: 'ws', type: 'oauth' }]) + queueTableRows(credentialMember, [{ role: 'admin' }]) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) const ctx = await getCredentialActorContext('c1', 'user1') @@ -65,7 +34,7 @@ describe('getCredentialActorContext', () => { }) it('derives credential admin from workspace admin for shared credentials', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + queueTableRows(credential, [{ id: 'c1', workspaceId: 'ws', type: 'oauth' }]) mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess) const ctx = await getCredentialActorContext('c1', 'admin-user') @@ -74,7 +43,7 @@ describe('getCredentialActorContext', () => { }) it('does not derive credential admin on personal env credentials', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }], []] + queueTableRows(credential, [{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }]) mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess) const ctx = await getCredentialActorContext('c1', 'admin-user') @@ -83,7 +52,7 @@ describe('getCredentialActorContext', () => { }) it('is not admin for a non-admin without membership', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + queueTableRows(credential, [{ id: 'c1', workspaceId: 'ws', type: 'oauth' }]) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: false, @@ -96,8 +65,6 @@ describe('getCredentialActorContext', () => { }) it('returns empty context when the credential does not exist', async () => { - dbState.results = [[]] - const ctx = await getCredentialActorContext('missing', 'user1') expect(ctx.credential).toBeNull() @@ -106,7 +73,7 @@ describe('getCredentialActorContext', () => { }) it('exposes workspace access flags from checkWorkspaceAccess', async () => { - dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []] + queueTableRows(credential, [{ id: 'c1', workspaceId: 'ws', type: 'oauth' }]) mockCheckWorkspaceAccess.mockResolvedValue(noWorkspaceAccess) const ctx = await getCredentialActorContext('c1', 'outsider') diff --git a/apps/sim/lib/execution/payloads/large-value-metadata.test.ts b/apps/sim/lib/execution/payloads/large-value-metadata.test.ts index b7b0b337f15..2675a1f5bd2 100644 --- a/apps/sim/lib/execution/payloads/large-value-metadata.test.ts +++ b/apps/sim/lib/execution/payloads/large-value-metadata.test.ts @@ -2,142 +2,10 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAnd, - mockDelete, - mockEq, - mockExecute, - mockInsert, - mockOnConflictDoNothing, - mockSelect, - mockSelectFrom, - mockSelectLimit, - mockSelectWhere, - mockTransaction, - mockTxDelete, - mockTxInsert, - mockTxSelect, - mockTxSelectDistinct, - mockTxSelectFrom, - mockTxSelectLimit, - mockTxSelectWhere, - mockTxValues, - mockValues, - mockWhere, - mockTxWhere, - mockNotInArray, -} = vi.hoisted(() => { - const mockOnConflictDoNothing = vi.fn(async () => undefined) - const mockValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing })) - const mockInsert = vi.fn(() => ({ values: mockValues })) - const mockWhere = vi.fn(async () => undefined) - const mockDelete = vi.fn(() => ({ where: mockWhere })) - const mockSelectLimit = vi.fn(async () => []) - const mockSelectWhere = vi.fn(() => ({ limit: mockSelectLimit })) - const mockSelectFrom = vi.fn(() => ({ where: mockSelectWhere })) - const mockSelect = vi.fn(() => ({ from: mockSelectFrom })) - - const mockTxValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing })) - const mockTxInsert = vi.fn(() => ({ values: mockTxValues })) - const mockTxWhere = vi.fn(async () => undefined) - const mockTxDelete = vi.fn(() => ({ where: mockTxWhere })) - const mockTxSelectLimit = vi.fn(async () => []) - const mockTxSelectWhere = vi.fn(() => ({ limit: mockTxSelectLimit })) - const mockTxSelectFrom = vi.fn(() => ({ where: mockTxSelectWhere })) - const mockTxSelect = vi.fn(() => ({ from: mockTxSelectFrom })) - const mockTxSelectDistinct = vi.fn(() => ({ from: mockTxSelectFrom })) - - return { - mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - mockDelete, - mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), - mockExecute: vi.fn(async () => [{ count: 0 }]), - mockInsert, - mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })), - mockOnConflictDoNothing, - mockSelect, - mockSelectFrom, - mockSelectLimit, - mockSelectWhere, - mockTransaction: vi.fn(async (callback) => - callback({ - delete: mockTxDelete, - insert: mockTxInsert, - select: mockTxSelect, - selectDistinct: mockTxSelectDistinct, - }) - ), - mockTxDelete, - mockTxInsert, - mockTxSelect, - mockTxSelectDistinct, - mockTxSelectFrom, - mockTxSelectLimit, - mockTxSelectWhere, - mockTxValues, - mockValues, - mockWhere, - mockTxWhere, - } -}) - -vi.mock('@sim/db', () => { - const db = { - delete: mockDelete, - execute: mockExecute, - insert: mockInsert, - select: mockSelect, - transaction: mockTransaction, - } - return { - db, - // Exec-pool client shares the instance so the seeded chains still apply. - dbFor: () => db, - } -}) - -vi.mock('@sim/db/schema', () => ({ - executionLargeValueDependencies: { - childKey: 'executionLargeValueDependencies.childKey', - parentKey: 'executionLargeValueDependencies.parentKey', - workspaceId: 'executionLargeValueDependencies.workspaceId', - }, - executionLargeValueReferences: { - executionId: 'executionLargeValueReferences.executionId', - key: 'executionLargeValueReferences.key', - source: 'executionLargeValueReferences.source', - workspaceId: 'executionLargeValueReferences.workspaceId', - }, - executionLargeValues: { - key: 'executionLargeValues.key', - ownerExecutionId: 'executionLargeValues.ownerExecutionId', - workspaceId: 'executionLargeValues.workspaceId', - }, - pausedExecutions: { - executionId: 'pausedExecutions.executionId', - status: 'pausedExecutions.status', - }, - workflowExecutionLogs: { - executionId: 'workflowExecutionLogs.executionId', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - warn: vi.fn(), - })), -})) - -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - eq: mockEq, - inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), - notInArray: mockNotInArray, - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) - +import { executionLargeValueDependencies, executionLargeValueReferences } from '@sim/db/schema' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { eq, notInArray } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { addLargeValueReference, MAX_LARGE_VALUE_REFERENCES_PER_SCOPE, @@ -150,9 +18,13 @@ function largeValueKey(id: string, executionId = 'source-execution'): string { return `execution/workspace-1/workflow-1/${executionId}/large-value-lv_${id}.json` } +afterAll(resetDbChainMock) + describe('large value metadata', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.execute.mockResolvedValue([{ count: 0 }]) }) it('registers valid large value owner metadata', async () => { @@ -165,15 +37,15 @@ describe('large value metadata', () => { }) expect(registered).toBe(true) - expect(mockTxInsert).toHaveBeenCalledOnce() - expect(mockTxValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.insert).toHaveBeenCalledOnce() + expect(dbChainMockFns.values).toHaveBeenCalledWith({ key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json', workspaceId: 'workspace-1', workflowId: 'workflow-1', ownerExecutionId: 'execution-1', size: 124, }) - expect(mockOnConflictDoNothing).toHaveBeenCalledOnce() + expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledOnce() }) it('skips malformed owner keys', async () => { @@ -186,14 +58,14 @@ describe('large value metadata', () => { }) expect(registered).toBe(false) - expect(mockTxInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('records dependency closure for nested large value refs', async () => { const directKey = largeValueKey('abcdefghijkl') const transitiveKey = largeValueKey('mnopqrstuvwx', 'root-execution') const deepTransitiveKey = largeValueKey('deepqrstuvwx', 'deep-execution') - mockTxSelectLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ childKey: transitiveKey }]) .mockResolvedValueOnce([{ childKey: deepTransitiveKey }]) .mockResolvedValueOnce([]) @@ -210,8 +82,8 @@ describe('large value metadata', () => { ) expect(registered).toBe(true) - expect(mockTxSelectDistinct).toHaveBeenCalledTimes(3) - expect(mockTxValues).toHaveBeenLastCalledWith([ + expect(dbChainMockFns.selectDistinct).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.values).toHaveBeenLastCalledWith([ { parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json', childKey: directKey, @@ -246,9 +118,9 @@ describe('large value metadata', () => { keys ) - expect(mockTxValues).toHaveBeenCalledTimes(3) - expect(mockTxValues.mock.calls[1]?.[0]).toHaveLength(500) - expect(mockTxValues.mock.calls[2]?.[0]).toHaveLength(1) + expect(dbChainMockFns.values).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.values.mock.calls[1]?.[0]).toHaveLength(500) + expect(dbChainMockFns.values.mock.calls[2]?.[0]).toHaveLength(1) }) it('rejects reference sets over the metadata cardinality limit', async () => { @@ -272,7 +144,7 @@ describe('large value metadata', () => { it('limits dependency closure reads to the remaining reference budget', async () => { const directKey = largeValueKey('a00000000000') - mockTxSelectLimit.mockResolvedValueOnce( + dbChainMockFns.limit.mockResolvedValueOnce( Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({ childKey: largeValueKey(`c${index.toString(36).padStart(11, '0')}`), })) @@ -291,7 +163,7 @@ describe('large value metadata', () => { ) ).rejects.toThrow('Large value dependency closure exceeds the limit') - expect(mockTxSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) }) it('filters known dependency children before applying the remaining reference budget', async () => { @@ -300,13 +172,15 @@ describe('large value metadata', () => { ) const knownChildKey = directKeys[1] const unseenChildKey = largeValueKey('unseenchild1', 'source-execution') - mockTxSelectLimit.mockImplementationOnce(async () => { - const filtersKnownChildren = mockNotInArray.mock.calls.some( - ([field, values]) => - field === 'executionLargeValueDependencies.childKey' && - Array.isArray(values) && - values.includes(knownChildKey) - ) + dbChainMockFns.limit.mockImplementationOnce(async () => { + const filtersKnownChildren = vi + .mocked(notInArray) + .mock.calls.some( + ([field, values]) => + field === executionLargeValueDependencies.childKey && + Array.isArray(values) && + values.includes(knownChildKey) + ) return [{ childKey: filtersKnownChildren ? unseenChildKey : knownChildKey }] }) @@ -323,7 +197,7 @@ describe('large value metadata', () => { ) ).rejects.toThrow('Large value dependency closure exceeds the limit') - expect(mockTxSelectLimit).toHaveBeenCalledWith(1) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) }) it('replaces an execution reference set with same-workspace unique keys', async () => { @@ -366,10 +240,10 @@ describe('large value metadata', () => { } ) - expect(mockTransaction).toHaveBeenCalledOnce() - expect(mockTxDelete).toHaveBeenCalledOnce() - expect(mockEq).toHaveBeenCalledWith('executionLargeValueReferences.source', 'execution_log') - expect(mockTxValues).toHaveBeenCalledWith([ + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + expect(eq).toHaveBeenCalledWith(executionLargeValueReferences.source, 'execution_log') + expect(dbChainMockFns.values).toHaveBeenCalledWith([ { key: matchingKey, workspaceId: 'workspace-1', @@ -393,9 +267,9 @@ describe('large value metadata', () => { key ) - expect(mockSelectLimit).toHaveBeenCalledWith(1) - expect(mockSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1) - expect(mockValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1) + expect(dbChainMockFns.values).toHaveBeenCalledWith({ key, workspaceId: 'workspace-1', workflowId: 'workflow-1', @@ -405,7 +279,7 @@ describe('large value metadata', () => { }) it('rejects materialized references once the scope reaches the reference cap', async () => { - mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce( + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce( Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({ key: largeValueKey(`d${index.toString(36).padStart(11, '0')}`), })) @@ -423,11 +297,11 @@ describe('large value metadata', () => { ) ).rejects.toThrow('exceeding the limit') - expect(mockInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('prunes large value metadata in bounded batches', async () => { - mockExecute + dbChainMockFns.execute .mockResolvedValueOnce([{ count: 2 }]) .mockResolvedValueOnce([{ count: 3 }]) .mockResolvedValueOnce([{ count: 4 }]) @@ -454,7 +328,7 @@ describe('large value metadata', () => { maxRowsPerTable: 100, }) - const [query] = mockExecute.mock.calls[0] ?? [] + const [query] = dbChainMockFns.execute.mock.calls[0] ?? [] const sqlText = Array.isArray(query?.strings) ? query.strings.join(' ') : '' expect(sqlText).toContain("ref.source = 'execution_log'") expect(sqlText).toContain("ref.source = 'paused_snapshot'") diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index b8a2e8e3c24..6aff30c4ea0 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -22,8 +22,6 @@ const { mockResolveSystemBillingAttribution: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('drizzle-orm', () => ({ eq: vi.fn() })) vi.mock('@/lib/auth/ban', () => ({ getActivelyBannedUserIds: mockGetActivelyBannedUserIds, })) diff --git a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts index 56f54cee6aa..4dc1b8bf9c0 100644 --- a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts +++ b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts @@ -9,8 +9,6 @@ const { mockResolveSystemBillingAttribution } = vi.hoisted(() => ({ mockResolveSystemBillingAttribution: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: {} })) -vi.mock('drizzle-orm', () => ({ eq: vi.fn() })) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkServerSideUsageLimits: vi.fn(), })) diff --git a/apps/sim/lib/global-work/summary.test.ts b/apps/sim/lib/global-work/summary.test.ts index 4e6317e6b82..b97cf771aa5 100644 --- a/apps/sim/lib/global-work/summary.test.ts +++ b/apps/sim/lib/global-work/summary.test.ts @@ -2,15 +2,15 @@ * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import type { SQL } from 'drizzle-orm' import { PgDialect } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' - -const execute = vi.hoisted(() => vi.fn()) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.unmock('drizzle-orm') vi.unmock('@sim/db/schema') -vi.mock('@sim/db', () => ({ dbReplica: { execute } })) + +const execute = dbChainMockFns.execute const dialect = new PgDialect() @@ -32,7 +32,14 @@ import { getLatestCompletedGlobalWorkMonth, } from '@/lib/global-work/summary' +afterAll(resetDbChainMock) + describe('Global Work Pacific reporting windows', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + it('defaults to the latest completed Pacific month across a year boundary', () => { expect(getLatestCompletedGlobalWorkMonth(new Date('2026-01-15T12:00:00.000Z'))).toBe('2025-12') }) diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index 070ed03ea55..81c5627e456 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -1,34 +1,18 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockDbChain, - mockExecuteSync, - mockIsTriggerAvailable, - mockResolveTriggerRegion, - mockTrigger, -} = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } - return { - mockDbChain: chain, +const { mockExecuteSync, mockIsTriggerAvailable, mockResolveTriggerRegion, mockTrigger } = + vi.hoisted(() => ({ mockExecuteSync: vi.fn(), mockIsTriggerAvailable: vi.fn(), mockResolveTriggerRegion: vi.fn(), mockTrigger: vi.fn(), - } -}) + })) -vi.mock('@sim/db', () => ({ db: mockDbChain })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTrigger } })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: mockResolveTriggerRegion, @@ -66,13 +50,8 @@ const BILLING_ATTRIBUTION = { describe('connector sync queue', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.innerJoin.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.update.mockReturnThis() - mockDbChain.set.mockReturnThis() - mockDbChain.limit.mockResolvedValue([ + resetDbChainMock() + queueTableRows(schemaMock.knowledgeConnector, [ { knowledgeBaseId: 'knowledge-base-1', connectorArchivedAt: null, @@ -86,6 +65,10 @@ describe('connector sync queue', () => { mockTrigger.mockResolvedValue({ id: 'run-1' }) }) + afterAll(() => { + resetDbChainMock() + }) + it('preserves the actor and immutable workspace payer in the queued payload', async () => { await dispatchSync('connector-1', { billingAttribution: BILLING_ATTRIBUTION, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 5d30ce45dde..faa7fca4717 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1,11 +1,11 @@ /** * @vitest-environment node */ -import { authOAuthUtilsMock, urlsMock } from '@sim/testing' +import { authOAuthUtilsMock, dbChainMock, urlsMock } from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ db: {} })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('drizzle-orm', () => ({ and: vi.fn(), eq: vi.fn(), diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 4b83f955541..740986611fb 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -1,39 +1,10 @@ -import { envFlagsMock } from '@sim/testing' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { usageLog, workflow } from '@sim/db/schema' +import { dbChainMockFns, envFlagsMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' import { recordUsage } from '@/lib/billing/core/usage-log' import { ExecutionLogger } from '@/lib/logs/execution/logger' -const dbSelectMock = vi.hoisted(() => vi.fn()) -const dbExecuteMock = vi.hoisted(() => vi.fn()) -const txUpdateMock = vi.hoisted(() => - vi.fn(() => ({ set: () => ({ where: () => Promise.resolve() }) })) -) - -vi.mock('@sim/db', () => { - // The reconcile runs inside db.transaction with an advisory lock. The tx - // shares dbSelectMock so the existing call-order seeding (call 1 = workflow - // row via .limit, call 2 = already-billed via .groupBy) still applies; - // tx.execute (set_config + pg_advisory_xact_lock) is a no-op; tx.update backs - // the exact cost_total refine. - const tx = { - select: dbSelectMock, - insert: vi.fn(), - update: txUpdateMock, - execute: dbExecuteMock, - } - const db = { - select: dbSelectMock, - insert: vi.fn(), - update: vi.fn(), - execute: dbExecuteMock, - transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), - } - return { - db, - // Exec-pool client shares the instance so call-order seeding still applies. - dbFor: () => db, - } -}) +afterAll(resetDbChainMock) // Mock billing modules vi.mock('@/lib/billing/core/subscription', () => ({ @@ -149,6 +120,7 @@ describe('ExecutionLogger', () => { beforeEach(() => { logger = new ExecutionLogger() vi.clearAllMocks() + resetDbChainMock() }) describe('class instantiation', () => { @@ -549,6 +521,7 @@ describe('recordExecutionUsage boundary-delta reconciliation', () => { beforeEach(() => { logger = new ExecutionLogger() as any vi.clearAllMocks() + resetDbChainMock() }) const costSummary = (overrides: Record = {}) => ({ @@ -571,22 +544,12 @@ describe('recordExecutionUsage boundary-delta reconciliation', () => { }, } - // db.select() is called twice in recordExecutionUsage: first the workflow row - // (terminated by .limit), then the already-billed usage_log rows (terminated - // by .groupBy). Return each in order. + // recordExecutionUsage reads two tables: the workflow row (from(workflow) + // ... .limit(1)), then the already-billed usage_log rows (from(usageLog) + // ... .groupBy(...)). Route each result set by its table. const mockDb = (billedRows: Array>) => { - let call = 0 - dbSelectMock.mockImplementation(() => { - call += 1 - const rows = call === 1 ? [{ id: 'workflow-1', workspaceId: 'ws-1' }] : billedRows - const chain: any = { - from: () => chain, - where: () => chain, - limit: () => Promise.resolve(rows), - groupBy: () => Promise.resolve(rows), - } - return chain - }) + queueTableRows(workflow, [{ id: 'workflow-1', workspaceId: 'ws-1' }]) + queueTableRows(usageLog, billedRows) } const run = ( @@ -639,12 +602,12 @@ describe('recordExecutionUsage boundary-delta reconciliation', () => { // Returns the amount recorded at this boundary (drives threshold-email math). expect(recorded).toBeCloseTo(1.005, 8) // cost_total is refined to the exact ledger sum inside the locked tx. - expect(txUpdateMock).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) }) test('leaves Mothership model spend to cumulative update-cost while ledgering ordinary models', async () => { const setCostTotalMock = vi.fn(() => ({ where: () => Promise.resolve() })) - txUpdateMock.mockImplementationOnce(() => ({ set: setCostTotalMock })) + dbChainMockFns.update.mockReturnValueOnce({ set: setCostTotalMock }) const recorded = await run( costSummary({ @@ -938,7 +901,7 @@ describe('recordExecutionUsage boundary-delta reconciliation', () => { ) // set_config('lock_timeout') + pg_advisory_xact_lock both run on the tx. - expect(dbExecuteMock).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) expect(recordUsage).toHaveBeenCalledTimes(1) // The ledger INSERT participates in the locked transaction. expect(vi.mocked(recordUsage).mock.calls[0][0]).toHaveProperty('tx') diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index d07fe618eb5..4e9bf1f1646 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -1,39 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const dbMocks = vi.hoisted(() => { - const selectLimit = vi.fn() - const selectWhere = vi.fn() - const selectFrom = vi.fn() - const select = vi.fn() - const updateWhere = vi.fn() - const updateSet = vi.fn() - const update = vi.fn() - const execute = vi.fn() - const eq = vi.fn() - const and = vi.fn((...args: unknown[]) => ({ type: 'and', args })) - const sql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })) - - select.mockReturnValue({ from: selectFrom }) - selectFrom.mockReturnValue({ where: selectWhere }) - selectWhere.mockReturnValue({ limit: selectLimit }) - - update.mockReturnValue({ set: updateSet }) - updateSet.mockReturnValue({ where: updateWhere }) - - return { - select, - selectFrom, - selectWhere, - selectLimit, - update, - updateSet, - updateWhere, - execute, - eq, - and, - sql, - } -}) +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const dbMocks = vi.hoisted(() => ({ + eq: vi.fn(), + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), +})) const { completeWorkflowExecutionMock, @@ -47,19 +19,6 @@ const { releaseExecutionSlotMock: vi.fn(), })) -vi.mock('@sim/db', () => { - const db = { - select: dbMocks.select, - update: dbMocks.update, - execute: dbMocks.execute, - } - return { - db, - // Exec-pool client shares the instance so the seeded chains still apply. - dbFor: () => db, - } -}) - vi.mock('drizzle-orm', () => ({ eq: dbMocks.eq, and: dbMocks.and, @@ -116,9 +75,12 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import { LoggingSession } from './logging-session' +afterAll(resetDbChainMock) + describe('LoggingSession start snapshots', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() startWorkflowExecutionMock.mockResolvedValue({}) loadWorkflowStateForExecutionMock.mockResolvedValue({ blocks: { @@ -223,9 +185,8 @@ describe('LoggingSession start snapshots', () => { describe('LoggingSession completion retries', () => { beforeEach(() => { vi.clearAllMocks() - dbMocks.selectLimit.mockResolvedValue([{ executionData: {} }]) - dbMocks.updateWhere.mockResolvedValue(undefined) - dbMocks.execute.mockResolvedValue(undefined) + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValue([{ executionData: {} }]) }) it('keeps completion best-effort when a later error completion retries after full completion and fallback both fail', async () => { @@ -489,8 +450,8 @@ describe('LoggingSession completion retries', () => { await session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z') - expect(dbMocks.select).not.toHaveBeenCalled() - expect(dbMocks.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) }) it('enforces started marker monotonicity in the database write path', async () => { @@ -499,7 +460,7 @@ describe('LoggingSession completion retries', () => { await session.onBlockStart('block-1', 'Fetch', 'api', '2025-01-01T00:00:00.000Z') expect(dbMocks.sql).toHaveBeenCalled() - expect(dbMocks.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) }) it('allows same-millisecond started markers to replace the prior marker', async () => { @@ -522,8 +483,8 @@ describe('LoggingSession completion retries', () => { output: { value: true }, }) - expect(dbMocks.select).not.toHaveBeenCalled() - expect(dbMocks.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) }) it('allows same-millisecond completed markers to replace the prior marker', async () => { @@ -651,12 +612,11 @@ describe('LoggingSession completion retries', () => { describe('completeWithError cancelled-status guard', () => { beforeEach(() => { vi.clearAllMocks() - dbMocks.updateWhere.mockResolvedValue(undefined) - dbMocks.execute.mockResolvedValue(undefined) + resetDbChainMock() }) it('skips writing failed and marks session complete when DB status is already cancelled', async () => { - dbMocks.selectLimit.mockResolvedValue([{ status: 'cancelled' }]) + dbChainMockFns.limit.mockResolvedValue([{ status: 'cancelled' }]) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') await session.safeCompleteWithError({ error: { message: 'block errored mid-cancel' } }) @@ -666,7 +626,7 @@ describe('completeWithError cancelled-status guard', () => { }) it('writes failed when DB status is running (no cancel in flight)', async () => { - dbMocks.selectLimit.mockResolvedValue([{ status: 'running' }]) + dbChainMockFns.limit.mockResolvedValue([{ status: 'running' }]) completeWorkflowExecutionMock.mockResolvedValue({}) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') @@ -679,7 +639,7 @@ describe('completeWithError cancelled-status guard', () => { }) it('writes failed when no execution log exists yet', async () => { - dbMocks.selectLimit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) completeWorkflowExecutionMock.mockResolvedValue({}) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') @@ -691,7 +651,7 @@ describe('completeWithError cancelled-status guard', () => { }) it('deduplicates all subsequent completion attempts after guard early-return', async () => { - dbMocks.selectLimit.mockResolvedValue([{ status: 'cancelled' }]) + dbChainMockFns.limit.mockResolvedValue([{ status: 'cancelled' }]) completeWorkflowExecutionMock.mockResolvedValue({}) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') @@ -704,7 +664,7 @@ describe('completeWithError cancelled-status guard', () => { }) it('falls through to cost-only fallback when the DB check itself throws', async () => { - dbMocks.selectLimit.mockRejectedValueOnce(new Error('DB connection lost')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('DB connection lost')) completeWorkflowExecutionMock.mockResolvedValue({}) const session = new LoggingSession('workflow-1', 'execution-1', 'api', 'req-1') @@ -720,28 +680,27 @@ describe('completeWithError cancelled-status guard', () => { describe('LoggingSession.markExecutionAsFailed workflowId scoping', () => { beforeEach(() => { vi.clearAllMocks() - dbMocks.updateWhere.mockResolvedValue(undefined) + resetDbChainMock() }) it('scopes UPDATE by both executionId and workflowId', async () => { await LoggingSession.markExecutionAsFailed('exec-1', undefined, undefined, 'wf-1') - expect(dbMocks.update).toHaveBeenCalledTimes(1) - expect(dbMocks.updateSet).toHaveBeenCalledTimes(1) - expect(dbMocks.updateWhere).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) - const whereArgs = dbMocks.updateWhere.mock.calls[0] + const whereArgs = dbChainMockFns.where.mock.calls[0] expect(whereArgs).toBeDefined() }) it('instance markAsFailed forwards workflowId to the static method', async () => { - const updateWhereSpy = dbMocks.updateWhere - dbMocks.selectLimit.mockResolvedValue([{ executionData: {} }]) + dbChainMockFns.limit.mockResolvedValue([{ executionData: {} }]) const session = new LoggingSession('wf-42', 'exec-42', 'api', 'req-1') await session.markAsFailed('something went wrong') - expect(updateWhereSpy).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) expect(releaseExecutionSlotMock).toHaveBeenCalledWith('exec-42') }) @@ -800,7 +759,7 @@ describe('LoggingSession progress-marker write path', () => { loops: {}, parallels: {}, }) - dbMocks.execute.mockResolvedValue(undefined) + resetDbChainMock() }) it('writes markers to Redis (not the row) when Redis accepts the write', async () => { @@ -820,7 +779,7 @@ describe('LoggingSession progress-marker write path', () => { 'exec-redis', expect.objectContaining({ blockId: 'b1', success: true }) ) - expect(dbMocks.execute).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).not.toHaveBeenCalled() }) it('falls back to the SQL UPDATE when the Redis write fails', async () => { @@ -831,6 +790,6 @@ describe('LoggingSession progress-marker write path', () => { await session.onBlockStart('b1', 'Fetch', 'api', '2026-06-27T10:00:00.000Z') expect(setLastStartedBlockMock).toHaveBeenCalled() - expect(dbMocks.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts index d8ad2cdb6cf..ff6168733fc 100644 --- a/apps/sim/lib/logs/list-logs.test.ts +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -2,18 +2,12 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { jobExecutionLogs, workflowExecutionLogs } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { selectMock } = vi.hoisted(() => ({ selectMock: vi.fn() })) - -vi.mock('@sim/db', () => { - const instance = { select: selectMock } - return { db: instance, dbReplica: instance } -}) - -// Local drizzle-orm mock: the global mock's `sql` lacks `.as()` and the chain -// mock doesn't support `.orderBy().limit()`. We only need condition/sql builders -// to produce truthy stubs (the mocked db ignores them). +// Local drizzle-orm mock: the global mock's `sql` lacks `.as()`. We only need +// condition/sql builders to produce truthy stubs (the mocked db ignores them). vi.mock('drizzle-orm', () => { const make = (): Record => { const o: Record = {} @@ -64,15 +58,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import type { ListLogsParams } from './list-logs' import { decodeCursor, listLogs } from './list-logs' -/** A chainable, thenable query-builder stub that resolves to the given rows. */ -function builder(rows: unknown[]) { - const b: Record = {} - for (const method of ['from', 'leftJoin', 'innerJoin', 'where', 'orderBy', 'limit']) { - b[method] = () => b - } - ;(b as { then: unknown }).then = (resolve: (value: unknown) => unknown) => resolve(rows) - return b -} +afterAll(resetDbChainMock) function workflowRow(overrides: Record = {}) { return { @@ -136,12 +122,12 @@ function baseParams(overrides: Partial = {}): ListLogsParams { describe('listLogs', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('merges workflow and job rows into summaries', async () => { - selectMock - .mockReturnValueOnce(builder([workflowRow()])) - .mockReturnValueOnce(builder([jobRow()])) + queueTableRows(workflowExecutionLogs, [workflowRow()]) + queueTableRows(jobExecutionLogs, [jobRow()]) const result = await listLogs(baseParams(), 'user-1') @@ -165,14 +151,11 @@ describe('listLogs', () => { it('returns a decodable nextCursor when results exceed the limit', async () => { // limit 1, two workflow rows → page of 1, hasMore true - selectMock - .mockReturnValueOnce( - builder([ - workflowRow({ id: 'log-a', sortValue: new Date('2026-01-02T00:00:00.000Z') }), - workflowRow({ id: 'log-b', sortValue: new Date('2026-01-01T00:00:00.000Z') }), - ]) - ) - .mockReturnValueOnce(builder([])) + queueTableRows(workflowExecutionLogs, [ + workflowRow({ id: 'log-a', sortValue: new Date('2026-01-02T00:00:00.000Z') }), + workflowRow({ id: 'log-b', sortValue: new Date('2026-01-01T00:00:00.000Z') }), + ]) + queueTableRows(jobExecutionLogs, []) const result = await listLogs(baseParams({ limit: 1 }), 'user-1') @@ -183,12 +166,12 @@ describe('listLogs', () => { }) it('excludes job logs when a workflow-specific filter is present', async () => { - selectMock.mockReturnValueOnce(builder([workflowRow()])) + queueTableRows(workflowExecutionLogs, [workflowRow()]) const result = await listLogs(baseParams({ workflowIds: 'wf-1' }), 'user-1') // Only the workflow query runs; the job query is Promise.resolve([]). - expect(selectMock).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) expect(result.data).toHaveLength(1) expect(result.data[0].workflowId).toBe('wf-1') }) diff --git a/apps/sim/lib/mcp/oauth/revoke.test.ts b/apps/sim/lib/mcp/oauth/revoke.test.ts index 1e69c99f8d8..73583eb5926 100644 --- a/apps/sim/lib/mcp/oauth/revoke.test.ts +++ b/apps/sim/lib/mcp/oauth/revoke.test.ts @@ -8,7 +8,8 @@ * raw `fetch`. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/' const PUBLIC_SERVER_URL = 'https://mcp.attacker.com' @@ -20,14 +21,12 @@ const { mockDiscoverOAuthServerInfo, mockLoadOauthRow, mockDecryptSecret, - mockDbSelect, } = vi.hoisted(() => ({ mockUndiciFetch: vi.fn(), mockValidateMcpServerSsrf: vi.fn(), mockDiscoverOAuthServerInfo: vi.fn(), mockLoadOauthRow: vi.fn(), mockDecryptSecret: vi.fn(), - mockDbSelect: vi.fn(), })) vi.mock('@/lib/core/security/input-validation.server', () => ({ @@ -50,24 +49,22 @@ vi.mock('@/lib/mcp/oauth/storage', () => ({ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) -vi.mock('@sim/db', () => ({ - db: { select: mockDbSelect }, -})) +vi.mock('@sim/db', () => dbChainMock) import { revokeMcpOauthTokens } from './revoke' function wireServerRow(row: Record) { - const builder = { - from: () => builder, - where: () => builder, - limit: () => Promise.resolve([row]), - } - mockDbSelect.mockReturnValue(builder) + queueTableRows(schemaMock.mcpServers, [row]) } describe('revokeMcpOauthTokens — SSRF guard', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockLoadOauthRow.mockResolvedValue({ tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' }, diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index b4fa0fcffe4..fab52b73106 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -1,23 +1,18 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { RowExecutionMetadata, TableDefinition, WorkflowGroup } from '@/lib/table/types' -const { mockAppendTableEvent, mockTransaction, mockUpdateRow, mockWriteExecutionsPatch } = - vi.hoisted(() => ({ - mockAppendTableEvent: vi.fn(), - mockTransaction: vi.fn(), - mockUpdateRow: vi.fn(), - mockWriteExecutionsPatch: vi.fn(), - })) - -vi.mock('@sim/db', () => ({ - db: { - transaction: mockTransaction, - }, +const { mockAppendTableEvent, mockUpdateRow, mockWriteExecutionsPatch } = vi.hoisted(() => ({ + mockAppendTableEvent: vi.fn(), + mockUpdateRow: vi.fn(), + mockWriteExecutionsPatch: vi.fn(), })) +vi.mock('@sim/db', () => dbChainMock) + vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent, })) @@ -77,9 +72,13 @@ const RUNNING_STATE: RowExecutionMetadata = { } describe('writeWorkflowGroupState', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() - mockTransaction.mockImplementation(async (callback) => callback({})) + resetDbChainMock() mockWriteExecutionsPatch.mockResolvedValue('wrote') mockUpdateRow.mockResolvedValue({}) mockAppendTableEvent.mockResolvedValue(null) @@ -90,7 +89,7 @@ describe('writeWorkflowGroupState', () => { 'wrote' ) - expect(mockTransaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() expect(mockWriteExecutionsPatch).toHaveBeenCalledWith( expect.anything(), TABLE.id, diff --git a/apps/sim/lib/table/snapshot-cache.test.ts b/apps/sim/lib/table/snapshot-cache.test.ts index 2fe0927c537..35f5b4d1310 100644 --- a/apps/sim/lib/table/snapshot-cache.test.ts +++ b/apps/sim/lib/table/snapshot-cache.test.ts @@ -1,26 +1,18 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockDb, - mockSelectExportRowPage, - mockCreateMultipartUpload, - mockHeadObject, - mockDeleteFile, -} = vi.hoisted(() => { - const limit = vi.fn() - return { - mockDb: { limit, select: () => ({ from: () => ({ where: () => ({ limit }) }) }) }, +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSelectExportRowPage, mockCreateMultipartUpload, mockHeadObject, mockDeleteFile } = + vi.hoisted(() => ({ mockSelectExportRowPage: vi.fn(), mockCreateMultipartUpload: vi.fn(), mockHeadObject: vi.fn(), mockDeleteFile: vi.fn(), - } -}) + })) -vi.mock('@sim/db', () => ({ db: mockDb })) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/table/jobs/service', () => ({ selectExportRowPage: mockSelectExportRowPage })) vi.mock('@/lib/uploads/core/storage-service', () => ({ createMultipartUpload: mockCreateMultipartUpload, @@ -45,12 +37,17 @@ let lastHandle: { /** Queue the values successive `readRowsVersion` calls return. */ function versions(...values: number[]) { - for (const v of values) mockDb.limit.mockResolvedValueOnce([{ rowsVersion: v }]) + for (const v of values) queueTableRows(schemaMock.userTableDefinitions, [{ rowsVersion: v }]) } describe('getOrCreateTableSnapshot', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() lastHandle = null mockDeleteFile.mockResolvedValue(undefined) mockSelectExportRowPage.mockResolvedValueOnce([ diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 8409705850d..2ffbf360fe7 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -2,27 +2,12 @@ * @vitest-environment node */ import { credential } from '@sim/db/schema' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetDbChainMock } from '@sim/testing' +import { eq } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' -const { mockEq, mockFrom, mockLimit, mockSelect, mockWhere } = vi.hoisted(() => ({ - mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), - mockFrom: vi.fn(), - mockLimit: vi.fn(), - mockSelect: vi.fn(), - mockWhere: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ db: { select: mockSelect } })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions })), - eq: mockEq, - inArray: vi.fn((...args: unknown[]) => ({ args })), - isNull: vi.fn((value: unknown) => ({ value })), - or: vi.fn((...conditions: unknown[]) => ({ conditions })), -})) - // deploy.ts pulls in the trigger/block/provider registries at module load; none are exercised by // buildProviderConfig (a pure function), so stub them to keep this unit test fast and isolated. vi.mock('@/blocks', () => ({ getBlock: vi.fn() })) @@ -42,6 +27,8 @@ vi.mock('@/lib/webhooks/pending-verification', () => ({ import { buildProviderConfig, resolveTriggerCredentialId } from '@/lib/webhooks/deploy' +afterAll(resetDbChainMock) + const trigger = (subBlocks: Partial[]): { subBlocks: SubBlockConfig[] } => ({ subBlocks: subBlocks as SubBlockConfig[], }) @@ -96,10 +83,7 @@ function makeBlock( beforeEach(() => { vi.clearAllMocks() - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([{ id: 'credential-1' }]) + resetDbChainMock() }) describe('buildProviderConfig canonical collapse', () => { @@ -200,10 +184,10 @@ describe('resolveTriggerCredentialId', () => { it('canonicalizes an OAuth service alias at the credential lookup boundary', async () => { await resolveTriggerCredentialId('credential-1', 'workspace-1', 'gmail') - expect(mockEq).toHaveBeenCalledWith(credential.workspaceId, 'workspace-1') - expect(mockEq).toHaveBeenCalledWith(credential.type, 'oauth') - expect(mockEq).toHaveBeenCalledWith(credential.providerId, 'google-email') - expect(mockEq).toHaveBeenCalledWith(credential.id, 'credential-1') - expect(mockEq).toHaveBeenCalledWith(credential.accountId, 'credential-1') + expect(eq).toHaveBeenCalledWith(credential.workspaceId, 'workspace-1') + expect(eq).toHaveBeenCalledWith(credential.type, 'oauth') + expect(eq).toHaveBeenCalledWith(credential.providerId, 'google-email') + expect(eq).toHaveBeenCalledWith(credential.id, 'credential-1') + expect(eq).toHaveBeenCalledWith(credential.accountId, 'credential-1') }) }) diff --git a/apps/sim/lib/webhooks/polling/utils.test.ts b/apps/sim/lib/webhooks/polling/utils.test.ts index 137c70b3667..48deafe868d 100644 --- a/apps/sim/lib/webhooks/polling/utils.test.ts +++ b/apps/sim/lib/webhooks/polling/utils.test.ts @@ -1,37 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockUpdate, mockSet, mockWhere, mockSelect, mockSelectRows, sqlCalls } = vi.hoisted(() => { - const mockSelectRows = vi.fn() - return { - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhere: vi.fn(), - mockSelectRows, - mockSelect: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: mockSelectRows, - })), - })), - })), - sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>, - } -}) +import { account } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ db: { update: mockUpdate, select: mockSelect } })) -vi.mock('@sim/db/schema', () => ({ - webhook: { - id: 'webhook.id', - providerConfig: 'webhook.providerConfig', - updatedAt: 'webhook.updatedAt', - }, - account: {}, - workflow: {}, - workflowDeploymentVersion: {}, +const { sqlCalls } = vi.hoisted(() => ({ + sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>, })) + vi.mock('drizzle-orm', () => ({ sql: (strings: readonly string[], ...values: unknown[]) => { const node = { strings, values } @@ -59,6 +36,8 @@ import { resolveOAuthAccountId, } from '@/app/api/auth/oauth/utils' +afterAll(resetDbChainMock) + const logger = { error: vi.fn() } as never function allInterpolatedValues(): unknown[] { @@ -72,10 +51,8 @@ function allSqlText(): string { describe('updateWebhookProviderConfig (atomic jsonb merge)', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() sqlCalls.length = 0 - mockWhere.mockResolvedValue(undefined) - mockSet.mockReturnValue({ where: mockWhere }) - mockUpdate.mockReturnValue({ set: mockSet }) }) it('merges defined keys (null preserved) and removes undefined keys', async () => { @@ -85,7 +62,7 @@ describe('updateWebhookProviderConfig (atomic jsonb merge)', () => { logger ) - expect(mockUpdate).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) expect(allInterpolatedValues()).toContain(JSON.stringify({ historyId: 'h1', nulled: null })) expect(allInterpolatedValues()).toContainEqual(['cleared']) }) @@ -114,14 +91,14 @@ describe('resolveOAuthCredential (single-credential polling)', () => { beforeEach(() => { vi.clearAllMocks() - mockSelectRows.mockResolvedValue([]) + resetDbChainMock() }) it('resolves via credentialId: account lookup then token refresh', async () => { vi.mocked(resolveOAuthAccountId).mockResolvedValue({ accountId: 'acc-1', } as Awaited>) - mockSelectRows.mockResolvedValue([{ userId: 'owner-1' }]) + queueTableRows(account, [{ userId: 'owner-1' }]) vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('tok-abc') const token = await resolveOAuthCredential( @@ -148,7 +125,6 @@ describe('resolveOAuthCredential (single-credential polling)', () => { vi.mocked(resolveOAuthAccountId).mockResolvedValue({ accountId: 'acc-missing', } as Awaited>) - mockSelectRows.mockResolvedValue([]) await expect( resolveOAuthCredential(makeWebhook({ credentialId: 'cred-1' }), 'google-email', 'req-1') diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 92f0e9c6927..21d5c00eee0 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -5,14 +5,18 @@ import type { webhook, workflow } from '@sim/db/schema' import { createMockRequest, + dbChainMock, envFlagsMock, executionPreprocessingMock, executionPreprocessingMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ADMISSION_ERROR_CODE, ADMISSION_RETRY_AFTER_SECONDS, @@ -33,7 +37,6 @@ const { mockReleaseExecutionSlot, mockProviderHandler, mockShouldExecuteInline, - mockWebhookLookupResult, } = vi.hoisted(() => ({ mockGenerateId: vi.fn(), mockAdmissionRelease: vi.fn(), @@ -42,39 +45,11 @@ const { mockReleaseExecutionSlot: vi.fn(), mockProviderHandler: { current: {} as Record }, mockShouldExecuteInline: vi.fn(), - mockWebhookLookupResult: { - rows: [] as WebhookLookupRow[], - claim: [] as Array<{ workflowId: string }>, - }, })) const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution -vi.mock('@sim/db', () => { - const selectChain = { - from: () => selectChain, - innerJoin: () => selectChain, - leftJoin: () => selectChain, - where: () => ({ - then: (resolve: (rows: WebhookLookupRow[]) => void) => resolve(mockWebhookLookupResult.rows), - limit: () => Promise.resolve(mockWebhookLookupResult.claim), - }), - } - return { - db: { select: () => selectChain }, - webhook: {}, - webhookPathClaim: {}, - workflow: {}, - workflowDeploymentVersion: {}, - } -}) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), - or: vi.fn(), -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId, @@ -167,6 +142,8 @@ import { processPolledWebhookEvent, } from '@/lib/webhooks/processor' +afterAll(resetDbChainMock) + function makeWebhookRecord(overrides: Partial): WebhookRecord { const now = new Date('2026-01-01T00:00:00.000Z') return { @@ -228,8 +205,7 @@ const billingAttribution = { describe('findAllWebhooksForPath cross-tenant collision', () => { beforeEach(() => { vi.clearAllMocks() - mockWebhookLookupResult.rows = [] - mockWebhookLookupResult.claim = [] + resetDbChainMock() }) const makeRow = (workflowId: string, webhookId: string, createdAt: Date) => ({ @@ -237,11 +213,16 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { workflow: { id: workflowId }, }) + const queueLookup = (rows: WebhookLookupRow[], claim: Array<{ workflowId: string }> = []) => { + queueTableRows(schemaMock.webhook, rows) + queueTableRows(schemaMock.webhookPathClaim, claim) + } + it('returns all rows when they belong to a single workflow', async () => { - mockWebhookLookupResult.rows = [ + queueLookup([ makeRow('workflow-1', 'wh-a', new Date('2026-01-01')), makeRow('workflow-1', 'wh-b', new Date('2026-01-02')), - ] + ]) const results = await findAllWebhooksForPath({ requestId: 'req-1', path: 'shared-path' }) @@ -252,7 +233,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { it('drops foreign rows when a path collides across workflows, keeping the earliest owner', async () => { const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01')) const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01')) - mockWebhookLookupResult.rows = [attacker, victim] + queueLookup([attacker, victim]) const results = await findAllWebhooksForPath({ requestId: 'req-2', path: 'shared-path' }) @@ -264,8 +245,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { it('prefers the path-claim owner over an earlier-created interloper', async () => { const interloper = makeRow('interloper-workflow', 'interloper-wh', new Date('2026-01-01')) const claimHolder = makeRow('claim-workflow', 'claim-wh', new Date('2026-05-01')) - mockWebhookLookupResult.rows = [interloper, claimHolder] - mockWebhookLookupResult.claim = [{ workflowId: 'claim-workflow' }] + queueLookup([interloper, claimHolder], [{ workflowId: 'claim-workflow' }]) const results = await findAllWebhooksForPath({ requestId: 'req-6', path: 'shared-path' }) @@ -276,8 +256,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { it('falls back to earliest registration when the claim owner has no deliverable rows', async () => { const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01')) const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01')) - mockWebhookLookupResult.rows = [attacker, victim] - mockWebhookLookupResult.claim = [{ workflowId: 'absent-workflow' }] + queueLookup([attacker, victim], [{ workflowId: 'absent-workflow' }]) const results = await findAllWebhooksForPath({ requestId: 'req-7', path: 'shared-path' }) @@ -289,7 +268,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01')) const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03')) const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01')) - mockWebhookLookupResult.rows = [victimB, attacker, victimA] + queueLookup([victimB, attacker, victimA]) const results = await findAllWebhooksForPath({ requestId: 'req-5', path: 'shared-path' }) @@ -299,8 +278,6 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { }) it('returns an empty array when no webhooks match', async () => { - mockWebhookLookupResult.rows = [] - const results = await findAllWebhooksForPath({ requestId: 'req-3', path: 'missing' }) expect(results).toEqual([]) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.test.ts b/apps/sim/lib/webhooks/providers/whatsapp.test.ts index a0ff2b8a9ba..e356365c051 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.test.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.test.ts @@ -2,13 +2,11 @@ * @vitest-environment node */ import { createHmac } from 'node:crypto' +import { dbChainMock, schemaMock } from '@sim/testing' import { NextRequest } from 'next/server' import { describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: {}, - workflowDeploymentVersion: {}, -})) +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) import { whatsappHandler } from './whatsapp' diff --git a/apps/sim/lib/webhooks/registration-store.test.ts b/apps/sim/lib/webhooks/registration-store.test.ts index bcb54a43e79..5a0641f3304 100644 --- a/apps/sim/lib/webhooks/registration-store.test.ts +++ b/apps/sim/lib/webhooks/registration-store.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' interface Condition { kind: string @@ -10,16 +11,9 @@ interface Condition { conditions?: Condition[] } -const { mockTransaction, mockIsDeploymentOperationCurrent, mockClaimWebhookPath } = vi.hoisted( - () => ({ - mockTransaction: vi.fn(), - mockIsDeploymentOperationCurrent: vi.fn(), - mockClaimWebhookPath: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { transaction: mockTransaction }, +const { mockIsDeploymentOperationCurrent, mockClaimWebhookPath } = vi.hoisted(() => ({ + mockIsDeploymentOperationCurrent: vi.fn(), + mockClaimWebhookPath: vi.fn(), })) vi.mock('drizzle-orm', () => ({ @@ -60,6 +54,8 @@ import { type WebhookRegistrationOperationFence, } from '@/lib/webhooks/registration-store' +afterAll(resetDbChainMock) + const FENCE: WebhookRegistrationOperationFence = { workflowId: 'workflow-1', operationId: 'operation-1', @@ -154,6 +150,7 @@ function activeRow(overrides: Record = {}) { describe('activateWebhookRegistrations', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsDeploymentOperationCurrent.mockResolvedValue(true) }) @@ -219,17 +216,18 @@ describe('activateWebhookRegistrations', () => { describe('prepareWebhookRegistrationIntents', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsDeploymentOperationCurrent.mockResolvedValue(true) mockClaimWebhookPath.mockResolvedValue('hooks/a') - mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise) => { - throw new Error('mockTransaction not configured for this test') + dbChainMockFns.transaction.mockImplementation(async () => { + throw new Error('db.transaction not configured for this test') }) }) function runInTx(selectResults: unknown[][]) { const harness = createTx(selectResults) - mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise) => - callback(harness.tx) + dbChainMockFns.transaction.mockImplementation( + async (callback: (tx: DbOrTx) => Promise) => callback(harness.tx) ) return harness } diff --git a/apps/sim/lib/webhooks/utils.server.test.ts b/apps/sim/lib/webhooks/utils.server.test.ts index 6668984a600..15ddf9e3d6b 100644 --- a/apps/sim/lib/webhooks/utils.server.test.ts +++ b/apps/sim/lib/webhooks/utils.server.test.ts @@ -1,60 +1,21 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -interface Condition { - kind: string - column?: unknown - value?: unknown - conditions?: Condition[] -} - -const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() })) - -vi.mock('@sim/db', () => ({ db: { select: mockSelect } })) - -vi.mock('drizzle-orm', () => ({ - and: (...conditions: Condition[]) => ({ kind: 'and', conditions }), - eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }), - isNull: (column: unknown) => ({ kind: 'isNull', column }), -})) - +import { webhook, webhookPathClaim } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' -function claimLookupChain(rows: unknown[], captureCondition?: (condition: Condition) => void) { - return { - from: vi.fn(() => ({ - where: vi.fn((condition: Condition) => { - captureCondition?.(condition) - return { limit: vi.fn().mockResolvedValue(rows) } - }), - })), - } -} - -function liveRowsChain(rows: unknown[]) { - return { - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn().mockResolvedValue(rows), - })), - })), - } -} +afterAll(resetDbChainMock) describe('findConflictingWebhookPathOwner', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('returns the claim owner while the claim holder is mid-rotation', async () => { - let claimCondition: Condition | undefined - mockSelect.mockReturnValueOnce( - claimLookupChain([{ workflowId: 'workflow-owner' }], (condition) => { - claimCondition = condition - }) - ) + queueTableRows(webhookPathClaim, [{ workflowId: 'workflow-owner' }]) const owner = await findConflictingWebhookPathOwner({ path: ' /leads/ ', @@ -62,14 +23,15 @@ describe('findConflictingWebhookPathOwner', () => { }) expect(owner).toBe('workflow-owner') - expect(mockSelect).toHaveBeenCalledTimes(1) - expect(claimCondition).toEqual(expect.objectContaining({ kind: 'eq', value: 'leads' })) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ type: 'eq', right: 'leads' }) + ) }) it('ignores the caller-owned claim and falls through to live rows', async () => { - mockSelect - .mockReturnValueOnce(claimLookupChain([{ workflowId: 'workflow-caller' }])) - .mockReturnValueOnce(liveRowsChain([])) + queueTableRows(webhookPathClaim, [{ workflowId: 'workflow-caller' }]) + queueTableRows(webhook, []) const owner = await findConflictingWebhookPathOwner({ path: 'leads', @@ -77,15 +39,12 @@ describe('findConflictingWebhookPathOwner', () => { }) expect(owner).toBeNull() - expect(mockSelect).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) }) it('returns a foreign live-row owner when no claim exists', async () => { - mockSelect - .mockReturnValueOnce(claimLookupChain([])) - .mockReturnValueOnce( - liveRowsChain([{ workflowId: 'workflow-caller' }, { workflowId: 'workflow-foreign' }]) - ) + queueTableRows(webhookPathClaim, []) + queueTableRows(webhook, [{ workflowId: 'workflow-caller' }, { workflowId: 'workflow-foreign' }]) const owner = await findConflictingWebhookPathOwner({ path: 'leads', @@ -96,7 +55,7 @@ describe('findConflictingWebhookPathOwner', () => { }) it('skips the claim lookup entirely for empty paths', async () => { - mockSelect.mockReturnValueOnce(liveRowsChain([])) + queueTableRows(webhook, []) const owner = await findConflictingWebhookPathOwner({ path: ' ', @@ -104,6 +63,6 @@ describe('findConflictingWebhookPathOwner', () => { }) expect(owner).toBeNull() - expect(mockSelect).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index 1013b2280f4..671e6c73df6 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -1,23 +1,24 @@ /** * @vitest-environment node */ -import { permissionsMock, permissionsMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + permissionsMock, + permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockTransaction: vi.fn(), +const { mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({ mockArchiveWorkflowsForWorkspace: vi.fn(), })) const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - transaction: mockTransaction, - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/lifecycle', () => ({ archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args), @@ -38,6 +39,11 @@ function createUpdateChain() { describe('workspace lifecycle', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('archives workspace and dependent resources', async () => { @@ -48,11 +54,7 @@ describe('workspace lifecycle', () => { archivedAt: null, }) mockArchiveWorkflowsForWorkspace.mockResolvedValue(2) - mockSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'server-1' }]), - }), - }) + queueTableRows(schemaMock.workflowMcpServer, [{ id: 'server-1' }]) const tx = { select: vi.fn().mockReturnValue({ @@ -65,8 +67,8 @@ describe('workspace lifecycle', () => { where: vi.fn().mockResolvedValue([]), })), } - mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => - callback(tx) + dbChainMockFns.transaction.mockImplementation( + async (callback: (trx: typeof tx) => Promise) => callback(tx) ) const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' }) @@ -99,6 +101,6 @@ describe('workspace lifecycle', () => { expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { requestId: 'req-1', }) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspaces/organization-workspaces.test.ts b/apps/sim/lib/workspaces/organization-workspaces.test.ts index d9f2804624d..d84c3571cf7 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.test.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.test.ts @@ -1,101 +1,32 @@ /** * @vitest-environment node */ -import { schemaMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbResults, - mockUpdateWhere, - mockUpdateReturning, - mockUpdateSet, - mockDbUpdate, - mockOnConflictDoUpdate, - mockInsertValues, - mockDbInsert, mockEnsureUserInOrganizationTx, mockSyncUsageLimitsFromSubscription, mockReapplyPaidOrgJoinBillingForExistingMemberTx, mockAcquireOrganizationMutationLock, mockAcquireInvitationMutationLocks, mockChangeWorkspaceStoragePayersInTx, - mockSelectForUpdate, -} = vi.hoisted(() => { - const mockDbResults: { value: any[] } = { value: [] } - const mockUpdateReturning = vi.fn() - const mockUpdateWhere = vi.fn().mockReturnValue({ returning: mockUpdateReturning }) - const mockUpdateSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) - const mockDbUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) - const mockOnConflictDoUpdate = vi.fn().mockResolvedValue(undefined) - const mockInsertValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: mockOnConflictDoUpdate, - }) - const mockDbInsert = vi.fn().mockReturnValue({ values: mockInsertValues }) - const mockEnsureUserInOrganizationTx = vi.fn() - const mockSyncUsageLimitsFromSubscription = vi.fn().mockResolvedValue(undefined) - const mockReapplyPaidOrgJoinBillingForExistingMemberTx = vi.fn().mockResolvedValue({ - proUsageSnapshotted: false, - proCancelledAtPeriodEnd: false, - }) - const mockAcquireOrganizationMutationLock = vi.fn() - const mockAcquireInvitationMutationLocks = vi.fn() - const mockChangeWorkspaceStoragePayersInTx = vi.fn() - const mockSelectForUpdate = vi.fn() - - return { - mockDbResults, - mockUpdateWhere, - mockUpdateReturning, - mockUpdateSet, - mockDbUpdate, - mockOnConflictDoUpdate, - mockInsertValues, - mockDbInsert, - mockEnsureUserInOrganizationTx, - mockSyncUsageLimitsFromSubscription, - mockReapplyPaidOrgJoinBillingForExistingMemberTx, - mockAcquireOrganizationMutationLock, - mockAcquireInvitationMutationLocks, - mockChangeWorkspaceStoragePayersInTx, - mockSelectForUpdate, - } -}) - -vi.mock('@sim/db', () => { - const selectImpl = vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.orderBy = vi.fn().mockReturnValue(chain) - chain.for = vi.fn().mockImplementation(() => { - mockSelectForUpdate() - return chain - }) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const result = mockDbResults.value.shift() || [] - return Promise.resolve(callback ? callback(result) : result) - }) - return chain - }) - const txObject = { - select: selectImpl, - update: mockDbUpdate, - insert: mockDbInsert, - } - return { - db: { - select: selectImpl, - update: mockDbUpdate, - insert: mockDbInsert, - transaction: vi.fn(async (fn: (tx: typeof txObject) => unknown) => fn(txObject)), - }, - } -}) +} = vi.hoisted(() => ({ + mockEnsureUserInOrganizationTx: vi.fn(), + mockSyncUsageLimitsFromSubscription: vi.fn(), + mockReapplyPaidOrgJoinBillingForExistingMemberTx: vi.fn(), + mockAcquireOrganizationMutationLock: vi.fn(), + mockAcquireInvitationMutationLocks: vi.fn(), + mockChangeWorkspaceStoragePayersInTx: vi.fn(), +})) -vi.mock('@sim/db/schema', () => schemaMock) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: mockAcquireOrganizationMutationLock, @@ -129,26 +60,31 @@ import { describe('organization workspace helpers', () => { beforeEach(() => { vi.clearAllMocks() - mockDbResults.value = [] + resetDbChainMock() mockEnsureUserInOrganizationTx.mockReset() - mockUpdateReturning.mockReset() mockChangeWorkspaceStoragePayersInTx.mockReset() mockSyncUsageLimitsFromSubscription.mockResolvedValue(undefined) + mockReapplyPaidOrgJoinBillingForExistingMemberTx.mockResolvedValue({ + proUsageSnapshotted: false, + proCancelledAtPeriodEnd: false, + }) + }) + + afterAll(() => { + resetDbChainMock() }) it('attaches owned workspaces to an organization and syncs existing members', async () => { - mockDbResults.value = [ - [{ id: 'ws-1' }, { id: 'ws-2' }], - [{ id: 'ws-1' }, { id: 'ws-2' }], - [ - { id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }, - { id: 'ws-2', billedAccountUserId: 'user-1', organizationId: null }, - ], - [{ userId: 'owner-1' }], - [{ userId: 'owner-1' }, { userId: 'member-1' }], - [{ userId: 'owner-1', organizationId: 'org-1' }], - ] - mockUpdateReturning.mockResolvedValueOnce([{ id: 'ws-2' }, { id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }, { id: 'ws-2' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }, { id: 'ws-2' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }, + { id: 'ws-2', billedAccountUserId: 'user-1', organizationId: null }, + ]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.permissions, [{ userId: 'owner-1' }, { userId: 'member-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1', organizationId: 'org-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'ws-2' }, { id: 'ws-1' }]) mockEnsureUserInOrganizationTx .mockResolvedValueOnce({ success: true, @@ -194,11 +130,11 @@ describe('organization workspace helpers', () => { 'owner-1', 'org-1' ) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ organizationAssignedAt: expect.any(Date) }) ) expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledTimes(1) - expect(mockSelectForUpdate.mock.invocationCallOrder[0]).toBeLessThan( + expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( mockEnsureUserInOrganizationTx.mock.invocationCallOrder[0] ) expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledWith(expect.anything(), [ @@ -221,23 +157,23 @@ describe('organization workspace helpers', () => { }, }, ]) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbInsert).toHaveBeenCalledTimes(1) - expect(mockInsertValues).toHaveBeenCalledWith([ + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ expect.objectContaining({ entityId: 'ws-1', userId: 'owner-1' }), expect.objectContaining({ entityId: 'ws-2', userId: 'owner-1' }), ]) }) it('fails before attaching workspaces when an existing member belongs to another organization', async () => { - mockDbResults.value = [ - [{ id: 'ws-1' }], - [{ id: 'ws-1' }], - [{ id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }], - [{ userId: 'owner-1' }], - [{ userId: 'owner-1' }, { userId: 'member-2' }], - [{ userId: 'member-2', organizationId: 'org-2' }], - ] + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }, + ]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.permissions, [{ userId: 'owner-1' }, { userId: 'member-2' }]) + queueTableRows(schemaMock.member, [{ userId: 'member-2', organizationId: 'org-2' }]) await expect( attachOwnedWorkspacesToOrganization({ @@ -247,19 +183,19 @@ describe('organization workspace helpers', () => { ).rejects.toBeInstanceOf(WorkspaceOrganizationMembershipConflictError) expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('keeps cross-org members external and still attaches when policy is keep-external', async () => { - mockDbResults.value = [ - [{ id: 'ws-1' }], - [{ id: 'ws-1' }], - [{ id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }], - [{ userId: 'owner-1' }], - [{ userId: 'owner-1' }, { userId: 'member-2' }], - [{ userId: 'member-2', organizationId: 'org-2' }], - ] - mockUpdateReturning.mockResolvedValueOnce([{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }, + ]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.permissions, [{ userId: 'owner-1' }, { userId: 'member-2' }]) + queueTableRows(schemaMock.member, [{ userId: 'member-2', organizationId: 'org-2' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'ws-1' }]) mockEnsureUserInOrganizationTx.mockResolvedValueOnce({ success: true, alreadyMember: true, @@ -287,11 +223,13 @@ describe('organization workspace helpers', () => { expect.anything(), expect.objectContaining({ userId: 'owner-1' }) ) - expect(mockDbUpdate).toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalled() }) it('rolls back membership work when a concurrent move wins before the locked re-read', async () => { - mockDbResults.value = [[{ id: 'ws-1' }], [{ id: 'ws-1' }], []] + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, []) const result = await attachOwnedWorkspacesToOrganization({ ownerUserId: 'user-1', @@ -309,19 +247,19 @@ describe('organization workspace helpers', () => { workspaceIds: ['ws-1'], }) expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() - expect(mockDbUpdate).not.toHaveBeenCalled() - expect(mockDbInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('does not report a committed attachment as failed when derived usage refresh fails', async () => { - mockDbResults.value = [ - [{ id: 'ws-1' }], - [{ id: 'ws-1' }], - [{ id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }], - [{ userId: 'owner-1' }], - [{ userId: 'member-1' }], - [], - ] + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', billedAccountUserId: 'user-1', organizationId: null }, + ]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.permissions, [{ userId: 'member-1' }]) + queueTableRows(schemaMock.member, []) mockEnsureUserInOrganizationTx.mockResolvedValueOnce({ success: true, alreadyMember: false, @@ -331,7 +269,7 @@ describe('organization workspace helpers', () => { proCancelledAtPeriodEnd: false, }, }) - mockUpdateReturning.mockResolvedValueOnce([{ id: 'ws-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'ws-1' }]) mockSyncUsageLimitsFromSubscription.mockRejectedValueOnce(new Error('refresh failed')) await expect( @@ -343,18 +281,18 @@ describe('organization workspace helpers', () => { }) it('detaches organization workspaces into grandfathered shared mode', async () => { - mockDbResults.value = [ - [{ userId: 'owner-1' }], - [{ id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }], - [{ id: 'ws-1' }], - ] + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }, + ]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) const result = await detachOrganizationWorkspaces('org-1') expect(result.detachedWorkspaceIds).toEqual(['ws-1']) expect(result.billedAccountUserId).toBe('owner-1') expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledTimes(1) - expect(mockSelectForUpdate.mock.invocationCallOrder[0]).toBeLessThan( + expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( mockChangeWorkspaceStoragePayersInTx.mock.invocationCallOrder[0] ) expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledWith(expect.anything(), [ @@ -368,17 +306,17 @@ describe('organization workspace helpers', () => { }, }, ]) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ workspaceMode: 'grandfathered_shared', organizationAssignedAt: null, }) ) - expect(mockDbUpdate).toHaveBeenCalledTimes(1) - expect(mockDbInsert).toHaveBeenCalledTimes(1) - expect(mockInsertValues).toHaveBeenCalledWith([ + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ expect.objectContaining({ entityId: 'ws-1', userId: 'owner-1' }), ]) - expect(mockOnConflictDoUpdate).toHaveBeenCalled() + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) }) From 8cce661a37921b01e02865d0449859aeee45d5df Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 16:24:31 -0700 Subject: [PATCH 19/24] feat(api): proxyUrl for residential/custom proxy egress on the API block (#5867) * feat(api): add proxyUrl for residential/custom proxy egress on the API block The HTTP/API block egresses from the app runtime's fixed datacenter IPs via secureFetchWithPinnedIP, so targets behind Cloudflare/WAF that block datacenter IPs (e.g. state .gov license portals) return 403/429 even when the identical request works from a browser. There was no way to route a request through a residential/custom proxy. Add an optional `proxyUrl` field (Advanced) to the API block. When set, the request routes through the given http:// proxy so it egresses from that proxy's IP. Security: - validateAndPinProxyUrl resolves the proxy host's DNS and blocks private/reserved/loopback IPs (same SSRF guard as target URLs), then pins the connection by rewriting the host to the resolved IP (creds/port preserved), closing the DNS-rebinding window. - Restricted to the http: proxy scheme (https/socks rejected) so host pinning is safe without breaking TLS-to-proxy SNI. - Target-IP pinning is intentionally bypassed when a proxy is active (the proxy resolves the target); target URL validation still runs. Threaded block field -> http tool param -> formatRequestParams -> executeToolRequest (validate + pin) -> secureFetchWithPinnedIP, which swaps its pinned Node agent for HttpsProxyAgent/HttpProxyAgent (keyed off target protocol) when proxyUrl is set. * docs(api): document the Proxy URL advanced field and steer proxy credentials to env vars * fix(api): reject loopback/private proxy hosts unconditionally, closing the self-hosted rebinding gap * chore(api): tighten proxy-path inline comments --------- Co-authored-by: Marcus Chandra --- .../content/docs/en/workflows/blocks/api.mdx | 1 + apps/sim/blocks/blocks/api.ts | 13 +++ .../core/security/input-validation.server.ts | 89 +++++++++++++++++-- .../core/security/input-validation.test.ts | 68 ++++++++++++++ apps/sim/package.json | 2 + apps/sim/tools/http/request.ts | 5 ++ apps/sim/tools/http/types.ts | 1 + apps/sim/tools/index.test.ts | 71 +++++++++++++++ apps/sim/tools/index.ts | 11 +++ apps/sim/tools/utils.test.ts | 11 +++ apps/sim/tools/utils.ts | 8 +- bun.lock | 2 + .../src/mocks/input-validation.mock.ts | 2 + 13 files changed, 278 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/api.mdx b/apps/docs/content/docs/en/workflows/blocks/api.mdx index 3d8fd7c22e3..8a0354ae5cb 100644 --- a/apps/docs/content/docs/en/workflows/blocks/api.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/api.mdx @@ -40,6 +40,7 @@ The request payload for POST, PUT, and PATCH, sent as JSON. Type it directly, or - **Retries.** Number of retry attempts on timeouts, `429` responses, and `5xx` errors. Defaults to 0. - **Retry delay / Max retry delay (ms).** The exponential-backoff bounds used between retries. - **Retry non-idempotent methods.** Off by default, so POST and PATCH are not retried, which avoids duplicate writes. Turn it on only when a repeated request is safe. +- **Proxy URL.** Optional `http://` proxy the request egresses through, such as a residential proxy for targets that block datacenter IPs. The proxy host must be publicly reachable, and only the `http://` scheme is supported (an `https://` target still tunnels through it securely). Keep proxy credentials in an environment variable and reference it as `{{PROXY_URL}}` rather than typing them into the field. ## Outputs diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index cecd661e4c7..72450921163 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -123,6 +123,15 @@ Example: description: 'Allow retries for POST/PATCH requests (may create duplicate requests)', mode: 'advanced', }, + { + id: 'proxyUrl', + title: 'Proxy URL', + type: 'short-input', + placeholder: 'http://user:pass@proxy.host:port or {{PROXY_URL}}', + description: + 'Optional. Route this request through an http:// proxy (e.g. a residential proxy). Must be http://; the proxy host must be publicly reachable. Keep credentials in an environment variable and reference it like {{PROXY_URL}}.', + mode: 'advanced', + }, ], tools: { access: ['http_request'], @@ -144,6 +153,10 @@ Example: type: 'boolean', description: 'Allow retries for non-idempotent methods like POST/PATCH', }, + proxyUrl: { + type: 'string', + description: 'Optional http:// proxy URL to route the request through', + }, }, outputs: { data: { type: 'json', description: 'API response data (JSON, text, or other formats)' }, diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 09e758fa008..17de7aa63c8 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,6 +5,8 @@ import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' +import { HttpProxyAgent } from 'http-proxy-agent' +import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' @@ -148,6 +150,71 @@ export async function validateUrlWithDNS( } } +/** + * Result of validating a user-supplied HTTP proxy URL. + */ +export interface ProxyValidationResult { + isValid: boolean + /** Proxy URL with hostname rewritten to the resolved IP (creds/port preserved) to pin the proxy connection. */ + pinnedProxyUrl?: string + error?: string +} + +/** + * Validates a user-supplied HTTP proxy URL and returns an IP-pinned form. + * + * When a request routes through a proxy, the TCP connection targets the proxy + * host (the proxy resolves the destination), so target-IP pinning no longer + * governs egress and the proxy URL becomes the SSRF surface. This function: + * 1. Enforces the `http:` scheme (raw TCP to the proxy, no TLS-to-proxy SNI to + * reconcile, so the host can be safely rewritten to an IP). + * 2. Resolves the proxy host's DNS and blocks private/reserved/loopback IPs via + * {@link validateUrlWithDNS}. + * 3. Pins the connection by rewriting the hostname to the resolved IP while + * preserving credentials/port, closing the DNS-rebinding (TOCTOU) window. + * + * @param proxyUrl - The proxy URL (e.g. `http://user:pass@host:port`) + */ +export async function validateAndPinProxyUrl( + proxyUrl: string | null | undefined +): Promise { + if (!proxyUrl || typeof proxyUrl !== 'string') { + return { isValid: false, error: 'proxyUrl must be a string' } + } + + let parsed: URL + try { + parsed = new URL(proxyUrl) + } catch { + return { isValid: false, error: 'proxyUrl must be a valid URL' } + } + + if (parsed.protocol !== 'http:') { + return { + isValid: false, + error: 'proxyUrl must use http:// (https/socks proxies are not supported)', + } + } + + const validation = await validateUrlWithDNS(proxyUrl, 'proxyUrl', { allowHttp: true }) + if (!validation.isValid) { + return { isValid: false, error: validation.error } + } + + const resolvedIP = validation.resolvedIP! + + // validateUrlWithDNS permits loopback for self-hosted dev targets; a proxy governs + // egress, so loopback/private proxy hosts stay blocked unconditionally. + if (isPrivateOrReservedIP(resolvedIP)) { + return { isValid: false, error: 'proxyUrl resolves to a blocked IP address' } + } + + // Bracket IPv6 literals: assigning an unbracketed IPv6 address to URL.hostname + // is a no-op, which would leave the DNS hostname in place and reopen rebinding. + parsed.hostname = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP + return { isValid: true, pinnedProxyUrl: parsed.toString() } +} + /** * Validates a database hostname by resolving DNS and checking the resolved IP * against private/reserved ranges to prevent SSRF via database connections. @@ -343,6 +410,12 @@ export interface SecureFetchOptions { signal?: AbortSignal /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */ stripAuthOnRedirect?: boolean + /** + * Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}). + * When set, the connection routes through this proxy and target-IP pinning is + * bypassed (the proxy resolves the target). + */ + proxyUrl?: string } export class SecureFetchHeaders { @@ -678,11 +751,17 @@ export async function secureFetchWithPinnedIP( const defaultPort = isHttps ? 443 : 80 const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort - const lookup = createPinnedLookup(resolvedIP) - - const agentOptions: http.AgentOptions = { lookup } - - const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) + let agent: http.Agent + if (options.proxyUrl) { + // Proxy connection is already IP-pinned by validateAndPinProxyUrl; target-IP + // pinning is intentionally bypassed (the proxy resolves the target). https + // targets tunnel via CONNECT, http targets use absolute-URI forwarding. + agent = isHttps ? new HttpsProxyAgent(options.proxyUrl) : new HttpProxyAgent(options.proxyUrl) + } else { + const lookup = createPinnedLookup(resolvedIP) + const agentOptions: http.AgentOptions = { lookup } + agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) + } const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {} diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index b4c87e09374..eb44eb55939 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -28,6 +28,7 @@ import { } from '@/lib/core/security/input-validation' import { isPrivateOrReservedIP, + validateAndPinProxyUrl, validateDatabaseHost, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' @@ -843,6 +844,73 @@ describe('validateDatabaseHost', () => { }) }) +describe('validateAndPinProxyUrl', () => { + it('should reject a null/empty proxy URL', async () => { + expect((await validateAndPinProxyUrl(null)).isValid).toBe(false) + expect((await validateAndPinProxyUrl('')).isValid).toBe(false) + }) + + it('should reject a malformed URL', async () => { + const result = await validateAndPinProxyUrl('not a url') + expect(result.isValid).toBe(false) + expect(result.error).toContain('valid URL') + }) + + it('should reject an https:// proxy scheme', async () => { + const result = await validateAndPinProxyUrl('https://proxy.example.com:8080') + expect(result.isValid).toBe(false) + expect(result.error).toContain('http://') + }) + + it('should reject a socks5:// proxy scheme', async () => { + const result = await validateAndPinProxyUrl('socks5://proxy.example.com:1080') + expect(result.isValid).toBe(false) + expect(result.error).toContain('http://') + }) + + it('should reject a proxy host that is a private IP', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@192.168.1.1:8080') + expect(result.isValid).toBe(false) + expect(result.error).toMatch(/private IP|blocked IP/) + }) + + it('should reject a loopback proxy host even off the hosted platform', async () => { + const localhost = await validateAndPinProxyUrl('http://localhost:3128') + expect(localhost.isValid).toBe(false) + expect(localhost.error).toContain('blocked IP') + const loopback = await validateAndPinProxyUrl('http://127.0.0.1:3128') + expect(loopback.isValid).toBe(false) + expect(loopback.error).toContain('blocked IP') + }) + + it('should reject a proxy host that is the metadata IP', async () => { + const result = await validateAndPinProxyUrl('http://169.254.169.254:80') + expect(result.isValid).toBe(false) + expect(result.error).toMatch(/private IP|blocked IP/) + }) + + it('should accept a public proxy host and pin the hostname to the resolved IP, preserving creds/port', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@8.8.8.8:8080') + expect(result.isValid).toBe(true) + const pinned = new URL(result.pinnedProxyUrl!) + expect(pinned.protocol).toBe('http:') + expect(pinned.hostname).toBe('8.8.8.8') + expect(pinned.username).toBe('user') + expect(pinned.password).toBe('pass') + expect(pinned.port).toBe('8080') + }) + + it('should bracket an IPv6 resolved address so the pinned host is the IP, not the original name', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@[2606:4700:4700::1111]:8080') + expect(result.isValid).toBe(true) + const pinned = new URL(result.pinnedProxyUrl!) + expect(pinned.hostname).toBe('[2606:4700:4700::1111]') + expect(pinned.username).toBe('user') + expect(pinned.password).toBe('pass') + expect(pinned.port).toBe('8080') + }) +}) + describe('validateInteger', () => { describe('valid integers', () => { it.concurrent('should accept positive integers', () => { diff --git a/apps/sim/package.json b/apps/sim/package.json index 8e13a844bd9..9b2b9b1413b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -155,6 +155,8 @@ "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", "html-to-text": "^9.0.5", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", "image-size": "1.2.1", "imapflow": "1.2.4", diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index c4a94db75aa..130a28a3fa6 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -51,6 +51,11 @@ export const requestTool: ToolConfig = { visibility: 'user-or-llm', description: 'Form data to send (will set appropriate Content-Type)', }, + proxyUrl: { + type: 'string', + visibility: 'user-only', + description: 'Route the request through an http:// proxy (e.g. http://user:pass@host:port).', + }, timeout: { type: 'number', visibility: 'user-only', diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index 7d2e95aa4bd..8ca91e1b102 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -8,6 +8,7 @@ export interface RequestParams { params?: TableRow[] | string pathParams?: Record formData?: Record + proxyUrl?: string timeout?: number retries?: number retryDelayMs?: number diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index f16e5927eeb..5df9984cf10 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -958,6 +958,77 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) + it('should validate + pin a proxyUrl param and pass it to secureFetchWithPinnedIP', async () => { + inputValidationMockFns.mockValidateAndPinProxyUrl.mockResolvedValue({ + isValid: true, + pinnedProxyUrl: 'http://user:pass@1.2.3.4:8080/', + }) + + const mockTool = { + id: 'test_external_proxy', + name: 'Test External Proxy Tool', + description: 'A test tool that routes through a proxy', + version: '1.0.0', + params: {}, + request: { + url: 'https://api.example.com/endpoint', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + + const originalTools = { ...tools } + ;(tools as any).test_external_proxy = mockTool + + await executeTool('test_external_proxy', { proxyUrl: 'http://user:pass@proxy.host:8080' }) + + expect(inputValidationMockFns.mockValidateAndPinProxyUrl).toHaveBeenCalledWith( + 'http://user:pass@proxy.host:8080' + ) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://api.example.com/endpoint', + '93.184.216.34', + expect.objectContaining({ proxyUrl: 'http://user:pass@1.2.3.4:8080/' }) + ) + + Object.assign(tools, originalTools) + }) + + it('should throw when the proxyUrl param fails validation', async () => { + inputValidationMockFns.mockValidateAndPinProxyUrl.mockResolvedValue({ + isValid: false, + error: 'proxyUrl must use http:// (https/socks proxies are not supported)', + }) + + const mockTool = { + id: 'test_external_bad_proxy', + name: 'Test External Bad Proxy Tool', + description: 'A test tool with an invalid proxy', + version: '1.0.0', + params: {}, + request: { + url: 'https://api.example.com/endpoint', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + + const originalTools = { ...tools } + ;(tools as any).test_external_bad_proxy = mockTool + + const result = await executeTool('test_external_bad_proxy', { + proxyUrl: 'https://proxy.host:8080', + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid proxy URL') + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + + Object.assign(tools, originalTools) + }) + it('should handle dynamic URLs that resolve to internal routes', async () => { const mockTool = { id: 'test_dynamic_internal', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3ca1fbe1e15..c4fb4775447 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -14,6 +14,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { secureFetchWithPinnedIP, + validateAndPinProxyUrl, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { PlatformEvents } from '@/lib/core/telemetry' @@ -1809,6 +1810,15 @@ async function executeToolRequest( throw new Error(`Invalid tool URL: ${urlValidation.error}`) } + let proxyOption: string | undefined + if (requestParams.proxyUrl) { + const proxyValidation = await validateAndPinProxyUrl(requestParams.proxyUrl) + if (!proxyValidation.isValid) { + throw new Error(`Invalid proxy URL: ${proxyValidation.error}`) + } + proxyOption = proxyValidation.pinnedProxyUrl + } + const secureResponse = await secureFetchWithPinnedIP(fullUrl, urlValidation.resolvedIP!, { method: requestParams.method, headers: headersRecord, @@ -1816,6 +1826,7 @@ async function executeToolRequest( timeout: requestParams.timeout, maxResponseBytes: MAX_TOOL_RESPONSE_BODY_BYTES, signal, + proxyUrl: proxyOption, }) const responseHeaders = new Headers(secureResponse.headers.toRecord()) diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index e5e88ff20e6..17f390c9661 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -209,6 +209,17 @@ describe('formatRequestParams', () => { expect(result.body).toBe('{"prompt": "Hello"}\n{"prompt": "World"}') }) + + it.concurrent('should pass through a non-empty proxyUrl (trimmed)', () => { + const result = formatRequestParams(mockTool, { proxyUrl: ' http://user:pass@host:8080 ' }) + expect(result.proxyUrl).toBe('http://user:pass@host:8080') + }) + + it.concurrent('should omit proxyUrl when blank, whitespace, or absent', () => { + expect(formatRequestParams(mockTool, {}).proxyUrl).toBeUndefined() + expect(formatRequestParams(mockTool, { proxyUrl: '' }).proxyUrl).toBeUndefined() + expect(formatRequestParams(mockTool, { proxyUrl: ' ' }).proxyUrl).toBeUndefined() + }) }) describe('validateRequiredParametersAfterMerge', () => { diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 177928e64e1..229a5830795 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -82,6 +82,7 @@ export interface RequestParams { headers: Record body?: string timeout?: number + proxyUrl?: string } /** @@ -137,7 +138,12 @@ export function formatRequestParams(tool: ToolConfig, params: Record Date: Wed, 22 Jul 2026 16:42:59 -0700 Subject: [PATCH 20/24] =?UTF-8?q?feat(auth):=20org=20session=20policies=20?= =?UTF-8?q?=E2=80=94=20lifetime/idle=20limits,=20org-wide=20revocation=20(?= =?UTF-8?q?#5862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): org session policies — lifetime/idle limits, org-wide revocation, cookie-cache versioning * refactor(auth): consolidate session-policy clamp semantics, shared security-policy version module, canonical bounds, docs * polish(session-policy): cleanup pass — muted field labels, spinner reset, state tracker, response-seeded baseline, comment trims * fix(session-policy): govern member sessions by membership (closes revoke cookie-cache hole), normalize createdAt, remount on org switch, sync audit mock * fix(session-policy): clamp pre-join sessions on invite acceptance, normalize expiresAt, sync unified nav test * fix(session-policy): invalidate membership cache on removal/transfer, spare impersonator sessions in revoke-all, raise idle floor to 2x cookie window * fix(session-policy): resolve governing org by membership only — activeOrganizationId goes stale across transfer/leave * fix(session-policy): atomic policy save + eager clamp, asymmetric membership TTL, admin-add cache invalidation * fix(session-policy): org-scoped cookie version string, atomic revoke delete+bump * fix(session-policy): plan-gate effective policy so downgraded orgs stop enforcing automatically * chore(session-policy): drop dead bumpSecurityPolicyVersion helper — call sites bump transactionally * fix(session-policy): unify join paths on applySessionPolicyToNewMember; final audit polish (dead exports, response bound, test name) --- .../docs/en/platform/enterprise/meta.json | 1 + .../platform/enterprise/session-policies.mdx | 76 + .../[id]/session-policy/route.test.ts | 172 + .../[id]/session-policy/route.ts | 190 + .../[id]/sessions/revoke/route.ts | 134 + .../settings/[section]/settings.tsx | 8 + .../[workspaceId]/settings/navigation.test.ts | 1 + .../components/settings/navigation.test.ts | 3 + apps/sim/components/settings/navigation.ts | 27 +- .../organization-settings-renderer.tsx | 8 + .../components/session-policy-settings.tsx | 215 + .../ee/session-policy/hooks/session-policy.ts | 69 + apps/sim/lib/api/contracts/organization.ts | 90 + apps/sim/lib/auth/auth.ts | 35 +- apps/sim/lib/auth/security-policy.ts | 147 + apps/sim/lib/auth/session-policy.test.ts | 84 + apps/sim/lib/auth/session-policy.ts | 229 + .../lib/billing/organizations/membership.ts | 19 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/invitations/core.ts | 5 + packages/audit/src/types.ts | 2 + .../db/migrations/0265_org_session_policy.sql | 2 + .../db/migrations/meta/0265_snapshot.json | 17406 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 24 + packages/testing/src/mocks/audit.mock.ts | 2 + scripts/check-api-validation-contracts.ts | 4 +- 27 files changed, 18955 insertions(+), 7 deletions(-) create mode 100644 apps/docs/content/docs/en/platform/enterprise/session-policies.mdx create mode 100644 apps/sim/app/api/organizations/[id]/session-policy/route.test.ts create mode 100644 apps/sim/app/api/organizations/[id]/session-policy/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts create mode 100644 apps/sim/ee/session-policy/components/session-policy-settings.tsx create mode 100644 apps/sim/ee/session-policy/hooks/session-policy.ts create mode 100644 apps/sim/lib/auth/security-policy.ts create mode 100644 apps/sim/lib/auth/session-policy.test.ts create mode 100644 apps/sim/lib/auth/session-policy.ts create mode 100644 packages/db/migrations/0265_org_session_policy.sql create mode 100644 packages/db/migrations/meta/0265_snapshot.json diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index c8a33a7e287..924a8263768 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "sso", + "session-policies", "access-control", "custom-blocks", "whitelabeling", diff --git a/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx b/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx new file mode 100644 index 00000000000..fb26c2f0a32 --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx @@ -0,0 +1,76 @@ +--- +title: Session Policies +description: Set session lifetime limits and sign out every member of your organization at once +--- + +import { FAQ } from '@/components/ui/faq' + +Session Policies let organization owners and admins on Enterprise plans control how long member sign-in sessions last, and sign out every member org-wide in one action. Policies apply to every member of the organization on every device. + +--- + +## Setup + +Go to **Settings → Security → Session policies** in your organization settings. + +Both limits are optional. Leave a field empty to keep the default behavior: sessions last 30 days and extend automatically while a member stays active. + +--- + +## Settings + +### Max session lifetime + +Caps how long a session can exist from the moment a member signs in, regardless of activity. When the limit is reached, the member must sign in again. + +Use this to enforce periodic re-authentication — for example, a value of `168` requires everyone to sign in again at least weekly. Accepts 1 to 8760 hours (1 year). + +### Idle timeout + +Signs a member out after this many hours without activity. Activity extends the session, so members who use Sim regularly stay signed in; dormant sessions expire. + +Accepts 48 to 8760 hours. The 48-hour minimum exists because session activity is recorded at most once per day — a shorter window could sign out members who are actively working. + +### Sign out all members + +The **Sign out all members** action immediately revokes every member session in the organization except your own. Members are signed out on their next request — typically within a minute — and must sign in again. + +Use this after a security incident, an offboarding wave, or before tightening a policy you want to take effect everywhere at once. + +--- + +## How enforcement works + +- **New sign-ins** get an expiry that respects the policy from the moment the session is created. +- **Existing sessions** are shortened immediately when you save a tighter policy — no member keeps a longer session than the new policy allows. +- **Loosening a policy never extends existing sessions.** Members pick up the longer limit the next time they sign in. +- Changes propagate to active members within about a minute; there is no need to redeploy or wait for sessions to naturally expire. + +--- + +## FAQ + + diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts new file mode 100644 index 00000000000..5530045fd50 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import { member, organization } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockEagerClamp: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/auth/session-policy', () => ({ + eagerClampOrgSessions: mockEagerClamp, + invalidateSessionPolicyCache: vi.fn(), +})) + +vi.mock('@/lib/auth/security-policy', () => ({ + invalidateSecurityPolicyVersionCache: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isBillingEnabled: true, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { + ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', + }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route' + +const ORG_ID = 'org-1' +const routeContext = { params: Promise.resolve({ id: ORG_ID }) } + +describe('session policy route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + session: { token: 'tok-1' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + describe('GET', () => { + it('returns 401 when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(401) + }) + + it('returns 403 for non-members', async () => { + queueTableRows(member, []) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(403) + }) + + it('returns the configured policy for members', async () => { + queueTableRows(member, [{ id: 'member-1' }]) + queueTableRows(organization, [ + { sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } }, + ]) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toEqual({ + isEnterprise: true, + configured: { maxSessionHours: 72, idleTimeoutHours: null }, + }) + }) + }) + + describe('PUT', () => { + function putRequest(body: unknown) { + return createMockRequest('PUT', body) + } + + it('rejects non-admin members', async () => { + queueTableRows(member, [{ role: 'member' }]) + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(403) + }) + + it('rejects an idle timeout below the cookie-cache window', async () => { + queueTableRows(member, [{ role: 'admin' }]) + const response = await PUT( + putRequest({ maxSessionHours: null, idleTimeoutHours: 5 }), + routeContext + ) + expect(response.status).toBe(400) + }) + + it('rejects non-enterprise organizations', async () => { + queueTableRows(member, [{ role: 'owner' }]) + mockIsEnterprise.mockResolvedValue(false) + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(403) + }) + + it('saves the policy, eagerly clamps sessions, and bumps the version', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Acme' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }]) + + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }), + routeContext + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 }) + expect(mockEagerClamp).toHaveBeenCalledWith( + ORG_ID, + { maxSessionHours: 72, idleTimeoutHours: 48 }, + expect.anything() + ) + // The version bump rides the settings UPDATE (single round trip). + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ securityPolicyVersion: expect.anything() }) + ) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.session_policy.updated' }) + ) + }) + + it('clearing both fields still saves and delegates the no-op to the clamp', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Acme' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }]) + + const response = await PUT( + putRequest({ maxSessionHours: null, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(200) + expect(mockEagerClamp).toHaveBeenCalledWith( + ORG_ID, + { maxSessionHours: null, idleTimeoutHours: null }, + expect.anything() + ) + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.ts new file mode 100644 index 00000000000..d3ce9d53bc3 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.ts @@ -0,0 +1,190 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import type { SessionPolicySettings } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { updateOrganizationSessionPolicyContract } from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' +import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('SessionPolicyAPI') + +function normalizeConfigured(settings: SessionPolicySettings | null | undefined) { + return { + maxSessionHours: settings?.maxSessionHours ?? null, + idleTimeoutHours: settings?.idleTimeoutHours ?? null, + } +} + +/** + * GET /api/organizations/[id]/session-policy + * Returns the organization's session policy. Accessible by any member. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [memberEntry] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const [org] = await db + .select({ sessionPolicySettings: organization.sessionPolicySettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + + return NextResponse.json({ + success: true, + data: { + isEnterprise, + configured: normalizeConfigured(org.sessionPolicySettings), + }, + }) + } +) + +/** + * PUT /api/organizations/[id]/session-policy + * Updates the organization's session policy and bumps the security-policy + * version so existing cached session cookies re-validate against the new + * policy. Requires enterprise plan and owner/admin role. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateOrganizationSessionPolicyContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const body = parsed.data.body + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can update the session policy' }, + { status: 403 } + ) + } + + if (isBillingEnabled) { + const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Session policies are available on Enterprise plans only' }, + { status: 403 } + ) + } + } + + const [currentOrg] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!currentOrg) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const merged = { + maxSessionHours: body.maxSessionHours, + idleTimeoutHours: body.idleTimeoutHours, + } + + // Settings write (with the version bump riding the same row) and the + // eager clamp of existing sessions commit atomically — a stored policy is + // never left unenforced by a partial failure. + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(organization) + .set({ + sessionPolicySettings: merged, + securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`, + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + .returning({ id: organization.id }) + if (!row) return null + await eagerClampOrgSessions(organizationId, merged, tx) + return row + }) + + if (!updated) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + invalidateSessionPolicyCache(organizationId) + invalidateSecurityPolicyVersionCache(organizationId) + + logger.info('Updated organization session policy', { organizationId }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_SESSION_POLICY_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: currentOrg.name, + description: 'Updated session policy', + metadata: { changes: body }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + isEnterprise: true, + configured: normalizeConfigured(merged), + }, + }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts b/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts new file mode 100644 index 00000000000..2731d2882ac --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts @@ -0,0 +1,134 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, organization, session as sessionTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { revokeOrganizationSessionsContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgSessionsRevokeAPI') + +/** + * POST /api/organizations/[id]/sessions/revoke + * Deletes every member session in the organization except the caller's + * current one, then bumps the security-policy version so cached session + * cookies invalidate on their next request. Requires enterprise plan and + * owner/admin role. Impersonation sessions are platform tooling and spared. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(revokeOrganizationSessionsContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can revoke sessions' }, + { status: 403 } + ) + } + + if (isBillingEnabled) { + const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Session management is available on Enterprise plans only' }, + { status: 403 } + ) + } + } + + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + // When the caller is a platform admin impersonating an org member, the + // current token is the impersonation session — also spare the admin's own + // real sessions so ending impersonation doesn't leave them signed out. + const impersonatorId = + (session.session as { impersonatedBy?: string | null }).impersonatedBy ?? null + // Delete and version bump commit atomically: a bump failure must roll the + // delete back, or members would stay authenticated from the cookie cache + // for up to 24h with their DB sessions already gone. + const revoked = await db.transaction(async (tx) => { + const deleted = await tx + .delete(sessionTable) + .where( + and( + inArray( + sessionTable.userId, + tx + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + ), + isNull(sessionTable.impersonatedBy), + ne(sessionTable.token, session.session.token), + ...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : []) + ) + ) + .returning({ id: sessionTable.id }) + await tx + .update(organization) + .set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` }) + .where(eq(organization.id, organizationId)) + return deleted + }) + invalidateSecurityPolicyVersionCache(organizationId) + + logger.info('Revoked organization sessions', { + organizationId, + revokedSessions: revoked.length, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_SESSIONS_REVOKED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: org.name, + description: `Revoked ${revoked.length} member session${revoked.length === 1 ? '' : 's'}`, + metadata: { revokedSessions: revoked.length }, + request, + }) + + return NextResponse.json({ + success: true, + data: { revokedSessions: revoked.length }, + }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 1f6fe23b076..cf2bf5caf18 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -81,6 +81,11 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const SessionPolicySettings = dynamic(() => + import('@/ee/session-policy/components/session-policy-settings').then( + (m) => m.SessionPolicySettings + ) +) const DataRetentionSettings = dynamic(() => import('@/ee/data-retention/components/data-retention-settings').then( (m) => m.DataRetentionSettings @@ -158,6 +163,9 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } + {effectiveSection === 'sessions' && organizationId && ( + + )} {effectiveSection === 'data-retention' && organizationId && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 789cf923fd9..fc2e914762b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -39,6 +39,7 @@ describe('unified settings navigation', () => { { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, + { id: 'sessions', label: 'Session policies', section: 'enterprise' }, { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' }, diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 2348ced7a97..3693f88fd48 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -42,6 +42,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'sso', + 'sessions', 'data-retention', 'data-drains', 'whitelabeling', @@ -63,6 +64,7 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', + 'sessions', 'data-retention', 'data-drains', 'whitelabeling', @@ -118,6 +120,7 @@ describe('settings navigation boundaries', () => { 'data-drains', 'data-retention', 'organization', + 'sessions', 'sso', 'whitelabeling', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c03e332de9e..acf5ac6c8b6 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import { ClipboardList, + Clock, Database, HexSimple, Key, @@ -41,6 +42,7 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' + | 'sessions' | 'data-retention' | 'data-drains' | 'whitelabeling' @@ -94,6 +96,7 @@ export type UnifiedSettingsSection = | 'workflow-mcp-servers' | 'inbox' | 'admin' + | 'sessions' | 'data-retention' | 'data-drains' | 'mothership' @@ -168,6 +171,7 @@ const SETTINGS_SELF_HOSTED_OVERRIDES = { dataDrains: isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')), dataRetention: isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')), inbox: isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')), + sessionPolicies: isTruthy(getEnv('NEXT_PUBLIC_SESSION_POLICIES_ENABLED')), sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')), whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')), } as const @@ -537,6 +541,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, + { + label: 'Session policies', + icon: Clock, + docsLink: 'https://docs.sim.ai/platform/enterprise/session-policies', + unified: { + id: 'sessions', + description: 'Limit session lifetimes and sign out members org-wide.', + group: 'enterprise', + requiresHosted: true, + requiresEnterprise: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, + }, + planes: { + organization: { id: 'sessions', group: 'security', order: 5 }, + }, + }, { label: 'Data retention', icon: Database, @@ -551,7 +571,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 5 }, + organization: { id: 'data-retention', group: 'enterprise', order: 6 }, }, }, { @@ -567,7 +587,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 6 }, + organization: { id: 'data-drains', group: 'enterprise', order: 7 }, }, }, { @@ -583,7 +603,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 7 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, }, }, { @@ -719,6 +739,7 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index e14714772e8..66ae7a1efc3 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,6 +23,11 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) +const SessionPolicySettings = dynamic(() => + import('@/ee/session-policy/components/session-policy-settings').then( + (module) => module.SessionPolicySettings + ) +) const DataRetentionSettings = dynamic(() => import('@/ee/data-retention/components/data-retention-settings').then( (module) => module.DataRetentionSettings @@ -63,6 +68,9 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return + if (section === 'sessions') { + return + } if (section === 'data-retention') { return } diff --git a/apps/sim/ee/session-policy/components/session-policy-settings.tsx b/apps/sim/ee/session-policy/components/session-policy-settings.tsx new file mode 100644 index 00000000000..d3b6a1cb977 --- /dev/null +++ b/apps/sim/ee/session-policy/components/session-policy-settings.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useEffect, useState } from 'react' +import { ChipConfirmModal, ChipInput, Label, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { + MAX_SESSION_POLICY_HOURS, + MIN_IDLE_TIMEOUT_HOURS, + MIN_SESSION_LIFETIME_HOURS, +} from '@/lib/api/contracts/organization' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { + useOrganizationSessionPolicy, + useRevokeOrganizationSessions, + useUpdateOrganizationSessionPolicy, +} from '@/ee/session-policy/hooks/session-policy' + +interface SessionPolicySettingsProps { + organizationId: string +} + +interface HourFieldProps { + id: string + title: string + hint: string + value: string + onChange: (value: string) => void +} + +function HourField({ id, title, hint, value, onChange }: HourFieldProps) { + return ( +
+ + onChange(event.target.value)} + placeholder='No limit' + className='w-[220px]' + inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' + /> +

{hint}

+
+ ) +} + +function parseHours(value: string): number | null { + if (value.trim() === '') return null + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : Number.NaN +} + +export function SessionPolicySettings({ organizationId }: SessionPolicySettingsProps) { + const { data, isLoading } = useOrganizationSessionPolicy(organizationId) + const updatePolicy = useUpdateOrganizationSessionPolicy() + const revokeSessions = useRevokeOrganizationSessions() + + const [maxSessionHours, setMaxSessionHours] = useState('') + const [idleTimeoutHours, setIdleTimeoutHours] = useState('') + const [savedMaxSessionHours, setSavedMaxSessionHours] = useState('') + const [savedIdleTimeoutHours, setSavedIdleTimeoutHours] = useState('') + const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) + const [formInitialized, setFormInitialized] = useState(false) + + useEffect(() => { + if (!data || formInitialized) return + const max = data.configured.maxSessionHours?.toString() ?? '' + const idle = data.configured.idleTimeoutHours?.toString() ?? '' + setMaxSessionHours(max) + setIdleTimeoutHours(idle) + setSavedMaxSessionHours(max) + setSavedIdleTimeoutHours(idle) + setFormInitialized(true) + }, [data, formInitialized]) + + const hasChanges = + formInitialized && + (maxSessionHours !== savedMaxSessionHours || idleTimeoutHours !== savedIdleTimeoutHours) + + useSettingsUnsavedGuard({ isDirty: hasChanges }) + + if (isLoading) { + return ( + + Loading session policy... + + ) + } + + if (isBillingEnabled && data && !data.isEnterprise) { + return ( + + + Session policies are available on Enterprise plans only. + + + ) + } + + async function handleSave() { + const max = parseHours(maxSessionHours) + const idle = parseHours(idleTimeoutHours) + + if ( + Number.isNaN(max) || + (max !== null && (max < MIN_SESSION_LIFETIME_HOURS || max > MAX_SESSION_POLICY_HOURS)) + ) { + toast.error( + `Max session lifetime must be a whole number between ${MIN_SESSION_LIFETIME_HOURS} and ${MAX_SESSION_POLICY_HOURS}` + ) + return + } + if ( + Number.isNaN(idle) || + (idle !== null && (idle < MIN_IDLE_TIMEOUT_HOURS || idle > MAX_SESSION_POLICY_HOURS)) + ) { + toast.error( + `Idle timeout must be a whole number between ${MIN_IDLE_TIMEOUT_HOURS} and ${MAX_SESSION_POLICY_HOURS}` + ) + return + } + + try { + const result = await updatePolicy.mutateAsync({ + orgId: organizationId, + settings: { maxSessionHours: max, idleTimeoutHours: idle }, + }) + const savedMax = result.data.configured.maxSessionHours?.toString() ?? '' + const savedIdle = result.data.configured.idleTimeoutHours?.toString() ?? '' + setMaxSessionHours(savedMax) + setIdleTimeoutHours(savedIdle) + setSavedMaxSessionHours(savedMax) + setSavedIdleTimeoutHours(savedIdle) + toast.success('Session policy updated') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update session policy')) + } + } + + function handleDiscard() { + setMaxSessionHours(savedMaxSessionHours) + setIdleTimeoutHours(savedIdleTimeoutHours) + } + + async function handleConfirmRevoke() { + try { + const result = await revokeSessions.mutateAsync({ orgId: organizationId }) + setShowRevokeConfirm(false) + toast.success( + `Signed out ${result.data.revokedSessions} session${result.data.revokedSessions === 1 ? '' : 's'}` + ) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revoke sessions')) + } + } + + return ( + <> + setShowRevokeConfirm(true), + }, + ...saveDiscardActions({ + dirty: hasChanges, + saving: updatePolicy.isPending, + onSave: handleSave, + onDiscard: handleDiscard, + }), + ]} + > +
+ + +
+
+ + + ) +} diff --git a/apps/sim/ee/session-policy/hooks/session-policy.ts b/apps/sim/ee/session-policy/hooks/session-policy.ts new file mode 100644 index 00000000000..d9875763f95 --- /dev/null +++ b/apps/sim/ee/session-policy/hooks/session-policy.ts @@ -0,0 +1,69 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getOrganizationSessionPolicyContract, + type OrganizationSessionPolicy, + revokeOrganizationSessionsContract, + type UpdateOrganizationSessionPolicyBody, + updateOrganizationSessionPolicyContract, +} from '@/lib/api/contracts/organization' + +export type SessionPolicyResponse = OrganizationSessionPolicy + +export const SESSION_POLICY_STALE_TIME = 60 * 1000 + +export const sessionPolicyKeys = { + all: ['sessionPolicy'] as const, + settings: (orgId: string) => [...sessionPolicyKeys.all, 'settings', orgId] as const, +} + +async function fetchSessionPolicy( + orgId: string, + signal?: AbortSignal +): Promise { + const { data } = await requestJson(getOrganizationSessionPolicyContract, { + params: { id: orgId }, + signal, + }) + return data +} + +export function useOrganizationSessionPolicy(orgId: string | undefined) { + return useQuery({ + queryKey: sessionPolicyKeys.settings(orgId ?? ''), + queryFn: ({ signal }) => fetchSessionPolicy(orgId as string, signal), + enabled: Boolean(orgId), + staleTime: SESSION_POLICY_STALE_TIME, + }) +} + +interface UpdateSessionPolicyVariables { + orgId: string + settings: UpdateOrganizationSessionPolicyBody +} + +export function useUpdateOrganizationSessionPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ orgId, settings }: UpdateSessionPolicyVariables) => + requestJson(updateOrganizationSessionPolicyContract, { + params: { id: orgId }, + body: settings, + }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: sessionPolicyKeys.settings(orgId) }) + }, + }) +} + +export function useRevokeOrganizationSessions() { + return useMutation({ + mutationFn: ({ orgId }: { orgId: string }) => + requestJson(revokeOrganizationSessionsContract, { + params: { id: orgId }, + }), + }) +} diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index 83fd5bb62d7..c3ac2f94e85 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -142,6 +142,65 @@ export const organizationDataRetentionResponseSchema = z.object({ data: organizationDataRetentionDataSchema, }) +/** + * Session-policy bounds — the single source for the contract validation, the + * server-side clamp (`@/lib/auth/session-policy`), and the settings UI. + * `MIN_IDLE_TIMEOUT_HOURS` is twice the session cookie-cache window (24h): + * cached reads never record activity, so a continuously active user only + * refreshes their session when the cookie cache expires. A floor of one + * window would sign out active users exactly at the cache boundary; two + * windows guarantees a DB-path refresh lands before the idle limit can. + */ +export const MIN_SESSION_LIFETIME_HOURS = 1 +export const MIN_IDLE_TIMEOUT_HOURS = 48 +export const MAX_SESSION_POLICY_HOURS = 8760 + +export const updateOrganizationSessionPolicyBodySchema = z.object({ + maxSessionHours: z + .number() + .int() + .min(MIN_SESSION_LIFETIME_HOURS, 'Max session lifetime must be at least 1 hour') + .max(MAX_SESSION_POLICY_HOURS, 'Max session lifetime cannot exceed 8760 hours (1 year)') + .nullable(), + idleTimeoutHours: z + .number() + .int() + .min( + MIN_IDLE_TIMEOUT_HOURS, + 'Idle timeout must be at least 48 hours — session activity is recorded at most once per 24h cookie-cache window' + ) + .max(MAX_SESSION_POLICY_HOURS, 'Idle timeout cannot exceed 8760 hours (1 year)') + .nullable(), +}) + +export type UpdateOrganizationSessionPolicyBody = z.input< + typeof updateOrganizationSessionPolicyBodySchema +> + +const organizationSessionPolicyValuesSchema = z.object({ + maxSessionHours: z.number().int().nullable(), + idleTimeoutHours: z.number().int().nullable(), +}) + +const organizationSessionPolicyDataSchema = z.object({ + isEnterprise: z.boolean(), + configured: organizationSessionPolicyValuesSchema, +}) + +export type OrganizationSessionPolicy = z.output + +export const organizationSessionPolicyResponseSchema = z.object({ + success: z.boolean(), + data: organizationSessionPolicyDataSchema, +}) + +export const revokeOrganizationSessionsResponseSchema = z.object({ + success: z.boolean(), + data: z.object({ + revokedSessions: z.number().int().min(0), + }), +}) + export const updateOrganizationWhitelabelBodySchema = z.object({ brandName: z .string() @@ -491,6 +550,37 @@ export const updateOrganizationDataRetentionContract = defineRouteContract({ }, }) +export const getOrganizationSessionPolicyContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/session-policy', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: organizationSessionPolicyResponseSchema, + }, +}) + +export const updateOrganizationSessionPolicyContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/session-policy', + params: organizationParamsSchema, + body: updateOrganizationSessionPolicyBodySchema, + response: { + mode: 'json', + schema: organizationSessionPolicyResponseSchema, + }, +}) + +export const revokeOrganizationSessionsContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/sessions/revoke', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: revokeOrganizationSessionsResponseSchema, + }, +}) + // Read shape mirrors `OrganizationWhitelabelSettings` from // `@/lib/branding/types`. All fields are optional (nullable on the way in // for the PUT contract, but stored without nulls on the way out — the diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 27543c2ffce..167e02112dd 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -34,6 +34,8 @@ import { import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' +import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' +import { clampExpiryForSession } from '@/lib/auth/session-policy' import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' import { sendPlanWelcomeEmail } from '@/lib/billing' import { @@ -222,6 +224,14 @@ export const auth = betterAuth({ cookieCache: { enabled: true, maxAge: 24 * 60 * 60, // 24 hours in seconds + /** + * Embeds the member org's security-policy version. Bumping the version + * (policy change, org-wide revocation) invalidates every cached session + * cookie in the org on its next request, forcing a DB session read — + * revocation latency becomes the policy cache TTL, not 24h. + */ + version: async (session) => + getSessionCookieCacheVersion(session as { userId?: string | null }), }, expiresIn: 30 * 24 * 60 * 60, // 30 days (how long a session can last overall) updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry) @@ -654,7 +664,7 @@ export const auth = betterAuth({ try { // Find the first organization this user is a member of const members = await db - .select() + .select({ organizationId: schema.member.organizationId }) .from(schema.member) .where(eq(schema.member.userId, session.userId)) .limit(1) @@ -665,9 +675,12 @@ export const auth = betterAuth({ organizationId: members[0].organizationId, }) + const expiresAt = await clampExpiryForSession(session, members[0].organizationId) + return { data: { ...session, + expiresAt, activeOrganizationId: members[0].organizationId, }, } @@ -685,6 +698,26 @@ export const auth = betterAuth({ } }, }, + update: { + /** + * Better Auth's sliding refresh rewrites `expiresAt` to + * `now + expiresIn` (30 days), which would silently stretch a + * policy-shortened session back out — re-clamp on every refresh. + * The current session row is read from the endpoint context; when + * it is unavailable (non-refresh update paths) the update passes + * through untouched and the next refresh re-clamps. + */ + before: async (data, ctx) => { + if (!data.expiresAt) return { data } + const current = ctx?.context?.session?.session + if (!current) return { data } + const expiresAt = await clampExpiryForSession({ + ...current, + expiresAt: new Date(data.expiresAt), + }) + return { data: { ...data, expiresAt } } + }, + }, }, }, account: { diff --git a/apps/sim/lib/auth/security-policy.ts b/apps/sim/lib/auth/security-policy.ts new file mode 100644 index 00000000000..a8019ea0971 --- /dev/null +++ b/apps/sim/lib/auth/security-policy.ts @@ -0,0 +1,147 @@ +import { db } from '@sim/db' +import { member, organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' + +const logger = createLogger('SecurityPolicy') + +/** + * How long a resolved org security-policy version is served from process + * memory before the next request re-reads it. This TTL is the effective upper + * bound on org-wide session-revocation latency: a version bump changes the + * cookie-cache version, and every cached session cookie in the org falls + * through to a DB read within one TTL. + */ +export const SECURITY_POLICY_VERSION_CACHE_TTL_MS = 60 * 1000 + +const DEFAULT_VERSION = 1 + +interface VersionCacheEntry { + version: number + fetchedAt: number +} + +const versionCache = new Map() + +/** + * Resolves the org's security-policy version — the shared monotonic counter + * behind the Better Auth cookie-cache version. It backs ALL org security + * policies (session policies today; IP allowlisting and MFA enforcement are + * planned consumers): any feature that needs cached session cookies to + * re-validate bumps this one counter. + */ +export async function getSecurityPolicyVersion( + organizationId: string | null | undefined +): Promise { + if (!organizationId) return DEFAULT_VERSION + + const cached = versionCache.get(organizationId) + if (cached && Date.now() - cached.fetchedAt < SECURITY_POLICY_VERSION_CACHE_TTL_MS) { + return cached.version + } + + try { + const [row] = await db + .select({ version: organization.securityPolicyVersion }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + const version = row?.version ?? DEFAULT_VERSION + versionCache.set(organizationId, { version, fetchedAt: Date.now() }) + return version + } catch (error) { + logger.error('Failed to resolve security policy version; using default', { + organizationId, + error, + }) + return DEFAULT_VERSION + } +} + +/** Drops the cached version for an org so the next read is fresh. */ +export function invalidateSecurityPolicyVersionCache(organizationId: string): void { + versionCache.delete(organizationId) +} + +interface MembershipCacheEntry { + organizationId: string | null + fetchedAt: number +} + +const membershipCache = new Map() + +/** + * Negative (non-member) membership results use a much shorter TTL than + * positive ones: a user's cached `null` would otherwise let them dodge a new + * org's policy for the full TTL after joining through ANY path — including + * ones outside this codebase (Better Auth SSO JIT provisioning). Positive + * results change only through leave/transfer, which invalidate explicitly. + */ +const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000 + +/** Drops the cached membership for a user (call when they join/leave an org). */ +export function invalidateMembershipCache(userId: string): void { + membershipCache.delete(userId) +} + +/** + * Resolves the org a user belongs to (users belong to at most one org), + * served from a short TTL cache. Org security policies govern MEMBERS, not + * just sessions that happen to carry an `activeOrganizationId` — a session + * created before the user joined an org has none, and without this fallback + * such sessions would dodge cookie-cache invalidation (and therefore + * org-wide revocation) for up to the 24h cookie lifetime. + */ +export async function getMemberOrganizationId( + userId: string | null | undefined +): Promise { + if (!userId) return null + + const cached = membershipCache.get(userId) + if (cached) { + const ttl = cached.organizationId + ? SECURITY_POLICY_VERSION_CACHE_TTL_MS + : NEGATIVE_MEMBERSHIP_CACHE_TTL_MS + if (Date.now() - cached.fetchedAt < ttl) return cached.organizationId + } + + try { + const [row] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + + const organizationId = row?.organizationId ?? null + membershipCache.set(userId, { organizationId, fetchedAt: Date.now() }) + return organizationId + } catch (error) { + logger.error('Failed to resolve org membership; treating session as org-less', { + userId, + error, + }) + return null + } +} + +/** + * Cookie-cache version for a session, consumed by Better Auth's + * `session.cookieCache.version`. Embeds the member org's security-policy + * version so bumps propagate to cached cookies. Resolved from the user's + * MEMBERSHIP, never the session's `activeOrganizationId` — that field goes + * stale on join/leave/transfer (it is only written at session creation), and + * a stale org here would let cookies dodge the destination org's version + * bumps for up to the 24h cookie lifetime. Sessions of non-members use the + * static default. + */ +export async function getSessionCookieCacheVersion(session: { + userId?: string | null +}): Promise { + const organizationId = await getMemberOrganizationId(session.userId) + if (!organizationId) return 'none' + // The org id is part of the version so moving between orgs always changes + // the string — two orgs whose counters happen to hold the same number must + // not produce interchangeable cookie versions. + return `${organizationId}:${await getSecurityPolicyVersion(organizationId)}` +} diff --git a/apps/sim/lib/auth/session-policy.test.ts b/apps/sim/lib/auth/session-policy.test.ts new file mode 100644 index 00000000000..6484f179b91 --- /dev/null +++ b/apps/sim/lib/auth/session-policy.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization' +import { clampSessionExpiry, type ResolvedSessionPolicy } from '@/lib/auth/session-policy' + +const HOUR_MS = 60 * 60 * 1000 + +function policy(overrides: Partial = {}): ResolvedSessionPolicy { + return { maxSessionHours: null, idleTimeoutHours: null, ...overrides } +} + +describe('clampSessionExpiry', () => { + const createdAt = new Date('2026-07-22T00:00:00Z') + const now = new Date('2026-07-22T12:00:00Z') + /** Better Auth's sliding refresh proposes now + 30 days. */ + const proposed = new Date(now.getTime() + 30 * 24 * HOUR_MS) + + it('returns the proposed time unchanged when no policy fields are set', () => { + expect(clampSessionExpiry(policy(), createdAt, proposed, now).getTime()).toBe( + proposed.getTime() + ) + }) + + it('caps absolute lifetime at createdAt + maxSessionHours', () => { + const result = clampSessionExpiry(policy({ maxSessionHours: 24 }), createdAt, proposed, now) + expect(result.getTime()).toBe(createdAt.getTime() + 24 * HOUR_MS) + }) + + it('re-clamps a sliding refresh that would stretch the session back out', () => { + // 12h into a 24h-max session, a refresh proposing +30d must still end at + // createdAt + 24h — this is the regression the update hook exists for. + const midSession = new Date(createdAt.getTime() + 12 * HOUR_MS) + const refreshProposal = new Date(midSession.getTime() + 30 * 24 * HOUR_MS) + const result = clampSessionExpiry( + policy({ maxSessionHours: 24 }), + createdAt, + refreshProposal, + midSession + ) + expect(result.getTime()).toBe(createdAt.getTime() + 24 * HOUR_MS) + }) + + it('caps idle expiry at now + idleTimeoutHours', () => { + const result = clampSessionExpiry(policy({ idleTimeoutHours: 48 }), createdAt, proposed, now) + expect(result.getTime()).toBe(now.getTime() + 48 * HOUR_MS) + }) + + it('floors idleTimeoutHours at twice the cookie-cache window', () => { + const result = clampSessionExpiry(policy({ idleTimeoutHours: 1 }), createdAt, proposed, now) + expect(result.getTime()).toBe(now.getTime() + MIN_IDLE_TIMEOUT_HOURS * HOUR_MS) + }) + + it('applies the stricter of max lifetime and idle timeout', () => { + const result = clampSessionExpiry( + policy({ maxSessionHours: 8760, idleTimeoutHours: 48 }), + createdAt, + proposed, + now + ) + expect(result.getTime()).toBe(now.getTime() + 48 * HOUR_MS) + + const nearEnd = new Date(createdAt.getTime() + 71 * HOUR_MS) + const endOfLife = clampSessionExpiry( + policy({ maxSessionHours: 72, idleTimeoutHours: 48 }), + createdAt, + new Date(nearEnd.getTime() + 30 * 24 * HOUR_MS), + nearEnd + ) + expect(endOfLife.getTime()).toBe(createdAt.getTime() + 72 * HOUR_MS) + }) + + it('never extends a proposal already shorter than the policy', () => { + const shortProposal = new Date(now.getTime() + 1 * HOUR_MS) + const result = clampSessionExpiry( + policy({ maxSessionHours: 720, idleTimeoutHours: 720 }), + createdAt, + shortProposal, + now + ) + expect(result.getTime()).toBe(shortProposal.getTime()) + }) +}) diff --git a/apps/sim/lib/auth/session-policy.ts b/apps/sim/lib/auth/session-policy.ts new file mode 100644 index 00000000000..484423dfde3 --- /dev/null +++ b/apps/sim/lib/auth/session-policy.ts @@ -0,0 +1,229 @@ +import { db } from '@sim/db' +import type { SessionPolicySettings } from '@sim/db/schema' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq, sql } from 'drizzle-orm' +import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization' +import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('SessionPolicy') + +/** How long a resolved org session policy is served from process memory. */ +export const SESSION_POLICY_CACHE_TTL_MS = 60 * 1000 + +const HOUR_MS = 60 * 60 * 1000 + +export interface ResolvedSessionPolicy { + maxSessionHours: number | null + idleTimeoutHours: number | null +} + +interface PolicyCacheEntry { + policy: ResolvedSessionPolicy + fetchedAt: number +} + +const policyCache = new Map() + +const NO_POLICY: ResolvedSessionPolicy = { + maxSessionHours: null, + idleTimeoutHours: null, +} + +/** + * Resolves the EFFECTIVE session policy for an organization, served from a + * short TTL cache. Returns a no-op policy for personal (org-less) sessions + * and — mirroring data-retention's plan-gated effective settings — for + * hosted orgs no longer on an Enterprise plan: stored limits stop enforcing + * automatically on downgrade, since the enterprise-gated settings UI can no + * longer manage them. + */ +export async function getSessionPolicy( + organizationId: string | null | undefined +): Promise { + if (!organizationId) return NO_POLICY + + const cached = policyCache.get(organizationId) + if (cached && Date.now() - cached.fetchedAt < SESSION_POLICY_CACHE_TTL_MS) { + return cached.policy + } + + try { + const [row] = await db + .select({ settings: organization.sessionPolicySettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + const settings: SessionPolicySettings = row?.settings ?? {} + const hasBounds = Boolean(settings.maxSessionHours || settings.idleTimeoutHours) + const isEntitled = + !hasBounds || !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + const policy: ResolvedSessionPolicy = isEntitled + ? { + maxSessionHours: settings.maxSessionHours ?? null, + idleTimeoutHours: settings.idleTimeoutHours ?? null, + } + : NO_POLICY + policyCache.set(organizationId, { policy, fetchedAt: Date.now() }) + return policy + } catch (error) { + logger.error('Failed to resolve session policy; applying no policy', { + organizationId, + error, + }) + return NO_POLICY + } +} + +/** Drops the cached policy for an org so the next read is fresh. */ +export function invalidateSessionPolicyCache(organizationId: string): void { + policyCache.delete(organizationId) +} + +/** + * Clamps a proposed session `expiresAt` to the org policy: + * `min(proposed, createdAt + maxSessionHours, now + idleTimeoutHours)`. + * + * Better Auth's sliding refresh rewrites `expiresAt` to `now + expiresIn` + * (30 days) on every refresh, which would silently stretch a shortened + * session back out — so this clamp must run in BOTH the session create and + * session update database hooks. The idle floor guards values that bypassed + * contract validation (legacy rows, direct DB writes). + */ +export function clampSessionExpiry( + policy: ResolvedSessionPolicy, + createdAt: Date, + proposedExpiresAt: Date, + now: Date = new Date() +): Date { + let clamped = proposedExpiresAt.getTime() + if (policy.maxSessionHours) { + clamped = Math.min(clamped, createdAt.getTime() + policy.maxSessionHours * HOUR_MS) + } + if (policy.idleTimeoutHours) { + const idleHours = Math.max(policy.idleTimeoutHours, MIN_IDLE_TIMEOUT_HOURS) + clamped = Math.min(clamped, now.getTime() + idleHours * HOUR_MS) + } + return new Date(clamped) +} + +/** + * Session shape shared by the Better Auth create/update database hooks — + * the fields the clamp guards need. + */ +interface ClampableSession { + userId?: string | null + impersonatedBy?: string | null + createdAt?: Date | string | null + expiresAt?: Date | string | null +} + +/** + * Applies the org session policy to a session's proposed `expiresAt` from a + * Better Auth database hook. The governing org is the user's MEMBERSHIP — + * never the session row's `activeOrganizationId`, which goes stale on + * join/leave/transfer — matching the cookie-cache version resolution, so + * every member session (including ones created before the user joined or + * carried across a transfer) is governed consistently. Callers that have + * JUST resolved the membership themselves (the session create hook) pass it + * as `freshMembershipOrgId` to skip the duplicate lookup. Returns the + * original date when no clamp applies: impersonation sessions are + * platform-admin tooling with their own short expiry, and non-member + * sessions have no policy. + */ +export async function clampExpiryForSession( + session: ClampableSession, + freshMembershipOrgId?: string | null +): Promise { + // Better Auth context values can cross a serialization boundary — normalize + // date fields in case they arrive as ISO strings rather than Dates. + const expiresAt = session.expiresAt ? new Date(session.expiresAt) : undefined + if (!expiresAt || session.impersonatedBy) { + return expiresAt + } + const organizationId = + freshMembershipOrgId !== undefined + ? freshMembershipOrgId + : await getMemberOrganizationId(session.userId) + if (!organizationId) return expiresAt + + const policy = await getSessionPolicy(organizationId) + const createdAt = session.createdAt ? new Date(session.createdAt) : new Date() + return clampSessionExpiry(policy, createdAt, expiresAt) +} + +/** + * Eagerly clamps every existing member session to the given policy in a + * single SQL statement — the SQL twin of {@link clampSessionExpiry}, kept in + * this module so the two encodings of the clamp cannot drift. Runs when a + * policy is saved so tightening applies without waiting for each session's + * next refresh; `LEAST` never extends an already-shorter expiry, and + * impersonation sessions are exempt. Targets sessions by org MEMBERSHIP (not + * `active_organization_id`) — the same scope the hooks govern via the + * membership fallback. No-ops when the policy sets no bounds. + */ +export async function eagerClampOrgSessions( + organizationId: string, + policy: ResolvedSessionPolicy, + executor: Pick = db +): Promise { + const bounds = clampBoundsSql(policy) + if (!bounds) return + + await executor.execute(sql` + UPDATE "session" SET expires_at = LEAST(${bounds}) + WHERE impersonated_by IS NULL + AND user_id IN ( + SELECT user_id FROM member WHERE organization_id = ${organizationId} + ) + `) +} + +/** + * Applies the org's session policy to a user who just JOINED the org: + * invalidates their cached membership (so the cookie-version and hook-clamp + * fallbacks see the new org immediately) and clamps their pre-join sessions, + * which otherwise keep their old expiry until the next sliding refresh. + * Best-effort by design — a failure here must never fail the join; the + * update-hook clamp self-heals within one refresh cycle. + */ +export async function applySessionPolicyToNewMember( + userId: string, + organizationId: string +): Promise { + try { + invalidateMembershipCache(userId) + const policy = await getSessionPolicy(organizationId) + const bounds = clampBoundsSql(policy) + if (!bounds) return + + await db.execute(sql` + UPDATE "session" SET expires_at = LEAST(${bounds}) + WHERE user_id = ${userId} AND impersonated_by IS NULL + `) + } catch (error) { + logger.error('Failed to apply session policy to new member; next refresh re-clamps', { + userId, + organizationId, + error, + }) + } +} + +/** SQL argument list for the LEAST() clamp, or null when the policy is empty. */ +function clampBoundsSql(policy: ResolvedSessionPolicy) { + const bounds = [sql`expires_at`] + if (policy.maxSessionHours) { + const maxSecs = policy.maxSessionHours * 3600 + bounds.push(sql`created_at + make_interval(secs => ${maxSecs})`) + } + if (policy.idleTimeoutHours) { + const idleSecs = Math.max(policy.idleTimeoutHours, MIN_IDLE_TIMEOUT_HOURS) * 3600 + bounds.push(sql`now() + make_interval(secs => ${idleSecs})`) + } + if (bounds.length === 1) return null + return sql.join(bounds, sql`, `) +} diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 571c652b02e..7d2955ef587 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -24,6 +24,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' +import { invalidateMembershipCache } from '@/lib/auth/security-policy' +import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { assertNoUnresolvedEnterpriseIssuance, @@ -630,6 +632,12 @@ export async function ensureUserInOrganization( const result = await addUserToOrganization(params) + if (result.success) { + // Invalidates the membership cache and clamps pre-join sessions to the + // org policy — same treatment as invite acceptance. Best-effort. + await applySessionPolicyToNewMember(params.userId, params.organizationId) + } + return { ...result, alreadyMember: false, @@ -1343,7 +1351,7 @@ export async function transferUserBetweenOrganizations( } try { - return await withInvitationSafeOrganizationAccessMutation( + const transferResult = await withInvitationSafeOrganizationAccessMutation( { userId: params.userId, organizationId: params.sourceOrganizationId, @@ -1513,6 +1521,11 @@ export async function transferUserBetweenOrganizations( } } ) + // The transferred member's fallbacks must resolve to the destination org + // immediately, and their sessions clamp to its policy — same treatment as + // invite acceptance. Best-effort. + await applySessionPolicyToNewMember(params.userId, params.destinationOrganizationId) + return transferResult } catch (error) { logger.error('Failed to transfer organization member', { ...params, error }) return { success: false, error: getErrorMessage(error), ...emptyResult } @@ -1733,6 +1746,10 @@ export async function removeUserFromOrganization( billingActions.workspaceAccessRevoked = result.workspaceIdsToRevoke.length billingActions.pendingInvitationsCancelled = result.pendingInvitationsCancelled + // The departed member's cookie-version/hook-clamp fallbacks must stop + // resolving to this org immediately, not after the membership-cache TTL. + invalidateMembershipCache(userId) + if (result.usageCaptured > 0) { logger.info('Captured departed member usage', { organizationId, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index c899ccbf01b..683f4356ac8 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -566,6 +566,7 @@ export const env = createEnv({ NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements) + NEXT_PUBLIC_SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: z.boolean().optional(), // Show the "Workflow" column type in user tables (defaults to false) NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) @@ -606,6 +607,7 @@ export const env = createEnv({ NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED, NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED, NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED, + NEXT_PUBLIC_SESSION_POLICIES_ENABLED: process.env.NEXT_PUBLIC_SESSION_POLICIES_ENABLED, NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED, NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED, NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 42d05e98316..a743765f9f4 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -19,6 +19,7 @@ import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' +import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { acquireOrganizationMutationLock, @@ -646,6 +647,10 @@ async function runInvitationAcceptancePostCommitEffects( } if (effects.organizationId && effects.memberRole) { + // Pre-join sessions keep their old expiry until the next sliding refresh; + // apply the org's session policy to them now (best-effort, never throws). + await applySessionPolicyToNewMember(input.userId, effects.organizationId) + recordAudit({ workspaceId: null, actorId: input.userId, diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index a8d1679adb6..90e02afe2af 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -131,6 +131,8 @@ export const AuditAction = { // Organizations ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', + ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', + ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', diff --git a/packages/db/migrations/0265_org_session_policy.sql b/packages/db/migrations/0265_org_session_policy.sql new file mode 100644 index 00000000000..636f9295803 --- /dev/null +++ b/packages/db/migrations/0265_org_session_policy.sql @@ -0,0 +1,2 @@ +ALTER TABLE "organization" ADD COLUMN "session_policy_settings" json;--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "security_policy_version" integer DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0265_snapshot.json b/packages/db/migrations/meta/0265_snapshot.json new file mode 100644 index 00000000000..369cf4ece68 --- /dev/null +++ b/packages/db/migrations/meta/0265_snapshot.json @@ -0,0 +1,17406 @@ +{ + "id": "18059bad-80c8-4739-a925-3883824a5cd2", + "prevId": "12a47eb4-408b-41d5-92e3-c26a03d1d754", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 5b970c86327..54732eb975f 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1849,6 +1849,13 @@ "when": 1784680777879, "tag": "0264_fat_ikaris", "breakpoints": true + }, + { + "idx": 265, + "version": "7", + "when": 1784756534601, + "tag": "0265_org_session_policy", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 3ee3b557ef5..083cdb72e72 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1230,12 +1230,36 @@ export interface DataRetentionSettings { retentionOverrides?: RetentionOverride[] | null } +/** + * Org-level session policy (enterprise). Absent or empty = Better Auth + * defaults (30-day sliding sessions). `maxSessionHours` caps absolute session + * lifetime from creation; `idleTimeoutHours` caps time between refreshes. + * Enforced by clamping `session.expiresAt` in the Better Auth session + * create/update database hooks; `securityPolicyVersion` invalidates cached + * session cookies org-wide when bumped. + */ +export interface SessionPolicySettings { + /** Absolute session lifetime cap in hours from session creation. */ + maxSessionHours?: number | null + /** Idle timeout in hours — session expires this long after its last refresh. */ + idleTimeoutHours?: number | null +} + export const organization = pgTable('organization', { id: text('id').primaryKey(), name: text('name').notNull(), slug: text('slug').notNull(), logo: text('logo'), metadata: json('metadata'), + sessionPolicySettings: json('session_policy_settings').$type(), + /** + * Monotonic counter embedded in the Better Auth cookie-cache version for + * this org's members. Bumped on any security-policy change or org-wide + * session revocation so every cached session cookie in the org is + * invalidated (falls through to a DB session read) within the policy + * cache TTL instead of the 24h cookie-cache lifetime. + */ + securityPolicyVersion: integer('security_policy_version').notNull().default(1), whitelabelSettings: json('whitelabel_settings').$type<{ brandName?: string logoUrl?: string diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 87dccc0f01c..e18095f5d36 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -105,6 +105,8 @@ export const auditMock = { PASSWORD_RESET_REQUESTED: 'password.reset_requested', ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', + ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', + ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index e7644a4d00d..8289bba83d8 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 967, - zodRoutes: 967, + totalRoutes: 969, + zodRoutes: 969, nonZodRoutes: 0, } as const From 02bc8f182845a206dbcca1632934ba2096b9cd9c Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 17:10:24 -0700 Subject: [PATCH 21/24] feat(ci): warm Next.js builds via Turbopack persistent cache on a sticky disk (#5869) * feat(ci): warm Next.js builds via Turbopack persistent cache on a sticky disk - enable experimental.turbopackFileSystemCacheForBuild behind NEXT_TURBOPACK_BUILD_CACHE so only the CI check build opts in; production image builds stay on the default cold path until the feature stabilizes - mount ./apps/sim/.next/cache as a Blacksmith sticky disk (cache-mount) instead of actions/cache: the turbopack cache is ~5 GB, which a sticky disk mounts in ~1s while an actions/cache round-trip would eat the win - measured locally: 105s cold compile vs 22s warm (4.8x) * chore(ci): drop the superseded actions/cache comment and restore trailing newline --- .github/workflows/test-build.yml | 27 +++++++++++++-------------- apps/sim/next.config.ts | 6 ++++++ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 285c7ec53f6..8e7ff3e9eab 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -236,21 +236,17 @@ jobs: key: ${{ github.repository }}-turbo-cache-build-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo - # Keyed per commit so EVERY run saves its refreshed cache — a key without - # a unique suffix is written once and then frozen (GitHub caches are - # immutable per key; a primary-key hit skips the save), which left builds - # compiling against a cache stale since the last lockfile change. The - # restore-keys prefix match picks the most recently saved cache for this - # lockfile, falling back across lockfile changes. - - name: Restore Next.js build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + # Turbopack's persistent build cache (NEXT_TURBOPACK_BUILD_CACHE below) + # writes ~5 GB into .next/cache — a sticky disk mounts it in ~1s where an + # actions/cache round-trip would eat the warm-build win. Same event/fork + # namespacing as the other mounts; the GitHub fallback inside cache-mount + # still uses actions/cache with a run_id-suffixed key. + - name: Mount Next.js build cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} + key: ${{ github.repository }}-nextjs-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./apps/sim/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ github.sha }}- - ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}- - ${{ runner.os }}-nextjs- - name: Install dependencies run: bun install --frozen-lockfile @@ -266,4 +262,7 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim \ No newline at end of file + # Opt into Turbopack's persistent build cache (beta) for this CI + # check build only — measured 105s cold vs 22s warm locally. + NEXT_TURBOPACK_BUILD_CACHE: '1' + run: bunx turbo run build --filter=sim diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 5cf26fbaeea..ebcb52775ea 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -144,6 +144,12 @@ const nextConfig: NextConfig = { experimental: { optimizeCss: !isDev, turbopackFileSystemCacheForDev: false, + /** + * Turbopack's persistent build cache (beta) — opt-in via env so only the + * CI check build uses it; production image builds stay on the default + * cold-build path until the feature stabilizes. + */ + turbopackFileSystemCacheForBuild: process.env.NEXT_TURBOPACK_BUILD_CACHE === '1', preloadEntriesOnStart: false, optimizePackageImports: [ 'lodash', From 9d8e14ce9f4f0532c28818d6b50e1ef6d52a91a3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 17:31:18 -0700 Subject: [PATCH 22/24] improvement(tests): converge env-flags mocks onto a complete shared mock (#5871) * improvement(tests): converge env-flags mocks onto a complete shared mock * fix(tests): drop the repo's only bare vi.mock automock A bare vi.mock('drizzle-orm') automock colliding with factory mocks of the same module in a shared worker corrupts vitest's mock registry (upstream vitest-dev/vitest#10290 / #10145, reproduced in isolation). The global factory mock already covers this suite. * chore(tests): drop defensive resets in non-mutating suites, merge sequential setEnvFlags calls * fix(tests): run providers/utils cases sequentially over shared env-flags state --- apps/sim/app/api/auth/[...all]/route.test.ts | 19 +- .../app/api/billing/switch-plan/route.test.ts | 14 +- .../app/api/billing/update-cost/route.test.ts | 33 +- .../app/api/chat/manage/[id]/route.test.ts | 15 +- .../copilot/api-keys/validate/route.test.ts | 27 +- .../app/api/function/execute/route.test.ts | 7 +- .../app/api/knowledge/search/utils.test.ts | 2 - .../[memberId]/usage-limit/route.test.ts | 28 +- .../[id]/session-policy/route.test.ts | 14 +- .../app/api/schedules/execute/route.test.ts | 29 +- apps/sim/app/api/speech/token/route.test.ts | 5 - .../[tableId]/delete-async/route.test.ts | 19 +- .../app/api/tools/onepassword/utils.test.ts | 20 +- .../credits-chip/credits-chip.test.tsx | 13 +- .../invite-modal/invite-modal.test.tsx | 13 +- apps/sim/blocks/utils.test.ts | 50 +- .../utils/permission-check.test.ts | 28 +- .../ee/sso/components/sso-settings.test.tsx | 13 +- .../handlers/agent/agent-handler.test.ts | 40 +- apps/sim/executor/handlers/pi/keys.test.ts | 10 +- apps/sim/lib/api-key/byok.test.ts | 4 - apps/sim/lib/auth/access-control.test.ts | 18 +- apps/sim/lib/auth/ban.test.ts | 1 - .../calculations/usage-monitor.test.ts | 34 +- .../usage-reservation-env.test.ts | 15 +- .../calculations/usage-reservation.test.ts | 29 +- .../lib/billing/cleanup-dispatcher.test.ts | 20 +- .../billing/core/billing-attribution.test.ts | 27 +- .../billing/core/limit-notifications.test.ts | 36 +- .../sim/lib/billing/core/subscription.test.ts | 18 +- apps/sim/lib/billing/core/usage-log.test.ts | 20 +- .../billing/organizations/seat-drift.test.ts | 24 +- .../lib/billing/organizations/seats.test.ts | 17 +- apps/sim/lib/billing/storage/limits.test.ts | 25 +- apps/sim/lib/billing/storage/tracking.test.ts | 23 +- .../validation/seat-management.test.ts | 34 +- apps/sim/lib/copilot/chat/payload.test.ts | 4 +- .../lib/copilot/request/lifecycle/run.test.ts | 33 +- .../copilot/request/lifecycle/start.test.ts | 28 +- .../tools/server/files/doc-servable.test.ts | 21 +- .../workflow/edit-workflow/validation.test.ts | 21 +- .../lib/core/config/block-visibility.test.ts | 23 +- .../sim/lib/core/config/feature-flags.test.ts | 22 +- .../lib/core/execution-limits/types.test.ts | 20 +- .../core/rate-limiter/rate-limiter.test.ts | 10 +- apps/sim/lib/core/rate-limiter/types.test.ts | 19 +- apps/sim/lib/core/security/csp.test.ts | 4 +- .../core/security/input-validation.test.ts | 6 +- .../core/security/pinned-fetch.server.test.ts | 3 - apps/sim/lib/core/utils/urls.test.ts | 4 - apps/sim/lib/data-drains/dispatcher.test.ts | 17 +- apps/sim/lib/invitations/core.test.ts | 23 +- .../documents/processing-queue.test.ts | 21 +- apps/sim/lib/logs/execution/logger.test.ts | 4 +- apps/sim/lib/mcp/connection-manager.test.ts | 10 +- apps/sim/lib/mcp/domain-check.test.ts | 33 +- apps/sim/lib/mcp/service-pool.test.ts | 1 - apps/sim/lib/table/billing.test.ts | 16 +- .../lib/table/dispatch-concurrency.test.ts | 18 +- apps/sim/lib/table/workflow-columns.test.ts | 13 +- apps/sim/lib/webhooks/processor.test.ts | 3 - apps/sim/lib/workspaces/policy.test.ts | 32 +- apps/sim/providers/index.test.ts | 4 - apps/sim/providers/utils.test.ts | 457 ++++++++---------- apps/sim/tools/index.test.ts | 32 +- apps/sim/tools/supabase/utils.test.ts | 6 +- apps/sim/vitest.setup.ts | 2 + packages/testing/src/mocks/env-flags.mock.ts | 144 +++++- packages/testing/src/mocks/index.ts | 8 +- 69 files changed, 902 insertions(+), 904 deletions(-) diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 0d82eac149d..4c577409990 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const handlerMocks = vi.hoisted(() => ({ betterAuthGET: vi.fn(), @@ -12,7 +12,6 @@ const handlerMocks = vi.hoisted(() => ({ user: { id: 'anon' }, session: { id: 'anon-session' }, })), - isAuthDisabled: false, })) vi.mock('better-auth/next-js', () => ({ @@ -31,22 +30,18 @@ vi.mock('@/lib/auth/anonymous', () => ({ createAnonymousSession: handlerMocks.createAnonymousSession, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isAuthDisabled() { - return handlerMocks.isAuthDisabled - }, -})) - import { GET, POST } from '@/app/api/auth/[...all]/route' +afterAll(resetEnvFlagsMock) + describe('auth catch-all route (DISABLE_AUTH get-session)', () => { beforeEach(() => { vi.clearAllMocks() - handlerMocks.isAuthDisabled = false + setEnvFlags({ isAuthDisabled: false }) }) it('returns anonymous session in better-auth response envelope when auth is disabled', async () => { - handlerMocks.isAuthDisabled = true + setEnvFlags({ isAuthDisabled: true }) const req = createMockRequest( 'GET', @@ -67,7 +62,7 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { }) it('delegates to better-auth handler when auth is enabled', async () => { - handlerMocks.isAuthDisabled = false + setEnvFlags({ isAuthDisabled: false }) const { NextResponse } = await import('next/server') handlerMocks.betterAuthGET.mockResolvedValueOnce( diff --git a/apps/sim/app/api/billing/switch-plan/route.test.ts b/apps/sim/app/api/billing/switch-plan/route.test.ts index 778020298d4..377907a9f8e 100644 --- a/apps/sim/app/api/billing/switch-plan/route.test.ts +++ b/apps/sim/app/api/billing/switch-plan/route.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCanManageWorkspaceBilling, @@ -48,10 +48,6 @@ vi.mock('@/lib/billing/workspace-permissions', () => ({ canManageWorkspaceBilling: mockCanManageWorkspaceBilling, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn(), })) @@ -62,6 +58,12 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { POST } from '@/app/api/billing/switch-plan/route' +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('POST /api/billing/switch-plan', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index df2a53a61d1..2edb84d2a31 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckInternalApiKey, @@ -15,7 +15,6 @@ const { mockToBillingContext, MockCumulativeUsageContextMismatchError, MockThresholdSettlementError, - billingState, } = vi.hoisted(() => ({ mockCheckInternalApiKey: vi.fn(), mockRecordCumulativeUsage: vi.fn(), @@ -36,10 +35,6 @@ const { this.code = code } }, - billingState: { - isBillingEnabled: true, - isCopilotBillingProtocolRequired: false, - }, })) vi.mock('@/lib/copilot/request/http', () => ({ @@ -82,18 +77,11 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ ThresholdSettlementError: MockThresholdSettlementError, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return billingState.isBillingEnabled - }, - get isCopilotBillingProtocolRequired() { - return billingState.isCopilotBillingProtocolRequired - }, -})) - import { billingUpdateCostBodySchema } from '@/lib/api/contracts/subscription' import { POST } from '@/app/api/billing/update-cost/route' +afterAll(resetEnvFlagsMock) + const ACCOUNT_BILLING_DECISION = { userId: 'user-1', billingEntity: { type: 'organization' as const, id: 'account-org' }, @@ -159,8 +147,7 @@ const KEYLESS_UPDATE_COST_BODY = { describe('POST /api/billing/update-cost — workspaceId attribution', () => { beforeEach(() => { vi.clearAllMocks() - billingState.isBillingEnabled = true - billingState.isCopilotBillingProtocolRequired = false + setEnvFlags({ isBillingEnabled: true, isCopilotBillingProtocolRequired: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 }) mockCheckAndBillOverageThreshold.mockResolvedValue(undefined) @@ -178,7 +165,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('returns 401 for a billing-disabled request without valid internal auth', async () => { - billingState.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockCheckInternalApiKey.mockReturnValue({ success: false, error: 'Invalid internal API key' }) const res = await POST( @@ -200,7 +187,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('returns no-op success for old markerless Go when billing is disabled', async () => { - billingState.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const res = await POST( createMockRequest( @@ -303,7 +290,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('rejects markerless callbacks only when protocol-required is explicitly enabled', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' }) ) @@ -328,7 +315,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('rejects explicitly labeled legacy callbacks without admission attribution', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal', @@ -343,7 +330,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('bills explicitly labeled legacy callbacks from their admission attribution', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal', diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index bc765862b4c..f883fdaf6c3 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -11,6 +11,8 @@ import { encryptionMock, encryptionMockFns, resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, workflowsApiUtilsMock, workflowsApiUtilsMockFns, workflowsOrchestrationMock, @@ -18,7 +20,7 @@ import { workflowsPersistenceUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckChatAccess, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), @@ -37,11 +39,6 @@ const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/core/config/env-flags', () => ({ - isDev: true, - isHosted: false, - isProd: false, -})) vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) @@ -66,6 +63,12 @@ vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' +beforeAll(() => { + setEnvFlags({ isDev: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('Chat Edit API Route', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index 7e8b86e5a87..d0faa279153 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + createMockRequest, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -18,7 +25,6 @@ const { mockSerializeBillingAttributionHeader, mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, - mockFlags, } = vi.hoisted(() => ({ mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -33,9 +39,6 @@ const { mockSerializeBillingAttributionHeader: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), - mockFlags: { - isCopilotBillingProtocolRequired: false, - }, })) const ATTRIBUTION = { @@ -119,12 +122,6 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isCopilotBillingProtocolRequired() { - return mockFlags.isCopilotBillingProtocolRequired - }, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -136,6 +133,8 @@ vi.mock('@/lib/workspaces/utils', () => ({ import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot' import { POST } from '@/app/api/copilot/api-keys/validate/route' +afterAll(resetEnvFlagsMock) + function request(body: Record, headers: Record = {}) { return createMockRequest('POST', body, { 'x-api-key': 'internal', ...headers }) } @@ -144,7 +143,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isCopilotBillingProtocolRequired = false + setEnvFlags({ isCopilotBillingProtocolRequired: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) queueTableRows(schemaMock.user, [{ id: 'user-1' }]) mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION) @@ -258,7 +257,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) it('rejects markerless admission only when protocol-required is explicitly enabled', async () => { - mockFlags.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY)) expect(res.status).toBe(400) @@ -267,7 +266,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) it('allows explicitly labeled legacy requests when markerless traffic is disabled', async () => { - mockFlags.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( request(OLD_GO_HOSTED_VALIDATE_BODY, { 'x-sim-billing-protocol': 'legacy-v0' }) ) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 012b6f9f458..4b5c2194584 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -7,10 +7,11 @@ import { createMockRequest, envFlagsMock, hybridAuthMockFns, + resetEnvFlagsMock, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInE2B, @@ -101,14 +102,14 @@ vi.mock('@/lib/uploads', () => ({ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - import { validateProxyUrl } from '@/lib/core/security/input-validation' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { POST } from '@/app/api/function/execute/route' +afterAll(resetEnvFlagsMock) + describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 35535fa913a..0c0d4128d09 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -9,8 +9,6 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' -vi.mock('drizzle-orm') - /** * Spy on the real documents/utils namespace instead of vi.mock: the shared * `@/lib/knowledge/embeddings` module may be cached bound to the real module, diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index 753fb41b249..8d8428f78d1 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -1,8 +1,14 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest, createSession } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + auditMock, + createMockRequest, + createSession, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, @@ -11,7 +17,6 @@ const { mockGetOrgMemberUsageForCurrentPeriod, mockSetOrgMemberUsageLimit, mockGetOrganizationSubscription, - mockFlags, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockIsOrganizationOwnerOrAdmin: vi.fn(), @@ -19,7 +24,6 @@ const { mockGetOrgMemberUsageForCurrentPeriod: vi.fn(), mockSetOrgMemberUsageLimit: vi.fn(), mockGetOrganizationSubscription: vi.fn(), - mockFlags: { isHosted: true }, })) vi.mock('@/lib/auth', () => ({ @@ -43,14 +47,10 @@ vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockFlags.isHosted - }, -})) - import { GET, PUT } from '@/app/api/organizations/[id]/members/[memberId]/usage-limit/route' +afterAll(resetEnvFlagsMock) + function context() { return { params: Promise.resolve({ id: 'org-1', memberId: 'user-2' }) } } @@ -66,7 +66,7 @@ function getRequest() { describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isHosted = true + setEnvFlags({ isHosted: true }) mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockGetOrgMemberUsageForCurrentPeriod.mockResolvedValue(1) // $1 -> 200 credits @@ -81,7 +81,7 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { }) it('returns 404 when not hosted', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const res = await GET(getRequest(), context()) expect(res.status).toBe(404) }) @@ -144,14 +144,14 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isHosted = true + setEnvFlags({ isHosted: true }) mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockSetOrgMemberUsageLimit.mockResolvedValue(undefined) }) it('returns 404 when not hosted', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const res = await PUT(putRequest({ creditLimit: 400 }), context()) expect(res.status).toBe(404) expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts index 5530045fd50..0e1d39adaaa 100644 --- a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts @@ -8,8 +8,10 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({ mockGetSession: vi.fn(), @@ -37,10 +39,6 @@ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsEnterprise, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, AuditAction: { @@ -54,6 +52,12 @@ import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route' const ORG_ID = 'org-1' const routeContext = { params: Promise.resolve({ id: ORG_ID }) } +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('session policy route', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index bcf0eaf5d3f..4de1d5aaebf 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -3,9 +3,16 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, requestUtilsMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + requestUtilsMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { type NextRequest, NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const orderByLimitMock = vi.fn() @@ -14,7 +21,6 @@ const { mockExecuteScheduleJob, mockExecuteJobInline, mockReleaseScheduleLock, - mockFeatureFlags, mockEnqueue, mockGetJob, mockStartJob, @@ -29,12 +35,6 @@ const { mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), mockExecuteJobInline: vi.fn().mockResolvedValue(undefined), mockReleaseScheduleLock: vi.fn().mockResolvedValue(undefined), - mockFeatureFlags: { - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - }, mockEnqueue: vi.fn().mockResolvedValue('job-id-1'), mockGetJob: vi.fn().mockResolvedValue(null), mockStartJob: vi.fn().mockResolvedValue(undefined), @@ -70,8 +70,6 @@ vi.mock('@/background/schedule-execution', () => ({ }), })) -vi.mock('@/lib/core/config/env-flags', () => mockFeatureFlags) - vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueue, @@ -277,6 +275,8 @@ function createMockRequest(): NextRequest { } as NextRequest } +afterAll(resetEnvFlagsMock) + describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { vi.clearAllMocks() @@ -289,10 +289,7 @@ describe('Scheduled Workflow Execution API Route', () => { dbChainMockFns.orderBy.mockReturnValue({ limit: orderByLimitMock } as never) dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') - mockFeatureFlags.isTriggerDevEnabled = false - mockFeatureFlags.isHosted = false - mockFeatureFlags.isProd = false - mockFeatureFlags.isDev = true + setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true }) mockShouldExecuteInline.mockReturnValue(false) mockEnqueue.mockReset() mockEnqueue.mockResolvedValue('job-id-1') @@ -337,7 +334,7 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should queue schedules to Trigger.dev when enabled', async () => { - mockFeatureFlags.isTriggerDevEnabled = true + setEnvFlags({ isTriggerDevEnabled: true }) dbChainMockFns.limit .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) .mockResolvedValueOnce([]) diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index 5f42626ba41..ab3fd86ed23 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -72,11 +72,6 @@ vi.mock('@/app/api/workflows/utils', () => ({ vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: false, - getCostMultiplier: () => 1, -})) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true }) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts index 5ca3fb8c04d..38b24e06fb0 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -1,9 +1,9 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns } from '@sim/testing' +import { hybridAuthMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { NextRequest, NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' const { @@ -13,7 +13,6 @@ const { mockRunTableDelete, mockTableFilterError, mockTasksTrigger, - flags, } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockMarkTableJobRunning: vi.fn(), @@ -21,7 +20,6 @@ const { mockRunTableDelete: vi.fn(), mockTableFilterError: vi.fn(), mockTasksTrigger: vi.fn(), - flags: { triggerDev: false }, })) vi.mock('@sim/utils/id', () => ({ @@ -33,11 +31,6 @@ vi.mock('@/lib/table/jobs/service', () => ({ releaseJobClaim: mockReleaseJobClaim, })) vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isTriggerDevEnabled() { - return flags.triggerDev - }, -})) vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), @@ -63,6 +56,8 @@ vi.mock('@/app/api/table/utils', async () => { import { POST } from '@/app/api/table/[tableId]/delete-async/route' +afterAll(resetEnvFlagsMock) + function buildTable(overrides: Partial = {}): TableDefinition { return { id: 'tbl_1', @@ -109,7 +104,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { mockRunTableDelete.mockResolvedValue(undefined) mockTableFilterError.mockReturnValue(null) mockTasksTrigger.mockResolvedValue({ id: 'run_1' }) - flags.triggerDev = false + setEnvFlags({ isTriggerDevEnabled: false }) }) it('claims the job slot and kicks off the delete worker with filter + exclusions', async () => { @@ -185,7 +180,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { }) it('routes through trigger.dev (ISO cutoff, tagged) when the flag is on', async () => { - flags.triggerDev = true + setEnvFlags({ isTriggerDevEnabled: true }) const response = await makeRequest(validBody) expect(response.status).toBe(200) @@ -204,7 +199,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { }) it('releases the job claim when the trigger.dev dispatch fails (no ghost running job)', async () => { - flags.triggerDev = true + setEnvFlags({ isTriggerDevEnabled: true }) mockTasksTrigger.mockRejectedValueOnce(new Error('trigger.dev unreachable')) const response = await makeRequest(validBody) diff --git a/apps/sim/app/api/tools/onepassword/utils.test.ts b/apps/sim/app/api/tools/onepassword/utils.test.ts index 8d9e9a76672..160bbe69b7f 100644 --- a/apps/sim/app/api/tools/onepassword/utils.test.ts +++ b/apps/sim/app/api/tools/onepassword/utils.test.ts @@ -1,17 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDnsLookup, hostedFlag } = vi.hoisted(() => ({ +const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn(), - hostedFlag: { value: false }, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return hostedFlag.value - }, })) vi.mock('dns/promises', () => ({ @@ -20,10 +14,12 @@ vi.mock('dns/promises', () => ({ import { validateConnectServerUrl } from '@/app/api/tools/onepassword/utils' +afterAll(resetEnvFlagsMock) + describe('validateConnectServerUrl', () => { beforeEach(() => { vi.clearAllMocks() - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it('rejects a non-URL string', async () => { @@ -32,7 +28,7 @@ describe('validateConnectServerUrl', () => { describe('hosted deployment', () => { beforeEach(() => { - hostedFlag.value = true + setEnvFlags({ isHosted: true }) }) it.each([ @@ -87,7 +83,7 @@ describe('validateConnectServerUrl', () => { describe('self-hosted deployment', () => { beforeEach(() => { - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it.each([ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx index 6fdcac86f98..6e84fab43ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockHostContext, mockPush, mockSession, mockUseWorkspaceCreditAvailability } = vi.hoisted( () => ({ @@ -55,10 +56,6 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: mockSession.current }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => mockHostContext.current, })) @@ -76,6 +73,12 @@ import { CreditsChip } from '@/app/workspace/[workspaceId]/home/components/credi let container: HTMLDivElement let root: Root +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('CreditsChip', () => { beforeEach(() => { container = document.createElement('div') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx index cbf1d3c3cd0..c1f8bf34dec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { hostContext, mockUseOrganizationBilling } = vi.hoisted(() => ({ hostContext: { @@ -32,10 +33,6 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { email: 'viewer@example.com' } } }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => hostContext.current, })) @@ -66,6 +63,12 @@ import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ let container: HTMLDivElement let root: Root +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('InviteModal organization billing isolation', () => { beforeEach(() => { container = document.createElement('div') diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 3dc571b9dd7..bf90c25d656 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -1,13 +1,10 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsHosted, mockIsAzureConfigured, mockIsOllamaConfigured } = vi.hoisted(() => ({ - mockIsHosted: { value: false }, - mockIsAzureConfigured: { value: false }, - mockIsOllamaConfigured: { value: false }, -})) +afterAll(resetEnvFlagsMock) const { mockGetHostedModels, @@ -34,18 +31,6 @@ const { mockProviders } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockIsHosted.value - }, - get isAzureConfigured() { - return mockIsAzureConfigured.value - }, - get isOllamaConfigured() { - return mockIsOllamaConfigured.value - }, -})) - vi.mock('@/providers/models', () => ({ getProviderFileAttachment: vi .fn() @@ -103,9 +88,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHosted.value = false - mockIsAzureConfigured.value = false - mockIsOllamaConfigured.value = false + setEnvFlags({ isHosted: false, isAzureConfigured: false, isOllamaConfigured: false }) mockProviders.value = { base: { models: [], isLoading: false }, ollama: { models: [], isLoading: false }, @@ -131,14 +114,14 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('hosted models', () => { it('does not require API key for hosted models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue(['gpt-4o', 'claude-sonnet-4-5']) expect(evaluateCondition('gpt-4o')).toBe(false) expect(evaluateCondition('claude-sonnet-4-5')).toBe(false) }) it('requires API key for non-hosted models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue(['gpt-4o']) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) }) @@ -158,14 +141,14 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('Azure models', () => { it('does not require API key for azure/ models when Azure is configured', () => { - mockIsAzureConfigured.value = true + setEnvFlags({ isAzureConfigured: true }) expect(evaluateCondition('azure/gpt-4o')).toBe(false) expect(evaluateCondition('azure-openai/gpt-4o')).toBe(false) expect(evaluateCondition('azure-anthropic/claude-sonnet-4-5')).toBe(false) }) it('requires API key for azure/ models when Azure is not configured', () => { - mockIsAzureConfigured.value = false + setEnvFlags({ isAzureConfigured: false }) expect(evaluateCondition('azure/gpt-4o')).toBe(true) }) }) @@ -218,7 +201,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('Ollama — OLLAMA_URL env var (server-safe)', () => { it('does not require API key for unknown models when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('llama3:latest')).toBe(false) expect(evaluateCondition('phi3:latest')).toBe(false) expect(evaluateCondition('gemma2:latest')).toBe(false) @@ -226,7 +209,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('does not require API key for Ollama models that match cloud provider regex patterns', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('mistral:latest')).toBe(false) expect(evaluateCondition('mistral')).toBe(false) expect(evaluateCondition('mistral-nemo')).toBe(false) @@ -234,7 +217,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for known cloud models even when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) mockGetBaseModelProviders.mockReturnValue(BASE_CLOUD_MODELS) expect(evaluateCondition('gpt-4o')).toBe(true) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) @@ -243,7 +226,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for slash-prefixed cloud models when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('azure/gpt-4o')).toBe(true) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) @@ -253,7 +236,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('cloud provider models that need API key', () => { it('requires API key for standard cloud models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue([]) expect(evaluateCondition('gpt-4o')).toBe(true) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) @@ -262,7 +245,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for prefixed cloud models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) expect(evaluateCondition('groq/llama-3')).toBe(true) @@ -270,7 +253,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for prefixed cloud models on self-hosted', () => { - mockIsHosted.value = false + setEnvFlags({ isHosted: false }) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) expect(evaluateCondition('groq/llama-3')).toBe(true) @@ -280,8 +263,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('self-hosted without OLLAMA_URL', () => { it('requires API key for any model (Ollama models cannot appear without OLLAMA_URL)', () => { - mockIsHosted.value = false - mockIsOllamaConfigured.value = false + setEnvFlags({ isHosted: false, isOllamaConfigured: false }) expect(evaluateCondition('llama3:latest')).toBe(true) expect(evaluateCondition('mistral:latest')).toBe(true) expect(evaluateCondition('gpt-4o')).toBe(true) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 6ea563bcec9..2aea7cf535c 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -2,12 +2,17 @@ * @vitest-environment node */ import { permissionGroup } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + envFlagsMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { DEFAULT_PERMISSION_GROUP_CONFIG, - mockGetAllowedIntegrationsFromEnv, mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner, mockGetProviderFromModel, @@ -39,7 +44,6 @@ const { hideDeployChatbot: false, allowedChatDeployAuthTypes: null, }, - mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(), mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), mockGetProviderFromModel: vi.fn<(model: string) => string>(), @@ -54,14 +58,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv, - isAccessControlEnabled: true, - isHosted: true, - isInvitationsDisabled: false, - isPublicApiDisabled: false, -})) - vi.mock('@/lib/permission-groups/types', () => ({ DEFAULT_PERMISSION_GROUP_CONFIG, parsePermissionGroupConfig: (config: unknown) => { @@ -141,6 +137,14 @@ beforeEach(() => { mockGetBlock.mockImplementation(() => undefined) }) +const mockGetAllowedIntegrationsFromEnv = envFlagsMockFns.getAllowedIntegrationsFromEnv + +beforeAll(() => { + setEnvFlags({ isAccessControlEnabled: true, isHosted: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('IntegrationNotAllowedError', () => { it.concurrent('creates error with correct name and message', () => { const error = new IntegrationNotAllowedError('discord') diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index f252bf6cefc..9ce0f36f06b 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act, type ChangeEventHandler, type ReactNode } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockUseConfigureSSO, mockUseOrganizationBilling, mockUseSession, mockUseSSOProviders } = vi.hoisted(() => ({ @@ -50,10 +51,6 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: mockUseSession, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock( '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions', () => ({ @@ -130,6 +127,12 @@ function renderSso(organizationId: string) { }) } +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('SSO organization transitions', () => { beforeEach(() => { // The component reads getBaseUrl() during render; make sure the env var is diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index a5a5b36b410..6fc48121454 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,5 +1,22 @@ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { + dbChainMock, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' import { getAllBlocks } from '@/blocks' import { BlockType, isMcpTool } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' @@ -11,19 +28,6 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' -vi.mock('@/lib/core/config/env-flags', () => ({ - isHosted: false, - isProd: false, - isDev: true, - isTest: false, - getCostMultiplier: vi.fn().mockReturnValue(1), - getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null), - isEmailVerificationEnabled: false, - isBillingEnabled: false, - isOrganizationsEnabled: false, - isAccessControlEnabled: false, -})) - vi.mock('@/providers/utils', () => ({ getProviderFromModel: vi.fn().mockReturnValue('mock-provider'), transformBlockTool: vi.fn(), @@ -110,6 +114,12 @@ const mockTransformBlockTool = transformBlockTool as Mock const mockFetch = vi.fn() const mockExecuteProviderRequest = executeProviderRequest as Mock +beforeAll(() => { + setEnvFlags({ isDev: true, isTest: false }) +}) + +afterAll(resetEnvFlagsMock) + describe('AgentBlockHandler', () => { let handler: AgentBlockHandler let mockBlock: SerializedBlock diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 97d17264f6c..cb1c6bbcde2 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetApiKeyWithBYOK, mockGetBYOKKey, mockCalculateCost, mockShouldBill } = vi.hoisted( () => ({ @@ -20,10 +21,15 @@ vi.mock('@/providers/utils', () => ({ calculateCost: mockCalculateCost, shouldBillModelUsage: mockShouldBill, })) -vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 })) import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys' +beforeAll(() => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) +}) + +afterAll(resetEnvFlagsMock) + describe('providerApiKeyEnvVar', () => { it('maps key-based providers and rejects unsupported ones', () => { expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY') diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index aec86b6e6b1..b049ae42738 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -22,10 +22,6 @@ vi.mock('@/lib/core/config/env', () => ({ env: {}, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isHosted: false, -})) - vi.mock('@/providers/models', () => ({ getProviderFileAttachment: vi .fn() diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 5667f724ca6..38b50e378e8 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessControlConfig } from '@/lib/auth/access-control' -const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({ +const { mockFetch, envRef } = vi.hoisted(() => ({ mockFetch: vi.fn(), envRef: { APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, @@ -15,7 +16,6 @@ const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({ ALLOWED_LOGIN_DOMAINS: undefined as string | undefined, BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, }, - flagRef: { isAppConfigEnabled: false }, })) vi.mock('@/lib/core/config/appconfig', () => ({ @@ -28,12 +28,6 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isAppConfigEnabled() { - return flagRef.isAppConfigEnabled - }, -})) - import { getAccessControlConfig, isEmailBlockedByAccessControl, @@ -48,10 +42,12 @@ const empty: AccessControlConfig = { blockedEmailMxHosts: [], } +afterAll(resetEnvFlagsMock) + describe('getAccessControlConfig', () => { beforeEach(() => { vi.clearAllMocks() - flagRef.isAppConfigEnabled = false + setEnvFlags({ isAppConfigEnabled: false }) Object.assign(envRef, { BLOCKED_SIGNUP_DOMAINS: undefined, BLOCKED_EMAILS: undefined, @@ -81,7 +77,7 @@ describe('getAccessControlConfig', () => { describe('AppConfig source (enabled)', () => { beforeEach(() => { - flagRef.isAppConfigEnabled = true + setEnvFlags({ isAppConfigEnabled: true }) }) it('reads the access-control profile and normalizes the payload', async () => { diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index fffdfb634ae..a6c81ad4faf 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -25,7 +25,6 @@ vi.mock('@/lib/core/config/env', () => ({ return envRef }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false })) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index b1a498cd455..ddd9b902474 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -1,30 +1,25 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockFlags, mockGetOrgMemberUsageForBillingPeriod, mockGetOrgMemberUsageLimit, mockIsOrganizationBillingBlocked, } = vi.hoisted(() => ({ - mockFlags: { isHosted: true, isBillingEnabled: true }, mockGetOrgMemberUsageForBillingPeriod: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockFlags.isHosted - }, - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) - vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/organizations/member-limits', () => ({ @@ -53,12 +48,13 @@ afterAll(() => { resetDbChainMock() }) +afterAll(resetEnvFlagsMock) + describe('checkBillingBlocked', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isHosted = true - mockFlags.isBillingEnabled = true + setEnvFlags({ isHosted: true, isBillingEnabled: true }) dbChainMockFns.limit.mockResolvedValue([{ blocked: false, blockedReason: null }]) }) @@ -76,8 +72,7 @@ describe('checkBillingEntityBlocked', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isHosted = true - mockFlags.isBillingEnabled = true + setEnvFlags({ isHosted: true, isBillingEnabled: true }) mockIsOrganizationBillingBlocked.mockResolvedValue(false) dbChainMockFns.limit.mockResolvedValue([]) }) @@ -116,8 +111,7 @@ describe('checkOrganizationMemberUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isHosted = true - mockFlags.isBillingEnabled = true + setEnvFlags({ isHosted: true, isBillingEnabled: true }) mockGetOrgMemberUsageLimit.mockResolvedValue(2) mockGetOrgMemberUsageForBillingPeriod.mockResolvedValue(1) }) @@ -139,14 +133,14 @@ describe('checkOrganizationMemberUsageLimit', () => { }) it('no-ops when not hosted', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const result = await checkOrganizationMemberUsageLimit('actor-1', 'org-1', billingPeriod) expect(result.isExceeded).toBe(false) expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled() }) it('no-ops when billing is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const result = await checkOrganizationMemberUsageLimit('actor-1', 'org-1', billingPeriod) expect(result.isExceeded).toBe(false) expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled() diff --git a/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts index 9a998355bf7..1a2697253c0 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { redisConfigMock, redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { evalMock, mockEnv } = vi.hoisted(() => ({ evalMock: vi.fn(), @@ -31,15 +31,16 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, - isHosted: true, -})) - vi.mock('@/lib/core/config/redis', () => redisConfigMock) import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true, isHosted: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('usage reservation environment overrides', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index ea8d6332aec..23615006588 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -1,21 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockFlags } = vi.hoisted(() => ({ - mockFlags: { isBillingEnabled: true, isHosted: true }, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, - get isHosted() { - return mockFlags.isHosted - }, -})) +import { redisConfigMock, redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/redis', () => redisConfigMock) @@ -54,11 +41,13 @@ function hashTag(key: string): string | undefined { return key.match(/\{([^}]+)\}/)?.[1] } +afterAll(resetEnvFlagsMock) + describe('usage-reservation', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isBillingEnabled = true - mockFlags.isHosted = true + setEnvFlags({ isBillingEnabled: true }) + setEnvFlags({ isHosted: true }) redisConfigMockFns.mockGetRedisClient.mockReturnValue(fakeRedis) }) @@ -253,14 +242,14 @@ describe('usage-reservation', () => { }) it('is a no-op when billing enforcement is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const result = await reserveExecutionSlot(baseParams) expect(result.reserved).toBe(true) expect(evalMock).not.toHaveBeenCalled() }) it('is a no-op on self-hosted deployments', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const result = await reserveExecutionSlot(baseParams) expect(result).toEqual({ reserved: true, created: false }) expect(evalMock).not.toHaveBeenCalled() @@ -427,7 +416,7 @@ describe('usage-reservation', () => { }) it('is a no-op when billing enforcement is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) await releaseExecutionSlot('exec-1') expect(getMock).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts index ed126c26535..fccdaf8e6c9 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts @@ -1,11 +1,16 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFlags, mockIsTriggerAvailable } = vi.hoisted(() => ({ - mockFlags: { isBillingEnabled: false }, +const { mockIsTriggerAvailable } = vi.hoisted(() => ({ mockIsTriggerAvailable: vi.fn(), })) @@ -18,11 +23,6 @@ vi.mock('@/lib/cleanup/batch-delete', () => ({ chunkArray: vi.fn() })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) vi.mock('@/lib/core/async-jobs/config', () => ({ shouldExecuteInline: vi.fn(() => false) })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn() })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) vi.mock('@/lib/knowledge/documents/service', () => ({ isTriggerAvailable: mockIsTriggerAvailable, })) @@ -33,11 +33,13 @@ vi.mock('@/lib/workspaces/policy', () => ({ import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' +afterAll(resetEnvFlagsMock) + describe('dispatchCleanupJobs billing gate', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) }) afterAll(() => { diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts index 05f42edf884..588a5a985c2 100644 --- a/apps/sim/lib/billing/core/billing-attribution.test.ts +++ b/apps/sim/lib/billing/core/billing-attribution.test.ts @@ -1,11 +1,16 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockFlags, mockCheckBillingBlocked, mockCheckBillingEntityBlocked, mockCheckOrganizationMemberUsageLimit, @@ -13,7 +18,6 @@ const { mockGetHighestPriorityPersonalSubscription, mockGetOrganizationSubscription, } = vi.hoisted(() => ({ - mockFlags: { isBillingEnabled: true, isHosted: true }, mockCheckBillingBlocked: vi.fn(), mockCheckBillingEntityBlocked: vi.fn(), mockCheckOrganizationMemberUsageLimit: vi.fn(), @@ -22,15 +26,6 @@ const { mockGetOrganizationSubscription: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, - get isHosted() { - return mockFlags.isHosted - }, -})) - vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ @@ -79,6 +74,8 @@ const ORG_SUBSCRIPTION = { periodEnd: new Date('2026-08-01T00:00:00.000Z'), } +afterAll(resetEnvFlagsMock) + describe('resolveBillingAttribution', () => { beforeEach(() => { vi.clearAllMocks() @@ -480,8 +477,8 @@ describe('checkAttributedUsageLimits', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isBillingEnabled = true - mockFlags.isHosted = true + setEnvFlags({ isBillingEnabled: true }) + setEnvFlags({ isHosted: true }) mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) mockCheckBillingEntityBlocked.mockResolvedValue({ blocked: false }) mockCheckUsageStatus.mockResolvedValue({ @@ -522,7 +519,7 @@ describe('checkAttributedUsageLimits', () => { } it('skips hosted freezes and caps when billing is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) await expect(checkAttributedUsageLimits(attribution)).resolves.toEqual({ isExceeded: false, diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index 5c82b418b72..37ce9ac295a 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -6,33 +6,23 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvFlagsMock, schemaMock, + setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - billingFlag, - sendEmailSpy, - getEmailPreferencesMock, - renderMock, - subjectMock, - isOrgAdminRoleMock, -} = vi.hoisted(() => ({ - billingFlag: { enabled: true }, - sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })), - getEmailPreferencesMock: vi.fn(() => Promise.resolve(null as unknown)), - renderMock: vi.fn(() => Promise.resolve('')), - subjectMock: vi.fn(() => 'Subject'), - isOrgAdminRoleMock: vi.fn(() => true), -})) +const { sendEmailSpy, getEmailPreferencesMock, renderMock, subjectMock, isOrgAdminRoleMock } = + vi.hoisted(() => ({ + sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })), + getEmailPreferencesMock: vi.fn(() => Promise.resolve(null as unknown)), + renderMock: vi.fn(() => Promise.resolve('')), + subjectMock: vi.fn(() => 'Subject'), + isOrgAdminRoleMock: vi.fn(() => true), + })) vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return billingFlag.enabled - }, -})) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://app.sim.ai' })) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ @@ -57,11 +47,13 @@ const baseUserParams = { userName: 'Ada', } +afterAll(resetEnvFlagsMock) + describe('maybeSendLimitThresholdEmail', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - billingFlag.enabled = true + setEnvFlags({ isBillingEnabled: true }) dbChainMockFns.returning.mockResolvedValue([{ id: 'u1' }]) getEmailPreferencesMock.mockResolvedValue(null) }) @@ -134,7 +126,7 @@ describe('maybeSendLimitThresholdEmail', () => { }) it('skips entirely when billing is disabled', async () => { - billingFlag.enabled = false + setEnvFlags({ isBillingEnabled: false }) await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 5, limit: 5 }) expect(dbChainMockFns.returning).not.toHaveBeenCalled() expect(sendEmailSpy).not.toHaveBeenCalled() diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index f5b2c114c08..af08492f9e4 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, urlsMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetEnvFlagsMock, setEnvFlags, urlsMock } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetHighestPrioritySubscription, @@ -55,14 +55,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isAccessControlEnabled: false, - isBillingEnabled: true, - isHosted: true, - isInboxEnabled: false, - isSsoEnabled: false, -})) - vi.mock('@/lib/core/utils/urls', () => urlsMock) import { @@ -74,6 +66,12 @@ import { syncSubscriptionPlan, } from '@/lib/billing/core/subscription' +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true, isHosted: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('hasPaidSubscription', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 6b1861ed1ac..b03d8ff569b 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -2,8 +2,14 @@ * @vitest-environment node */ import { usageLog } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetHighestPrioritySubscription, @@ -35,10 +41,6 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: mockIsOrgScopedSubscription, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - import { CUMULATIVE_COST_EPSILON, CumulativeUsageContextMismatchError, @@ -61,6 +63,12 @@ afterAll(() => { resetDbChainMock() }) +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('recordUsage', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/organizations/seat-drift.test.ts b/apps/sim/lib/billing/organizations/seat-drift.test.ts index a95ffa0d2f9..4b36402bdaf 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.test.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.test.ts @@ -1,12 +1,18 @@ /** * @vitest-environment node */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + dbChainMock, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockReconcileOrganizationSeats, mockFeatureFlags } = vi.hoisted(() => ({ +const { mockReconcileOrganizationSeats } = vi.hoisted(() => ({ mockReconcileOrganizationSeats: vi.fn(), - mockFeatureFlags: { isBillingEnabled: true }, })) vi.mock('@sim/db', () => dbChainMock) @@ -15,19 +21,15 @@ vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mockReconcileOrganizationSeats, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFeatureFlags.isBillingEnabled - }, -})) - import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift' +afterAll(resetEnvFlagsMock) + describe('reconcileTeamSeatDrift', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockReconcileOrganizationSeats.mockResolvedValue({ changed: true, previousSeats: 1, seats: 2 }) }) @@ -99,7 +101,7 @@ describe('reconcileTeamSeatDrift', () => { }) it('no-ops when billing is disabled', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const result = await reconcileTeamSeatDrift() diff --git a/apps/sim/lib/billing/organizations/seats.test.ts b/apps/sim/lib/billing/organizations/seats.test.ts index 26154aa1987..fbfac95f4ab 100644 --- a/apps/sim/lib/billing/organizations/seats.test.ts +++ b/apps/sim/lib/billing/organizations/seats.test.ts @@ -7,14 +7,15 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvFlagsMock, schemaMock, + setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSyncSubscriptionUsageLimits, enqueueMock, mockFeatureFlags } = vi.hoisted(() => ({ +const { mockSyncSubscriptionUsageLimits, enqueueMock } = vi.hoisted(() => ({ mockSyncSubscriptionUsageLimits: vi.fn(), enqueueMock: vi.fn(), - mockFeatureFlags: { isBillingEnabled: true }, })) vi.mock('@sim/db', () => dbChainMock) @@ -33,12 +34,6 @@ vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFeatureFlags.isBillingEnabled - }, -})) - vi.mock('@sim/audit', () => auditMock) import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' @@ -57,12 +52,14 @@ function queueReconcileReads(subscriptionRows: unknown[], memberCountRows: unkno queueTableRows(schemaMock.member, memberCountRows) } +afterAll(resetEnvFlagsMock) + describe('reconcileOrganizationSeats', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() enqueueMock.mockResolvedValue('evt-1') - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) }) afterAll(() => { @@ -201,7 +198,7 @@ describe('reconcileOrganizationSeats', () => { }) it('no-ops when billing is disabled', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const result = await reconcileOrganizationSeats({ organizationId: 'org-1', diff --git a/apps/sim/lib/billing/storage/limits.test.ts b/apps/sim/lib/billing/storage/limits.test.ts index c690221cdfb..117dd1d2a58 100644 --- a/apps/sim/lib/billing/storage/limits.test.ts +++ b/apps/sim/lib/billing/storage/limits.test.ts @@ -1,12 +1,17 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEq, mockFlags, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ +const { mockEq, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - mockFlags: { isBillingEnabled: true }, mockGetHighestPrioritySubscription: vi.fn(), })) @@ -39,12 +44,6 @@ vi.mock('@/lib/core/config/env', () => ({ getEnv: mockGetEnv, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) - import type { StorageBillingContext } from '@/lib/billing/storage/context' import { checkStorageQuota, @@ -73,11 +72,13 @@ const USER_CONTEXT: StorageBillingContext = { const GIB = 1024 ** 3 +afterAll(resetEnvFlagsMock) + describe('storage limits and quota', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetEnv.mockReturnValue(undefined) dbChainMockFns.limit.mockResolvedValue([{ storageUsedBytes: 1024 }]) mockGetHighestPrioritySubscription.mockResolvedValue(null) @@ -147,7 +148,7 @@ describe('storage limits and quota', () => { }) it('applies identical disabled-billing behavior without resolving context', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const expected = { allowed: true, @@ -161,7 +162,7 @@ describe('storage limits and quota', () => { }) it('opts into free-tier enforcement when FREE_STORAGE_LIMIT_GB is explicitly set', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetEnv.mockImplementation((variable: string) => variable === 'FREE_STORAGE_LIMIT_GB' ? '1' : undefined ) diff --git a/apps/sim/lib/billing/storage/tracking.test.ts b/apps/sim/lib/billing/storage/tracking.test.ts index 40cbcf664d6..33709cbbcf3 100644 --- a/apps/sim/lib/billing/storage/tracking.test.ts +++ b/apps/sim/lib/billing/storage/tracking.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockFlags, mockGetStorageLimitForBillingContext, mockGetStorageUsageForBillingContext, mockGetUserStorageLimit, @@ -24,7 +24,6 @@ const { mockTxWhere, mockWorkspaceRow, } = vi.hoisted(() => ({ - mockFlags: { isBillingEnabled: true }, mockGetStorageLimitForBillingContext: vi.fn(), mockGetStorageUsageForBillingContext: vi.fn(), mockGetUserStorageLimit: vi.fn(), @@ -92,19 +91,13 @@ vi.mock('@/lib/billing/storage/limits', () => ({ getUserStorageLimit: mockGetUserStorageLimit, getUserStorageUsage: mockGetUserStorageUsage, // No FREE_STORAGE_LIMIT_GB opt-in in these tests, so enforcement === billing. - isStorageEnforcementEnabled: () => mockFlags.isBillingEnabled, + isStorageEnforcementEnabled: () => envFlagsMock.isBillingEnabled, })) vi.mock('@/lib/core/config/env', () => ({ getEnv: vi.fn(() => undefined), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) - vi.mock('@sim/logger', () => ({ createLogger: () => ({ error: mockLoggerError, @@ -130,10 +123,16 @@ const ORG_CONTEXT: StorageBillingContext = { customStorageLimitGB: null, } +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('workspace storage counter mutations', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockWorkspaceRow.current = { billedAccountUserId: 'workspace-owner', organizationId: 'workspace-org', @@ -298,7 +297,7 @@ describe('workspace storage counter mutations', () => { }) it('keeps durable workspace and payer ledgers accurate while billing is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) await incrementStorageUsageForBillingContextInTx(mockTx as unknown as DbOrTx, ORG_CONTEXT, 100) diff --git a/apps/sim/lib/billing/validation/seat-management.test.ts b/apps/sim/lib/billing/validation/seat-management.test.ts index cbab22d9898..61194fca2ed 100644 --- a/apps/sim/lib/billing/validation/seat-management.test.ts +++ b/apps/sim/lib/billing/validation/seat-management.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockFeatureFlags, mockGetOrganizationSubscription, mockHasInflightOutboxEvent } = - vi.hoisted(() => ({ - mockFeatureFlags: { isBillingEnabled: false }, - mockGetOrganizationSubscription: vi.fn(), - mockHasInflightOutboxEvent: vi.fn(), - })) +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetOrganizationSubscription, mockHasInflightOutboxEvent } = vi.hoisted(() => ({ + mockGetOrganizationSubscription: vi.fn(), + mockHasInflightOutboxEvent: vi.fn(), +})) vi.mock('@sim/db', () => dbChainMock) @@ -37,12 +41,6 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ getEffectiveSeats: vi.fn().mockReturnValue(10), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFeatureFlags.isBillingEnabled - }, -})) - vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })), })) @@ -73,11 +71,13 @@ function queueSelectResponses(responses: unknown[][]) { }) } +afterAll(resetEnvFlagsMock) + describe('getOrganizationSeatInfo', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetOrganizationSubscription.mockResolvedValue(null) }) @@ -103,7 +103,7 @@ describe('validateSeatAvailability', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetOrganizationSubscription.mockResolvedValue({ id: 'sub-1', plan: 'team', diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 85aec553ba3..00cdaa0ff19 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { envFlagsMock, workflowsUtilsMock } from '@sim/testing' +import { workflowsUtilsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ @@ -19,8 +19,6 @@ vi.mock('@/lib/billing/plan-helpers', () => ({ ), })) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - vi.mock('@/lib/mcp/utils', () => ({ createMcpToolId: vi.fn(), })) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 1fbd1802997..ad2551c7ce8 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,7 +2,8 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' const { @@ -16,7 +17,6 @@ const { mockToolWatchdogTimeoutMs, mockUpdateRunStatus, mockEnv, - mockFlags, } = vi.hoisted(() => ({ mockCreateRunSegment: vi.fn(), mockForceFailHungToolCall: vi.fn(), @@ -30,10 +30,6 @@ const { mockEnv: { COPILOT_API_KEY: undefined as string | undefined, }, - mockFlags: { - isHosted: false, - isCopilotBillingAttributionV1Enabled: false, - }, })) vi.mock('@/lib/copilot/async-runs/repository', () => ({ @@ -81,15 +77,6 @@ vi.mock('@/lib/core/config/env', () => ({ isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isCopilotBillingAttributionV1Enabled() { - return mockFlags.isCopilotBillingAttributionV1Enabled - }, - get isHosted() { - return mockFlags.isHosted - }, -})) - vi.mock('@/lib/environment/utils', () => ({ getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, })) @@ -112,12 +99,14 @@ import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothershi import { CopilotBackendError } from '@/lib/copilot/request/go/stream' import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' +afterAll(resetEnvFlagsMock) + describe('runCopilotLifecycle', () => { beforeEach(() => { vi.clearAllMocks() mockEnv.COPILOT_API_KEY = undefined - mockFlags.isHosted = false - mockFlags.isCopilotBillingAttributionV1Enabled = false + setEnvFlags({ isHosted: false }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) }) @@ -381,8 +370,8 @@ describe('runCopilotLifecycle', () => { }, payerSubscription: null, } - mockFlags.isHosted = true - mockFlags.isCopilotBillingAttributionV1Enabled = true + setEnvFlags({ isHosted: true }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( @@ -444,7 +433,7 @@ describe('runCopilotLifecycle', () => { }) it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { - mockFlags.isHosted = true + setEnvFlags({ isHosted: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) @@ -479,8 +468,8 @@ describe('runCopilotLifecycle', () => { }) it('runs modern hosted work without legacy compatibility storage', async () => { - mockFlags.isHosted = true - mockFlags.isCopilotBillingAttributionV1Enabled = true + setEnvFlags({ isHosted: true }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index c3da25457c2..30733864ff7 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,7 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { dbChainMock, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, @@ -26,7 +26,6 @@ const { hasAbortMarker, releasePendingChatStream, fetchGo, - billingFlags, } = vi.hoisted(() => ({ runCopilotLifecycle: vi.fn(), createRunSegment: vi.fn(), @@ -41,10 +40,6 @@ const { hasAbortMarker: vi.fn(), releasePendingChatStream: vi.fn(), fetchGo: vi.fn(), - billingFlags: { - isHosted: false, - isCopilotBillingAttributionV1Enabled: false, - }, })) const BILLING_ATTRIBUTION = { @@ -133,15 +128,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return billingFlags.isHosted - }, - get isCopilotBillingAttributionV1Enabled() { - return billingFlags.isCopilotBillingAttributionV1Enabled - }, -})) - import { createSSEStream, requestChatTitle } from './start' async function drainStream(stream: ReadableStream) { @@ -152,6 +138,8 @@ async function drainStream(stream: ReadableStream) { } } +afterAll(resetEnvFlagsMock) + describe('createSSEStream terminal error handling', () => { afterAll(() => { resetDbChainMock() @@ -160,8 +148,8 @@ describe('createSSEStream terminal error handling', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - billingFlags.isHosted = false - billingFlags.isCopilotBillingAttributionV1Enabled = false + setEnvFlags({ isHosted: false }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) fetchGo.mockResolvedValue( new Response(JSON.stringify({ title: 'Test title' }), { status: 200, @@ -347,8 +335,8 @@ describe('requestChatTitle billing protocol', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - billingFlags.isHosted = true - billingFlags.isCopilotBillingAttributionV1Enabled = true + setEnvFlags({ isHosted: true }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) fetchGo.mockResolvedValue( new Response(JSON.stringify({ title: 'Billing Protocol' }), { status: 200, @@ -382,7 +370,7 @@ describe('requestChatTitle billing protocol', () => { }) it('sends explicit legacy-v0 during the Sim-first compatibility stage', async () => { - billingFlags.isCopilotBillingAttributionV1Enabled = false + setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) await requestChatTitle({ message: 'explain billing', diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index f72695cb539..73a1b4d4892 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { e2bFlag, betaFlag, mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({ - e2bFlag: { value: true }, +const { betaFlag, mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({ betaFlag: { value: false }, mockLoadCompiledDoc: vi.fn(), mockRunSandboxTask: vi.fn(), @@ -31,11 +31,6 @@ vi.mock('./doc-compiled-store', () => ({ vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn(async () => betaFlag.value), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isE2BDocEnabled() { - return e2bFlag.value - }, -})) vi.mock('@/app/api/files/utils', () => ({ getContentType: (name: string) => name.endsWith('.pdf') @@ -53,10 +48,12 @@ const PDF_SOURCE = Buffer.from('from reportlab.pdfgen import canvas\n# generates const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01]) const XLSX_SOURCE = Buffer.from('from openpyxl import Workbook\n# generates an xlsx', 'utf-8') +afterAll(resetEnvFlagsMock) + describe('resolveServableDocBytes', () => { beforeEach(() => { vi.clearAllMocks() - e2bFlag.value = true + setEnvFlags({ isE2BDocEnabled: true }) betaFlag.value = false }) @@ -93,7 +90,7 @@ describe('resolveServableDocBytes', () => { it('throws DocCompileUserError when a generated doc artifact is not ready (E2B regime)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - e2bFlag.value = true + setEnvFlags({ isE2BDocEnabled: true }) await expect( resolveServableDocBytes({ @@ -108,7 +105,7 @@ describe('resolveServableDocBytes', () => { it('compiles via the sandbox when E2B is disabled and no artifact is stored', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - e2bFlag.value = false + setEnvFlags({ isE2BDocEnabled: false }) const compiled = Buffer.from('%PDF-isolated-vm-binary') mockRunSandboxTask.mockResolvedValue(compiled) @@ -153,7 +150,7 @@ describe('resolveServableDocBytes', () => { it('throws when a generated XLSX artifact is not ready (E2B + mothership-beta enabled)', async () => { mockLoadCompiledDoc.mockResolvedValue(null) - e2bFlag.value = true + setEnvFlags({ isE2BDocEnabled: true }) betaFlag.value = true await expect( diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index e9d8c30e5d4..85ed283550b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -1,21 +1,19 @@ /** * @vitest-environment node */ -import { envFlagsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { normalizeConditionRouterIds } from './builders' const { mockValidateSelectorIds, mockGetModelOptions, - mockEnvFlags, mockGetTool, mockGetCustomToolById, mockGetSkillById, } = vi.hoisted(() => ({ mockValidateSelectorIds: vi.fn(), mockGetModelOptions: vi.fn(() => []), - mockEnvFlags: { isHosted: false }, mockGetTool: vi.fn(), mockGetCustomToolById: vi.fn(), mockGetSkillById: vi.fn(), @@ -228,13 +226,6 @@ vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById: mockGetSkillById, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - ...envFlagsMock, - get isHosted() { - return mockEnvFlags.isHosted - }, -})) - vi.mock('@/providers/utils', () => ({ getHostedModels: () => [], })) @@ -247,6 +238,8 @@ import { validateWorkflowSelectorIds, } from './validation' +afterAll(resetEnvFlagsMock) + describe('validateInputsForBlock', () => { beforeEach(() => { vi.clearAllMocks() @@ -533,11 +526,11 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => { vi.clearAllMocks() mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) mockGetTool.mockImplementation((id: string) => toolsByIdMock[id]) - mockEnvFlags.isHosted = true + setEnvFlags({ isHosted: true }) }) afterEach(() => { - mockEnvFlags.isHosted = false + setEnvFlags({ isHosted: false }) }) const ctx = { userId: 'user-1', workspaceId: 'workspace-1' } @@ -871,7 +864,7 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => { }) it('preserves apiKey on self-hosted deployments (isHosted false)', async () => { - mockEnvFlags.isHosted = false + setEnvFlags({ isHosted: false }) const operations = [ { operation_type: 'add' as const, diff --git a/apps/sim/lib/core/config/block-visibility.test.ts b/apps/sim/lib/core/config/block-visibility.test.ts index ef7e44c1006..55fabe28702 100644 --- a/apps/sim/lib/core/config/block-visibility.test.ts +++ b/apps/sim/lib/core/config/block-visibility.test.ts @@ -1,16 +1,16 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ +const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ mockFetch: vi.fn(), mockIsPlatformAdmin: vi.fn(), envRef: { APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, }, - flagRef: { isAppConfigEnabled: false, previewBlocks: [] as string[] }, })) vi.mock('@/lib/core/config/appconfig', () => ({ @@ -23,13 +23,6 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isAppConfigEnabled() { - return flagRef.isAppConfigEnabled - }, - getPreviewBlocksFromEnv: () => flagRef.previewBlocks, -})) - vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mockIsPlatformAdmin, })) @@ -38,20 +31,22 @@ import { getBlockVisibility } from '@/lib/core/config/block-visibility' /** Make `getBlockVisibility` resolve `doc` via the AppConfig path (also exercises parsing). */ function withAppConfig(doc: unknown) { - flagRef.isAppConfigEnabled = true + setEnvFlags({ isAppConfigEnabled: true }) mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) } +afterAll(resetEnvFlagsMock) + describe('getBlockVisibility', () => { beforeEach(() => { vi.clearAllMocks() - flagRef.isAppConfigEnabled = false - flagRef.previewBlocks = [] + setEnvFlags({ isAppConfigEnabled: false }) + envFlagsMockFns.getPreviewBlocksFromEnv.mockReturnValue([]) }) describe('off-AppConfig (env fallback)', () => { it('reveals and preview-tags the PREVIEW_BLOCKS types without fetching', async () => { - flagRef.previewBlocks = ['gmail_v2', 'notion_v3'] + envFlagsMockFns.getPreviewBlocksFromEnv.mockReturnValue(['gmail_v2', 'notion_v3']) const vis = await getBlockVisibility({ userId: 'u1' }) expect(vis.revealed).toEqual(new Set(['gmail_v2', 'notion_v3'])) expect(vis.previewTagged).toEqual(new Set(['gmail_v2', 'notion_v3'])) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index de017d9826c..aae8a7a2b6b 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags' -const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ +const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ mockFetch: vi.fn(), mockIsPlatformAdmin: vi.fn(), envRef: { @@ -13,7 +14,6 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ FORKING_ENABLED: undefined as boolean | undefined, DEPLOY_AS_BLOCK: undefined as boolean | undefined, }, - flagRef: { isAppConfigEnabled: false }, })) vi.mock('@/lib/core/config/appconfig', () => ({ @@ -27,12 +27,6 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isAppConfigEnabled() { - return flagRef.isAppConfigEnabled - }, -})) - vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mockIsPlatformAdmin, })) @@ -58,7 +52,7 @@ import { /** Make `getFeatureFlags` resolve to `doc` via the AppConfig path (also exercises parseConfig). */ function withAppConfig(doc: unknown) { - flagRef.isAppConfigEnabled = true + setEnvFlags({ isAppConfigEnabled: true }) mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) } @@ -70,10 +64,12 @@ function withAppConfig(doc: unknown) { const enabled = (flag: string, ctx?: FeatureFlagContext) => isFeatureEnabled(flag as FeatureFlagName, ctx) +afterAll(resetEnvFlagsMock) + describe('getFeatureFlags', () => { beforeEach(() => { vi.clearAllMocks() - flagRef.isAppConfigEnabled = false + setEnvFlags({ isAppConfigEnabled: false }) }) it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => { @@ -104,7 +100,7 @@ describe('getFeatureFlags', () => { }) it('falls back to the secret-derived document when the fetch yields null', async () => { - flagRef.isAppConfigEnabled = true + setEnvFlags({ isAppConfigEnabled: true }) mockFetch.mockResolvedValue(null) const flags = await getFeatureFlags() expect(flags['mothership-beta']).toEqual({ enabled: false }) @@ -124,7 +120,7 @@ describe('getFeatureFlags', () => { describe('isFeatureEnabled', () => { beforeEach(() => { vi.clearAllMocks() - flagRef.isAppConfigEnabled = false + setEnvFlags({ isAppConfigEnabled: false }) envRef.FORKING_ENABLED = undefined envRef.DEPLOY_AS_BLOCK = undefined }) diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts index a166a9d7496..a6a78777419 100644 --- a/apps/sim/lib/core/execution-limits/types.test.ts +++ b/apps/sim/lib/core/execution-limits/types.test.ts @@ -1,28 +1,22 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' /** * Free-tier timeouts are baked into EXECUTION_TIMEOUTS at module load, while * the billing-disabled opt-in check reads the env at call time. Seeding here * mirrors production, where both reads observe the same process env. */ -const { mockEnv, mockFlags } = vi.hoisted(() => ({ +const { mockEnv } = vi.hoisted(() => ({ mockEnv: { EXECUTION_TIMEOUT_FREE: '120', EXECUTION_TIMEOUT_ASYNC_FREE: '240', } as Record, - mockFlags: { isBillingEnabled: true }, })) vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) - /** * Query-suffixed import gives this file a private instance of the module under * test (the barrel's `./types` source, so the fresh evaluation bakes the mocked @@ -43,9 +37,11 @@ import { getExecutionTimeout, } from '@/lib/core/execution-limits/types?execution-limits-test' +afterAll(resetEnvFlagsMock) + describe('getExecutionTimeout', () => { beforeEach(() => { - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockEnv.EXECUTION_TIMEOUT_FREE = '120' mockEnv.EXECUTION_TIMEOUT_ASYNC_FREE = '240' }) @@ -57,7 +53,7 @@ describe('getExecutionTimeout', () => { }) it('disables timeouts when billing is disabled and no free env is set', () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockEnv.EXECUTION_TIMEOUT_FREE = undefined mockEnv.EXECUTION_TIMEOUT_ASYNC_FREE = undefined @@ -66,7 +62,7 @@ describe('getExecutionTimeout', () => { }) it('opts back into the free timeout when the env var is explicitly set', () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) expect(getExecutionTimeout('free', 'sync')).toBe(120 * 1000) expect(getExecutionTimeout('free', 'async')).toBe(240 * 1000) diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 48d14f671d7..67a3f332719 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' /** * Query-suffixed imports give this file private instances of the modules under @@ -20,7 +21,6 @@ declare module '@/lib/core/rate-limiter/types?rate-limiter-test' { export * from '@/lib/core/rate-limiter/types' } -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) vi.mock( '@/lib/core/rate-limiter/types', () => import('@/lib/core/rate-limiter/types?rate-limiter-test') @@ -42,6 +42,12 @@ const createMockAdapter = (): MockAdapter => ({ resetBucket: vi.fn(), }) +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('RateLimiter', () => { const testUserId = 'test-user-123' const freeSubscription = { plan: 'free', referenceId: testUserId } diff --git a/apps/sim/lib/core/rate-limiter/types.test.ts b/apps/sim/lib/core/rate-limiter/types.test.ts index 1a33c9c642a..26b59b224df 100644 --- a/apps/sim/lib/core/rate-limiter/types.test.ts +++ b/apps/sim/lib/core/rate-limiter/types.test.ts @@ -1,33 +1,30 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' /** * Free-tier env values are baked into RATE_LIMITS at module load, while the * billing-disabled opt-in check reads them at call time. Seeding them here * mirrors production, where both reads observe the same process env. */ -const { mockEnv, mockFlags } = vi.hoisted(() => ({ +const { mockEnv } = vi.hoisted(() => ({ mockEnv: { RATE_LIMIT_FREE_SYNC: '25', RATE_LIMIT_FREE_API_ENDPOINT: '10', } as Record, - mockFlags: { isBillingEnabled: true }, })) vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) import { getRateLimit } from '@/lib/core/rate-limiter/types' +afterAll(resetEnvFlagsMock) + describe('getRateLimit', () => { beforeEach(() => { - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockEnv.RATE_LIMIT_FREE_SYNC = '25' mockEnv.RATE_LIMIT_FREE_API_ENDPOINT = '10' }) @@ -40,7 +37,7 @@ describe('getRateLimit', () => { }) it('is effectively unlimited when billing is disabled and no free env is set', () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockEnv.RATE_LIMIT_FREE_SYNC = undefined mockEnv.RATE_LIMIT_FREE_API_ENDPOINT = undefined @@ -50,7 +47,7 @@ describe('getRateLimit', () => { }) it('opts back into enforcement per counter when a free env var is explicitly set', () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) expect(getRateLimit('free', 'sync').refillRate).toBe(25) expect(getRateLimit('free', 'api-endpoint').refillRate).toBe(10) diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index a91c97d6501..1cf199dc08c 100644 --- a/apps/sim/lib/core/security/csp.test.ts +++ b/apps/sim/lib/core/security/csp.test.ts @@ -1,4 +1,4 @@ -import { createEnvMock, envFlagsMock } from '@sim/testing' +import { createEnvMock } from '@sim/testing' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => @@ -17,8 +17,6 @@ vi.mock('@/lib/core/config/env', () => }) ) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - import { addCSPSource, buildCSPString, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index eb44eb55939..e6d90a1518e 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,5 +1,5 @@ -import { envFlagsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMock, resetEnvFlagsMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' import { validateAirtableId, validateAlphanumericId, @@ -34,7 +34,7 @@ import { } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +afterAll(resetEnvFlagsMock) describe('validatePathSegment', () => { describe('valid inputs', () => { diff --git a/apps/sim/lib/core/security/pinned-fetch.server.test.ts b/apps/sim/lib/core/security/pinned-fetch.server.test.ts index 292439034fe..66c798ad556 100644 --- a/apps/sim/lib/core/security/pinned-fetch.server.test.ts +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { envFlagsMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoisted(() => { @@ -25,8 +24,6 @@ const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoi }) vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - /** * Query-suffixed import gives this file a private instance of the module under * test. Under `isolate: false` the worker's module graph is shared across test diff --git a/apps/sim/lib/core/utils/urls.test.ts b/apps/sim/lib/core/utils/urls.test.ts index f32618d3ee5..55cc21e71a5 100644 --- a/apps/sim/lib/core/utils/urls.test.ts +++ b/apps/sim/lib/core/utils/urls.test.ts @@ -12,10 +12,6 @@ vi.mock('@/lib/core/config/env', () => ({ getEnv: mockGetEnv, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isProd: false, -})) - import { getBrowserOrigin, getSocketUrl, diff --git a/apps/sim/lib/data-drains/dispatcher.test.ts b/apps/sim/lib/data-drains/dispatcher.test.ts index 0d94887b7dd..17edc85de08 100644 --- a/apps/sim/lib/data-drains/dispatcher.test.ts +++ b/apps/sim/lib/data-drains/dispatcher.test.ts @@ -1,8 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => dbChainMock) @@ -19,7 +25,6 @@ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsEnterprise, })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue })) -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) import { dispatchDueDrains, reapOrphanedRuns } from '@/lib/data-drains/dispatcher' @@ -36,6 +41,12 @@ beforeEach(() => { resetDbChainMock() }) +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('reapOrphanedRuns', () => { it('returns the count of rows updated to failed', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'run-1' }, { id: 'run-2' }]) diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 0050080ea9c..a3f258e0a02 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -1,8 +1,15 @@ /** * @vitest-environment node */ -import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + auditMock, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockEnsureUserInOrganization, @@ -16,7 +23,6 @@ const { mockSyncUsageLimitsFromSubscription, mockSyncWorkspaceEnvCredentials, mockIsWorkspaceOnEnterprisePlan, - mockFeatureFlags, } = vi.hoisted(() => ({ mockEnsureUserInOrganization: vi.fn(), mockGetUserOrganization: vi.fn(), @@ -29,7 +35,6 @@ const { mockSyncUsageLimitsFromSubscription: vi.fn(), mockSyncWorkspaceEnvCredentials: vi.fn(), mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true), - mockFeatureFlags: { isBillingEnabled: true }, })) vi.mock('@sim/db', () => dbChainMock) @@ -53,12 +58,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFeatureFlags.isBillingEnabled - }, -})) - vi.mock('@/lib/auth/active-organization', () => ({ setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession, })) @@ -104,11 +103,13 @@ function executedSqlContaining(substring: string): boolean { }) } +afterAll(resetEnvFlagsMock) + describe('acceptInvitation', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetUserOrganization.mockResolvedValue(null) mockGetWorkspaceWithOwner.mockResolvedValue(null) mockEnsureTeamOrganizationForAcceptance.mockResolvedValue({ diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index 26944e5d52c..62de6d9d01d 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -1,8 +1,15 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, defaultMockEnv, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + dbChainMockFns, + defaultMockEnv, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { env } from '@/lib/core/config/env' @@ -33,10 +40,6 @@ afterAll(() => { } Object.assign(env, envSnapshot) }) -vi.mock('@/lib/core/config/env-flags', () => ({ - getCostMultiplier: vi.fn().mockReturnValue(1), - isTriggerDevEnabled: true, -})) import { processDocumentsWithQueue } from '@/lib/knowledge/documents/service' @@ -61,6 +64,12 @@ const DOCUMENT = { mimeType: 'text/plain', } +beforeAll(() => { + setEnvFlags({ isTriggerDevEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('processDocumentsWithQueue billing attribution', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 740986611fb..5ae0036ed00 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -1,5 +1,5 @@ import { usageLog, workflow } from '@sim/db/schema' -import { dbChainMockFns, envFlagsMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' import { recordUsage } from '@/lib/billing/core/usage-log' import { ExecutionLogger } from '@/lib/logs/execution/logger' @@ -70,8 +70,6 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillPayerOverageThreshold: vi.fn(() => Promise.resolve()), })) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - // Mock security module vi.mock('@/lib/core/security/redaction', () => ({ redactApiKeys: vi.fn((data) => data), diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index f4845b099ec..47c91f79b28 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' interface MockMcpClient { connect: ReturnType @@ -40,7 +41,6 @@ const { mockGetOrCreateOauthRow: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ isTest: false })) vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: { onToolsChanged: mockOnToolsChanged, @@ -64,6 +64,12 @@ vi.mock('@/lib/mcp/oauth', () => ({ import { McpConnectionManager } from '@/lib/mcp/connection-manager' +beforeAll(() => { + setEnvFlags({ isTest: false }) +}) + +afterAll(resetEnvFlagsMock) + describe('McpConnectionManager', () => { let manager: McpConnectionManager | null = null diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index ab0616c6ac2..b30b2d373fd 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -1,20 +1,17 @@ /** * @vitest-environment node */ -import { inputValidationMock, inputValidationMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoisted(() => ({ - mockGetAllowedMcpDomainsFromEnv: vi.fn<() => string[] | null>(), +import { + envFlagsMockFns, + inputValidationMock, + inputValidationMockFns, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn(), - hostedFlag: { value: false }, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, - get isHosted() { - return hostedFlag.value - }, })) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -48,6 +45,10 @@ import { validateMcpServerSsrf, } from './domain-check' +const mockGetAllowedMcpDomainsFromEnv = envFlagsMockFns.getAllowedMcpDomainsFromEnv + +afterAll(resetEnvFlagsMock) + describe('McpDomainNotAllowedError', () => { it.concurrent('creates error with correct name and message', () => { const error = new McpDomainNotAllowedError('evil.com') @@ -335,7 +336,7 @@ describe('validateMcpServerSsrf', () => { beforeEach(() => { vi.clearAllMocks() mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null) - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it('returns null for undefined URL', async () => { @@ -453,7 +454,7 @@ describe('validateMcpServerSsrf', () => { describe('hosted environment', () => { beforeEach(() => { - hostedFlag.value = true + setEnvFlags({ isHosted: true }) }) it('rejects localhost URLs on hosted', async () => { @@ -515,7 +516,7 @@ describe('validateMcpServerSsrf', () => { describe('self-hosted environment (regression)', () => { beforeEach(() => { - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it('still allows localhost URLs (returns null, no pinning needed)', async () => { diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index 406471bcced..ac334e22b41 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -61,7 +61,6 @@ const { }) vi.mock('@sim/logger', () => loggerMock) -vi.mock('@/lib/core/config/env-flags', () => ({ isTest: true })) vi.mock('@/lib/mcp/connection-pool', () => ({ mcpConnectionPool: { acquire: mockAcquire, evictServer: vi.fn() }, })) diff --git a/apps/sim/lib/table/billing.test.ts b/apps/sim/lib/table/billing.test.ts index ec71d49c9bf..edcc9dcf882 100644 --- a/apps/sim/lib/table/billing.test.ts +++ b/apps/sim/lib/table/billing.test.ts @@ -1,16 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockFlags, mockResolveWorkspaceBillingPayer, mockGetPlanTypeForLimits, mockGetBillingDisabledTableLimits, mockGetTablePlanLimits, } = vi.hoisted(() => ({ - mockFlags: { isBillingEnabled: true }, mockResolveWorkspaceBillingPayer: vi.fn(), mockGetPlanTypeForLimits: vi.fn(), mockGetBillingDisabledTableLimits: vi.fn(), @@ -23,11 +22,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ vi.mock('@/lib/billing/plan-helpers', () => ({ getPlanTypeForLimits: mockGetPlanTypeForLimits, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) vi.mock('@/lib/table/constants', () => ({ getBillingDisabledTableLimits: mockGetBillingDisabledTableLimits, getTablePlanLimits: mockGetTablePlanLimits, @@ -56,7 +50,7 @@ const nextWorkspaceId = () => `ws-${++wsCounter}` beforeEach(() => { vi.clearAllMocks() - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetTablePlanLimits.mockReturnValue(LIMITS) mockGetBillingDisabledTableLimits.mockReturnValue({ maxTables: Number.MAX_SAFE_INTEGER, @@ -70,6 +64,8 @@ beforeEach(() => { mockGetPlanTypeForLimits.mockReturnValue('pro') }) +afterAll(resetEnvFlagsMock) + describe('getWorkspaceTableLimits', () => { it('returns the limits for the workspace subscription plan', async () => { expect(await getWorkspaceTableLimits(nextWorkspaceId())).toEqual(LIMITS.pro) @@ -96,7 +92,7 @@ describe('getWorkspaceTableLimits', () => { }) it('bypasses billing plan resolution entirely when billing is disabled', async () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetBillingDisabledTableLimits.mockReturnValue({ maxTables: Number.MAX_SAFE_INTEGER, maxRowsPerTable: 12345, diff --git a/apps/sim/lib/table/dispatch-concurrency.test.ts b/apps/sim/lib/table/dispatch-concurrency.test.ts index 2581b6e987b..770be25bb1b 100644 --- a/apps/sim/lib/table/dispatch-concurrency.test.ts +++ b/apps/sim/lib/table/dispatch-concurrency.test.ts @@ -1,11 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnv, mockFlags } = vi.hoisted(() => ({ +const { mockEnv } = vi.hoisted(() => ({ mockEnv: {} as Record, - mockFlags: { isBillingEnabled: true }, })) vi.mock('@/lib/core/config/env', () => ({ @@ -25,21 +25,17 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFlags.isBillingEnabled - }, -})) - import { getMaxTableDispatchConcurrency, getTableDispatchConcurrency, } from '@/lib/table/dispatch-concurrency' +afterAll(resetEnvFlagsMock) + describe('getTableDispatchConcurrency', () => { beforeEach(() => { for (const key of Object.keys(mockEnv)) delete mockEnv[key] - mockFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) }) it('resolves free vs paid defaults', () => { @@ -60,7 +56,7 @@ describe('getTableDispatchConcurrency', () => { }) it('uses the paid value when billing is disabled', () => { - mockFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) expect(getTableDispatchConcurrency(null)).toBe(50) mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '120' diff --git a/apps/sim/lib/table/workflow-columns.test.ts b/apps/sim/lib/table/workflow-columns.test.ts index 544344a114e..c516d06c3e1 100644 --- a/apps/sim/lib/table/workflow-columns.test.ts +++ b/apps/sim/lib/table/workflow-columns.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { RowExecutionMetadata, TableDefinition, @@ -33,10 +34,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isTriggerDevEnabled: true, -})) - import { buildEnqueueItems, pickNextEligibleGroupForRow, @@ -104,6 +101,12 @@ function queuedMarker(workflowId: string): RowExecutionMetadata { return { status: 'pending', executionId: null, jobId: null, workflowId, error: null } } +beforeAll(() => { + setEnvFlags({ isTriggerDevEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('pickNextEligibleGroupForRow — queued-marker handoff', () => { it('runs an autoRun:false group that carries a queued marker (explicit request)', () => { const group = makeGroup({ id: 'g1', autoRun: false }) diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 21d5c00eee0..9218ab35403 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -6,7 +6,6 @@ import type { webhook, workflow } from '@sim/db/schema' import { createMockRequest, dbChainMock, - envFlagsMock, executionPreprocessingMock, executionPreprocessingMockFns, queueTableRows, @@ -78,8 +77,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockAdmissionRelease })), })) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - vi.mock('@sim/security/compare', () => ({ safeCompare: vi.fn().mockReturnValue(true), })) diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 0fc262b6036..fc5f2f86b0e 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -2,19 +2,23 @@ * @vitest-environment node */ import { member, workspace } from '@sim/db/schema' -import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, - mockFeatureFlags, } = vi.hoisted(() => ({ mockGetUserOrganization: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), - mockFeatureFlags: { isBillingEnabled: true }, })) vi.mock('@sim/db', () => dbChainMock) @@ -31,12 +35,6 @@ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return mockFeatureFlags.isBillingEnabled - }, -})) - import { getWorkspaceCreationPolicy, getWorkspaceInvitePolicy, @@ -46,11 +44,13 @@ import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' afterAll(resetDbChainMock) +afterAll(resetEnvFlagsMock) + describe('getWorkspaceCreationPolicy', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) @@ -115,7 +115,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('allows unlimited personal workspaces when billing is disabled', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) queueTableRows(workspace, [{ value: 9 }]) const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) @@ -128,7 +128,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('without pinning, a null active org falls back to the caller membership org', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetUserOrganization.mockResolvedValue({ organizationId: 'user-org', role: 'admin', @@ -146,7 +146,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('pins to the source org: a personal source (null) stays personal regardless of caller org', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetUserOrganization.mockResolvedValue({ organizationId: 'user-org', role: 'admin', @@ -191,7 +191,7 @@ describe('getWorkspaceCreationPolicy', () => { }) it('allows org admins to create organization workspaces when billing is disabled', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'admin', @@ -257,7 +257,7 @@ describe('getWorkspaceInvitePolicy', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockFeatureFlags.isBillingEnabled = true + setEnvFlags({ isBillingEnabled: true }) mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) }) @@ -270,7 +270,7 @@ describe('getWorkspaceInvitePolicy', () => { } as const it('allows invites unconditionally when billing is disabled', async () => { - mockFeatureFlags.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const result = await getWorkspaceInvitePolicy(baseState) diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 73b62a79abc..2f52c842461 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -18,10 +18,6 @@ vi.mock('@/providers/registry', () => ({ }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - getCostMultiplier: vi.fn(() => 1), -})) - import { executeProviderRequest } from '@/providers' import type { ProviderResponse } from '@/providers/types' diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 87d576b79c5..cf5778d9db3 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as environmentModule from '@/lib/core/config/env-flags' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { calculateCost, extractAndParseJSON, @@ -43,19 +43,18 @@ import { updateOllamaProviderModels, } from '@/providers/utils' -const isHostedSpy = vi.spyOn(environmentModule, 'isHosted', 'get') as unknown as { - mockReturnValue: (value: boolean) => void -} const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require +afterAll(resetEnvFlagsMock) + describe('getApiKey', () => { const originalEnv = { ...process.env } beforeEach(() => { vi.clearAllMocks() - isHostedSpy.mockReturnValue(false) + setEnvFlags({ isHosted: false }) module.require = vi.fn(() => ({ getRotatingApiKey: mockGetRotatingApiKey, @@ -67,8 +66,8 @@ describe('getApiKey', () => { module.require = originalRequire }) - it.concurrent('should return user-provided key when not in hosted environment', () => { - isHostedSpy.mockReturnValue(false) + it('should return user-provided key when not in hosted environment', () => { + setEnvFlags({ isHosted: false }) const key1 = getApiKey('openai', 'gpt-4', 'user-key-openai') expect(key1).toBe('user-key-openai') @@ -80,8 +79,8 @@ describe('getApiKey', () => { expect(key3).toBe('user-key-google') }) - it.concurrent('should throw error if no key provided in non-hosted environment', () => { - isHostedSpy.mockReturnValue(false) + it('should throw error if no key provided in non-hosted environment', () => { + setEnvFlags({ isHosted: false }) expect(() => getApiKey('openai', 'gpt-4')).toThrow('API key is required for openai gpt-4') expect(() => getApiKey('anthropic', 'claude-3')).toThrow( @@ -89,8 +88,8 @@ describe('getApiKey', () => { ) }) - it.concurrent('should fall back to user key in hosted environment if rotation fails', () => { - isHostedSpy.mockReturnValue(true) + it('should fall back to user key in hosted environment if rotation fails', () => { + setEnvFlags({ isHosted: true }) module.require = vi.fn(() => { throw new Error('Rotation failed') @@ -100,56 +99,47 @@ describe('getApiKey', () => { expect(key).toBe('user-fallback-key') }) - it.concurrent( - 'should throw error in hosted environment if rotation fails and no user key', - () => { - isHostedSpy.mockReturnValue(true) + it('should throw error in hosted environment if rotation fails and no user key', () => { + setEnvFlags({ isHosted: true }) - module.require = vi.fn(() => { - throw new Error('Rotation failed') - }) + module.require = vi.fn(() => { + throw new Error('Rotation failed') + }) - expect(() => getApiKey('openai', 'gpt-4o')).toThrow('No API key available for openai gpt-4o') - } - ) + expect(() => getApiKey('openai', 'gpt-4o')).toThrow('No API key available for openai gpt-4o') + }) - it.concurrent( - 'should require user key for non-OpenAI/Anthropic providers even in hosted environment', - () => { - isHostedSpy.mockReturnValue(true) + it('should require user key for non-OpenAI/Anthropic providers even in hosted environment', () => { + setEnvFlags({ isHosted: true }) - const key = getApiKey('other-provider', 'some-model', 'user-key') - expect(key).toBe('user-key') + const key = getApiKey('other-provider', 'some-model', 'user-key') + expect(key).toBe('user-key') - expect(() => getApiKey('other-provider', 'some-model')).toThrow( - 'API key is required for other-provider some-model' - ) - } - ) + expect(() => getApiKey('other-provider', 'some-model')).toThrow( + 'API key is required for other-provider some-model' + ) + }) - it.concurrent( - 'should require user key for models NOT in hosted list even if provider matches', - () => { - isHostedSpy.mockReturnValue(true) + it('should require user key for models NOT in hosted list even if provider matches', () => { + setEnvFlags({ isHosted: true }) - const key1 = getApiKey('anthropic', 'claude-sonnet-4-20250514', 'user-key-anthropic') - expect(key1).toBe('user-key-anthropic') + const key1 = getApiKey('anthropic', 'claude-sonnet-4-20250514', 'user-key-anthropic') + expect(key1).toBe('user-key-anthropic') - expect(() => getApiKey('anthropic', 'claude-sonnet-4-20250514')).toThrow( - 'API key is required for anthropic claude-sonnet-4-20250514' - ) + expect(() => getApiKey('anthropic', 'claude-sonnet-4-20250514')).toThrow( + 'API key is required for anthropic claude-sonnet-4-20250514' + ) - const key2 = getApiKey('openai', 'gpt-4o-2024-08-06', 'user-key-openai') - expect(key2).toBe('user-key-openai') + const key2 = getApiKey('openai', 'gpt-4o-2024-08-06', 'user-key-openai') + expect(key2).toBe('user-key-openai') - expect(() => getApiKey('openai', 'gpt-4o-2024-08-06')).toThrow( - 'API key is required for openai gpt-4o-2024-08-06' - ) - } - ) + expect(() => getApiKey('openai', 'gpt-4o-2024-08-06')).toThrow( + 'API key is required for openai gpt-4o-2024-08-06' + ) + }) - it.concurrent('should return empty for ollama provider without requiring API key', () => { - isHostedSpy.mockReturnValue(false) + it('should return empty for ollama provider without requiring API key', () => { + setEnvFlags({ isHosted: false }) const key = getApiKey('ollama', 'llama2') expect(key).toBe('empty') @@ -158,36 +148,30 @@ describe('getApiKey', () => { expect(key2).toBe('empty') }) - it.concurrent( - 'should return empty or user-provided key for vllm provider without requiring API key', - () => { - isHostedSpy.mockReturnValue(false) + it('should return empty or user-provided key for vllm provider without requiring API key', () => { + setEnvFlags({ isHosted: false }) - const key = getApiKey('vllm', 'vllm/qwen-3') - expect(key).toBe('empty') + const key = getApiKey('vllm', 'vllm/qwen-3') + expect(key).toBe('empty') - const key2 = getApiKey('vllm', 'vllm/llama', 'user-key') - expect(key2).toBe('user-key') - } - ) + const key2 = getApiKey('vllm', 'vllm/llama', 'user-key') + expect(key2).toBe('user-key') + }) - it.concurrent( - 'should return empty or user-provided key for litellm provider without requiring API key', - () => { - isHostedSpy.mockReturnValue(false) + it('should return empty or user-provided key for litellm provider without requiring API key', () => { + setEnvFlags({ isHosted: false }) - const key = getApiKey('litellm', 'litellm/anthropic/claude-sonnet-4-6') - expect(key).toBe('empty') + const key = getApiKey('litellm', 'litellm/anthropic/claude-sonnet-4-6') + expect(key).toBe('empty') - const key2 = getApiKey('litellm', 'litellm/openai/gpt-4', 'user-key') - expect(key2).toBe('user-key') - } - ) + const key2 = getApiKey('litellm', 'litellm/openai/gpt-4', 'user-key') + expect(key2).toBe('user-key') + }) }) describe('Model Capabilities', () => { describe('supportsTemperature', () => { - it.concurrent('should return true for models that support temperature', () => { + it('should return true for models that support temperature', () => { const supportedModels = [ 'gpt-4o', 'gpt-4.1', @@ -211,7 +195,7 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return false for models that do not support temperature', () => { + it('should return false for models that do not support temperature', () => { const unsupportedModels = [ 'unsupported-model', 'claude-sonnet-5', @@ -241,22 +225,19 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should be case insensitive', () => { + it('should be case insensitive', () => { expect(supportsTemperature('GPT-4O')).toBe(true) expect(supportsTemperature('claude-sonnet-4-5')).toBe(true) }) - it.concurrent( - 'should inherit temperature support from provider for dynamically fetched models', - () => { - expect(supportsTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(true) - expect(supportsTemperature('openrouter/openai/gpt-4')).toBe(true) - } - ) + it('should inherit temperature support from provider for dynamically fetched models', () => { + expect(supportsTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(true) + expect(supportsTemperature('openrouter/openai/gpt-4')).toBe(true) + }) }) describe('getMaxTemperature', () => { - it.concurrent('should return 2 for models with temperature range 0-2', () => { + it('should return 2 for models with temperature range 0-2', () => { const modelsRange02 = [ 'gpt-4o', 'azure/gpt-4o', @@ -276,7 +257,7 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return 1 for models with temperature range 0-1', () => { + it('should return 1 for models with temperature range 0-1', () => { const modelsRange01 = ['claude-sonnet-4-5', 'claude-opus-4-1'] for (const model of modelsRange01) { @@ -284,7 +265,7 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return 1.5 for models with temperature range 0-1.5', () => { + it('should return 1.5 for models with temperature range 0-1.5', () => { const modelsRange015 = ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'] for (const model of modelsRange015) { @@ -292,7 +273,7 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return undefined for models that do not support temperature', () => { + it('should return undefined for models that do not support temperature', () => { expect(getMaxTemperature('unsupported-model')).toBeUndefined() expect(getMaxTemperature('cerebras/llama-3.3-70b')).toBeUndefined() expect(getMaxTemperature('o1')).toBeUndefined() @@ -314,22 +295,19 @@ describe('Model Capabilities', () => { expect(getMaxTemperature('azure/gpt-5-nano')).toBeUndefined() }) - it.concurrent('should be case insensitive', () => { + it('should be case insensitive', () => { expect(getMaxTemperature('GPT-4O')).toBe(2) expect(getMaxTemperature('CLAUDE-SONNET-4-5')).toBe(1) }) - it.concurrent( - 'should inherit max temperature from provider for dynamically fetched models', - () => { - expect(getMaxTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(2) - expect(getMaxTemperature('openrouter/openai/gpt-4')).toBe(2) - } - ) + it('should inherit max temperature from provider for dynamically fetched models', () => { + expect(getMaxTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(2) + expect(getMaxTemperature('openrouter/openai/gpt-4')).toBe(2) + }) }) describe('supportsToolUsageControl', () => { - it.concurrent('should return true for providers that support tool usage control', () => { + it('should return true for providers that support tool usage control', () => { const supportedProviders = [ 'openai', 'azure-openai', @@ -345,20 +323,17 @@ describe('Model Capabilities', () => { } }) - it.concurrent( - 'should return false for providers that do not support tool usage control', - () => { - const unsupportedProviders = ['ollama', 'non-existent-provider'] + it('should return false for providers that do not support tool usage control', () => { + const unsupportedProviders = ['ollama', 'non-existent-provider'] - for (const provider of unsupportedProviders) { - expect(supportsToolUsageControl(provider)).toBe(false) - } + for (const provider of unsupportedProviders) { + expect(supportsToolUsageControl(provider)).toBe(false) } - ) + }) }) describe('supportsReasoningEffort', () => { - it.concurrent('should return true for models with reasoning effort capability', () => { + it('should return true for models with reasoning effort capability', () => { expect(supportsReasoningEffort('gpt-5')).toBe(true) expect(supportsReasoningEffort('gpt-5-mini')).toBe(true) expect(supportsReasoningEffort('gpt-5.1')).toBe(true) @@ -369,7 +344,7 @@ describe('Model Capabilities', () => { expect(supportsReasoningEffort('azure/o3')).toBe(true) }) - it.concurrent('should return false for models without reasoning effort capability', () => { + it('should return false for models without reasoning effort capability', () => { expect(supportsReasoningEffort('gpt-4o')).toBe(false) expect(supportsReasoningEffort('gpt-4.1')).toBe(false) expect(supportsReasoningEffort('claude-sonnet-4-5')).toBe(false) @@ -378,7 +353,7 @@ describe('Model Capabilities', () => { expect(supportsReasoningEffort('unknown-model')).toBe(false) }) - it.concurrent('should be case-insensitive', () => { + it('should be case-insensitive', () => { expect(supportsReasoningEffort('GPT-5')).toBe(true) expect(supportsReasoningEffort('O3')).toBe(true) expect(supportsReasoningEffort('GPT-4O')).toBe(false) @@ -386,7 +361,7 @@ describe('Model Capabilities', () => { }) describe('supportsVerbosity', () => { - it.concurrent('should return true for models with verbosity capability', () => { + it('should return true for models with verbosity capability', () => { expect(supportsVerbosity('gpt-5')).toBe(true) expect(supportsVerbosity('gpt-5-mini')).toBe(true) expect(supportsVerbosity('gpt-5.1')).toBe(true) @@ -394,7 +369,7 @@ describe('Model Capabilities', () => { expect(supportsVerbosity('azure/gpt-5')).toBe(true) }) - it.concurrent('should return false for models without verbosity capability', () => { + it('should return false for models without verbosity capability', () => { expect(supportsVerbosity('gpt-4o')).toBe(false) expect(supportsVerbosity('o3')).toBe(false) expect(supportsVerbosity('o4-mini')).toBe(false) @@ -402,14 +377,14 @@ describe('Model Capabilities', () => { expect(supportsVerbosity('unknown-model')).toBe(false) }) - it.concurrent('should be case-insensitive', () => { + it('should be case-insensitive', () => { expect(supportsVerbosity('GPT-5')).toBe(true) expect(supportsVerbosity('GPT-4O')).toBe(false) }) }) describe('supportsThinking', () => { - it.concurrent('should return true for models with thinking capability', () => { + it('should return true for models with thinking capability', () => { expect(supportsThinking('claude-opus-4-6')).toBe(true) expect(supportsThinking('claude-opus-4-5')).toBe(true) expect(supportsThinking('claude-sonnet-4-5')).toBe(true) @@ -418,7 +393,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('gemini-3-flash-preview')).toBe(true) }) - it.concurrent('should return false for models without thinking capability', () => { + it('should return false for models without thinking capability', () => { expect(supportsThinking('gpt-4o')).toBe(false) expect(supportsThinking('gpt-5')).toBe(false) expect(supportsThinking('o3')).toBe(false) @@ -426,14 +401,14 @@ describe('Model Capabilities', () => { expect(supportsThinking('unknown-model')).toBe(false) }) - it.concurrent('should be case-insensitive', () => { + it('should be case-insensitive', () => { expect(supportsThinking('CLAUDE-OPUS-4-6')).toBe(true) expect(supportsThinking('GPT-4O')).toBe(false) }) }) describe('Model Constants', () => { - it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_2', () => { + it('should have correct models in MODELS_TEMP_RANGE_0_2', () => { expect(MODELS_TEMP_RANGE_0_2).toContain('gpt-4o') expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash') expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3') @@ -441,13 +416,13 @@ describe('Model Capabilities', () => { expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-5') }) - it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => { + it('should have correct models in MODELS_TEMP_RANGE_0_1', () => { expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-5') expect(MODELS_TEMP_RANGE_0_1).not.toContain('grok-3-latest') expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') }) - it.concurrent('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => { + it('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => { expect(PROVIDERS_WITH_TOOL_USAGE_CONTROL).toContain('openai') expect(PROVIDERS_WITH_TOOL_USAGE_CONTROL).toContain('anthropic') expect(PROVIDERS_WITH_TOOL_USAGE_CONTROL).toContain('deepseek') @@ -455,20 +430,15 @@ describe('Model Capabilities', () => { expect(PROVIDERS_WITH_TOOL_USAGE_CONTROL).not.toContain('ollama') }) - it.concurrent( - 'should combine both temperature ranges in MODELS_WITH_TEMPERATURE_SUPPORT', - () => { - expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe( - MODELS_TEMP_RANGE_0_2.length + - MODELS_TEMP_RANGE_0_15.length + - MODELS_TEMP_RANGE_0_1.length - ) - expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') - expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-5') - } - ) + it('should combine both temperature ranges in MODELS_WITH_TEMPERATURE_SUPPORT', () => { + expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe( + MODELS_TEMP_RANGE_0_2.length + MODELS_TEMP_RANGE_0_15.length + MODELS_TEMP_RANGE_0_1.length + ) + expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') + expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-5') + }) - it.concurrent('should have correct models in MODELS_WITH_REASONING_EFFORT', () => { + it('should have correct models in MODELS_WITH_REASONING_EFFORT', () => { expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-codex') @@ -499,7 +469,7 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') }) - it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { + it('should have correct models in MODELS_WITH_VERBOSITY', () => { expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-codex') @@ -528,7 +498,7 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-5') }) - it.concurrent('should have correct models in MODELS_WITH_THINKING', () => { + it('should have correct models in MODELS_WITH_THINKING', () => { expect(MODELS_WITH_THINKING).toContain('claude-opus-4-6') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-5') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-1') @@ -543,7 +513,7 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).not.toContain('o3') }) - it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => { + it('should have GPT-5 models in both reasoning effort and verbosity arrays', () => { const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter( (m) => m.includes('gpt-5') && @@ -575,7 +545,7 @@ describe('Model Capabilities', () => { }) }) describe('Reasoning Effort Values Per Model', () => { - it.concurrent('should return correct values for GPT-5.2', () => { + it('should return correct values for GPT-5.2', () => { const values = getReasoningEffortValuesForModel('gpt-5.2') expect(values).toBeDefined() expect(values).toContain('none') @@ -586,7 +556,7 @@ describe('Model Capabilities', () => { expect(values).not.toContain('minimal') }) - it.concurrent('should return correct values for GPT-5', () => { + it('should return correct values for GPT-5', () => { const values = getReasoningEffortValuesForModel('gpt-5') expect(values).toBeDefined() expect(values).toContain('minimal') @@ -595,7 +565,7 @@ describe('Model Capabilities', () => { expect(values).toContain('high') }) - it.concurrent('should return correct values for o-series models', () => { + it('should return correct values for o-series models', () => { for (const model of ['o1', 'o3', 'o4-mini']) { const values = getReasoningEffortValuesForModel(model) expect(values).toBeDefined() @@ -607,13 +577,13 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return null for non-reasoning models', () => { + it('should return null for non-reasoning models', () => { expect(getReasoningEffortValuesForModel('gpt-4o')).toBeNull() expect(getReasoningEffortValuesForModel('claude-sonnet-4-5')).toBeNull() expect(getReasoningEffortValuesForModel('gemini-2.5-flash')).toBeNull() }) - it.concurrent('should return correct values for Azure GPT-5.2', () => { + it('should return correct values for Azure GPT-5.2', () => { const values = getReasoningEffortValuesForModel('azure/gpt-5.2') expect(values).toBeDefined() expect(values).not.toContain('minimal') @@ -624,7 +594,7 @@ describe('Model Capabilities', () => { }) describe('Verbosity Values Per Model', () => { - it.concurrent('should return correct values for GPT-5 family', () => { + it('should return correct values for GPT-5 family', () => { for (const model of ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano']) { const values = getVerbosityValuesForModel(model) expect(values).toBeDefined() @@ -634,20 +604,20 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return null for o-series models', () => { + it('should return null for o-series models', () => { expect(getVerbosityValuesForModel('o1')).toBeNull() expect(getVerbosityValuesForModel('o3')).toBeNull() expect(getVerbosityValuesForModel('o4-mini')).toBeNull() }) - it.concurrent('should return null for non-reasoning models', () => { + it('should return null for non-reasoning models', () => { expect(getVerbosityValuesForModel('gpt-4o')).toBeNull() expect(getVerbosityValuesForModel('claude-sonnet-4-5')).toBeNull() }) }) describe('Thinking Levels Per Model', () => { - it.concurrent('should return correct levels for Claude Opus 4.6 (adaptive)', () => { + it('should return correct levels for Claude Opus 4.6 (adaptive)', () => { const levels = getThinkingLevelsForModel('claude-opus-4-6') expect(levels).toBeDefined() expect(levels).toContain('low') @@ -656,7 +626,7 @@ describe('Model Capabilities', () => { expect(levels).toContain('max') }) - it.concurrent('should return correct levels for other Claude models (budget_tokens)', () => { + it('should return correct levels for other Claude models (budget_tokens)', () => { for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5']) { const levels = getThinkingLevelsForModel(model) expect(levels).toBeDefined() @@ -667,7 +637,7 @@ describe('Model Capabilities', () => { } }) - it.concurrent('should return correct levels for Gemini 3 models', () => { + it('should return correct levels for Gemini 3 models', () => { const flashLevels = getThinkingLevelsForModel('gemini-3-flash-preview') expect(flashLevels).toBeDefined() expect(flashLevels).toContain('minimal') @@ -676,7 +646,7 @@ describe('Model Capabilities', () => { expect(flashLevels).toContain('high') }) - it.concurrent('should return correct levels for Claude Haiku 4.5', () => { + it('should return correct levels for Claude Haiku 4.5', () => { const levels = getThinkingLevelsForModel('claude-haiku-4-5') expect(levels).toBeDefined() expect(levels).toContain('low') @@ -684,7 +654,7 @@ describe('Model Capabilities', () => { expect(levels).toContain('high') }) - it.concurrent('should return null for non-thinking models', () => { + it('should return null for non-thinking models', () => { expect(getThinkingLevelsForModel('gpt-4o')).toBeNull() expect(getThinkingLevelsForModel('gpt-5')).toBeNull() expect(getThinkingLevelsForModel('o3')).toBeNull() @@ -694,65 +664,65 @@ describe('Model Capabilities', () => { describe('Max Output Tokens', () => { describe('getMaxOutputTokensForModel', () => { - it.concurrent('should return published max for OpenAI GPT-4o', () => { + it('should return published max for OpenAI GPT-4o', () => { expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384) }) - it.concurrent('should return published max for OpenAI GPT-5.1', () => { + it('should return published max for OpenAI GPT-5.1', () => { expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000) }) - it.concurrent('should return published max for OpenAI GPT-5 Chat', () => { + it('should return published max for OpenAI GPT-5 Chat', () => { expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384) }) - it.concurrent('should return published max for OpenAI o1', () => { + it('should return published max for OpenAI o1', () => { expect(getMaxOutputTokensForModel('o1')).toBe(100000) }) - it.concurrent('should return updated max for Claude Sonnet 4.6', () => { + it('should return updated max for Claude Sonnet 4.6', () => { expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(128000) }) - it.concurrent('should return published max for Gemini 2.5 Pro', () => { + it('should return published max for Gemini 2.5 Pro', () => { expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536) }) - it.concurrent('should return published max for Azure GPT-5.2', () => { + it('should return published max for Azure GPT-5.2', () => { expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000) }) - it.concurrent('should return standard default for models without maxOutputTokens', () => { + it('should return standard default for models without maxOutputTokens', () => { expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096) expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096) }) - it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => { + it('should return published max for Bedrock Claude Opus 4.1', () => { expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe( 32000 ) }) - it.concurrent('should return correct max for Claude Opus 4.6', () => { + it('should return correct max for Claude Opus 4.6', () => { expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000) }) - it.concurrent('should return correct max for Claude Sonnet 4.5', () => { + it('should return correct max for Claude Sonnet 4.5', () => { expect(getMaxOutputTokensForModel('claude-sonnet-4-5')).toBe(64000) }) - it.concurrent('should return correct max for Claude Opus 4.1', () => { + it('should return correct max for Claude Opus 4.1', () => { expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000) }) - it.concurrent('should return standard default for unknown models', () => { + it('should return standard default for unknown models', () => { expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096) }) }) }) describe('Model Pricing Validation', () => { - it.concurrent('should have correct pricing for key Anthropic models', () => { + it('should have correct pricing for key Anthropic models', () => { const opus46 = getModelPricing('claude-opus-4-6') expect(opus46).toBeDefined() expect(opus46.input).toBe(5.0) @@ -764,7 +734,7 @@ describe('Model Pricing Validation', () => { expect(sonnet45.output).toBe(15.0) }) - it.concurrent('should have correct pricing for key OpenAI models', () => { + it('should have correct pricing for key OpenAI models', () => { const gpt4o = getModelPricing('gpt-4o') expect(gpt4o).toBeDefined() expect(gpt4o.input).toBe(2.5) @@ -776,20 +746,20 @@ describe('Model Pricing Validation', () => { expect(o3.output).toBe(8.0) }) - it.concurrent('should have correct pricing for Azure OpenAI o3', () => { + it('should have correct pricing for Azure OpenAI o3', () => { const azureO3 = getModelPricing('azure/o3') expect(azureO3).toBeDefined() expect(azureO3.input).toBe(2.0) expect(azureO3.output).toBe(8.0) }) - it.concurrent('should return null for unknown models', () => { + it('should return null for unknown models', () => { expect(getModelPricing('unknown-model')).toBeNull() }) }) describe('Context Window Validation', () => { - it.concurrent('should have correct context windows for key models', () => { + it('should have correct context windows for key models', () => { const allModels = getAllModels() expect(allModels).toContain('gpt-5-chat-latest') @@ -801,7 +771,7 @@ describe('Context Window Validation', () => { describe('Cost Calculation', () => { describe('calculateCost', () => { - it.concurrent('should calculate cost correctly for known models', () => { + it('should calculate cost correctly for known models', () => { const result = calculateCost('gpt-4o', 1000, 500, false) expect(result.input).toBeGreaterThan(0) @@ -811,7 +781,7 @@ describe('Cost Calculation', () => { expect(result.pricing.input).toBe(2.5) }) - it.concurrent('should handle cached input pricing when enabled', () => { + it('should handle cached input pricing when enabled', () => { const regularCost = calculateCost('gpt-4o', 1000, 500, false) const cachedCost = calculateCost('gpt-4o', 1000, 500, true) @@ -819,7 +789,7 @@ describe('Cost Calculation', () => { expect(cachedCost.output).toBe(regularCost.output) }) - it.concurrent('should return default pricing for unknown models', () => { + it('should return default pricing for unknown models', () => { const result = calculateCost('unknown-model', 1000, 500, false) expect(result.input).toBe(0) @@ -828,7 +798,7 @@ describe('Cost Calculation', () => { expect(result.pricing.input).toBe(1.0) }) - it.concurrent('should handle zero tokens', () => { + it('should handle zero tokens', () => { const result = calculateCost('gpt-4o', 0, 0, false) expect(result.input).toBe(0) @@ -838,26 +808,26 @@ describe('Cost Calculation', () => { }) describe('formatCost', () => { - it.concurrent('should format dollar amounts as credits', () => { + it('should format dollar amounts as credits', () => { expect(formatCost(1.234)).toBe('247 credits') expect(formatCost(10.567)).toBe('2,113 credits') }) - it.concurrent('should show <1 credit for very small costs', () => { + it('should show <1 credit for very small costs', () => { expect(formatCost(0.0024)).toBe('<1 credit') expect(formatCost(0.001)).toBe('<1 credit') }) - it.concurrent('should show credit count for small costs that round to at least 1', () => { + it('should show credit count for small costs that round to at least 1', () => { expect(formatCost(0.0234)).toBe('5 credits') expect(formatCost(0.1567)).toBe('31 credits') }) - it.concurrent('should handle zero cost', () => { + it('should handle zero cost', () => { expect(formatCost(0)).toBe('0 credits') }) - it.concurrent('should handle undefined/null costs', () => { + it('should handle undefined/null costs', () => { expect(formatCost(undefined as any)).toBe('—') expect(formatCost(null as any)).toBe('—') }) @@ -865,7 +835,7 @@ describe('Cost Calculation', () => { }) describe('getHostedModels', () => { - it.concurrent('should return OpenAI, Anthropic, Google, and xAI models as hosted', () => { + it('should return OpenAI, Anthropic, Google, and xAI models as hosted', () => { const hostedModels = getHostedModels() expect(hostedModels).toContain('gpt-4o') @@ -882,7 +852,7 @@ describe('getHostedModels', () => { expect(hostedModels).not.toContain('deepseek-v3') }) - it.concurrent('should return an array of strings', () => { + it('should return an array of strings', () => { const hostedModels = getHostedModels() expect(Array.isArray(hostedModels)).toBe(true) @@ -894,7 +864,7 @@ describe('getHostedModels', () => { }) describe('shouldBillModelUsage', () => { - it.concurrent('should return true for exact matches of hosted models', () => { + it('should return true for exact matches of hosted models', () => { expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true) @@ -907,25 +877,25 @@ describe('shouldBillModelUsage', () => { expect(shouldBillModelUsage('grok-4.5')).toBe(true) }) - it.concurrent('should return false for non-hosted models', () => { + it('should return false for non-hosted models', () => { expect(shouldBillModelUsage('deepseek-v3')).toBe(false) expect(shouldBillModelUsage('unknown-model')).toBe(false) }) - it.concurrent('should return false for versioned model names not in hosted list', () => { + it('should return false for versioned model names not in hosted list', () => { expect(shouldBillModelUsage('claude-sonnet-4-20250514')).toBe(false) expect(shouldBillModelUsage('gpt-4o-2024-08-06')).toBe(false) expect(shouldBillModelUsage('claude-3-5-sonnet-20241022')).toBe(false) }) - it.concurrent('should be case insensitive', () => { + it('should be case insensitive', () => { expect(shouldBillModelUsage('GPT-4O')).toBe(true) expect(shouldBillModelUsage('Claude-Sonnet-4-5')).toBe(true) expect(shouldBillModelUsage('GEMINI-2.5-PRO')).toBe(true) }) - it.concurrent('should not match partial model names', () => { + it('should not match partial model names', () => { expect(shouldBillModelUsage('gpt-4')).toBe(false) expect(shouldBillModelUsage('claude-sonnet')).toBe(false) expect(shouldBillModelUsage('gemini')).toBe(false) @@ -934,30 +904,30 @@ describe('shouldBillModelUsage', () => { describe('Provider Management', () => { describe('getProviderFromModel', () => { - it.concurrent('should return correct provider for known models', () => { + it('should return correct provider for known models', () => { expect(getProviderFromModel('gpt-4o')).toBe('openai') expect(getProviderFromModel('claude-sonnet-4-5')).toBe('anthropic') expect(getProviderFromModel('gemini-2.5-pro')).toBe('google') expect(getProviderFromModel('azure/gpt-4o')).toBe('azure-openai') }) - it.concurrent('should use model patterns for pattern matching', () => { + it('should use model patterns for pattern matching', () => { expect(getProviderFromModel('gpt-5-custom')).toBe('openai') expect(getProviderFromModel('claude-custom-model')).toBe('anthropic') }) - it.concurrent('should default to ollama for unknown models', () => { + it('should default to ollama for unknown models', () => { expect(getProviderFromModel('unknown-model')).toBe('ollama') }) - it.concurrent('should be case insensitive', () => { + it('should be case insensitive', () => { expect(getProviderFromModel('GPT-4O')).toBe('openai') expect(getProviderFromModel('CLAUDE-SONNET-4-0')).toBe('anthropic') }) }) describe('getProvider', () => { - it.concurrent('should return provider config for valid provider IDs', () => { + it('should return provider config for valid provider IDs', () => { const openaiProvider = getProvider('openai') expect(openaiProvider).toBeDefined() expect(openaiProvider?.id).toBe('openai') @@ -968,19 +938,19 @@ describe('Provider Management', () => { expect(anthropicProvider?.id).toBe('anthropic') }) - it.concurrent('should handle provider/service format', () => { + it('should handle provider/service format', () => { const provider = getProvider('openai/chat') expect(provider).toBeDefined() expect(provider?.id).toBe('openai') }) - it.concurrent('should return undefined for invalid provider IDs', () => { + it('should return undefined for invalid provider IDs', () => { expect(getProvider('nonexistent')).toBeUndefined() }) }) describe('getProviderConfigFromModel', () => { - it.concurrent('should return provider config for model', () => { + it('should return provider config for model', () => { const config = getProviderConfigFromModel('gpt-4o') expect(config).toBeDefined() expect(config?.id).toBe('openai') @@ -992,7 +962,7 @@ describe('Provider Management', () => { }) describe('getAllModels', () => { - it.concurrent('should return all models from all providers', () => { + it('should return all models from all providers', () => { const allModels = getAllModels() expect(Array.isArray(allModels)).toBe(true) expect(allModels.length).toBeGreaterThan(0) @@ -1004,7 +974,7 @@ describe('Provider Management', () => { }) describe('getAllProviderIds', () => { - it.concurrent('should return all provider IDs', () => { + it('should return all provider IDs', () => { const providerIds = getAllProviderIds() expect(Array.isArray(providerIds)).toBe(true) expect(providerIds).toContain('openai') @@ -1015,7 +985,7 @@ describe('Provider Management', () => { }) describe('getProviderModels', () => { - it.concurrent('should return models for specific providers', () => { + it('should return models for specific providers', () => { const openaiModels = getProviderModels('openai') expect(Array.isArray(openaiModels)).toBe(true) expect(openaiModels).toContain('gpt-4o') @@ -1026,14 +996,14 @@ describe('Provider Management', () => { expect(anthropicModels).toContain('claude-opus-4-1') }) - it.concurrent('should return empty array for unknown providers', () => { + it('should return empty array for unknown providers', () => { const unknownModels = getProviderModels('unknown' as any) expect(unknownModels).toEqual([]) }) }) describe('getBaseModelProviders and getAllModelProviders', () => { - it.concurrent('should return model to provider mapping', () => { + it('should return model to provider mapping', () => { const allProviders = getAllModelProviders() expect(typeof allProviders).toBe('object') expect(allProviders['gpt-4o']).toBe('openai') @@ -1045,7 +1015,7 @@ describe('Provider Management', () => { }) describe('updateOllamaProviderModels', () => { - it.concurrent('should update ollama models', () => { + it('should update ollama models', () => { const mockModels = ['llama2', 'codellama', 'mistral'] expect(() => updateOllamaProviderModels(mockModels)).not.toThrow() @@ -1058,19 +1028,19 @@ describe('Provider Management', () => { describe('JSON and Structured Output', () => { describe('extractAndParseJSON', () => { - it.concurrent('should extract and parse valid JSON', () => { + it('should extract and parse valid JSON', () => { const content = 'Some text before ```json\n{"key": "value"}\n``` some text after' const result = extractAndParseJSON(content) expect(result).toEqual({ key: 'value' }) }) - it.concurrent('should extract JSON without code blocks', () => { + it('should extract JSON without code blocks', () => { const content = 'Text before {"name": "test", "value": 42} text after' const result = extractAndParseJSON(content) expect(result).toEqual({ name: 'test', value: 42 }) }) - it.concurrent('should handle nested objects', () => { + it('should handle nested objects', () => { const content = '{"user": {"name": "John", "age": 30}, "active": true}' const result = extractAndParseJSON(content) expect(result).toEqual({ @@ -1079,24 +1049,24 @@ describe('JSON and Structured Output', () => { }) }) - it.concurrent('should clean up common JSON issues', () => { + it('should clean up common JSON issues', () => { const content = '{\n "key": "value",\n "number": 42,\n}' const result = extractAndParseJSON(content) expect(result).toEqual({ key: 'value', number: 42 }) }) - it.concurrent('should throw error for content without JSON', () => { + it('should throw error for content without JSON', () => { expect(() => extractAndParseJSON('No JSON here')).toThrow('No JSON object found in content') }) - it.concurrent('should throw error for invalid JSON', () => { + it('should throw error for invalid JSON', () => { const invalidJson = '{"key": invalid, "broken": }' expect(() => extractAndParseJSON(invalidJson)).toThrow('Failed to parse JSON after cleanup') }) }) describe('generateStructuredOutputInstructions', () => { - it.concurrent('should return empty string for JSON Schema format', () => { + it('should return empty string for JSON Schema format', () => { const schemaFormat = { schema: { type: 'object', @@ -1106,7 +1076,7 @@ describe('JSON and Structured Output', () => { expect(generateStructuredOutputInstructions(schemaFormat)).toBe('') }) - it.concurrent('should return empty string for object type with properties', () => { + it('should return empty string for object type with properties', () => { const objectFormat = { type: 'object', properties: { key: { type: 'string' } }, @@ -1114,7 +1084,7 @@ describe('JSON and Structured Output', () => { expect(generateStructuredOutputInstructions(objectFormat)).toBe('') }) - it.concurrent('should generate instructions for legacy fields format', () => { + it('should generate instructions for legacy fields format', () => { const fieldsFormat = { fields: [ { name: 'score', type: 'number', description: 'A score from 1-10' }, @@ -1129,7 +1099,7 @@ describe('JSON and Structured Output', () => { expect(result).toContain('A score from 1-10') }) - it.concurrent('should handle object fields with properties', () => { + it('should handle object fields with properties', () => { const fieldsFormat = { fields: [ { @@ -1150,7 +1120,7 @@ describe('JSON and Structured Output', () => { expect(result).toContain('count') }) - it.concurrent('should return empty string for missing fields', () => { + it('should return empty string for missing fields', () => { expect(generateStructuredOutputInstructions({})).toBe('') expect(generateStructuredOutputInstructions(null)).toBe('') expect(generateStructuredOutputInstructions({ fields: null })).toBe('') @@ -1170,7 +1140,7 @@ describe('Tool Management', () => { mockLogger.info.mockClear() }) - it.concurrent('should return early for no tools', () => { + it('should return early for no tools', () => { const result = prepareToolsWithUsageControl(undefined, undefined, mockLogger) expect(result.tools).toBeUndefined() @@ -1179,7 +1149,7 @@ describe('Tool Management', () => { expect(result.forcedTools).toEqual([]) }) - it.concurrent('should filter out tools with usageControl="none"', () => { + it('should filter out tools with usageControl="none"', () => { const tools = [ { function: { name: 'tool1' } }, { function: { name: 'tool2' } }, @@ -1199,7 +1169,7 @@ describe('Tool Management', () => { expect(mockLogger.info).toHaveBeenCalledWith("Filtered out 1 tools with usageControl='none'") }) - it.concurrent('should set toolChoice for forced tools (OpenAI format)', () => { + it('should set toolChoice for forced tools (OpenAI format)', () => { const tools = [{ function: { name: 'forcedTool' } }] const providerTools = [{ id: 'forcedTool', usageControl: 'force' }] @@ -1211,7 +1181,7 @@ describe('Tool Management', () => { }) }) - it.concurrent('should set toolChoice for forced tools (Anthropic format)', () => { + it('should set toolChoice for forced tools (Anthropic format)', () => { const tools = [{ function: { name: 'forcedTool' } }] const providerTools = [{ id: 'forcedTool', usageControl: 'force' }] @@ -1223,7 +1193,7 @@ describe('Tool Management', () => { }) }) - it.concurrent('should set toolConfig for Google format', () => { + it('should set toolConfig for Google format', () => { const tools = [{ function: { name: 'forcedTool' } }] const providerTools = [{ id: 'forcedTool', usageControl: 'force' }] @@ -1237,7 +1207,7 @@ describe('Tool Management', () => { }) }) - it.concurrent('should return empty when all tools are filtered', () => { + it('should return empty when all tools are filtered', () => { const tools = [{ function: { name: 'tool1' } }] const providerTools = [{ id: 'tool1', usageControl: 'none' }] @@ -1248,7 +1218,7 @@ describe('Tool Management', () => { expect(result.hasFilteredTools).toBe(true) }) - it.concurrent('should default to auto when no forced tools', () => { + it('should default to auto when no forced tools', () => { const tools = [{ function: { name: 'tool1' } }] const providerTools = [{ id: 'tool1', usageControl: 'auto' }] @@ -1261,7 +1231,7 @@ describe('Tool Management', () => { describe('prepareToolExecution', () => { describe('basic parameter merging', () => { - it.concurrent('should merge LLM args with user params', () => { + it('should merge LLM args with user params', () => { const tool = { params: { apiKey: 'user-key', channel: '#general' }, } @@ -1275,7 +1245,7 @@ describe('prepareToolExecution', () => { expect(toolParams.message).toBe('Hello world') }) - it.concurrent('should filter out empty string user params', () => { + it('should filter out empty string user params', () => { const tool = { params: { apiKey: 'user-key', channel: '' }, } @@ -1304,24 +1274,21 @@ describe('prepareToolExecution', () => { payerSubscription: null, } - it.concurrent( - 'should include billingAttribution in _context when the request carries it', - () => { - const tool = { params: {} } - const request = { - workflowId: 'wf-123', - workspaceId: 'workspace-1', - userId: 'user-1', - billingAttribution, - } + it('should include billingAttribution in _context when the request carries it', () => { + const tool = { params: {} } + const request = { + workflowId: 'wf-123', + workspaceId: 'workspace-1', + userId: 'user-1', + billingAttribution, + } - const { executionParams } = prepareToolExecution(tool, {}, request) + const { executionParams } = prepareToolExecution(tool, {}, request) - expect(executionParams._context.billingAttribution).toEqual(billingAttribution) - } - ) + expect(executionParams._context.billingAttribution).toEqual(billingAttribution) + }) - it.concurrent('should omit billingAttribution from _context when the request lacks it', () => { + it('should omit billingAttribution from _context when the request lacks it', () => { const tool = { params: {} } const request = { workflowId: 'wf-123', workspaceId: 'workspace-1' } @@ -1331,7 +1298,7 @@ describe('prepareToolExecution', () => { expect(executionParams._context).not.toHaveProperty('billingAttribution') }) - it.concurrent('should carry billingAttribution even when the request has no workflowId', () => { + it('should carry billingAttribution even when the request has no workflowId', () => { const tool = { params: {} } const request = { workspaceId: 'workspace-1', billingAttribution } @@ -1342,7 +1309,7 @@ describe('prepareToolExecution', () => { expect(executionParams._context).not.toHaveProperty('workflowId') }) - it.concurrent('should not build _context when there is no workflowId or attribution', () => { + it('should not build _context when there is no workflowId or attribution', () => { const tool = { params: {} } const { executionParams } = prepareToolExecution(tool, {}, { workspaceId: 'workspace-1' }) @@ -1352,7 +1319,7 @@ describe('prepareToolExecution', () => { }) describe('inputMapping deep merge for workflow tools', () => { - it.concurrent('should deep merge inputMapping when user provides empty object', () => { + it('should deep merge inputMapping when user provides empty object', () => { const tool = { params: { workflowId: 'child-workflow-123', @@ -1370,7 +1337,7 @@ describe('prepareToolExecution', () => { expect(toolParams.workflowId).toBe('child-workflow-123') }) - it.concurrent('should deep merge inputMapping with partial user values', () => { + it('should deep merge inputMapping with partial user values', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1391,7 +1358,7 @@ describe('prepareToolExecution', () => { }) }) - it.concurrent('should preserve non-empty user inputMapping values', () => { + it('should preserve non-empty user inputMapping values', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1412,7 +1379,7 @@ describe('prepareToolExecution', () => { }) }) - it.concurrent('should handle inputMapping as object (not JSON string)', () => { + it('should handle inputMapping as object (not JSON string)', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1433,7 +1400,7 @@ describe('prepareToolExecution', () => { }) }) - it.concurrent('should use LLM inputMapping when user does not provide it', () => { + it('should use LLM inputMapping when user does not provide it', () => { const tool = { params: { workflowId: 'child-workflow' }, } @@ -1447,7 +1414,7 @@ describe('prepareToolExecution', () => { expect(toolParams.inputMapping).toEqual({ query: 'llm-search', limit: 10 }) }) - it.concurrent('should use user inputMapping when LLM does not provide it', () => { + it('should use user inputMapping when LLM does not provide it', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1462,7 +1429,7 @@ describe('prepareToolExecution', () => { expect(toolParams.inputMapping).toEqual({ query: 'user-search' }) }) - it.concurrent('should handle invalid JSON in user inputMapping gracefully', () => { + it('should handle invalid JSON in user inputMapping gracefully', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1479,7 +1446,7 @@ describe('prepareToolExecution', () => { expect(toolParams.inputMapping).toEqual({ query: 'llm-search' }) }) - it.concurrent('should not affect other parameters - normal override behavior', () => { + it('should not affect other parameters - normal override behavior', () => { const tool = { params: { apiKey: 'user-key', channel: '#general' }, } @@ -1493,7 +1460,7 @@ describe('prepareToolExecution', () => { expect(toolParams.message).toBe('Hello') }) - it.concurrent('should preserve 0 and false as valid user values in inputMapping', () => { + it('should preserve 0 and false as valid user values in inputMapping', () => { const tool = { params: { workflowId: 'child-workflow', @@ -1516,7 +1483,7 @@ describe('prepareToolExecution', () => { }) describe('execution params context', () => { - it.concurrent('should include workflow context in executionParams', () => { + it('should include workflow context in executionParams', () => { const tool = { params: { message: 'test' } } const llmArgs = {} const request = { @@ -1536,7 +1503,7 @@ describe('prepareToolExecution', () => { }) }) - it.concurrent('should include environment and workflow variables', () => { + it('should include environment and workflow variables', () => { const tool = { params: {} } const llmArgs = {} const request = { @@ -1554,27 +1521,27 @@ describe('prepareToolExecution', () => { describe('Provider/Model Blacklist', () => { describe('isProviderBlacklisted', () => { - it.concurrent('should return false when no providers are blacklisted', () => { + it('should return false when no providers are blacklisted', () => { expect(isProviderBlacklisted('openai')).toBe(false) expect(isProviderBlacklisted('anthropic')).toBe(false) }) }) describe('filterBlacklistedModels', () => { - it.concurrent('should return all models when no blacklist is set', () => { + it('should return all models when no blacklist is set', () => { const models = ['gpt-4o', 'claude-sonnet-4-5', 'gemini-2.5-pro'] const result = filterBlacklistedModels(models) expect(result).toEqual(models) }) - it.concurrent('should return empty array for empty input', () => { + it('should return empty array for empty input', () => { const result = filterBlacklistedModels([]) expect(result).toEqual([]) }) }) describe('getBaseModelProviders blacklist filtering', () => { - it.concurrent('should return providers when no blacklist is set', () => { + it('should return providers when no blacklist is set', () => { const providers = getBaseModelProviders() expect(Object.keys(providers).length).toBeGreaterThan(0) expect(providers['gpt-4o']).toBe('openai') @@ -1583,12 +1550,12 @@ describe('Provider/Model Blacklist', () => { }) describe('getProviderFromModel execution-time enforcement', () => { - it.concurrent('should return provider for non-blacklisted models', () => { + it('should return provider for non-blacklisted models', () => { expect(getProviderFromModel('gpt-4o')).toBe('openai') expect(getProviderFromModel('claude-sonnet-4-5')).toBe('anthropic') }) - it.concurrent('should be case insensitive', () => { + it('should be case insensitive', () => { expect(getProviderFromModel('GPT-4O')).toBe('openai') expect(getProviderFromModel('CLAUDE-SONNET-4-5')).toBe('anthropic') }) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 5df9984cf10..929eed52aff 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -14,14 +14,15 @@ import { inputValidationMock, inputValidationMockFns, type MockFetchResponse, + resetEnvFlagsMock, + setEnvFlags, } from '@sim/testing' import { sleep } from '@sim/utils/helpers' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' // Hoisted mock state - these are available to vi.mock factories const { - mockIsHosted, mockEnv, mockGetBYOKKey, mockGetToolAsync, @@ -33,7 +34,6 @@ const { mockResolveWorkspaceFileReference, mockGetEffectiveDecryptedEnv, } = vi.hoisted(() => ({ - mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, mockGetBYOKKey: vi.fn(), mockGetToolAsync: vi.fn(), @@ -53,16 +53,6 @@ const { const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS -// Mock feature flags -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockIsHosted.value - }, - isProd: false, - isDev: true, - isTest: true, -})) - // Mock env config to control hosted key availability vi.mock('@/lib/core/config/env', () => ({ env: new Proxy({} as Record, { @@ -480,6 +470,12 @@ function setupEnvVars(variables: Record) { } } +beforeAll(() => { + setEnvFlags({ isDev: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('Tools Registry', () => { it('should include all expected built-in tools', () => { expect(tools.http_request).toBeDefined() @@ -2553,7 +2549,7 @@ describe('Rate Limiting and Retry Logic', () => { NEXT_PUBLIC_APP_URL: 'http://localhost:3000', }) vi.clearAllMocks() - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockEnv.TEST_HOSTED_KEY = 'test-hosted-api-key' mockGetBYOKKey.mockResolvedValue(null) // Set up throttler mock defaults @@ -2571,7 +2567,7 @@ describe('Rate Limiting and Retry Logic', () => { vi.useRealTimers() vi.resetAllMocks() cleanupEnvVars() - mockIsHosted.value = false + setEnvFlags({ isHosted: false }) mockEnv.TEST_HOSTED_KEY = undefined }) @@ -2937,7 +2933,7 @@ describe('Cost Field Handling', () => { NEXT_PUBLIC_APP_URL: 'http://localhost:3000', }) vi.clearAllMocks() - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockEnv.TEST_HOSTED_KEY = 'test-hosted-api-key' mockGetBYOKKey.mockResolvedValue(null) // Set up throttler mock defaults @@ -2954,7 +2950,7 @@ describe('Cost Field Handling', () => { afterEach(() => { vi.resetAllMocks() cleanupEnvVars() - mockIsHosted.value = false + setEnvFlags({ isHosted: false }) mockEnv.TEST_HOSTED_KEY = undefined }) @@ -3020,7 +3016,7 @@ describe('Cost Field Handling', () => { }) it('should not add cost when not using hosted key', async () => { - mockIsHosted.value = false + setEnvFlags({ isHosted: false }) const mockTool = { id: 'test_no_hosted_cost', diff --git a/apps/sim/tools/supabase/utils.test.ts b/apps/sim/tools/supabase/utils.test.ts index b12e98ac421..8ee7c963cf5 100644 --- a/apps/sim/tools/supabase/utils.test.ts +++ b/apps/sim/tools/supabase/utils.test.ts @@ -1,11 +1,7 @@ /** * @vitest-environment node */ -import { envFlagsMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - +import { describe, expect, it } from 'vitest' import { supabaseBaseUrl } from '@/tools/supabase/utils' describe('supabaseBaseUrl', () => { diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index 1213b48efc8..4abf8e34bb2 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -2,6 +2,7 @@ import { authMock, databaseMock, drizzleOrmMock, + envFlagsMock, hybridAuthMock, loggerMock, requestUtilsMock, @@ -25,6 +26,7 @@ vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) vi.mock('@/stores/console/store', () => ({ useConsoleStore: { diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 590a5b9fde6..ee6557a4245 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -1,25 +1,66 @@ import { vi } from 'vitest' /** - * Static mock module for `@/lib/core/config/env-flags`. - * All boolean flags default to `false` for safe test isolation. - * - * @example - * ```ts - * vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - * ``` + * Mutable value-export state for the shared `@/lib/core/config/env-flags` mock. + * Defaults mirror the real module evaluated under the vitest environment + * (NODE_ENV=test, no feature env vars set): only `isTest` and + * `isEmailPasswordEnabled` are true. */ -export const envFlagsMock = { +export interface EnvFlagsMockState { + isProd: boolean + isDev: boolean + isTest: boolean + isHosted: boolean + isCopilotBillingAttributionV1Enabled: boolean + isCopilotBillingProtocolRequired: boolean + isBillingEnabled: boolean + isEmailVerificationEnabled: boolean + isAuthDisabled: boolean + isPrivateDatabaseHostsAllowed: boolean + isRegistrationDisabled: boolean + isEmailPasswordEnabled: boolean + isSignupMxValidationEnabled: boolean + isAppConfigEnabled: boolean + isTriggerDevEnabled: boolean + isSsoEnabled: boolean + isAccessControlEnabled: boolean + isOrganizationsEnabled: boolean + isInboxEnabled: boolean + isWhitelabelingEnabled: boolean + isAuditLogsEnabled: boolean + isDataRetentionEnabled: boolean + isDataDrainsEnabled: boolean + isForkingEnabled: boolean + isE2bEnabled: boolean + isE2BDocEnabled: boolean + isOllamaConfigured: boolean + isAzureConfigured: boolean + isCohereConfigured: boolean + isInvitationsDisabled: boolean + isPublicApiDisabled: boolean + isGoogleAuthDisabled: boolean + isGithubAuthDisabled: boolean + isMicrosoftAuthDisabled: boolean + isEmailSignupDisabled: boolean + isReactGrabEnabled: boolean + isReactScanEnabled: boolean +} + +const defaultEnvFlagsState: EnvFlagsMockState = { isProd: false, isDev: false, isTest: true, isHosted: false, + isCopilotBillingAttributionV1Enabled: false, + isCopilotBillingProtocolRequired: false, isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, isPrivateDatabaseHostsAllowed: false, isRegistrationDisabled: false, - isEmailPasswordEnabled: false, + isEmailPasswordEnabled: true, + isSignupMxValidationEnabled: false, + isAppConfigEnabled: false, isTriggerDevEnabled: false, isSsoEnabled: false, isAccessControlEnabled: false, @@ -28,18 +69,95 @@ export const envFlagsMock = { isWhitelabelingEnabled: false, isAuditLogsEnabled: false, isDataRetentionEnabled: false, + isDataDrainsEnabled: false, + isForkingEnabled: false, isE2bEnabled: false, isE2BDocEnabled: false, isOllamaConfigured: false, isAzureConfigured: false, + isCohereConfigured: false, isInvitationsDisabled: false, isPublicApiDisabled: false, isGoogleAuthDisabled: false, isGithubAuthDisabled: false, + isMicrosoftAuthDisabled: false, + isEmailSignupDisabled: false, isReactGrabEnabled: false, isReactScanEnabled: false, - getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null), - getBlacklistedProvidersFromEnv: vi.fn().mockReturnValue([]), - getAllowedMcpDomainsFromEnv: vi.fn().mockReturnValue(null), - getCostMultiplier: vi.fn().mockReturnValue(1), } + +const envFlagsState: EnvFlagsMockState = { ...defaultEnvFlagsState } + +/** + * Controllable mock functions for the function exports of + * `@/lib/core/config/env-flags`. Override per-test, e.g. + * `envFlagsMockFns.getCostMultiplier.mockReturnValue(2)`. + * {@link resetEnvFlagsMock} restores the default implementations. + */ +export const envFlagsMockFns = { + getAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(() => null), + getPreviewBlocksFromEnv: vi.fn<() => string[]>(() => []), + getBlacklistedProvidersFromEnv: vi.fn<() => string[]>(() => []), + getAllowedMcpDomainsFromEnv: vi.fn<() => string[] | null>(() => null), + getCostMultiplier: vi.fn<() => number>(() => 1), +} + +/** + * Applies per-test overrides to the shared env-flags mock state. + * Reads through the mocked module observe the new values immediately. + * + * @example + * ```ts + * beforeEach(() => { + * setEnvFlags({ isBillingEnabled: true, isHosted: true }) + * }) + * afterAll(resetEnvFlagsMock) + * ``` + */ +export function setEnvFlags(overrides: Partial): void { + Object.assign(envFlagsState, overrides) +} + +/** + * Restores the shared env-flags mock to its defaults: default flag state and + * default implementations for the function exports. + */ +export function resetEnvFlagsMock(): void { + Object.assign(envFlagsState, defaultEnvFlagsState) + envFlagsMockFns.getAllowedIntegrationsFromEnv.mockReset().mockImplementation(() => null) + envFlagsMockFns.getPreviewBlocksFromEnv.mockReset().mockImplementation(() => []) + envFlagsMockFns.getBlacklistedProvidersFromEnv.mockReset().mockImplementation(() => []) + envFlagsMockFns.getAllowedMcpDomainsFromEnv.mockReset().mockImplementation(() => null) + envFlagsMockFns.getCostMultiplier.mockReset().mockImplementation(() => 1) +} + +/** + * Builds a live get/set accessor pair for one flag so both reads through the + * mocked module and direct assignments (`envFlagsMock.isHosted = true`) + * delegate to the shared mutable state. + */ +function flagAccessor(key: keyof EnvFlagsMockState): PropertyDescriptor { + return { + enumerable: true, + get: () => envFlagsState[key], + set: (value: boolean) => { + envFlagsState[key] = value + }, + } +} + +/** + * Complete, stateful mock module for `@/lib/core/config/env-flags`, installed + * globally in `apps/sim/vitest.setup.ts`. Every export of the real module is + * present. Flag reads are live: override via {@link setEnvFlags} (or direct + * property assignment) and restore with {@link resetEnvFlagsMock}. + */ +export const envFlagsMock: EnvFlagsMockState & typeof envFlagsMockFns = Object.defineProperties( + { ...envFlagsMockFns } as EnvFlagsMockState & typeof envFlagsMockFns, + Object.fromEntries( + (Object.keys(defaultEnvFlagsState) as (keyof EnvFlagsMockState)[]).map((key) => [ + key, + flagAccessor(key), + ]) + ) +) diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index e12a09a6b48..e3f6c2d6f62 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -53,7 +53,13 @@ export { encryptionMock, encryptionMockFns } from './encryption.mock' // Env mocks export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' // Env flag mocks -export { envFlagsMock } from './env-flags.mock' +export { + type EnvFlagsMockState, + envFlagsMock, + envFlagsMockFns, + resetEnvFlagsMock, + setEnvFlags, +} from './env-flags.mock' // Execution preprocessing mocks (for @/lib/execution/preprocessing) export { executionPreprocessingMock, From dda602a367dae395c851473f63364ceeecb26e10 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 19:04:17 -0700 Subject: [PATCH 23/24] =?UTF-8?q?improvement(tests+ci):=20phase=203=20?= =?UTF-8?q?=E2=80=94=20shared-mock=20convergence=20completion=20and=20CI?= =?UTF-8?q?=20runner-minute=20cuts=20(#5875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): cut redundant runner minutes — dedup promotion-PR test runs, companion-pr-check concurrency, right-size trivial jobs - ci.yml: new dedup-promotion gate skips the pull_request test-build on staging/main-headed promotion PRs only when the merge tree provably equals the head tree (empty base delta over the merge base) AND the push-event run at the same sha passed its test jobs (polled). Fail-open on any error/ timeout/failure, job-level skip only (skipped job reports Success); verified no required status checks are configured on main/staging rulesets. Measured 39 duplicate PR runs / 5.15 days (~227/mo) at ~7.1 min each on 8vcpu (~57 vcpu-min), probe costs ~9 vcpu-min worst case on 2vcpu. - companion-pr-check.yml: per-PR concurrency group with cancel-in-progress so superseded synchronize/edit runs stop; no paths filter (check depends on PR body + cross-repo state, not changed files). - detect-version and check-docs-changes: 4vcpu -> 2vcpu Blacksmith runners (pure shell / depth-2 checkout + path filter only). * improvement(testing): complete stateful shared mocks for env, urls, redis-config, environment-utils Shared mock infrastructure for vitest isolate:false convergence: - packages/testing/src/mocks/env.mock.ts: stateful envMock (live env proxy, setEnv/resetEnvMock, process.env fallback) - packages/testing/src/mocks/urls.mock.ts: complete urlsMock with real-behavior default impls + resetUrlsMock - packages/testing/src/mocks/redis-config.mock.ts: adds getRedisConnectionDefaults + resetRedisConfigMock - packages/testing/src/mocks/environment-utils.mock.ts: new environmentUtilsMock + fns + reset - contract tests: env.mock.test.ts, urls.mock.test.ts, redis-config.mock.test.ts, environment-utils.mock.test.ts - packages/testing/src/mocks/index.ts: barrel exports - apps/sim/vitest.setup.ts: global installs for env, urls, redis, environment/utils - real-module tests unmocked: lib/core/config/env.test.ts, lib/core/config/redis.test.ts, lib/core/utils/urls.test.ts, tools/index.test.ts (urls) - stubEnv/process.env fallout migrated to setEnv: lib/webhooks/providers/{revenuecat,rootly,instantly}.test.ts, app/api/auth/oauth2/authorize/route.test.ts * improvement(tests): drop redundant local mocks in executor/tools/providers and misc dirs (shared-worker readiness) * improvement(tests): drop redundant local mocks in app routes (shared-worker readiness) * improvement(tests): drop redundant local mocks in lib (shared-worker readiness) * fix(ci+testing): live base-tip recheck before dedup skip; prod-aware urls mock fallbacks - the dedup gate re-verifies merge-tree equivalence against the LIVE base tip at decision time, closing the window where the base branch gains real commits during the poll (frozen BASE_SHA check alone was stale) - the urls mock's getBaseUrl protocol prefix and getBaseDomain parse fallback now follow the shared isProd flag, mirroring the real module * fix(ci+testing): fail-closed nojobs fallback in dedup gate; TLS-aware redis defaults mock - the dedup gate no longer infers coverage from overall run conclusion when no 'Test and Build /' jobs match — a renamed or skipped test job now runs the tests instead of skipping them - the shared getRedisConnectionDefaults mock mirrors the real TLS resolution (rediss:// to a raw IP requires REDIS_TLS_SERVERNAME and yields tls.servername) * fix(ci): keep polling while nested test jobs have not appeared yet An in-progress push run lists its reusable-workflow jobs only after the caller starts; nojobs is now terminal (fail closed) only once the run has completed without them. --- .github/workflows/ci.yml | 136 ++++++++++++- .github/workflows/companion-pr-check.yml | 11 + .../billing/credit-usage/page.test.ts | 27 +-- .../app/api/audit-logs/export/route.test.ts | 11 +- .../api/auth/forget-password/route.test.ts | 12 +- .../api/auth/oauth/connections/route.test.ts | 4 - .../api/auth/oauth/disconnect/route.test.ts | 3 - apps/sim/app/api/auth/oauth/utils.test.ts | 4 +- .../api/auth/oauth2/authorize/route.test.ts | 18 +- .../app/api/auth/sso/register/route.test.ts | 7 +- .../app/api/billing/invoices/route.test.ts | 14 +- apps/sim/app/api/billing/route.test.ts | 13 +- .../app/api/billing/switch-plan/route.test.ts | 11 +- .../app/api/blocks/visibility/route.test.ts | 16 +- .../api/chat/[identifier]/otp/route.test.ts | 25 +-- .../app/api/chat/manage/[id]/route.test.ts | 13 +- apps/sim/app/api/chat/route.test.ts | 20 +- apps/sim/app/api/chat/utils.test.ts | 13 +- .../app/api/copilot/api-keys/route.test.ts | 11 +- .../app/api/copilot/chat/delete/route.test.ts | 4 +- .../app/api/copilot/chat/stop/route.test.ts | 4 +- .../copilot/checkpoints/revert/route.test.ts | 11 +- .../app/api/copilot/feedback/route.test.ts | 10 +- .../cron/renew-subscriptions/route.test.ts | 4 - .../custom-blocks/[id]/usages/route.test.ts | 24 +-- apps/sim/app/api/files/authorization.test.ts | 4 +- apps/sim/app/api/files/delete/route.test.ts | 25 --- apps/sim/app/api/files/upload/route.test.ts | 25 --- apps/sim/app/api/folders/[id]/route.test.ts | 2 - .../sim/app/api/folders/reorder/route.test.ts | 7 - apps/sim/app/api/folders/route.test.ts | 5 - .../api/guardrails/mask-batch/route.test.ts | 9 +- .../api/invitations/[id]/accept/route.test.ts | 11 +- apps/sim/app/api/jobs/[jobId]/route.test.ts | 14 +- .../[connectorId]/documents/route.test.ts | 2 - .../connectors/[connectorId]/route.test.ts | 2 - .../[connectorId]/sync/route.test.ts | 2 - .../knowledge/[id]/connectors/route.test.ts | 2 - .../[id]/documents/[documentId]/route.test.ts | 3 - .../knowledge/[id]/documents/route.test.ts | 3 - .../[id]/documents/upsert/route.test.ts | 4 - apps/sim/app/api/knowledge/[id]/route.test.ts | 3 - apps/sim/app/api/knowledge/route.test.ts | 3 - .../app/api/knowledge/search/route.test.ts | 22 +- apps/sim/app/api/knowledge/utils.test.ts | 3 - .../app/api/mcp/oauth/callback/route.test.ts | 9 - .../sim/app/api/mcp/oauth/start/route.test.ts | 11 - .../api/mcp/serve/[serverId]/route.test.ts | 25 +-- .../mcp/servers/[id]/refresh/route.test.ts | 4 +- apps/sim/app/api/mcp/servers/route.test.ts | 4 +- .../chats/[chatId]/fork/route.test.ts | 13 +- .../[id]/invitations/route.test.ts | 15 +- .../[memberId]/usage-limit/route.test.ts | 10 +- .../[id]/permission-groups/utils.test.ts | 4 +- .../organizations/[id]/roster/route.test.ts | 16 +- .../[id]/session-policy/route.test.ts | 13 +- apps/sim/app/api/organizations/route.test.ts | 15 +- .../providers/baseten/models/route.test.ts | 22 +- .../ollama-cloud/models/route.test.ts | 10 +- .../providers/together/models/route.test.ts | 42 ++-- apps/sim/app/api/resume/poll/route.test.ts | 34 +--- apps/sim/app/api/schedules/[id]/route.test.ts | 6 - apps/sim/app/api/schedules/route.test.ts | 7 - apps/sim/app/api/speech/token/route.test.ts | 16 +- apps/sim/app/api/tools/custom/route.test.ts | 3 - apps/sim/app/api/usage/route.test.ts | 17 +- .../subscription/[id]/transfer/route.test.ts | 5 - .../app/api/v1/audit-logs/[id]/route.test.ts | 4 +- apps/sim/app/api/v1/audit-logs/auth.test.ts | 10 +- apps/sim/app/api/v1/audit-logs/query.test.ts | 5 +- .../webhooks/poll/[provider]/route.test.ts | 4 +- apps/sim/app/api/webhooks/slack/route.test.ts | 12 +- .../sim/app/api/webhooks/tiktok/route.test.ts | 21 +- .../workflows/[id]/chat/status/route.test.ts | 8 - .../[id]/execute/route.async.test.ts | 17 +- .../[executionId]/stream/route.test.ts | 22 +- .../app/api/workflows/[id]/log/route.test.ts | 3 +- .../workflows/[id]/references/route.test.ts | 16 +- apps/sim/app/api/workflows/[id]/route.test.ts | 6 - apps/sim/app/api/workflows/middleware.test.ts | 2 - apps/sim/app/api/workflows/route.test.ts | 3 - .../api/workspace-events/poll/route.test.ts | 4 +- .../workspaces/[id]/api-keys/route.test.ts | 32 ++- .../workspaces/[id]/byok-keys/route.test.ts | 27 +-- .../[id]/credit-availability/route.test.ts | 17 +- .../workspaces/[id]/environment/route.test.ts | 33 +-- .../[id]/files/inline/route.test.ts | 10 +- .../fork/excluded-workflows/route.test.ts | 17 +- .../[id]/fork/lineage/route.test.ts | 11 +- .../[id]/host-context/route.test.ts | 12 +- .../workspaces/[id]/usage-gate/route.test.ts | 17 +- .../api/workspaces/invitations/route.test.ts | 3 - .../mention/mention-chip.test.tsx | 3 - .../workspace/[workspaceId]/layout.test.tsx | 10 +- .../async-preprocessing-correlation.test.ts | 7 - apps/sim/background/cleanup-logs.test.ts | 4 +- .../background/cleanup-soft-deletes.test.ts | 2 - apps/sim/background/webhook-execution.test.ts | 2 - .../utils/permission-check.test.ts | 19 +- .../lib/copy/cleanup-failed.test.ts | 4 +- .../lib/copy/copy-files.test.ts | 2 - .../lib/copy/copy-resources.test.ts | 2 - .../workspace-forking/lib/create-fork.test.ts | 3 +- .../lib/lineage/unlink.test.ts | 1 - .../handlers/agent/agent-handler.test.ts | 3 - .../handlers/agent/skills-resolver.test.ts | 11 +- .../handlers/pi/cloud-review-backend.test.ts | 14 +- .../workflow/workflow-handler.test.ts | 76 +++---- .../lib/admin/dashboard-credit-grant.test.ts | 3 +- .../lib/admin/dashboard-organizations.test.ts | 3 +- .../lib/admin/external-collaborators.test.ts | 4 +- apps/sim/lib/api-key/byok.test.ts | 4 +- apps/sim/lib/api-key/crypto.test.ts | 15 +- apps/sim/lib/api-key/service.test.ts | 4 +- apps/sim/lib/auth/access-control.test.ts | 48 ++--- apps/sim/lib/auth/ban.test.ts | 37 ++-- apps/sim/lib/auth/hybrid.test.ts | 33 ++- .../sim/lib/auth/stripe-adapter-guard.test.ts | 5 +- apps/sim/lib/billing/authorization.test.ts | 3 +- .../calculations/usage-monitor.test.ts | 10 +- .../usage-reservation-env.test.ts | 46 ++--- .../calculations/usage-reservation.test.ts | 5 +- .../lib/billing/cleanup-dispatcher.test.ts | 9 +- .../billing/core/billing-attribution.test.ts | 10 +- apps/sim/lib/billing/core/billing.test.ts | 2 - .../billing/core/limit-notifications.test.ts | 17 +- apps/sim/lib/billing/core/plan.test.ts | 4 +- .../sim/lib/billing/core/subscription.test.ts | 6 +- apps/sim/lib/billing/core/usage-log.test.ts | 10 +- apps/sim/lib/billing/core/usage.test.ts | 4 +- .../lib/billing/credits/daily-refresh.test.ts | 4 +- .../billing/enterprise-provisioning.test.ts | 10 +- apps/sim/lib/billing/organization.test.ts | 2 - .../organizations/create-organization.test.ts | 4 +- .../billing/organizations/lock-order.test.ts | 4 +- .../organizations/member-limits.test.ts | 4 +- .../pause-pro-for-coverage.test.ts | 3 +- .../organizations/provision-seat.test.ts | 4 +- .../billing/organizations/seat-drift.test.ts | 3 - .../lib/billing/organizations/seats.test.ts | 3 - apps/sim/lib/billing/storage/limits.test.ts | 15 +- apps/sim/lib/billing/storage/tracking.test.ts | 4 - .../sim/lib/billing/threshold-billing.test.ts | 15 +- .../validation/seat-management.test.ts | 10 +- .../enterprise-reconciliation-lease.test.ts | 5 +- .../sim/lib/billing/webhooks/invoices.test.ts | 8 - .../billing/webhooks/outbox-handlers.test.ts | 4 +- .../concurrency/__tests__/leader-lock.test.ts | 5 +- .../lib/copilot/async-runs/repository.test.ts | 5 +- apps/sim/lib/copilot/auth/permissions.test.ts | 15 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 22 +- .../lib/copilot/chat/messages-store.test.ts | 5 +- apps/sim/lib/copilot/chat/post.test.ts | 14 +- .../lib/copilot/chat/process-contents.test.ts | 4 +- .../lib/copilot/chat/stream-liveness.test.ts | 4 +- .../lib/copilot/chat/terminal-state.test.ts | 4 +- .../lib/copilot/request/lifecycle/run.test.ts | 17 +- .../copilot/request/lifecycle/start.test.ts | 4 +- .../lib/copilot/request/session/abort.test.ts | 3 +- .../copilot/request/session/buffer.test.ts | 4 +- .../request/session/explicit-abort.test.ts | 16 +- apps/sim/lib/copilot/server/agent-url.test.ts | 3 +- .../tools/handlers/materialize-file.test.ts | 2 - .../tools/handlers/upload-file-reader.test.ts | 4 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 33 +-- .../tools/handlers/workflow/mutations.test.ts | 32 +-- .../server/files/file-intent-store.test.ts | 5 +- .../server/knowledge/knowledge-base.test.ts | 13 +- .../tools/server/user/get-credentials.test.ts | 27 +-- .../user/set-environment-variables.test.ts | 32 ++- .../validation/selector-validator.test.ts | 4 +- apps/sim/lib/copilot/vfs/file-reader.test.ts | 2 - .../lib/core/config/block-visibility.test.ts | 25 +-- apps/sim/lib/core/config/env.test.ts | 4 +- apps/sim/lib/core/config/redis.test.ts | 1 + apps/sim/lib/core/idempotency/cleanup.test.ts | 3 +- apps/sim/lib/core/outbox/service.test.ts | 2 - .../rate-limiter/hosted-key/queue.test.ts | 4 +- .../core/rate-limiter/storage/factory.test.ts | 4 +- apps/sim/lib/core/utils.test.ts | 43 ++-- apps/sim/lib/core/utils/urls.test.ts | 1 + apps/sim/lib/credentials/environment.test.ts | 5 +- .../validators/trello.test.ts | 17 +- apps/sim/lib/data-drains/dispatcher.test.ts | 10 +- apps/sim/lib/data-drains/service.test.ts | 4 +- apps/sim/lib/execution/cancellation.test.ts | 3 +- apps/sim/lib/execution/e2b.test.ts | 11 +- apps/sim/lib/execution/event-buffer.test.ts | 14 +- apps/sim/lib/execution/isolated-vm.test.ts | 8 +- apps/sim/lib/execution/preprocessing.test.ts | 23 ++- .../preprocessing.webhook-correlation.test.ts | 24 ++- apps/sim/lib/guardrails/mask-client.test.ts | 11 +- apps/sim/lib/invitations/core.test.ts | 2 - apps/sim/lib/invitations/direct-grant.test.ts | 9 +- .../invitations/workspace-invitations.test.ts | 9 +- .../lib/knowledge/connectors/queue.test.ts | 3 +- .../knowledge/connectors/sync-engine.test.ts | 4 +- .../knowledge/documents/lock-order.test.ts | 4 +- .../documents/processing-queue.test.ts | 2 - .../documents/storage-billing.test.ts | 2 - apps/sim/lib/knowledge/service.test.ts | 9 +- .../logs/execution/progress-markers.test.ts | 13 +- apps/sim/lib/mcp/connection-pool.test.ts | 4 - apps/sim/lib/mcp/oauth/revoke.test.ts | 3 +- apps/sim/lib/mcp/oauth/storage.test.ts | 21 +- .../orchestration/server-lifecycle.test.ts | 4 - .../workflow-mcp-lifecycle.test.ts | 1 - apps/sim/lib/mcp/service-pool.test.ts | 4 +- apps/sim/lib/mcp/service.test.ts | 4 +- .../messaging/email/providers/gmail.test.ts | 32 ++- .../lib/messaging/email/unsubscribe.test.ts | 13 +- apps/sim/lib/messaging/email/utils.test.ts | 16 +- .../oauth/__tests__/terminal-errors.test.ts | 5 +- apps/sim/lib/oauth/oauth.test.ts | 12 +- .../lib/organizations/settings-access.test.ts | 5 +- .../table/__tests__/find-row-matches.test.ts | 4 +- .../lib/table/__tests__/lock-order.test.ts | 4 +- .../service-filter-threading.test.ts | 4 +- .../lib/table/__tests__/update-row.test.ts | 4 +- apps/sim/lib/table/cell-write.test.ts | 4 +- .../lib/table/dispatch-concurrency.test.ts | 49 ++--- apps/sim/lib/table/events.test.ts | 13 +- apps/sim/lib/table/rows/executions.test.ts | 5 +- apps/sim/lib/table/snapshot-cache.test.ts | 3 +- .../workspace/track-chat-upload.test.ts | 4 +- .../workspace-file-manager-errors.test.ts | 5 +- .../workspace-file-storage-accounting.test.ts | 4 +- .../workspace-file-storage-billing.test.ts | 4 +- .../utils/user-file-base64.server.test.ts | 12 +- apps/sim/lib/webhooks/env-resolver.test.ts | 11 +- .../lib/webhooks/pending-verification.test.ts | 7 +- apps/sim/lib/webhooks/processor.test.ts | 4 - .../webhooks/provider-subscriptions.test.ts | 15 +- .../lib/webhooks/providers/instantly.test.ts | 5 +- .../lib/webhooks/providers/revenuecat.test.ts | 5 +- .../sim/lib/webhooks/providers/rootly.test.ts | 5 +- .../lib/workflows/deployment-outbox.test.ts | 13 -- .../workflows/executor/execution-core.test.ts | 14 +- .../executor/execution-id-claim.test.ts | 4 +- .../human-in-the-loop-manager.test.ts | 4 +- apps/sim/lib/workflows/lifecycle.test.ts | 18 +- .../workflows/orchestration/deploy.test.ts | 9 - .../sanitization/json-sanitizer.test.ts | 11 +- apps/sim/lib/workflows/utils.test.ts | 20 +- apps/sim/lib/workspace-events/emitter.test.ts | 15 +- .../lib/workspace-events/no-activity.test.ts | 4 +- apps/sim/lib/workspace-events/rules.test.ts | 5 +- apps/sim/lib/workspaces/admin-move.test.ts | 6 +- apps/sim/lib/workspaces/lifecycle.test.ts | 3 - .../organization-workspaces.test.ts | 10 +- apps/sim/lib/workspaces/policy.test.ts | 10 +- apps/sim/lib/workspaces/utils.test.ts | 4 +- .../providers/azure-anthropic/index.test.ts | 16 +- apps/sim/providers/azure-openai/index.test.ts | 22 +- apps/sim/providers/litellm/index.test.ts | 11 +- apps/sim/providers/ollama/index.test.ts | 1 - apps/sim/providers/vllm/index.test.ts | 16 +- apps/sim/stores/workflow-diff/store.test.ts | 4 - apps/sim/tools/index.test.ts | 41 ++-- apps/sim/vitest.setup.ts | 8 + packages/testing/src/mocks/env.mock.test.ts | 65 ++++++ packages/testing/src/mocks/env.mock.ts | 184 +++++++++++++---- .../src/mocks/environment-utils.mock.test.ts | 44 ++++ .../src/mocks/environment-utils.mock.ts | 79 +++++++ packages/testing/src/mocks/index.ts | 26 ++- .../src/mocks/redis-config.mock.test.ts | 36 ++++ .../testing/src/mocks/redis-config.mock.ts | 71 ++++++- packages/testing/src/mocks/urls.mock.test.ts | 66 ++++++ packages/testing/src/mocks/urls.mock.ts | 192 +++++++++++++++++- 269 files changed, 1827 insertions(+), 1794 deletions(-) create mode 100644 packages/testing/src/mocks/env.mock.test.ts create mode 100644 packages/testing/src/mocks/environment-utils.mock.test.ts create mode 100644 packages/testing/src/mocks/environment-utils.mock.ts create mode 100644 packages/testing/src/mocks/redis-config.mock.test.ts create mode 100644 packages/testing/src/mocks/urls.mock.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa7a40ed9e..1f0dc4a2cbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,16 +34,144 @@ permissions: contents: read jobs: + # Promotion PRs (staging→main etc.) double-run the test suite: every commit + # on staging/main already gets a push-event run of the exact same test-build, + # and the pull_request "synchronize" run for the open release PR re-runs it on + # the same sha seconds later (~39 duplicate runs / 5 days measured Jul 2026). + # This gate skips the PR run's test-build ONLY when it can prove the identical + # work already passed elsewhere: + # 1. the PR base adds no file changes over the merge base with the head sha + # (compare head...base has an empty diff), so the merge result's tree is + # identical to the head tree the push run tested. Plain ancestry is not + # enough of a check here: main's merge-only ruleset leaves merge commits + # on main that staging lacks, so main...staging is permanently + # "diverged" — but those merge commits carry no tree delta. A real + # hotfix landed directly on the base makes the diff non-empty and we + # run tests here; + # 2. the push-event CI run at the same head sha finished its test jobs with + # conclusion success (polled, since push + PR runs start simultaneously). + # Fail-open by construction: any API error, timeout, missing run, or push-run + # failure leaves covered=false and the PR run tests normally, so the PR check + # is green only if tests passed either here or on the identical tree. Not a + # workflow-level filter on purpose — a job-level skip still reports a + # (successful) check context. NOTE: when test-build is skipped, its nested + # "Test and Build / ..." contexts are not created; verified 2026-07-22 that + # no required status checks are configured on main/staging (rulesets contain + # only pull_request/deletion/non_fast_forward). If required checks are ever + # added, require the caller "Test and Build" context, not the nested ones. + # dev is excluded: push runs on dev skip test-build, so dev-headed PRs have + # no push-run coverage to reuse. + dedup-promotion: + name: Dedup Promotion PR + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["main", "staging"]'), github.event.pull_request.head.ref) + permissions: + contents: read + actions: read + outputs: + covered: ${{ steps.probe.outputs.covered }} + steps: + - name: Probe for a passing push run at the same sha + id: probe + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + COVERED=false + + # (1) Merge-tree equivalence: compare head...base diffs the merge + # base against the base tip. An empty diff means the base contributes + # nothing beyond what head already contains (merge commits only), so + # the PR merge tree equals the head tree the push run tested. Any + # error yields "unknown" and we run the tests. + BASE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...${BASE_SHA}" --jq '.files | length' 2>/dev/null || echo unknown)" + if [ "$BASE_DELTA" != "0" ]; then + echo "Base tip changes ${BASE_DELTA} file(s) over the merge base; merge tree differs from head — running tests in this PR run." + echo "covered=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # (2) Poll the push-event CI run's test jobs (they start seconds after + # this run and take ~4 min). Any conclusion other than success, or + # deadline expiry, falls through to covered=false. + DEADLINE=$((SECONDS + 600)) + while [ "$SECONDS" -lt "$DEADLINE" ]; do + RUN_JSON="$(gh api "repos/${REPO}/actions/workflows/ci.yml/runs?event=push&head_sha=${HEAD_SHA}&per_page=5" 2>/dev/null || echo '')" + RUN_ID="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].id // empty' 2>/dev/null || echo '')" + if [ -n "$RUN_ID" ]; then + # Jobs from the reusable test-build workflow are prefixed + # "Test and Build /". If that name ever changes, fall back to the + # overall run conclusion (stricter, still correct). + STATE="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" --jq ' + [.jobs[] | select(.name | startswith("Test and Build /"))] as $t | + if ($t | length) == 0 then "nojobs" + elif all($t[]; .conclusion == "success") then "success" + elif any($t[]; .conclusion != null and .conclusion != "success") then "failed" + else "pending" end' 2>/dev/null || echo pending)" + if [ "$STATE" = "nojobs" ]; then + # Nested reusable-workflow jobs appear only after the caller + # starts, so an in-progress run with no "Test and Build /" + # jobs yet just needs another poll. Once the run has + # COMPLETED without them, fail closed: the job was renamed or + # tests were skipped — never infer coverage from the overall + # run conclusion. Update the prefix here on a rename. + RUN_STATUS="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].status // "unknown"' 2>/dev/null || echo unknown)" + if [ "$RUN_STATUS" = "completed" ]; then + echo "Push run ${RUN_ID} completed with no 'Test and Build /' jobs — running tests in this PR run (update the prefix if the job was renamed)." + break + fi + STATE="pending" + fi + if [ "$STATE" = "success" ]; then + # (3) Re-verify merge-tree equivalence against the LIVE base + # tip at decision time: the base branch may have gained real + # commits during the poll, in which case the frozen BASE_SHA + # check from step (1) is stale and the skip would be unsound. + # Any error yields "unknown" and we run the tests. + LIVE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...heads/${BASE_REF}" --jq '.files | length' 2>/dev/null || echo unknown)" + if [ "$LIVE_DELTA" = "0" ]; then + COVERED=true + echo "Push run ${RUN_ID} passed its test jobs for ${HEAD_SHA} and the live base tip still adds no file changes — skipping duplicate test-build." + else + echo "Base branch moved during the poll (live delta: ${LIVE_DELTA}) — running tests in this PR run." + fi + break + elif [ "$STATE" = "failed" ]; then + echo "Push run ${RUN_ID} did not pass (state: ${STATE}) — running tests in this PR run." + break + fi + fi + sleep 20 + done + + echo "covered=${COVERED}" >> "$GITHUB_OUTPUT" + test-build: name: Test and Build - if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request' + needs: [dedup-promotion] + # !cancelled(): dedup-promotion is skipped on every non-promotion event and + # a skipped need would otherwise skip this job too. covered != 'true' is + # fail-open — empty (skipped/failed probe) means run the tests. + if: >- + !cancelled() && + (github.ref != 'refs/heads/dev' || github.event_name == 'pull_request') && + needs.dedup-promotion.outputs.covered != 'true' uses: ./.github/workflows/test-build.yml secrets: inherit # Detect if this is a version release commit (e.g., "v0.5.24: ...") + # Smallest runner on purpose: a few seconds of pure shell over the commit + # message, no checkout and no install. detect-version: name: Detect Version - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') outputs: @@ -486,9 +614,11 @@ jobs: fi # Check if docs changed + # Smallest runner on purpose: a depth-2 checkout plus a path filter, no + # install and no build. check-docs-changes: name: Check Docs Changes - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && github.ref == 'refs/heads/main' outputs: diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 2d149210660..0cf31db934f 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -22,6 +22,17 @@ on: branches: [staging, main] workflow_dispatch: {} +# One live run per PR: a newer opened/edited/synchronize event supersedes the +# previous run's work entirely (the sticky comment/label upsert is idempotent +# and only the latest body matters), so cancel in-flight runs instead of +# letting them race the new one. workflow_dispatch bulk scans get a unique +# group via run_id and are never cancelled. No paths filter on purpose: the +# check reads the PR body and cross-repo PR state, not changed files, so a +# paths filter would both be semantically wrong and stop status refreshes. +concurrency: + group: companion-pr-check-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: pull-requests: write issues: write diff --git a/apps/sim/app/account/settings/billing/credit-usage/page.test.ts b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts index cfa256daba4..0c2781bf469 100644 --- a/apps/sim/app/account/settings/billing/credit-usage/page.test.ts +++ b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts @@ -1,30 +1,21 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCreditUsageLoading, - mockGetPersonalSubscription, - mockGetSession, - mockIsEnterprise, - mockRedirect, -} = vi.hoisted(() => ({ - mockCreditUsageLoading: vi.fn(() => null), - mockGetPersonalSubscription: vi.fn(), - mockGetSession: vi.fn(), - mockIsEnterprise: vi.fn(), - mockRedirect: vi.fn(), -})) +const { mockCreditUsageLoading, mockGetPersonalSubscription, mockIsEnterprise, mockRedirect } = + vi.hoisted(() => ({ + mockCreditUsageLoading: vi.fn(() => null), + mockGetPersonalSubscription: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRedirect: vi.fn(), + })) vi.mock('next/navigation', () => ({ redirect: mockRedirect, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/plan', () => ({ getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, })) @@ -44,6 +35,8 @@ vi.mock('@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading', ( import AccountCreditUsagePage from '@/app/account/settings/billing/credit-usage/page' +const mockGetSession = authMockFns.mockGetSession + describe('AccountCreditUsagePage', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts index 712d8db9e81..367c04cb86f 100644 --- a/apps/sim/app/api/audit-logs/export/route.test.ts +++ b/apps/sim/app/api/audit-logs/export/route.test.ts @@ -1,18 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockValidateEnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, mockQueryAuditLogs, mockBuildFilterConditions, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockValidateEnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), @@ -20,11 +18,6 @@ const { mockBuildFilterConditions: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) @@ -38,6 +31,8 @@ vi.mock('@/app/api/v1/audit-logs/query', () => ({ import { GET } from '@/app/api/audit-logs/export/route' +const mockGetSession = authMockFns.mockGetSession + const ORG_ID = 'org-1' const MEMBER_IDS = ['admin-1'] const SCOPE_SENTINEL = { type: 'org-scope-sentinel' } diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index e7dc5612554..7b43e1fbc5a 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => { const logger = { @@ -22,9 +22,6 @@ const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => { } }) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'https://app.example.com'), -})) vi.mock('@/lib/auth', () => ({ auth: { api: { @@ -43,9 +40,14 @@ import { POST } from '@/app/api/auth/forget-password/route' describe('Forget Password API Route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.example.com' }) mockRequestPasswordReset.mockResolvedValue(undefined) }) + afterAll(() => { + resetEnvMock() + }) + afterEach(() => { vi.clearAllMocks() }) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index e5e5d742711..593079aa20c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -25,10 +25,6 @@ vi.mock('@sim/db', () => ({ eq: mockEq, })) -vi.mock('drizzle-orm', () => ({ - eq: mockEq, -})) - vi.mock('jose', () => ({ decodeJwt: mockDecodeJwt, })) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 97215a0d923..757ea76c9df 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -7,14 +7,11 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 43ea672f9d1..4a22a97b8fc 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/oauth/oauth', () => ({ @@ -12,8 +12,6 @@ vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 894e543c3b2..8b4abab40b0 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,8 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + dbChainMockFns, + resetDbChainMock, + resetEnvMock, + setEnv, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, @@ -16,8 +22,6 @@ const { mockGetCredentialActorContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/auth/auth', () => ({ auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, getSession: mockGetSession, @@ -70,10 +74,14 @@ function oauthCredentialActor(overrides: Record = {}) { } describe('OAuth2 authorize route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL + setEnv({ NEXT_PUBLIC_APP_URL: BASE_URL }) mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index e3539b969e5..67b4beb8b13 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -2,13 +2,14 @@ * @vitest-environment node */ import { - createEnvMock, createMockRequest, dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvMock, schemaMock, + setEnv, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -64,8 +65,6 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' })) - import { POST } from '@/app/api/auth/sso/register/route' const OIDC_BODY = { @@ -89,6 +88,7 @@ describe('POST /api/auth/sso/register', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ SSO_ENABLED: 'true' }) mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) @@ -98,6 +98,7 @@ describe('POST /api/auth/sso/register', () => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) it('rejects callers without an Enterprise plan', async () => { diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index 58a95c888ca..db1aa12a181 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -1,29 +1,23 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest, dbChainMockFns } from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({ mockGetStripeClient: vi.fn(), mockStripeInvoicesList: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/stripe-client', () => ({ getStripeClient: mockGetStripeClient, })) import { GET } from '@/app/api/billing/invoices/route' +const mockGetSession = authMockFns.mockGetSession + function makeInvoice(overrides: Record = {}) { return { id: `in_${generateShortId()}`, diff --git a/apps/sim/app/api/billing/route.test.ts b/apps/sim/app/api/billing/route.test.ts index ee339be12c8..68d52a9c13e 100644 --- a/apps/sim/app/api/billing/route.test.ts +++ b/apps/sim/app/api/billing/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -9,24 +9,15 @@ const { mockGetOrganizationBillingData, mockGetOrganizationSubscription, mockGetPersonalBillingSummary, - mockGetSession, mockResolveBillingInterval, } = vi.hoisted(() => ({ mockGetCreditBalanceForEntity: vi.fn(), mockGetOrganizationBillingData: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetPersonalBillingSummary: vi.fn(), - mockGetSession: vi.fn(), mockResolveBillingInterval: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, getPersonalBillingSummary: mockGetPersonalBillingSummary, @@ -46,6 +37,8 @@ vi.mock('@/lib/billing/credits/balance', () => ({ import { GET } from '@/app/api/billing/route' +const mockGetSession = authMockFns.mockGetSession + const PERSONAL_SUMMARY = { type: 'individual', plan: 'pro_6000', diff --git a/apps/sim/app/api/billing/switch-plan/route.test.ts b/apps/sim/app/api/billing/switch-plan/route.test.ts index 377907a9f8e..6b1b4d7a814 100644 --- a/apps/sim/app/api/billing/switch-plan/route.test.ts +++ b/apps/sim/app/api/billing/switch-plan/route.test.ts @@ -1,28 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { authMockFns, createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCanManageWorkspaceBilling, mockGetEffectiveBillingStatus, mockGetOrganizationSubscription, - mockGetSession, mockGetWorkspaceHostContextForViewer, } = vi.hoisted(() => ({ mockCanManageWorkspaceBilling: vi.fn(), mockGetEffectiveBillingStatus: vi.fn(), mockGetOrganizationSubscription: vi.fn(), - mockGetSession: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/access', () => ({ getEffectiveBillingStatus: mockGetEffectiveBillingStatus, })) @@ -58,6 +51,8 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { POST } from '@/app/api/billing/switch-plan/route' +const mockGetSession = authMockFns.mockGetSession + beforeAll(() => { setEnvFlags({ isBillingEnabled: true }) }) diff --git a/apps/sim/app/api/blocks/visibility/route.test.ts b/apps/sim/app/api/blocks/visibility/route.test.ts index 1a59df82aa5..3d14370168a 100644 --- a/apps/sim/app/api/blocks/visibility/route.test.ts +++ b/apps/sim/app/api/blocks/visibility/route.test.ts @@ -1,21 +1,17 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } = vi.hoisted( + () => ({ mockCheckWorkspaceAccess: vi.fn(), mockIsPlatformAdmin: vi.fn(), mockGetBlockVisibility: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) + }) +) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, @@ -31,6 +27,8 @@ vi.mock('@/lib/core/config/block-visibility', () => ({ import { GET } from '@/app/api/blocks/visibility/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' function request(workspaceId = WORKSPACE_ID) { diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index b67e0942c4c..1c60db08fa3 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -4,14 +4,15 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, + envMockFns, queueTableRows, - redisConfigMock, redisConfigMockFns, requestUtilsMockFns, resetDbChainMock, + resetEnvMock, schemaMock, + setEnv, workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' @@ -30,7 +31,6 @@ const { mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, - mockGetEnv, } = vi.hoisted(() => { const mockRedisSet = vi.fn() const mockRedisGet = vi.fn() @@ -49,7 +49,6 @@ const { const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() - const mockGetEnv = vi.fn() return { mockRedisSet, @@ -63,18 +62,14 @@ const { mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, - mockGetEnv, } }) const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient +const mockGetEnv = envMockFns.getEnv const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, })) @@ -115,16 +110,6 @@ vi.mock('@/app/api/chat/utils', () => ({ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) -vi.mock('@/lib/core/config/env', () => ({ - env: { - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - NODE_ENV: 'test', - }, - getEnv: mockGetEnv, - isTruthy: vi.fn().mockReturnValue(false), - isFalsy: vi.fn().mockReturnValue(true), -})) - vi.mock('zod', () => { class ZodError extends Error { errors: Array<{ message: string }> @@ -226,6 +211,7 @@ describe('Chat OTP API Route', () => { mockZodParse.mockImplementation((data: unknown) => data) + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000', NODE_ENV: 'test' }) mockGetEnv.mockReturnValue('http://localhost:3000') }) @@ -235,6 +221,7 @@ describe('Chat OTP API Route', () => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) describe('POST - Store OTP (Redis path)', () => { diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index f883fdaf6c3..bedfbece4a6 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -6,12 +6,13 @@ import { auditMock, authMockFns, - dbChainMock, dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock, resetEnvFlagsMock, + resetEnvMock, + setEnv, setEnvFlags, workflowsApiUtilsMock, workflowsApiUtilsMockFns, @@ -39,12 +40,8 @@ const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged vi.mock('@sim/audit', () => auditMock) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) -vi.mock('@/lib/core/utils/urls', () => ({ - getEmailDomain: vi.fn().mockReturnValue('localhost:3000'), -})) vi.mock('@/app/api/chat/utils', () => ({ checkChatAccess: mockCheckChatAccess, })) @@ -65,9 +62,13 @@ import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permiss beforeAll(() => { setEnvFlags({ isDev: true }) + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) }) -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('Chat Edit API Route', () => { beforeEach(() => { diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index d85f62bd2b4..ab4ebded2cf 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -5,16 +5,16 @@ */ import { authMockFns, - createEnvMock, - dbChainMock, dbChainMockFns, + resetEnvMock, + setEnv, workflowsApiUtilsMock, workflowsApiUtilsMockFns, workflowsOrchestrationMock, workflowsOrchestrationMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckWorkflowAccessForChatCreation: vi.fn(), @@ -25,8 +25,6 @@ const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResp const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockPerformChatDeploy = workflowsOrchestrationMockFns.mockPerformChatDeploy -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/app/api/chat/utils', () => ({ @@ -45,19 +43,17 @@ vi.mock('@/ee/access-control/utils/permission-check', () => { vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@/lib/core/config/env', () => - createEnvMock({ - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }) -) - import { GET, POST } from '@/app/api/chat/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat API Route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() + setEnv({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockCreateSuccessResponse.mockImplementation((data) => { return new Response(JSON.stringify(data), { diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 4e457c1181d..6c41eeb21cc 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ import { - dbChainMock, + authMockFns, encryptionMock, encryptionMockFns, loggingSessionMock, @@ -19,7 +19,6 @@ const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed, - mockGetSession, mockCheckRateLimitDirect, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), @@ -27,23 +26,15 @@ const { mockValidateAuthToken: vi.fn().mockReturnValue(false), mockSetDeploymentAuthCookie: vi.fn(), mockIsEmailAllowed: vi.fn(), - mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = mockCheckRateLimitDirect }, })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - const mockDecryptSecret = encryptionMockFns.mockDecryptSecret vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) @@ -75,6 +66,8 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { decryptSecret } from '@/lib/core/security/encryption' import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' +const mockGetSession = authMockFns.mockGetSession + describe('Chat API Utils', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index e6c4b6cab60..d5bb8b49a1f 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -3,9 +3,9 @@ * * @vitest-environment node */ -import { authMockFns, createEnvMock } from '@sim/testing' +import { authMockFns, resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetch: vi.fn(), @@ -23,8 +23,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipBaseURL: mockGetMothershipBaseURL, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) - import { DELETE, GET } from '@/app/api/copilot/api-keys/route' // `fetchGo` reads `response.status` and `response.headers.get('content-length')` @@ -46,10 +44,15 @@ function buildMockResponse(init: { describe('Copilot API Keys API Route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ COPILOT_API_KEY: 'test-api-key' }) mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com') global.fetch = mockFetch }) + afterAll(() => { + resetEnvMock() + }) + describe('GET', () => { it('should return 401 when user is not authenticated', async () => { authMockFns.mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index df25c952463..6783fdbd265 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, dbChainMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,8 +19,6 @@ const { mockGetAccessibleCopilotChatAuth: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth, diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index e734cd9cde4..7c35ca8d355 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -1,12 +1,10 @@ /** * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ mockAppendCopilotChatMessages: vi.fn(), mockPublishStatusChanged: vi.fn(), diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 4918057f055..785718e9ba2 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -8,7 +8,9 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvMock, schemaMock, + setEnv, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' @@ -19,13 +21,6 @@ const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ mockGetAccessibleCopilotChat: vi.fn(), })) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'http://localhost:3000'), - getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'), - getBaseDomain: vi.fn(() => 'localhost:3000'), - getEmailDomain: vi.fn(() => 'localhost:3000'), -})) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ @@ -39,6 +34,7 @@ describe('Copilot Checkpoints Revert API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) authMockFns.mockGetSession.mockResolvedValue(null) @@ -79,6 +75,7 @@ describe('Copilot Checkpoints Revert API Route', () => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) /** Helper to set authenticated state */ diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index b1121ee4b8c..910bbed28a2 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -3,18 +3,10 @@ * * @vitest-environment node */ -import { - copilotHttpMock, - copilotHttpMockFns, - dbChainMock, - dbChainMockFns, - resetDbChainMock, -} from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) import { GET, POST } from '@/app/api/copilot/feedback/route' diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts index 1340ec486d7..6bdd19602ac 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts @@ -6,9 +6,7 @@ import { authOAuthUtilsMock, createMockRequest, - dbChainMock, dbChainMockFns, - redisConfigMock, redisConfigMockFns, resetDbChainMock, } from '@sim/testing' @@ -23,8 +21,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) import { GET } from './route' diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts index 836f7960969..458e5f220b7 100644 --- a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts @@ -1,22 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockHasWorkspaceAdminAccess: vi.fn(), - mockOperations: { - getCustomBlockManageContext: vi.fn(), - getCustomBlockUsageCounts: vi.fn(), - }, - })) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, +const { mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = vi.hoisted(() => ({ + mockIsFeatureEnabled: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockOperations: { + getCustomBlockManageContext: vi.fn(), + getCustomBlockUsageCounts: vi.fn(), + }, })) vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -31,6 +25,8 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations) import { GET } from '@/app/api/custom-blocks/[id]/usages/route' +const mockGetSession = authMockFns.mockGetSession + const MANAGE_CONTEXT = { organizationId: 'org-1', sourceWorkspaceId: 'ws-1', diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index e894bb387a0..8cce46882fc 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -9,7 +9,7 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted( @@ -20,8 +20,6 @@ const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetad }) ) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/uploads', () => ({ getFileMetadata: mockGetFileMetadata, })) diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index d843b573651..7dc868d0767 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -23,31 +23,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), - lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), - gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), - asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), - like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), - ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), - count: vi.fn((field: unknown) => ({ field, type: 'count' })), - sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), - avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), - min: vi.fn((field: unknown) => ({ field, type: 'min' })), - max: vi.fn((field: unknown) => ({ field, type: 'max' })), - sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), -})) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-uuid'), generateShortId: vi.fn(() => 'mock-short-id'), diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 357f5014b4a..034efb5eb70 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -48,31 +48,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), - lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), - gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), - asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), - like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), - ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), - count: vi.fn((field: unknown) => ({ field, type: 'count' })), - sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), - avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), - min: vi.fn((field: unknown) => ({ field, type: 'min' })), - max: vi.fn((field: unknown) => ({ field, type: 'max' })), - sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), -})) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-uuid'), generateShortId: vi.fn(() => 'mock-short-id'), diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index bbe7dfeba63..aed839f8743 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -7,7 +7,6 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, type MockUser, permissionsMock, @@ -49,7 +48,6 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 61b56e6fa50..c396bd5e46d 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -4,7 +4,6 @@ * @vitest-environment node */ import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger } = vi.hoisted(() => ({ @@ -21,12 +20,6 @@ const { mockLogger } = vi.hoisted(() => ({ const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('drizzle-orm', () => drizzleOrmMock) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), - getRequestContext: () => undefined, -})) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) import { db } from '@sim/db' diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index f7eb512da68..8c0210e12b9 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -11,7 +11,6 @@ import { permissionsMockFns, workflowAuthzMockFns, } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger } = vi.hoisted(() => { @@ -32,10 +31,6 @@ const { mockLogger } = vi.hoisted(() => { const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions vi.mock('@sim/audit', () => auditMock) -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), diff --git a/apps/sim/app/api/guardrails/mask-batch/route.test.ts b/apps/sim/app/api/guardrails/mask-batch/route.test.ts index 976ec1ec80b..6a37e3b9d1b 100644 --- a/apps/sim/app/api/guardrails/mask-batch/route.test.ts +++ b/apps/sim/app/api/guardrails/mask-batch/route.test.ts @@ -1,17 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckInternalAuth, mockMaskPIIBatch } = vi.hoisted(() => ({ - mockCheckInternalAuth: vi.fn(), +const { mockMaskPIIBatch } = vi.hoisted(() => ({ mockMaskPIIBatch: vi.fn(), })) -vi.mock('@/lib/auth/hybrid', () => ({ - checkInternalAuth: mockCheckInternalAuth, -})) +const mockCheckInternalAuth = hybridAuthMockFns.mockCheckInternalAuth vi.mock('@/lib/guardrails/validate_pii', () => ({ maskPIIBatch: mockMaskPIIBatch, diff --git a/apps/sim/app/api/invitations/[id]/accept/route.test.ts b/apps/sim/app/api/invitations/[id]/accept/route.test.ts index dd6f3833f2b..951b769389d 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.test.ts @@ -1,16 +1,11 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAcceptInvitation, mockGetSession } = vi.hoisted(() => ({ +const { mockAcceptInvitation } = vi.hoisted(() => ({ mockAcceptInvitation: vi.fn(), - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, })) vi.mock('@/lib/invitations/core', () => ({ @@ -19,6 +14,8 @@ vi.mock('@/lib/invitations/core', () => ({ import { POST } from '@/app/api/invitations/[id]/accept/route' +const mockGetSession = authMockFns.mockGetSession + function createInvitationRequest() { return createMockRequest( 'POST', diff --git a/apps/sim/app/api/jobs/[jobId]/route.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index 189e03b1052..5fc7de85435 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -1,13 +1,17 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetJobQueue, mockAuthorizeWorkflow, mockGetJob } = vi.hoisted(() => ({ +const { mockGetJobQueue, mockGetJob } = vi.hoisted(() => ({ mockGetJobQueue: vi.fn(), - mockAuthorizeWorkflow: vi.fn(), mockGetJob: vi.fn(), })) @@ -15,9 +19,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, -})) +const mockAuthorizeWorkflow = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts index bcbf905bb27..b31ec4e5ea1 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts @@ -4,7 +4,6 @@ import { auditMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, @@ -17,7 +16,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 4784c21943a..bf347078d73 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -5,7 +5,6 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, @@ -24,7 +23,6 @@ const { mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) vi.mock('@/connectors/registry.server', () => ({ diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index 54ef8f3fc4c..c79c85df58a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -4,7 +4,6 @@ import { auditMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, @@ -21,7 +20,6 @@ const { mockDispatchSync, mockResolveBillingAttribution } = vi.hoisted(() => ({ const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/billing/core/billing-attribution', () => ({ requireBillingAttributionHeader: vi.fn(), diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts index d1f9f852ae4..6087572fb40 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts @@ -5,7 +5,6 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, @@ -32,7 +31,6 @@ const { const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index a512d203d43..84bdd9736e5 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -7,14 +7,11 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, knowledgeApiUtilsMock, resetDbChainMock, } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/knowledge/documents/service', () => ({ diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 26ec37a6786..971c4a8f28b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -7,14 +7,11 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, knowledgeApiUtilsMock, resetDbChainMock, } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/knowledge/documents/service', () => ({ diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts index e48ef65e164..ddd9c80cb7f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -6,16 +6,12 @@ import { auditMock, createMockRequest, - dbChainMock, - hybridAuthMock, hybridAuthMockFns, knowledgeApiUtilsMock, resetDbChainMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index c146ee9a13c..882a1df6f0d 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -7,14 +7,11 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, knowledgeApiUtilsMock, resetDbChainMock, } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/knowledge/service', async (importOriginal) => { diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 499efa0d812..3c8f8083b79 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -7,7 +7,6 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, permissionsMock, permissionsMockFns, @@ -15,8 +14,6 @@ import { } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index b389990c744..88e379dafe7 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -6,14 +6,14 @@ * @vitest-environment node */ import { - createEnvMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, resetDbChainMock, + resetEnvMock, + setEnv, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' @@ -39,24 +39,8 @@ const { const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess -vi.mock('drizzle-orm', () => ({ - and: vi.fn().mockImplementation((...args) => ({ and: args })), - eq: vi.fn().mockImplementation((a, b) => ({ eq: [a, b] })), - inArray: vi.fn().mockImplementation((field, values) => ({ inArray: [field, values] })), - isNull: vi.fn().mockImplementation((arg) => ({ isNull: arg })), - sql: vi.fn().mockImplementation((strings, ...values) => ({ - sql: strings, - values, - as: vi.fn().mockReturnValue({ sql: strings, values, alias: 'mocked_alias' }), - })), -})) - -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' })) - vi.mock('@/lib/documents/utils', () => ({ retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()), })) @@ -132,6 +116,7 @@ describe('Knowledge Search API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ OPENAI_API_KEY: 'test-api-key' }) mockHandleTagOnlySearch.mockClear() mockHandleVectorOnlySearch.mockClear() @@ -173,6 +158,7 @@ describe('Knowledge Search API Route', () => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) describe('POST /api/knowledge/search', () => { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d61e8273ef6..f7a0297ef52 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -7,7 +7,6 @@ * including access checks, document processing, and embedding generation. */ import { - dbChainMock, dbChainMockFns, defaultMockEnv, queueTableRows, @@ -126,8 +125,6 @@ function createEmbeddingFetchMock() { vi.stubGlobal('fetch', createEmbeddingFetchMock()) -vi.mock('@sim/db', () => dbChainMock) - import { processDocumentAsync } from '@/lib/knowledge/documents/service' import { generateEmbeddings } from '@/lib/knowledge/embeddings' import { diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 96a7de727fe..1cad45541bd 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -3,12 +3,10 @@ */ import { authMockFns, - dbChainMock, dbChainMockFns, mcpOauthMock, mcpOauthMockFns, resetDbChainMock, - schemaMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,13 +15,6 @@ const { mockDiscoverServerTools } = vi.hoisted(() => ({ mockDiscoverServerTools: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => schemaMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools: mockDiscoverServerTools }, diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index 6b0df17c66e..11bbc1af623 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -2,9 +2,7 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, - hybridAuthMock, hybridAuthMockFns, McpOauthRedirectRequiredMock, mcpOauthMock, @@ -12,19 +10,10 @@ import { permissionsMock, permissionsMockFns, resetDbChainMock, - schemaMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => schemaMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index a67cb62ba37..99bf39603b6 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -4,15 +4,16 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, resetDbChainMock, + resetEnvMock, + setEnv, } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, @@ -54,27 +55,12 @@ function createBillingAttribution(actorUserId: string, workspaceId: string) { } } -vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - asc: vi.fn(), - eq: vi.fn(), - gt: vi.fn(), - isNull: vi.fn(), - sql: vi.fn(), -})) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'http://localhost:3000', - getInternalApiBaseUrl: () => 'http://localhost:3000', -})) - vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) @@ -82,9 +68,14 @@ vi.mock('@/lib/core/execution-limits', () => ({ import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) vi.stubGlobal('fetch', fetchMock) mockResolveBillingAttribution.mockImplementation( ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index a04dd7edf74..0feba60d055 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,8 +10,6 @@ const { mockClearCache, mockDiscoverServerTools } = vi.hoisted(() => ({ mockDiscoverServerTools: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: (handler: unknown) => handler, })) diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts index 2d397432507..beb8187dd41 100644 --- a/apps/sim/app/api/mcp/servers/route.test.ts +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,8 +9,6 @@ const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ mockPerformDeleteMcpServer: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/mcp/middleware', () => ({ getParsedBody: () => undefined, withMcpAuth: diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index e7ab8202a6e..9b4c80e23e9 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + resetDbChainMock, + resetEnvMock, + setEnv, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -70,8 +77,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -vi.mock('@/lib/core/config/env', () => ({ env: {} })) - vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) @@ -136,6 +141,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ COPILOT_API_KEY: undefined }) copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -158,6 +164,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) it('rejects unauthenticated callers', async () => { diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index 1066801bef8..e4592e1318f 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -4,17 +4,15 @@ import { member, organization, permissions, user, workspace } from '@sim/db/schema' import { auditMock, + authMockFns, createMockRequest, createSession, - dbChainMock, - loggerMock, queueTableRows, resetDbChainMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockValidateInvitationsAllowed, mockValidateSeatAvailability, mockCreatePendingInvitation, @@ -22,7 +20,6 @@ const { mockCancelPendingInvitation, mockGrantWorkspaceAccessDirectly, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockValidateInvitationsAllowed: vi.fn(), mockValidateSeatAvailability: vi.fn(), mockCreatePendingInvitation: vi.fn(), @@ -31,16 +28,8 @@ const { mockGrantWorkspaceAccessDirectly: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/validation/seat-management', () => ({ validateBulkInvitations: vi.fn(), validateSeatAvailability: mockValidateSeatAvailability, @@ -75,6 +64,8 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ import { POST } from '@/app/api/organizations/[id]/invitations/route' +const mockGetSession = authMockFns.mockGetSession + /** Queues the caller's admin-role check followed by the org-name lookup. */ function queueOwnerAndOrg() { queueTableRows(member, [{ role: 'owner' }]) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index 8d8428f78d1..d2898b60821 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -3,6 +3,7 @@ */ import { auditMock, + authMockFns, createMockRequest, createSession, resetEnvFlagsMock, @@ -11,14 +12,12 @@ import { import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockIsOrganizationOwnerOrAdmin, mockGetOrgMemberUsageLimit, mockGetOrgMemberUsageForCurrentPeriod, mockSetOrgMemberUsageLimit, mockGetOrganizationSubscription, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockIsOrganizationOwnerOrAdmin: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockGetOrgMemberUsageForCurrentPeriod: vi.fn(), @@ -26,11 +25,6 @@ const { mockGetOrganizationSubscription: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/billing/core/organization', () => ({ @@ -49,6 +43,8 @@ vi.mock('@/lib/billing/core/billing', () => ({ import { GET, PUT } from '@/app/api/organizations/[id]/members/[memberId]/usage-limit/route' +const mockGetSession = authMockFns.mockGetSession + afterAll(resetEnvFlagsMock) function context() { diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 2e89f6db56f..6e762e3bd2a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { permissionGroup, permissionGroupMember } from '@sim/db/schema' -import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ @@ -18,8 +18,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, })) -vi.mock('@sim/db', () => dbChainMock) - import { authorizeOrgAccessControl, findAllMembersWorkspaceConflict, diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 338e7a5e598..ac698fe3606 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -9,38 +9,30 @@ import { workspace, } from '@sim/db/schema' import { + authMockFns, createMockRequest, createSession, - dbChainMock, - loggerMock, queueTableRows, resetDbChainMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExpireStaleInvitations, mockGetSession } = vi.hoisted(() => ({ +const { mockExpireStaleInvitations } = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), - mockGetSession: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/invitations/core', () => ({ expireStalePendingInvitationsForOrganization: mockExpireStaleInvitations, })) import { GET } from '@/app/api/organizations/[id]/roster/route' +const mockGetSession = authMockFns.mockGetSession + const MEMBER_ROWS = [ { memberId: 'member-admin', diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts index 0e1d39adaaa..c8722a51154 100644 --- a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts @@ -3,8 +3,8 @@ */ import { member, organization } from '@sim/db/schema' import { + authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock, @@ -13,19 +13,12 @@ import { } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({ mockIsEnterprise: vi.fn(), mockEagerClamp: vi.fn(), mockRecordAudit: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/auth/session-policy', () => ({ eagerClampOrgSessions: mockEagerClamp, invalidateSessionPolicyCache: vi.fn(), @@ -49,6 +42,8 @@ vi.mock('@sim/audit', () => ({ import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route' +const mockGetSession = authMockFns.mockGetSession + const ORG_ID = 'org-1' const routeContext = { params: Promise.resolve({ id: ORG_ID }) } diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index 04f0146bff6..bbe8e36990e 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -4,23 +4,20 @@ import { member, subscription } from '@sim/db/schema' import { auditMock, + authMockFns, createSession, - dbChainMock, - loggerMock, queueTableRows, resetDbChainMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockSetActiveOrganizationForCurrentSession, mockCreateOrganizationForTeamPlan, mockEnsureOrganizationForTeamSubscription, mockAttachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockSetActiveOrganizationForCurrentSession: vi.fn().mockResolvedValue(undefined), mockCreateOrganizationForTeamPlan: vi.fn(), mockEnsureOrganizationForTeamSubscription: vi.fn(), @@ -28,16 +25,8 @@ const { WorkspaceOrganizationMembershipConflictError: class WorkspaceOrganizationMembershipConflictError extends Error {}, })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/auth/active-organization', () => ({ setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession, })) @@ -67,6 +56,8 @@ vi.mock('@/lib/workspaces/organization-workspaces', () => ({ import { POST } from '@/app/api/organizations/route' +const mockGetSession = authMockFns.mockGetSession + afterAll(resetDbChainMock) describe('POST /api/organizations', () => { diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts index 8ada3c427ea..fe53568ed51 100644 --- a/apps/sim/app/api/providers/baseten/models/route.test.ts +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -1,27 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authMockFns, createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, - mutableEnv, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mutableEnv: { BASETEN_API_KEY: undefined as string | undefined }, })) -vi.mock('@/lib/core/config/env', () => ({ env: mutableEnv })) - vi.mock('@/providers/utils', () => ({ filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, @@ -31,16 +25,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) import { GET } from '@/app/api/providers/baseten/models/route' +const mockGetSession = authMockFns.mockGetSession + const BASETEN_MODELS_URL = 'https://inference.baseten.co/v1/models' function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { @@ -55,7 +47,7 @@ function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = { } function setEnvKey(value: string | undefined): void { - mutableEnv.BASETEN_API_KEY = value + setEnv({ BASETEN_API_KEY: value }) } function authHeaderFromLastFetch(mockFetch: ReturnType): unknown { @@ -80,6 +72,10 @@ describe('GET /api/providers/baseten/models', () => { setEnvKey(undefined) }) + afterAll(() => { + resetEnvMock() + }) + it('returns empty models without fetching when the provider is blacklisted', async () => { mockIsProviderBlacklisted.mockReturnValue(true) setEnvKey('env-key') diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index fd70d1ae8b1..3b563a8f7cb 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -1,21 +1,19 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, mockFetch, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockFetch: vi.fn(), })) @@ -29,16 +27,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) import { GET } from '@/app/api/providers/ollama-cloud/models/route' +const mockGetSession = authMockFns.mockGetSession + const OLLAMA_CLOUD_TAGS_URL = 'https://ollama.com/api/tags' const okResponse = (body: unknown) => ({ diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts index ae801bb7c56..b7516070a0e 100644 --- a/apps/sim/app/api/providers/together/models/route.test.ts +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -1,25 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authMockFns, createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, mockFetch, - mutableEnv, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockFetch: vi.fn(), - mutableEnv: { TOGETHER_API_KEY: undefined as string | undefined }, })) vi.mock('@/providers/utils', () => ({ @@ -31,20 +27,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.mock('@/lib/core/config/env', () => ({ - env: mutableEnv, -})) - import { GET } from '@/app/api/providers/together/models/route' +const mockGetSession = authMockFns.mockGetSession + const TOGETHER_MODELS_URL = 'https://api.together.ai/v1/models' const okResponse = (body: unknown) => ({ @@ -84,7 +74,7 @@ describe('GET /api/providers/together/models', () => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) - mutableEnv.TOGETHER_API_KEY = undefined + setEnv({ TOGETHER_API_KEY: undefined }) mockIsProviderBlacklisted.mockReturnValue(false) mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) mockGetBYOKKey.mockResolvedValue(null) @@ -92,6 +82,10 @@ describe('GET /api/providers/together/models', () => { mockGetUserEntityPermissions.mockResolvedValue(null) }) + afterAll(() => { + resetEnvMock() + }) + it('returns empty models without calling fetch when the provider is blacklisted', async () => { mockIsProviderBlacklisted.mockReturnValue(true) @@ -111,7 +105,7 @@ describe('GET /api/providers/together/models', () => { }) it('fetches with the env key and prefixes each model id with together/', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }, { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo' }]) ) @@ -129,7 +123,7 @@ describe('GET /api/providers/together/models', () => { }) it('uses the BYOK key when a workspace, session, and permission are present', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-together-key' }) @@ -145,7 +139,7 @@ describe('GET /api/providers/together/models', () => { }) it('falls back to the env key when a workspaceId is given but there is no session', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue(null) mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) @@ -158,7 +152,7 @@ describe('GET /api/providers/together/models', () => { }) it('falls back to the env key when the session user lacks workspace permission', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue(null) mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) @@ -172,7 +166,7 @@ describe('GET /api/providers/together/models', () => { }) it('returns empty models when the upstream fetch responds non-ok', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue(errorResponse(401, 'Unauthorized')) const res = await GET(requestWithWorkspace()) @@ -182,7 +176,7 @@ describe('GET /api/providers/together/models', () => { }) it('returns empty models when the upstream fetch throws', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockRejectedValue(new Error('network down')) const res = await GET(requestWithWorkspace()) @@ -201,7 +195,7 @@ describe('GET /api/providers/together/models', () => { }) it('dedupes duplicate model ids from the upstream array', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([ { id: 'moonshotai/Kimi-K2-Instruct' }, @@ -219,7 +213,7 @@ describe('GET /api/providers/together/models', () => { }) it('applies the blacklist filter to the deduped model list', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFilterBlacklistedModels.mockImplementation((models: string[]) => models.filter((m) => !m.includes('Qwen')) ) @@ -238,7 +232,7 @@ describe('GET /api/providers/together/models', () => { }) it('filters out non-chat model types (image, embedding, rerank, etc.)', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([ { id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', type: 'chat' }, diff --git a/apps/sim/app/api/resume/poll/route.test.ts b/apps/sim/app/api/resume/poll/route.test.ts index 19808c6f2eb..8e175aeff29 100644 --- a/apps/sim/app/api/resume/poll/route.test.ts +++ b/apps/sim/app/api/resume/poll/route.test.ts @@ -1,12 +1,16 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + redisConfigMockFns, + resetDbChainMock, + resetRedisConfigMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - acquireLockMock, assertBillingAttributionSnapshotMock, dueRowsLimitMock, enqueueOrStartResumeMock, @@ -17,12 +21,10 @@ const { lteMock, preprocessExecutionMock, processQueuedResumesMock, - releaseLockMock, setAutomaticResumeWaitingMock, setNextResumeAtMock, sqlMock, } = vi.hoisted(() => ({ - acquireLockMock: vi.fn(), assertBillingAttributionSnapshotMock: vi.fn((value: unknown) => value), dueRowsLimitMock: vi.fn(), enqueueOrStartResumeMock: vi.fn(), @@ -33,7 +35,6 @@ const { lteMock: vi.fn(), preprocessExecutionMock: vi.fn(), processQueuedResumesMock: vi.fn(), - releaseLockMock: vi.fn(), setAutomaticResumeWaitingMock: vi.fn(), setNextResumeAtMock: vi.fn(), sqlMock: vi.fn((strings: TemplateStringsArray) => @@ -41,21 +42,8 @@ const { ), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@sim/db/schema', () => ({ - pausedExecutions: { - id: 'id', - executionId: 'executionId', - workflowId: 'workflowId', - pausePoints: 'pausePoints', - metadata: 'metadata', - executionSnapshot: 'executionSnapshot', - status: 'status', - nextResumeAt: 'nextResumeAt', - automaticResumeRetryCount: 'automaticResumeRetryCount', - }, -})) +const acquireLockMock = redisConfigMockFns.mockAcquireLock +const releaseLockMock = redisConfigMockFns.mockReleaseLock vi.mock('drizzle-orm', () => ({ and: vi.fn(), @@ -74,11 +62,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ assertBillingAttributionSnapshot: assertBillingAttributionSnapshotMock, })) -vi.mock('@/lib/core/config/redis', () => ({ - acquireLock: acquireLockMock, - releaseLock: releaseLockMock, -})) - vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: preprocessExecutionMock, })) @@ -217,6 +200,7 @@ describe('time-pause resume admission', () => { afterAll(() => { resetDbChainMock() + resetRedisConfigMock() }) it('keeps a timed pause unclaimed, records why, and schedules automatic retry', async () => { diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index df3407461f1..8522120ad8e 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -15,12 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) - vi.mock('@sim/audit', () => auditMock) import { PUT } from './route' diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 88929342615..0d8f5713ec9 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -9,13 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(), - and: vi.fn(), - or: vi.fn(), - isNull: vi.fn(), -})) - import { GET } from '@/app/api/schedules/route' function createRequest(url: string): NextRequest { diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index ab3fd86ed23..cf599da66c0 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -2,16 +2,17 @@ * @vitest-environment node */ import { + authMockFns, createMockRequest, - dbChainMock, queueTableRows, resetDbChainMock, + resetEnvMock, schemaMock, + setEnv, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockRecordUsage, mockCheckActorUsageLimits, mockVerifyWorkspaceMembership, @@ -21,7 +22,6 @@ const { mockToBillingContext, mockCheckAndBillPayerOverageThreshold, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockRecordUsage: vi.fn(), mockCheckActorUsageLimits: vi.fn(), mockVerifyWorkspaceMembership: vi.fn(), @@ -45,10 +45,6 @@ const SYSTEM_BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) - vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ @@ -70,8 +66,6 @@ vi.mock('@/app/api/workflows/utils', () => ({ verifyWorkspaceMembership: mockVerifyWorkspaceMembership, })) -vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } })) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true }) @@ -82,6 +76,8 @@ vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() = import { POST } from '@/app/api/speech/token/route' +const mockGetSession = authMockFns.mockGetSession + const publicChatRow = { id: 'chat-1', userId: 'owner-1', @@ -94,6 +90,7 @@ const publicChatRow = { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ ELEVENLABS_API_KEY: 'test-key' }) mockGetSession.mockResolvedValue({ user: { id: 'member-1' } }) mockRecordUsage.mockResolvedValue(undefined) mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) @@ -125,6 +122,7 @@ beforeEach(() => { afterAll(() => { resetDbChainMock() + resetEnvMock() }) describe('POST /api/speech/token — usage attribution', () => { diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index d84994ab522..d532688628a 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -6,7 +6,6 @@ import { authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, permissionsMock, @@ -83,8 +82,6 @@ const sampleTools = [ }, ] -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/workflows/custom-tools/operations', () => ({ diff --git a/apps/sim/app/api/usage/route.test.ts b/apps/sim/app/api/usage/route.test.ts index ab03f1a5982..8c922f6a170 100644 --- a/apps/sim/app/api/usage/route.test.ts +++ b/apps/sim/app/api/usage/route.test.ts @@ -1,19 +1,12 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetOrganizationBillingData, mockGetSession, mockIsOrganizationOwnerOrAdmin } = - vi.hoisted(() => ({ - mockGetOrganizationBillingData: vi.fn(), - mockGetSession: vi.fn(), - mockIsOrganizationOwnerOrAdmin: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +const { mockGetOrganizationBillingData, mockIsOrganizationOwnerOrAdmin } = vi.hoisted(() => ({ + mockGetOrganizationBillingData: vi.fn(), + mockIsOrganizationOwnerOrAdmin: vi.fn(), })) vi.mock('@/lib/billing', () => ({ @@ -28,6 +21,8 @@ vi.mock('@/lib/billing/core/organization', () => ({ import { GET } from '@/app/api/usage/route' +const mockGetSession = authMockFns.mockGetSession + describe('GET /api/usage organization context', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts index 4743b35041a..b2b44642e0a 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts @@ -2,11 +2,9 @@ * @vitest-environment node */ import { - authMock, authMockFns, createMockRequest, createSession, - dbChainMock, dbChainMockFns, resetDbChainMock, } from '@sim/testing' @@ -18,9 +16,6 @@ const { mockAcquireOrganizationMutationLock, mockAssertNoUnresolvedEnterpriseIss mockAssertNoUnresolvedEnterpriseIssuance: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/auth', () => authMock) - vi.mock('@/lib/billing/enterprise-outbox', () => { class EnterpriseIssuanceInProgressError extends Error {} return { diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index c44f99f0d25..0424663f9c1 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -4,7 +4,7 @@ * Tests for GET /api/v1/audit-logs/[id] — verifies the lookup is constrained * by the organization scope and 404s for rows outside it. */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { createMockRequest, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -19,8 +19,6 @@ const { mockGetOrgWorkspaceIds: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: vi.fn(), diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index d7130879a95..a16dee2b0c4 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -1,21 +1,13 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ mockIsOrganizationBillingBlocked: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, })) diff --git a/apps/sim/app/api/v1/audit-logs/query.test.ts b/apps/sim/app/api/v1/audit-logs/query.test.ts index c4a4aacecf7..72740ca537f 100644 --- a/apps/sim/app/api/v1/audit-logs/query.test.ts +++ b/apps/sim/app/api/v1/audit-logs/query.test.ts @@ -5,11 +5,8 @@ * mock returns structured operator objects, so these tests assert directly on * the predicate tree. */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' const ORG_ID = 'org-1' diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts index 859eba6b73d..432e38bf308 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { createMockRequest, redisConfigMockFns } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,8 +16,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - vi.mock('@/lib/webhooks/polling', () => ({ pollProvider: mockPollProvider, VALID_POLLING_PROVIDERS: new Set(['gmail', 'outlook', 'rss']), diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index ac20c7927cf..bbb2b0baf6b 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } = vi.hoisted(() => ({ @@ -15,10 +16,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ admissionRejectedResponse: () => new Response(null, { status: 503 }), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { SLACK_SIGNING_SECRET: 'test-secret' }, -})) - vi.mock('@/lib/webhooks/processor', () => ({ parseWebhookBody: mockParseWebhookBody, findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, @@ -56,8 +53,13 @@ const messageBody = { } describe('Slack app webhook route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() + setEnv({ SLACK_SIGNING_SECRET: 'test-secret' }) mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', diff --git a/apps/sim/app/api/webhooks/tiktok/route.test.ts b/apps/sim/app/api/webhooks/tiktok/route.test.ts index 6ad1f0676eb..1bd8d805627 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.test.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.test.ts @@ -3,8 +3,9 @@ */ import crypto from 'node:crypto' +import { requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ mockEnqueueTikTokWebhookIngress: vi.fn(), @@ -20,17 +21,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { - TIKTOK_CLIENT_ID: 'client-key', - TIKTOK_CLIENT_SECRET: 'client-secret', - }, -})) - -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'request-1'), -})) - vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: (handler: (request: NextRequest) => Promise) => (request: NextRequest) => @@ -66,9 +56,16 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest { describe('TikTok webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ TIKTOK_CLIENT_ID: 'client-key', TIKTOK_CLIENT_SECRET: 'client-secret' }) + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1') }) + afterAll(() => { + resetEnvMock() + requestUtilsMockFns.mockGenerateRequestId.mockReset() + }) + it('returns 200 only after the verified delivery is accepted by the job queue', async () => { const response = await POST(signedRequest()) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 188a5a47580..d94b3abcc2a 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -4,7 +4,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, hybridAuthMockFns, resetDbChainMock, @@ -14,13 +13,6 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), - eq: vi.fn(), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { GET } from '@/app/api/workflows/[id]/chat/status/route' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index ce55f06ee23..771e7cda706 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -4,7 +4,6 @@ import { createMockRequest, - dbChainMock, dbChainMockFns, executionPreprocessingMock, executionPreprocessingMockFns, @@ -12,6 +11,8 @@ import { loggingSessionMock, requestUtilsMockFns, resetDbChainMock, + resetEnvMock, + setEnv, workflowAuthzMockFns, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, @@ -19,7 +20,7 @@ import { workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' const { @@ -55,8 +56,6 @@ const { mockValidatePublicApiAllowed: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/billing-attribution', () => ({ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, requireBillingAttributionHeader: mockRequireBillingAttributionHeader, @@ -121,11 +120,6 @@ vi.mock('@/lib/core/async-jobs', () => ({ shouldExecuteInline: vi.fn().mockReturnValue(false), })) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'), - getOllamaUrl: vi.fn().mockReturnValue('http://localhost:11434'), -})) - vi.mock('@/lib/execution/call-chain', () => ({ SIM_VIA_HEADER: 'x-sim-via', parseCallChain: vi.fn().mockReturnValue([]), @@ -280,9 +274,14 @@ function createCallerExecutionRequest( } describe('workflow execute async route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockGenerateId.mockReset().mockReturnValue('execution-123') mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts index 27529007563..5ab5775652b 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts @@ -1,29 +1,17 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' -const { - mockAuthorizeWorkflowByWorkspacePermission, - mockGetSession, - mockReadExecutionEventsState, - mockReadExecutionMetaState, -} = vi.hoisted(() => ({ - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), - mockGetSession: vi.fn(), +const { mockReadExecutionEventsState, mockReadExecutionMetaState } = vi.hoisted(() => ({ mockReadExecutionEventsState: vi.fn(), mockReadExecutionMetaState: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, -})) +const mockAuthorizeWorkflowByWorkspacePermission = + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/execution/event-buffer', () => ({ readExecutionEventsState: mockReadExecutionEventsState, @@ -32,6 +20,8 @@ vi.mock('@/lib/execution/event-buffer', () => ({ import { GET } from './route' +const mockGetSession = authMockFns.mockGetSession + function completedEntry(eventId: number): ExecutionEventEntry { return { eventId, diff --git a/apps/sim/app/api/workflows/[id]/log/route.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts index b6d0169c1fe..9b6c35578d5 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts @@ -1,12 +1,11 @@ /** * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' // Override global db mock with the configurable chain mock -vi.mock('@sim/db', () => dbChainMock) const { mockValidateWorkflowAccess, diff --git a/apps/sim/app/api/workflows/[id]/references/route.test.ts b/apps/sim/app/api/workflows/[id]/references/route.test.ts index d6ef6045816..785a1ec2893 100644 --- a/apps/sim/app/api/workflows/[id]/references/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -1,22 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockAuthorizeWorkflow, mockGetWorkflowReferences } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockAuthorizeWorkflow: vi.fn(), +const { mockGetWorkflowReferences } = vi.hoisted(() => ({ mockGetWorkflowReferences: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, -})) +const mockAuthorizeWorkflow = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/workflows/references/operations', () => ({ getWorkflowReferences: mockGetWorkflowReferences, @@ -24,6 +16,8 @@ vi.mock('@/lib/workflows/references/operations', () => ({ import { GET } from '@/app/api/workflows/[id]/references/route' +const mockGetSession = authMockFns.mockGetSession + const REFERENCES = { callers: [{ id: 'b', name: 'B', cycle: false, children: [] }], callees: [], diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 56b0ffd8b11..9b9be85824c 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -7,9 +7,7 @@ import { auditMock, - dbChainMock, dbChainMockFns, - envMock, hybridAuthMockFns, resetDbChainMock, telemetryMock, @@ -52,8 +50,6 @@ function mockGetSession(session: { user: { id: string } } | null) { } } -vi.mock('@/lib/core/config/env', () => envMock) - vi.mock('@/lib/core/telemetry', () => telemetryMock) vi.mock('@sim/audit', () => auditMock) @@ -64,8 +60,6 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@sim/db', () => dbChainMock) - import { DELETE, GET, PUT } from './route' describe('Workflow By ID API Route', () => { diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts index 202326d2c15..e66f552df89 100644 --- a/apps/sim/app/api/workflows/middleware.test.ts +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -7,7 +7,6 @@ import { hybridAuthMockFns, - workflowAuthzMock, workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns, @@ -16,7 +15,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/api-key/service', () => ({ authenticateApiKeyFromHeader: vi.fn(), updateApiKeyLastUsed: vi.fn(), diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 732d4931200..106b6bf8faf 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -4,7 +4,6 @@ import { auditMock, createMockRequest, - dbChainMock, dbChainMockFns, hybridAuthMockFns, permissionsMock, @@ -25,8 +24,6 @@ const { mockWorkflowCreated } = vi.hoisted(() => ({ const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/workspace-events/poll/route.test.ts b/apps/sim/app/api/workspace-events/poll/route.test.ts index dee80482bc8..96d52e8b3a7 100644 --- a/apps/sim/app/api/workspace-events/poll/route.test.ts +++ b/apps/sim/app/api/workspace-events/poll/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { createMockRequest, redisConfigMockFns } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,8 +18,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - vi.mock('@/lib/workspace-events/no-activity', () => ({ pollNoActivityEvents: mockPollNoActivityEvents, })) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts index d5d3b874c78..39eb2394e0b 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts @@ -1,20 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetApiKeyDisplayFormat, - mockGetSession, - mockGetUserEntityPermissions, - mockGetWorkspaceById, -} = vi.hoisted(() => ({ - mockGetApiKeyDisplayFormat: vi.fn(), - mockGetSession: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceById: vi.fn(), -})) +const { mockGetApiKeyDisplayFormat, mockGetUserEntityPermissions, mockGetWorkspaceById } = + vi.hoisted(() => ({ + mockGetApiKeyDisplayFormat: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceById: vi.fn(), + })) vi.mock('@/lib/api-key/auth', () => ({ getApiKeyDisplayFormat: mockGetApiKeyDisplayFormat, @@ -24,11 +25,6 @@ vi.mock('@/lib/api-key/orchestration', () => ({ performCreateWorkspaceApiKey: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, getWorkspaceById: mockGetWorkspaceById, @@ -36,6 +32,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { GET } from '@/app/api/workspaces/[id]/api-keys/route' +const mockGetSession = authMockFns.mockGetSession + describe('GET /api/workspaces/[id]/api-keys', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts index 89b7f60b7b0..634d5e19137 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts @@ -3,6 +3,7 @@ */ import { auditMock, + authMockFns, createMockRequest, dbChainMockFns, queueTableRows, @@ -11,26 +12,16 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockGetUserEntityPermissions, - mockGetWorkspaceById, - mockEncryptSecret, - mockDecryptSecret, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceById: vi.fn(), - mockEncryptSecret: vi.fn(), - mockDecryptSecret: vi.fn(), -})) +const { mockGetUserEntityPermissions, mockGetWorkspaceById, mockEncryptSecret, mockDecryptSecret } = + vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockEncryptSecret: vi.fn(), + mockDecryptSecret: vi.fn(), + })) vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret, decryptSecret: mockDecryptSecret, @@ -47,6 +38,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { DELETE, GET, POST } from '@/app/api/workspaces/[id]/byok-keys/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } diff --git a/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts index b4d90966f77..637b4e5c0fa 100644 --- a/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts @@ -1,20 +1,15 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetWorkspaceCreditAvailability, mockGetWorkspaceHostContextForViewer } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetWorkspaceCreditAvailability, mockGetWorkspaceHostContextForViewer } = vi.hoisted( + () => ({ mockGetWorkspaceCreditAvailability: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) + }) +) vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({ getWorkspaceCreditAvailability: mockGetWorkspaceCreditAvailability, @@ -26,6 +21,8 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { GET } from '@/app/api/workspaces/[id]/credit-availability/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 6ad61921591..759abd0b1e5 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -1,37 +1,22 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, environmentUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockGetWorkspaceById, - mockGetUserEntityPermissions, - mockGetPersonalAndWorkspaceEnv, - mockGetWorkspaceEnvKeyAdminAccess, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockGetWorkspaceById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetPersonalAndWorkspaceEnv: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) +const { mockGetWorkspaceById, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess } = + vi.hoisted(() => ({ + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), + })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceById: mockGetWorkspaceById, getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.mock('@/lib/environment/utils', () => ({ - getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv, - invalidateEffectiveDecryptedEnvCache: vi.fn(), -})) +const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/credentials/environment', () => ({ getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, @@ -41,6 +26,8 @@ vi.mock('@/lib/credentials/environment', () => ({ import { GET } from '@/app/api/workspaces/[id]/environment/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'ws-1' function buildParams() { diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 3bb2a8a06ba..c57ac919e38 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -1,20 +1,16 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ mockGetPerms: vi.fn(), mockResolveImage: vi.fn(), mockDownloadFile: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) vi.mock('@/lib/uploads/server/inline-image', () => ({ resolveWorkspaceInlineImage: mockResolveImage, @@ -23,6 +19,8 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloa import { GET } from '@/app/api/workspaces/[id]/files/inline/route' +const mockGetSession = authMockFns.mockGetSession + const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) const params = { params: Promise.resolve({ id: 'ws-1' }) } const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts index a623def8937..f31acbbc923 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -4,23 +4,16 @@ import { auditMock, auditMockFns, + authMockFns, createMockRequest, dbChainMockFns, resetDbChainMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted( - () => ({ - mockGetSession: vi.fn(), - mockAssertWorkspaceAdminAccess: vi.fn(), - mockCaptureServerEvent: vi.fn(), - }) -) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +const { mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted(() => ({ + mockAssertWorkspaceAdminAccess: vi.fn(), + mockCaptureServerEvent: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ @@ -35,6 +28,8 @@ vi.mock('@/lib/posthog/server', () => ({ import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const ADMIN_ID = 'user-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts index 1970a50b9e4..c3c265ba4c6 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts @@ -1,18 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockAssertWorkspaceAdminAccess, mockGetForkParent, mockGetForkChildren, mockGetUndoableRunForTarget, mockGetEffectiveWorkspacePermission, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockAssertWorkspaceAdminAccess: vi.fn(), mockGetForkParent: vi.fn(), mockGetForkChildren: vi.fn(), @@ -20,11 +18,6 @@ const { mockGetEffectiveWorkspacePermission: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, })) @@ -44,6 +37,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const VIEWER_ID = 'user-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } diff --git a/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts index 15a573533e8..b893936cd63 100644 --- a/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts @@ -1,25 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ mockGetWorkspaceHostContextForViewer: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, })) import { GET } from '@/app/api/workspaces/[id]/host-context/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-1', diff --git a/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts index a95c44df843..1416eb53e61 100644 --- a/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts @@ -1,19 +1,12 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkspaceUsageGate, mockGetSession, mockGetWorkspaceHostContextForViewer } = - vi.hoisted(() => ({ - mockCheckWorkspaceUsageGate: vi.fn(), - mockGetSession: vi.fn(), - mockGetWorkspaceHostContextForViewer: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +const { mockCheckWorkspaceUsageGate, mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ + mockCheckWorkspaceUsageGate: vi.fn(), + mockGetWorkspaceHostContextForViewer: vi.fn(), })) vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({ @@ -26,6 +19,8 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { GET } from '@/app/api/workspaces/[id]/usage-gate/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index d4eefc75af4..2b600626204 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -3,7 +3,6 @@ */ import { auditMock, - authMock, authMockFns, createMockRequest, permissionsMock, @@ -35,8 +34,6 @@ const { mockFindPendingGrantForWorkspaceEmail: vi.fn(), })) -vi.mock('@/lib/auth', () => authMock) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/workspaces/policy', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx index e91226d8757..b3e08f18da1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx @@ -18,9 +18,6 @@ vi.mock('next/navigation', () => ({ })) // Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array. -vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [], -})) const { MentionChipView } = await import('./mention-chip') diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index b8f0399d751..10264ea9276 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -1,20 +1,20 @@ /** * @vitest-environment node */ + import type { ReactNode } from 'react' +import { authMockFns } from '@sim/testing' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockBrandingProvider, mockGetOrgWhitelabelSettings, - mockGetSession, mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), - mockGetSession: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), })) @@ -36,10 +36,6 @@ vi.mock('next/navigation', () => ({ redirect: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: () => ({ setQueryData: vi.fn() }), })) @@ -104,6 +100,8 @@ vi.mock('@/app/workspace/[workspaceId]/providers/workspace-scope-sync', () => ({ import WorkspaceLayout from '@/app/workspace/[workspaceId]/layout' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts index 97b9e7900c8..e86d3bd66de 100644 --- a/apps/sim/background/async-preprocessing-correlation.test.ts +++ b/apps/sim/background/async-preprocessing-correlation.test.ts @@ -35,13 +35,6 @@ vi.mock('@sim/db', () => ({ workflowSchedule: {}, })) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(), - and: vi.fn(), - isNull: vi.fn(), - sql: Object.assign(vi.fn(), { raw: vi.fn() }), -})) - vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) diff --git a/apps/sim/background/cleanup-logs.test.ts b/apps/sim/background/cleanup-logs.test.ts index 1b0f77fce94..9ee5306a39b 100644 --- a/apps/sim/background/cleanup-logs.test.ts +++ b/apps/sim/background/cleanup-logs.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -45,8 +45,6 @@ const { mockTask: vi.fn((config: unknown) => config), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) vi.mock('@/lib/cleanup/batch-delete', () => ({ diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index ea63df6a859..a52108e5d36 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -33,8 +33,6 @@ const { mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/cleanup/batch-delete', () => ({ batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp, chunkedBatchDelete: mockChunkedBatchDelete, diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index a075943ea40..fa44602b09b 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -3,7 +3,6 @@ */ import { - dbChainMock, dbChainMockFns, executionPreprocessingMock, executionPreprocessingMockFns, @@ -44,7 +43,6 @@ vi.mock('@opentelemetry/api', () => ({ trace: { getActiveSpan: mockGetActiveSpan }, })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 2aea7cf535c..c449d3d9608 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -9,14 +9,14 @@ import { resetEnvFlagsMock, setEnvFlags, } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { getBlock } from '@/blocks/registry' const { DEFAULT_PERMISSION_GROUP_CONFIG, mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner, mockGetProviderFromModel, - mockGetBlock, } = vi.hoisted(() => ({ DEFAULT_PERMISSION_GROUP_CONFIG: { allowedIntegrations: null, @@ -47,7 +47,6 @@ const { mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), mockGetProviderFromModel: vi.fn<(model: string) => string>(), - mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(), })) vi.mock('@/lib/billing', () => ({ @@ -70,11 +69,6 @@ vi.mock('@/providers/utils', () => ({ getProviderFromModel: mockGetProviderFromModel, })) -vi.mock('@/blocks/registry', () => ({ - getBlock: mockGetBlock, - getAllBlocks: vi.fn(() => []), -})) - import { assertPermissionsAllowed, ChatDeployAuthNotAllowedError, @@ -128,6 +122,15 @@ function queueGroupResolution( afterAll(resetDbChainMock) +/** The global registry mock's getBlock, driven per-test in this suite. */ +const mockGetBlock = getBlock as Mock + +const defaultGetBlockImpl = mockGetBlock.getMockImplementation() + +afterAll(() => { + mockGetBlock.mockImplementation(defaultGetBlockImpl as () => unknown) +}) + /** * Default every block to non-legacy. `vi.clearAllMocks()` (used by the * describe-level hooks) keeps implementations, so reset here to stop a legacy diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index 907f08c9cf1..c122f181427 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -2,15 +2,13 @@ * @vitest-environment node */ import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({ mockInvalidateDeployedStateCache: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workflows/persistence/utils', () => ({ invalidateDeployedStateCache: mockInvalidateDeployedStateCache, CREDENTIAL_SUBBLOCK_IDS: new Set(['credential', 'manualCredential', 'triggerCredentials']), diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts index b6a5568e7f5..01b780bcd75 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, resetDbChainMock, storageServiceMock, @@ -15,7 +14,6 @@ const { mockIncrementStorageUsageInTx, mockResolveStorageBillingContext } = vi.h mockResolveStorageBillingContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/billing/storage', () => ({ incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageInTx, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index ac3393c31ef..d3863d3fb6b 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, resetDbChainMock, storageServiceMock, @@ -20,7 +19,6 @@ const { mockResolveStorageBillingContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/billing/storage', () => ({ decrementStorageUsageForBillingContextInTx: mockDecrementStorageUsageInTx, diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 6c4a16999c1..9e0c268fffd 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -26,7 +26,6 @@ const { mockSeedEdgeMappings: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workflows/defaults', () => ({ buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })), })) diff --git a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts index 8ce5fc2f4d4..9a4affb507e 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/unlink.test.ts @@ -9,7 +9,6 @@ const { mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({ mockAcquireForkEdgeLock: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ setForkLockTimeout: mockSetForkLockTimeout, acquireForkEdgeLock: mockAcquireForkEdgeLock, diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 6fc48121454..8ef8c310b16 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,5 +1,4 @@ import { - dbChainMock, queueTableRows, resetDbChainMock, resetEnvFlagsMock, @@ -92,8 +91,6 @@ vi.mock('@/executor/utils/http', () => ({ }), })) -vi.mock('@sim/db', () => dbChainMock) - /** Connected MCP servers every workspace-server lookup in this suite resolves. */ const MCP_SERVER_ROWS = [ { id: 'mcp-search-server', connectionStatus: 'connected' }, diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index e562b63b7ec..fb9103da2bc 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -1,17 +1,8 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { resolveSkillContent } from './skills-resolver' // resolveSkillContent is the shared resolver invoked when a workflow agent block diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts index 37d22a178dd..d5131ec0403 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { createLogger } from '@sim/logger' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -17,7 +18,6 @@ const { mockRemoveRuntimeApiKey, mockCreateSealedResourceLoader, mockCreatePiModelRuntime, - mockLoggerWarn, } = vi.hoisted(() => ({ mockRun: vi.fn(), mockWriteFile: vi.fn(), @@ -32,7 +32,6 @@ const { mockRemoveRuntimeApiKey: vi.fn(), mockCreateSealedResourceLoader: vi.fn(), mockCreatePiModelRuntime: vi.fn(), - mockLoggerWarn: vi.fn(), })) let sessionEventListener: ((raw: unknown) => void) | undefined @@ -59,9 +58,6 @@ const mockModelRuntime = { removeRuntimeApiKey: mockRemoveRuntimeApiKey, } -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: mockLoggerWarn }), -})) vi.mock('@/lib/execution/e2b', () => ({ withPiSandbox: (fn: (runner: unknown) => unknown) => fn({ run: mockRun, writeFile: mockWriteFile }), @@ -95,6 +91,14 @@ vi.mock('@/executor/handlers/pi/pi-sdk', () => ({ import type { PiCloudReviewRunParams } from '@/executor/handlers/pi/backend' import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend' +/** + * The mock logger instance the global `@sim/logger` mock handed to the module + * under test at import time, captured so the suite can assert on its warns. + */ +const mockLoggerWarn = vi.mocked(createLogger).mock.results[ + vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'PiCloudReviewBackend') +].value.warn as ReturnType + const HEAD_SHA = 'a'.repeat(40) const BASE_SHA = 'b'.repeat(40) const REVIEW_TOOL_NAMES = [ diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 50bc0a7abef..4e9b8c9831a 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { getBlock } from '@/blocks/registry' import { BlockType } from '@/executor/constants' import { findMissingRequiredCustomBlockInputs, @@ -13,7 +15,6 @@ const { mockCreateSnapshot, mockResolveBillingAttribution, mockGetCustomBlockAuthority, - mockGetPersonalAndWorkspaceEnv, mockGetUserEmailById, executorOptions, } = vi.hoisted(() => ({ @@ -21,7 +22,6 @@ const { mockCreateSnapshot: vi.fn(), mockResolveBillingAttribution: vi.fn(), mockGetCustomBlockAuthority: vi.fn(), - mockGetPersonalAndWorkspaceEnv: vi.fn(), mockGetUserEmailById: vi.fn(), executorOptions: [] as Array>, })) @@ -39,9 +39,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: mockResolveBillingAttribution, })) -vi.mock('@/lib/environment/utils', () => ({ - getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv, -})) +const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ getCustomBlockAuthority: mockGetCustomBlockAuthority, @@ -51,40 +49,50 @@ vi.mock('@/lib/users/queries', () => ({ getUserEmailById: mockGetUserEmailById, })) -// Override the global registry mock so the Serializer can carry the start -// block's runMetadata param through child deployed-state serialization. -vi.mock('@/blocks/registry', () => ({ - getBlock: vi.fn((type: string) => { - if (type === 'start_trigger') { - return { - name: 'Start', - description: 'Unified workflow entry point', - category: 'triggers', - bgColor: '#34B5FF', - icon: () => null, - subBlocks: [ - { id: 'inputFormat', title: 'Inputs', type: 'input-format' }, - { id: 'runMetadata', title: 'Add run metadata', type: 'switch', defaultValue: false }, - ], - inputs: {}, - outputs: {}, - tools: { access: [] }, - triggers: { enabled: true, available: ['chat', 'manual', 'api'] }, - } - } +/** + * Overrides the global registry mock's getBlock so the Serializer can carry the + * start block's runMetadata param through child deployed-state serialization. + */ +function getBlockOverride(type: string) { + if (type === 'start_trigger') { return { - name: 'Mock Block', - description: 'Mock block description', + name: 'Start', + description: 'Unified workflow entry point', + category: 'triggers', + bgColor: '#34B5FF', icon: () => null, - subBlocks: [], + subBlocks: [ + { id: 'inputFormat', title: 'Inputs', type: 'input-format' }, + { id: 'runMetadata', title: 'Add run metadata', type: 'switch', defaultValue: false }, + ], inputs: {}, outputs: {}, tools: { access: [] }, + triggers: { enabled: true, available: ['chat', 'manual', 'api'] }, } - }), - getAllBlocks: vi.fn(() => ({})), - getLatestBlock: vi.fn(() => undefined), -})) + } + return { + name: 'Mock Block', + description: 'Mock block description', + icon: () => null, + subBlocks: [], + inputs: {}, + outputs: {}, + tools: { access: [] }, + } +} + +const mockGetBlock = getBlock as Mock +const defaultGetBlockImpl = mockGetBlock.getMockImplementation() + +beforeAll(() => { + mockGetBlock.mockImplementation(getBlockOverride) +}) + +afterAll(() => { + mockGetBlock.mockImplementation(defaultGetBlockImpl as () => unknown) + resetEnvironmentUtilsMock() +}) vi.mock('@/lib/logs/execution/snapshot/service', () => ({ snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot }, diff --git a/apps/sim/lib/admin/dashboard-credit-grant.test.ts b/apps/sim/lib/admin/dashboard-credit-grant.test.ts index 57e9f4677f8..d4a189418c8 100644 --- a/apps/sim/lib/admin/dashboard-credit-grant.test.ts +++ b/apps/sim/lib/admin/dashboard-credit-grant.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { member, organization, subscription, user, userStats, workspace } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -24,7 +24,6 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: { BILLING: 'billing' }, recordAudit: mocks.recordAudit, })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/idempotency/transaction', () => ({ executeTransactionallyIdempotent: async ( _tx: unknown, diff --git a/apps/sim/lib/admin/dashboard-organizations.test.ts b/apps/sim/lib/admin/dashboard-organizations.test.ts index 3bf092f9585..78009af4597 100644 --- a/apps/sim/lib/admin/dashboard-organizations.test.ts +++ b/apps/sim/lib/admin/dashboard-organizations.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment node */ import { member, organization, permissions, subscription } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.unmock('drizzle-orm') @@ -15,7 +15,6 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: {}, recordAudit: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) /** * Cuts the import chain dashboard.ts -> admin-move.ts -> invitations/core -> * lib/auth/auth.ts. The auth module throws at import time when another suite diff --git a/apps/sim/lib/admin/external-collaborators.test.ts b/apps/sim/lib/admin/external-collaborators.test.ts index 8cdff708d0b..faab5dc0658 100644 --- a/apps/sim/lib/admin/external-collaborators.test.ts +++ b/apps/sim/lib/admin/external-collaborators.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { member, permissions } from '@sim/db/schema' -import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -17,8 +17,6 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit, })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/member-limits', () => ({ setOrgMemberUsageLimit: mocks.setLimit, })) diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index b049ae42738..7bd905b16c5 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) diff --git a/apps/sim/lib/api-key/crypto.test.ts b/apps/sim/lib/api-key/crypto.test.ts index f6243b876e4..334a90ac9c7 100644 --- a/apps/sim/lib/api-key/crypto.test.ts +++ b/apps/sim/lib/api-key/crypto.test.ts @@ -9,15 +9,14 @@ * @vitest-environment node */ import { randomBytes } from 'crypto' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -const { mockEnv } = vi.hoisted(() => ({ - mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined }, -})) +beforeAll(() => { + setEnv({ API_ENCRYPTION_KEY: undefined }) +}) -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, -})) +afterAll(resetEnvMock) import { decryptApiKey, @@ -52,7 +51,7 @@ describe('hashApiKey', () => { describe('backfill idempotency — encrypted round-trip', () => { beforeEach(() => { - mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY + setEnv({ API_ENCRYPTION_KEY: FIXED_ENCRYPTION_KEY }) }) it('re-running the backfill on the same row yields the same keyHash', async () => { diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts index 04011bb46a8..898b4f5ea9e 100644 --- a/apps/sim/lib/api-key/service.test.ts +++ b/apps/sim/lib/api-key/service.test.ts @@ -7,11 +7,9 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { serviceLogger } = vi.hoisted(() => { const logger = { info: vi.fn(), diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index 38b50e378e8..a58b7df6d33 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -1,33 +1,30 @@ /** * @vitest-environment node */ -import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessControlConfig } from '@/lib/auth/access-control' -const { mockFetch, envRef } = vi.hoisted(() => ({ +const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn(), - envRef: { - APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, - APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, - BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, - BLOCKED_EMAILS: undefined as string | undefined, - ALLOWED_LOGIN_EMAILS: undefined as string | undefined, - ALLOWED_LOGIN_DOMAINS: undefined as string | undefined, - BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, - }, })) +beforeAll(() => { + setEnv({ + APPCONFIG_APPLICATION: 'sim-staging', + APPCONFIG_ENVIRONMENT: 'staging', + BLOCKED_SIGNUP_DOMAINS: undefined, + BLOCKED_EMAILS: undefined, + ALLOWED_LOGIN_EMAILS: undefined, + ALLOWED_LOGIN_DOMAINS: undefined, + BLOCKED_EMAIL_MX_HOSTS: undefined, + }) +}) + vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: mockFetch, })) -vi.mock('@/lib/core/config/env', () => ({ - get env() { - return envRef - }, -})) - import { getAccessControlConfig, isEmailBlockedByAccessControl, @@ -42,13 +39,16 @@ const empty: AccessControlConfig = { blockedEmailMxHosts: [], } -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('getAccessControlConfig', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isAppConfigEnabled: false }) - Object.assign(envRef, { + setEnv({ BLOCKED_SIGNUP_DOMAINS: undefined, BLOCKED_EMAILS: undefined, ALLOWED_LOGIN_EMAILS: undefined, @@ -64,9 +64,9 @@ describe('getAccessControlConfig', () => { }) it('parses, trims, lowercases, and dedupes csv env vars', async () => { - envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,' - envRef.BLOCKED_EMAILS = 'Spam@Evil.com, spam@evil.com' - envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai' + setEnv({ BLOCKED_SIGNUP_DOMAINS: 'Gmail.com, yahoo.com ,gmail.com,' }) + setEnv({ BLOCKED_EMAILS: 'Spam@Evil.com, spam@evil.com' }) + setEnv({ ALLOWED_LOGIN_DOMAINS: 'Sim.ai' }) const result = await getAccessControlConfig() expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com']) expect(result.blockedEmails).toEqual(['spam@evil.com']) @@ -102,7 +102,7 @@ describe('getAccessControlConfig', () => { }) it('falls back to env vars when the fetch yields null', async () => { - envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example' + setEnv({ BLOCKED_SIGNUP_DOMAINS: 'spam.example' }) mockFetch.mockResolvedValue(null) const result = await getAccessControlConfig() expect(result.blockedSignupDomains).toEqual(['spam.example']) diff --git a/apps/sim/lib/auth/ban.test.ts b/apps/sim/lib/auth/ban.test.ts index a6c81ad4faf..52bedf850f7 100644 --- a/apps/sim/lib/auth/ban.test.ts +++ b/apps/sim/lib/auth/ban.test.ts @@ -7,28 +7,25 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvMock, schemaMock, + setEnv, } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { envRef } = vi.hoisted(() => ({ - envRef: { - BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, - BLOCKED_EMAILS: undefined as string | undefined, - }, -})) +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() })) -vi.mock('@/lib/core/config/env', () => ({ - get env() { - return envRef - }, -})) import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban' -afterAll(resetDbChainMock) +beforeAll(() => { + setEnv({ BLOCKED_SIGNUP_DOMAINS: undefined, BLOCKED_EMAILS: undefined }) +}) + +afterAll(() => { + resetDbChainMock() + resetEnvMock() +}) describe('isBanActive', () => { it('returns true for a permanent ban', () => { @@ -53,8 +50,8 @@ describe('isEmailBlocked', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' - envRef.BLOCKED_EMAILS = 'spam@evil.com' + setEnv({ BLOCKED_SIGNUP_DOMAINS: 'bad.com' }) + setEnv({ BLOCKED_EMAILS: 'spam@evil.com' }) }) it('returns true for blocked domains and subdomains without querying users', async () => { @@ -84,8 +81,8 @@ describe('getActivelyBannedUserIds', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - envRef.BLOCKED_SIGNUP_DOMAINS = undefined - envRef.BLOCKED_EMAILS = undefined + setEnv({ BLOCKED_SIGNUP_DOMAINS: undefined }) + setEnv({ BLOCKED_EMAILS: undefined }) }) it('short-circuits on empty input without querying', async () => { @@ -110,7 +107,7 @@ describe('getActivelyBannedUserIds', () => { }) it('returns ids whose email is individually blocked', async () => { - envRef.BLOCKED_EMAILS = 'spam@evil.com' + setEnv({ BLOCKED_EMAILS: 'spam@evil.com' }) queueTableRows(user, [ { id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null }, { id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null }, @@ -119,7 +116,7 @@ describe('getActivelyBannedUserIds', () => { }) it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => { - envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com' + setEnv({ BLOCKED_SIGNUP_DOMAINS: 'bad.com' }) queueTableRows(user, [ { id: 'u1', email: 'a@bad.com', banned: false, banExpires: null }, { id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null }, diff --git a/apps/sim/lib/auth/hybrid.test.ts b/apps/sim/lib/auth/hybrid.test.ts index a67bf018ad9..35d37188b90 100644 --- a/apps/sim/lib/auth/hybrid.test.ts +++ b/apps/sim/lib/auth/hybrid.test.ts @@ -1,20 +1,23 @@ /** * @vitest-environment node */ + +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAuthenticateApiKeyFromHeader, - mockGetSession, - mockUpdateApiKeyLastUsed, - mockVerifyInternalToken, -} = vi.hoisted(() => ({ - mockAuthenticateApiKeyFromHeader: vi.fn(), - mockGetSession: vi.fn(), - mockUpdateApiKeyLastUsed: vi.fn(), - mockVerifyInternalToken: vi.fn(), -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticateApiKeyFromHeader, mockUpdateApiKeyLastUsed, mockVerifyInternalToken } = + vi.hoisted(() => ({ + mockAuthenticateApiKeyFromHeader: vi.fn(), + mockUpdateApiKeyLastUsed: vi.fn(), + mockVerifyInternalToken: vi.fn(), + })) + +const mockGetSession = authMockFns.mockGetSession + +afterAll(() => { + mockGetSession.mockReset() +}) vi.unmock('@/lib/auth/hybrid') @@ -23,10 +26,6 @@ vi.mock('@/lib/api-key/service', () => ({ updateApiKeyLastUsed: mockUpdateApiKeyLastUsed, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken, })) diff --git a/apps/sim/lib/auth/stripe-adapter-guard.test.ts b/apps/sim/lib/auth/stripe-adapter-guard.test.ts index 8c8521041ee..824921f0a71 100644 --- a/apps/sim/lib/auth/stripe-adapter-guard.test.ts +++ b/apps/sim/lib/auth/stripe-adapter-guard.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' function createBaseAdapter() { diff --git a/apps/sim/lib/billing/authorization.test.ts b/apps/sim/lib/billing/authorization.test.ts index 457564f13a8..e2843bd47a0 100644 --- a/apps/sim/lib/billing/authorization.test.ts +++ b/apps/sim/lib/billing/authorization.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -16,7 +16,6 @@ const { mockGetOrganizationCoverageForMember: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing', () => ({ hasPaidSubscription: mockHasPaidSubscription })) vi.mock('@/lib/billing/core/organization', () => ({ isOrganizationOwnerOrAdmin: mockIsOwnerOrAdmin, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index ddd9b902474..ef47567b436 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -20,8 +14,6 @@ const { mockIsOrganizationBillingBlocked: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/member-limits', () => ({ getOrgMemberUsageForBillingPeriod: mockGetOrgMemberUsageForBillingPeriod, getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit, diff --git a/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts index 1a2697253c0..353306d1eaa 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts @@ -1,45 +1,35 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + redisConfigMockFns, + resetEnvFlagsMock, + resetEnvMock, + setEnv, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { evalMock, mockEnv } = vi.hoisted(() => ({ +const { evalMock } = vi.hoisted(() => ({ evalMock: vi.fn(), - mockEnv: { - BILLING_CONCURRENCY_LIMIT_FREE: '11', - BILLING_CONCURRENCY_LIMIT_PRO: '55', - BILLING_CONCURRENCY_LIMIT_TEAM: '222', - BILLING_CONCURRENCY_LIMIT_ENTERPRISE: '1111', - }, })) -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, - envNumber: ( - value: number | string | undefined | null, - fallback: number, - options: { min?: number; integer?: boolean } = {} - ) => { - const parsed = Number(value) - const min = options.min ?? 0 - return Number.isFinite(parsed) && - parsed >= min && - (!options.integer || Number.isInteger(parsed)) - ? parsed - : fallback - }, -})) - -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation' beforeAll(() => { setEnvFlags({ isBillingEnabled: true, isHosted: true }) + setEnv({ + BILLING_CONCURRENCY_LIMIT_FREE: '11', + BILLING_CONCURRENCY_LIMIT_PRO: '55', + BILLING_CONCURRENCY_LIMIT_TEAM: '222', + BILLING_CONCURRENCY_LIMIT_ENTERPRISE: '1111', + }) }) -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('usage reservation environment overrides', () => { beforeEach(() => { diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts index 23615006588..8b45b5aca83 100644 --- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { redisConfigMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - import { releaseExecutionSlot, reserveExecutionSlot, diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts index fccdaf8e6c9..e923da4135a 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts @@ -1,20 +1,13 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsTriggerAvailable } = vi.hoisted(() => ({ mockIsTriggerAvailable: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() })) vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPriorityPersonalSubscription: vi.fn(), diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts index 588a5a985c2..67a3b348092 100644 --- a/apps/sim/lib/billing/core/billing-attribution.test.ts +++ b/apps/sim/lib/billing/core/billing-attribution.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -26,8 +20,6 @@ const { mockGetOrganizationSubscription: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkBillingBlocked: mockCheckBillingBlocked, checkBillingEntityBlocked: mockCheckBillingEntityBlocked, diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index fa35c7a46c2..ce938771089 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -20,8 +20,6 @@ const { mockResolveBillingInterval: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription, getHighestPrioritySubscription: mockGetHighestPrioritySubscription, diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index 37ce9ac295a..1d00608d855 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -2,15 +2,16 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock, resetEnvFlagsMock, + resetUrlsMock, schemaMock, setEnvFlags, + urlsMockFns, } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { sendEmailSpy, getEmailPreferencesMock, renderMock, subjectMock, isOrgAdminRoleMock } = vi.hoisted(() => ({ @@ -21,9 +22,6 @@ const { sendEmailSpy, getEmailPreferencesMock, renderMock, subjectMock, isOrgAdm isOrgAdminRoleMock: vi.fn(() => true), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://app.sim.ai' })) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ getEmailPreferences: getEmailPreferencesMock, @@ -47,7 +45,14 @@ const baseUserParams = { userName: 'Ada', } -afterAll(resetEnvFlagsMock) +beforeAll(() => { + urlsMockFns.mockGetBaseUrl.mockReturnValue('https://app.sim.ai') +}) + +afterAll(() => { + resetEnvFlagsMock() + resetUrlsMock() +}) describe('maybeSendLimitThresholdEmail', () => { beforeEach(() => { diff --git a/apps/sim/lib/billing/core/plan.test.ts b/apps/sim/lib/billing/core/plan.test.ts index 9e776770eb9..f3dab53c4fd 100644 --- a/apps/sim/lib/billing/core/plan.test.ts +++ b/apps/sim/lib/billing/core/plan.test.ts @@ -2,11 +2,9 @@ * @vitest-environment node */ import { member, organization, subscription } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - /** * Realistic plan-check predicates so `pickHighestPrioritySubscription` exercises * the real Enterprise > Team > Pro priority ordering over the rows we feed it. diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index af08492f9e4..758658d95a6 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetEnvFlagsMock, setEnvFlags, urlsMock } from '@sim/testing' +import { dbChainMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -20,8 +20,6 @@ const { mockHasUsableSubscriptionAccess: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/access', () => ({ getEffectiveBillingStatus: vi.fn(), isOrganizationBillingBlocked: vi.fn(), @@ -55,8 +53,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/core/utils/urls', () => urlsMock) - import { getOrganizationCoverageForMember, getOrganizationIdForSubscriptionReference, diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index b03d8ff569b..a4512999cdb 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -2,13 +2,7 @@ * @vitest-environment node */ import { usageLog } from '@sim/db/schema' -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -31,8 +25,6 @@ const { mockUpdate: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index a46caa1517a..c855f49ea94 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -8,11 +8,9 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - afterAll(() => { resetDbChainMock() }) diff --git a/apps/sim/lib/billing/credits/daily-refresh.test.ts b/apps/sim/lib/billing/credits/daily-refresh.test.ts index 932f487fe9e..760cc1bb729 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.test.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.test.ts @@ -1,11 +1,9 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, drizzleOrmMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('drizzle-orm', () => { const sqlTag = () => { const obj: { as: () => typeof obj } = { as: () => obj } diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts index d50dd0a8088..15cc7b7f0bd 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.test.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -32,8 +26,6 @@ vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'generated-id') })) vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: vi.fn(), diff --git a/apps/sim/lib/billing/organization.test.ts b/apps/sim/lib/billing/organization.test.ts index 00709265f20..8886f7b2975 100644 --- a/apps/sim/lib/billing/organization.test.ts +++ b/apps/sim/lib/billing/organization.test.ts @@ -24,8 +24,6 @@ const { mockIsSubscriptionOrgScoped: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/billing', () => ({ getPlanPricing: mockGetPlanPricing, isSubscriptionOrgScoped: mockIsSubscriptionOrgScoped, diff --git a/apps/sim/lib/billing/organizations/create-organization.test.ts b/apps/sim/lib/billing/organizations/create-organization.test.ts index a3fb3bfa74d..31fdef2a5d1 100644 --- a/apps/sim/lib/billing/organizations/create-organization.test.ts +++ b/apps/sim/lib/billing/organizations/create-organization.test.ts @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ mockGenerateId: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId, generateShortId: vi.fn(() => 'short-id'), diff --git a/apps/sim/lib/billing/organizations/lock-order.test.ts b/apps/sim/lib/billing/organizations/lock-order.test.ts index f06493cdcab..453828e0c0d 100644 --- a/apps/sim/lib/billing/organizations/lock-order.test.ts +++ b/apps/sim/lib/billing/organizations/lock-order.test.ts @@ -18,7 +18,7 @@ import { userStats, workspace, } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockChangeOrganizationWorkspaceBilledAccountsInTx, mockChangeWorkspaceStoragePayersInTx } = @@ -42,8 +42,6 @@ import { import type { DbOrTx } from '@/lib/db/types' import { attachOwnedWorkspacesToOrganizationTx } from '@/lib/workspaces/organization-workspaces' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn(), })) diff --git a/apps/sim/lib/billing/organizations/member-limits.test.ts b/apps/sim/lib/billing/organizations/member-limits.test.ts index 3558a65337f..0367b73c80f 100644 --- a/apps/sim/lib/billing/organizations/member-limits.test.ts +++ b/apps/sim/lib/billing/organizations/member-limits.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -48,8 +48,6 @@ const { mockGetOrganizationSubscription: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/db/schema', () => schemaTables) vi.mock('drizzle-orm', () => ({ diff --git a/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts b/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts index 4c8e50dedea..6731dfe1a32 100644 --- a/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts +++ b/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts @@ -1,14 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockEnqueueOutboxEvent } = vi.hoisted(() => ({ mockEnqueueOutboxEvent: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/storage/payer-transfer', () => ({ changeOrganizationWorkspaceBilledAccountsInTx: vi.fn(), changeWorkspaceStoragePayerInTx: vi.fn(), diff --git a/apps/sim/lib/billing/organizations/provision-seat.test.ts b/apps/sim/lib/billing/organizations/provision-seat.test.ts index 638d84897d4..a5881054627 100644 --- a/apps/sim/lib/billing/organizations/provision-seat.test.ts +++ b/apps/sim/lib/billing/organizations/provision-seat.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -24,8 +24,6 @@ const { updateCalls: { value: [] as Array> }, })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, })) diff --git a/apps/sim/lib/billing/organizations/seat-drift.test.ts b/apps/sim/lib/billing/organizations/seat-drift.test.ts index 4b36402bdaf..cb5b0bcb2e4 100644 --- a/apps/sim/lib/billing/organizations/seat-drift.test.ts +++ b/apps/sim/lib/billing/organizations/seat-drift.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { - dbChainMock, queueTableRows, resetDbChainMock, resetEnvFlagsMock, @@ -15,8 +14,6 @@ const { mockReconcileOrganizationSeats } = vi.hoisted(() => ({ mockReconcileOrganizationSeats: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mockReconcileOrganizationSeats, })) diff --git a/apps/sim/lib/billing/organizations/seats.test.ts b/apps/sim/lib/billing/organizations/seats.test.ts index fbfac95f4ab..e27e8bf9b80 100644 --- a/apps/sim/lib/billing/organizations/seats.test.ts +++ b/apps/sim/lib/billing/organizations/seats.test.ts @@ -3,7 +3,6 @@ */ import { auditMock, - dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock, @@ -18,8 +17,6 @@ const { mockSyncSubscriptionUsageLimits, enqueueMock } = vi.hoisted(() => ({ enqueueMock: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organization', () => ({ syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits, })) diff --git a/apps/sim/lib/billing/storage/limits.test.ts b/apps/sim/lib/billing/storage/limits.test.ts index 117dd1d2a58..4b9ffb21fd1 100644 --- a/apps/sim/lib/billing/storage/limits.test.ts +++ b/apps/sim/lib/billing/storage/limits.test.ts @@ -2,12 +2,15 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, + envMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags, } from '@sim/testing' + +const mockGetEnv = envMockFns.getEnv + import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockEq, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ @@ -15,8 +18,6 @@ const { mockEq, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/db/schema', () => ({ organization: { id: 'organization.id', @@ -36,14 +37,6 @@ vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) -const { mockGetEnv } = vi.hoisted(() => ({ - mockGetEnv: vi.fn((_variable: string): string | undefined => undefined), -})) - -vi.mock('@/lib/core/config/env', () => ({ - getEnv: mockGetEnv, -})) - import type { StorageBillingContext } from '@/lib/billing/storage/context' import { checkStorageQuota, diff --git a/apps/sim/lib/billing/storage/tracking.test.ts b/apps/sim/lib/billing/storage/tracking.test.ts index 33709cbbcf3..d994395a2ad 100644 --- a/apps/sim/lib/billing/storage/tracking.test.ts +++ b/apps/sim/lib/billing/storage/tracking.test.ts @@ -94,10 +94,6 @@ vi.mock('@/lib/billing/storage/limits', () => ({ isStorageEnforcementEnabled: () => envFlagsMock.isBillingEnabled, })) -vi.mock('@/lib/core/config/env', () => ({ - getEnv: vi.fn(() => undefined), -})) - vi.mock('@sim/logger', () => ({ createLogger: () => ({ error: mockLoggerError, diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index 63838978487..9e1276b278f 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -48,8 +42,6 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/access', () => ({ getEffectiveBillingStatus: mockGetEffectiveBillingStatus, isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, @@ -85,11 +77,6 @@ vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ }, })) -vi.mock('@/lib/core/config/env', () => ({ - env: {}, - envNumber: vi.fn((_value: string | undefined, fallback: number) => fallback), -})) - vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mockEnqueueOutboxEvent, })) diff --git a/apps/sim/lib/billing/validation/seat-management.test.ts b/apps/sim/lib/billing/validation/seat-management.test.ts index 61194fca2ed..a38880a6609 100644 --- a/apps/sim/lib/billing/validation/seat-management.test.ts +++ b/apps/sim/lib/billing/validation/seat-management.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetOrganizationSubscription, mockHasInflightOutboxEvent } = vi.hoisted(() => ({ @@ -15,8 +9,6 @@ const { mockGetOrganizationSubscription, mockHasInflightOutboxEvent } = vi.hoist mockHasInflightOutboxEvent: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/outbox/service', () => ({ hasInflightOutboxEvent: mockHasInflightOutboxEvent, })) diff --git a/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts b/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts index 8fcff1ee94e..81d65b00f0f 100644 --- a/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts +++ b/apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { assertEnterpriseReconciliationLeaseHeld, type EnterpriseReconciliationLease, diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 360b954b1c0..8dcdcd52cdd 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -3,13 +3,10 @@ */ import { createMockStripeEvent, - dbChainMock, dbChainMockFns, - drizzleOrmMock, resetDbChainMock, stripeClientMock, stripePaymentMethodMock, - urlsMock, urlsMockFns, } from '@sim/testing' import type Stripe from 'stripe' @@ -20,9 +17,6 @@ const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({ mockUnblockOrgMembers: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => drizzleOrmMock) - vi.mock('@/components/emails', () => ({ PaymentFailedEmail: vi.fn(), getEmailSubject: vi.fn(), @@ -83,8 +77,6 @@ vi.mock('@/lib/billing/webhooks/idempotency', () => ({ }, })) -vi.mock('@/lib/core/utils/urls', () => urlsMock) - vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: vi.fn(), })) diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts index 5cf73d5cb85..1f03bd7a164 100644 --- a/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetPlanByName, mockResolveDefaultPaymentMethod, stripeMock } = vi.hoisted(() => { @@ -18,8 +18,6 @@ const { mockGetPlanByName, mockResolveDefaultPaymentMethod, stripeMock } = vi.ho } }) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/stripe-client', () => ({ requireStripeClient: () => stripeMock, })) diff --git a/apps/sim/lib/concurrency/__tests__/leader-lock.test.ts b/apps/sim/lib/concurrency/__tests__/leader-lock.test.ts index bd0a11c548b..1314056e921 100644 --- a/apps/sim/lib/concurrency/__tests__/leader-lock.test.ts +++ b/apps/sim/lib/concurrency/__tests__/leader-lock.test.ts @@ -1,12 +1,9 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - import { withLeaderLock } from '@/lib/concurrency/leader-lock' beforeEach(() => { diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 9dd30c73673..a66fab12218 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -2,11 +2,8 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { claimCompletedAsyncToolCall, completeAsyncToolCall, diff --git a/apps/sim/lib/copilot/auth/permissions.test.ts b/apps/sim/lib/copilot/auth/permissions.test.ts index 93a62fc3171..605f82c0ae9 100644 --- a/apps/sim/lib/copilot/auth/permissions.test.ts +++ b/apps/sim/lib/copilot/auth/permissions.test.ts @@ -1,18 +1,17 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { workflowAuthzMockFns } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({ - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, -})) +const { mockAuthorizeWorkflowByWorkspacePermission } = workflowAuthzMockFns import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' +afterAll(() => { + mockAuthorizeWorkflowByWorkspacePermission.mockReset() +}) + describe('Copilot Auth Permissions', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 38dbe31de34..4e214ef22fa 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -1,20 +1,18 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMockFns, resetDbChainMock, workflowAuthzMockFns } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) +const { + mockAuthorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, + mockGetActiveWorkflowRecord: mockGetActiveWorkflow, +} = workflowAuthzMockFns -const { mockAuthorizeWorkflow, mockGetActiveWorkflow } = vi.hoisted(() => ({ - mockAuthorizeWorkflow: vi.fn(), - mockGetActiveWorkflow: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, - getActiveWorkflowRecord: mockGetActiveWorkflow, -})) +afterAll(() => { + mockAuthorizeWorkflow.mockReset() + mockGetActiveWorkflow.mockReset() +}) vi.mock('@/lib/workspaces/permissions/utils', () => ({ assertActiveWorkspaceAccess: vi.fn(), diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts index 3ca49834423..5a8b1155c4b 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { appendCopilotChatMessages, replaceCopilotChatMessages, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index bace6856104..ba72294c37b 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -4,10 +4,11 @@ import { authMockFns, - dbChainMock, + environmentUtilsMockFns, permissionsMock, permissionsMockFns, resetDbChainMock, + resetEnvironmentUtilsMock, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' @@ -17,8 +18,9 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions +const getEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv + const { - getEffectiveDecryptedEnv, generateWorkspaceSnapshot, processContextsServer, resolveActiveResourceContext, @@ -33,7 +35,6 @@ const { appendCopilotChatMessages, mockPublishStatusChanged, } = vi.hoisted(() => ({ - getEffectiveDecryptedEnv: vi.fn(), generateWorkspaceSnapshot: vi.fn(), processContextsServer: vi.fn(), resolveActiveResourceContext: vi.fn(), @@ -71,10 +72,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution, })) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv, -})) - vi.mock('@/lib/copilot/chat/workspace-context', () => ({ generateWorkspaceSnapshot, })) @@ -117,13 +114,12 @@ vi.mock('@/lib/copilot/chat-status', () => ({ }, })) -vi.mock('@sim/db', () => dbChainMock) - import { handleUnifiedChatPost } from './post' describe('handleUnifiedChatPost', () => { afterAll(() => { resetDbChainMock() + resetEnvironmentUtilsMock() }) beforeEach(() => { diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 970e1a9a531..bb2172e71f3 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' +import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' @@ -13,11 +13,11 @@ const { discoverServerTools, getSkillById } = vi.hoisted(() => ({ vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) + /** * Overrides the global `@sim/db` mock: the logs-context tests below need * controllable row data, which the stable `dbChainMockFns.limit` provides. */ -vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' diff --git a/apps/sim/lib/copilot/chat/stream-liveness.test.ts b/apps/sim/lib/copilot/chat/stream-liveness.test.ts index 27a17434878..f821be4cc8e 100644 --- a/apps/sim/lib/copilot/chat/stream-liveness.test.ts +++ b/apps/sim/lib/copilot/chat/stream-liveness.test.ts @@ -2,12 +2,10 @@ * @vitest-environment node */ import { copilotChats } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { and, eq } from 'drizzle-orm' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockGetChatStreamLockOwners } = vi.hoisted(() => ({ mockGetChatStreamLockOwners: vi.fn(), })) diff --git a/apps/sim/lib/copilot/chat/terminal-state.test.ts b/apps/sim/lib/copilot/chat/terminal-state.test.ts index 7adb8e42ae1..7f211472ce1 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.test.ts +++ b/apps/sim/lib/copilot/chat/terminal-state.test.ts @@ -3,12 +3,10 @@ */ import { copilotChats } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockAppendCopilotChatMessages } = vi.hoisted(() => ({ mockAppendCopilotChatMessages: vi.fn(), })) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index ad2551c7ce8..89d63bfb61a 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,14 +2,22 @@ * @vitest-environment node */ -import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + environmentUtilsMockFns, + resetEnvFlagsMock, + resetEnvironmentUtilsMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' +const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv + +afterAll(resetEnvironmentUtilsMock) + const { mockCreateRunSegment, mockForceFailHungToolCall, - mockGetEffectiveDecryptedEnv, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, mockPrepareExecutionContext, @@ -20,7 +28,6 @@ const { } = vi.hoisted(() => ({ mockCreateRunSegment: vi.fn(), mockForceFailHungToolCall: vi.fn(), - mockGetEffectiveDecryptedEnv: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), mockPrepareExecutionContext: vi.fn(), @@ -77,10 +84,6 @@ vi.mock('@/lib/core/config/env', () => ({ isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, -})) - vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index 30733864ff7..1a8541c811b 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,7 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { dbChainMock, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, @@ -113,8 +113,6 @@ vi.mock('@/lib/copilot/request/session/sse', () => ({ SSE_RESPONSE_HEADERS: {}, })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/chat-status', () => ({ chatPubSub: null, })) diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts index 9e8e2feae82..2404b12dc5c 100644 --- a/apps/sim/lib/copilot/request/session/abort.test.ts +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({ @@ -11,7 +11,6 @@ const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.ho mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) vi.mock('@/lib/copilot/request/session/buffer', () => ({ hasAbortMarker: mockHasAbortMarker, clearAbortMarker: mockClearAbortMarker, diff --git a/apps/sim/lib/copilot/request/session/buffer.test.ts b/apps/sim/lib/copilot/request/session/buffer.test.ts index 1556ed7c3d5..e0fec738227 100644 --- a/apps/sim/lib/copilot/request/session/buffer.test.ts +++ b/apps/sim/lib/copilot/request/session/buffer.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1EventType, @@ -100,8 +100,6 @@ const createRedisStub = () => { let mockRedis: ReturnType -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - import { allocateCursor, appendEvent, diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts index 8c267692cf9..5cfcd9efadf 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ COPILOT_API_KEY: 'sim-agent-key' }) +}) + +afterAll(resetEnvMock) const { mockFetchGo } = vi.hoisted(() => ({ mockFetchGo: vi.fn(), @@ -16,13 +23,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({ 'X-Sim-Source-Env': 'test' }), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { COPILOT_API_KEY: 'sim-agent-key' }, - getEnv: vi.fn().mockReturnValue(undefined), - isTruthy: (value: unknown) => Boolean(value), - isFalsy: (value: unknown) => value === false, -})) - import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' describe('requestExplicitStreamAbort', () => { diff --git a/apps/sim/lib/copilot/server/agent-url.test.ts b/apps/sim/lib/copilot/server/agent-url.test.ts index dae4021ad0a..5cdc9ce2f91 100644 --- a/apps/sim/lib/copilot/server/agent-url.test.ts +++ b/apps/sim/lib/copilot/server/agent-url.test.ts @@ -1,5 +1,5 @@ import { user } from '@sim/db/schema' -import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getMothershipBaseURL, @@ -16,7 +16,6 @@ const { envMock } = vi.hoisted(() => ({ }, })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/api/contracts', () => ({ mothershipEnvironmentSchema: { safeParse: (value: unknown) => diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index d81b81bc17c..d0e2cbc18cd 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -28,8 +28,6 @@ const { mockResolveStorageBillingContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ findMothershipUploadRowByChatAndName: mockFindUpload, })) diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts index 2f78577deef..2aa574c6a69 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts @@ -2,11 +2,9 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockReadFileRecord, mockFetchBuffer } = vi.hoisted(() => ({ mockReadFileRecord: vi.fn(), mockFetchBuffer: vi.fn(), diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 1b7860da1df..f14a565c292 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment node */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + dbChainMock, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ ensureWorkspaceAccess: vi.fn(), ensureWorkflowAccess: vi.fn(), getDefaultWorkspaceId: vi.fn(), - assertFolderMutable: vi.fn(), - assertWorkflowMutable: vi.fn(), getWorkspaceFileByName: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), @@ -30,11 +34,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mocks.assertFolderMutable, - assertWorkflowMutable: mocks.assertWorkflowMutable, -})) - vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, ensureWorkflowAccess: mocks.ensureWorkflowAccess, @@ -96,8 +95,8 @@ describe('vfs mv/cp', () => { resetDbChainMock() mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) - mocks.assertFolderMutable.mockResolvedValue(undefined) - mocks.assertWorkflowMutable.mockResolvedValue(undefined) + workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) mocks.getWorkspaceFileByName.mockResolvedValue(null) @@ -107,6 +106,8 @@ describe('vfs mv/cp', () => { afterAll(() => { resetDbChainMock() + workflowAuthzMockFns.mockAssertFolderMutable.mockReset().mockResolvedValue(undefined) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockReset().mockResolvedValue(undefined) }) describe('category rules', () => { @@ -289,7 +290,7 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.assertWorkflowMutable).toHaveBeenCalledWith('wf-1') + expect(workflowAuthzMockFns.mockAssertWorkflowMutable).toHaveBeenCalledWith('wf-1') expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) ) @@ -309,7 +310,7 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.assertFolderMutable).toHaveBeenCalledWith('fold-1') + expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('fold-1') expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) ) @@ -318,7 +319,9 @@ describe('vfs mv/cp', () => { it('surfaces locked-workflow rejections per item', async () => { queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Locked One', folderId: null }]) - mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new Error('Workflow is locked') + ) const result = await executeVfsMv( { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, @@ -338,7 +341,7 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.assertWorkflowMutable).not.toHaveBeenCalled() + expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( expect.objectContaining({ sourceWorkflowId: 'wf-1', @@ -429,7 +432,7 @@ describe('vfs mv/cp', () => { it('rejects creation inside a locked workflow folder', async () => { mocks.listFolders.mockResolvedValue([]) - mocks.assertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValue(new Error('Folder is locked')) const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 41e3c6c922e..37d2ee42a92 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -1,8 +1,26 @@ /** * @vitest-environment node */ -import { createEnvMock, dbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMock, + requestUtilsMockFns, + resetEnvMock, + schemaMock, + setEnv, + workflowAuthzMockFns, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ INTERNAL_API_SECRET: 'secret', SOCKET_SERVER_URL: 'http://socket.test' }) + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') +}) + +afterAll(() => { + resetEnvMock() + requestUtilsMockFns.mockGenerateRequestId.mockReset() +}) + import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { ExecutionContext } from '@/lib/copilot/request/types' @@ -61,16 +79,6 @@ vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ UsageReservationUnavailableError: class UsageReservationUnavailableError extends Error {}, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ INTERNAL_API_SECRET: 'secret' })) - -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: () => 'request-1', -})) - -vi.mock('@/lib/core/utils/urls', () => ({ - getSocketServerUrl: () => 'http://socket.test', -})) - vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ executeWorkflow: executeWorkflowMock, })) diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts b/apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts index be6852763c3..af26937c4b7 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts @@ -1,14 +1,11 @@ import { generateShortId } from '@sim/utils/id' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { consumeLatestFileIntent, type PendingFileIntent, storeFileIntent, } from './file-intent-store' -// Force the in-memory store path so the test is deterministic and Redis-free. -vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null })) - function makeIntent(overrides: Partial): PendingFileIntent { return { operation: 'update', diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index aa0dd96c9f6..af3374b925b 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { knowledgeConnector } from '@sim/db/schema' -import { dbChainMock, queueTableRows, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { queueTableRows, resetDbChainMock, resetUrlsMock, urlsMockFns } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, @@ -19,7 +19,6 @@ const { mockSerializeBillingAttributionHeader: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) @@ -38,9 +37,11 @@ vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ vi.mock('@/lib/copilot/tools/server/base-tool', () => ({ assertServerToolNotAborted: vi.fn(), })) -vi.mock('@/lib/core/utils/urls', () => ({ - getInternalApiBaseUrl: vi.fn(() => 'http://internal.test'), -})) +beforeAll(() => { + urlsMockFns.mockGetInternalApiBaseUrl.mockReturnValue('http://internal.test') +}) + +afterAll(resetUrlsMock) vi.mock('@/lib/knowledge/documents/service', () => ({ createSingleDocument: vi.fn(), deleteDocument: vi.fn(), diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index 716612512a7..a57b7f4699c 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -6,29 +6,30 @@ */ import { account, user } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + environmentUtilsMockFns, + queueTableRows, + resetDbChainMock, + resetEnvironmentUtilsMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const SECRET_ACCESS_TOKEN = 'ya29.a0SECRET_GOOGLE_BEARER_TOKEN_DO_NOT_LEAK' -const { getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } = vi.hoisted( - () => ({ - getAllOAuthServicesMock: vi.fn(), - getPersonalAndWorkspaceEnvMock: vi.fn(), - decodeJwtMock: vi.fn(), - }) -) +const { getAllOAuthServicesMock, decodeJwtMock } = vi.hoisted(() => ({ + getAllOAuthServicesMock: vi.fn(), + decodeJwtMock: vi.fn(), +})) + +const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv -vi.mock('@sim/db', () => dbChainMock) +afterAll(resetEnvironmentUtilsMock) vi.mock('@/lib/oauth', () => ({ getAllOAuthServices: getAllOAuthServicesMock, })) -vi.mock('@/lib/environment/utils', () => ({ - getPersonalAndWorkspaceEnv: getPersonalAndWorkspaceEnvMock, -})) - vi.mock('jose', () => ({ decodeJwt: decodeJwtMock, })) diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts index 38813e2383a..e6b159a3da4 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -2,21 +2,22 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - ensureWorkflowAccessMock, - ensureWorkspaceAccessMock, - getDefaultWorkspaceIdMock, - upsertPersonalEnvVarsMock, - upsertWorkspaceEnvVarsMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - ensureWorkspaceAccessMock: vi.fn(), - getDefaultWorkspaceIdMock: vi.fn(), - upsertPersonalEnvVarsMock: vi.fn(), - upsertWorkspaceEnvVarsMock: vi.fn(), -})) + mockUpsertPersonalEnvVars: upsertPersonalEnvVarsMock, + mockUpsertWorkspaceEnvVars: upsertWorkspaceEnvVarsMock, +} = environmentUtilsMockFns + +afterAll(resetEnvironmentUtilsMock) + +const { ensureWorkflowAccessMock, ensureWorkspaceAccessMock, getDefaultWorkspaceIdMock } = + vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + ensureWorkspaceAccessMock: vi.fn(), + getDefaultWorkspaceIdMock: vi.fn(), + })) vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkflowAccess: ensureWorkflowAccessMock, @@ -24,11 +25,6 @@ vi.mock('@/lib/copilot/tools/handlers/access', () => ({ getDefaultWorkspaceId: getDefaultWorkspaceIdMock, })) -vi.mock('@/lib/environment/utils', () => ({ - upsertPersonalEnvVars: upsertPersonalEnvVarsMock, - upsertWorkspaceEnvVars: upsertWorkspaceEnvVarsMock, -})) - import { setEnvironmentVariablesServerTool } from './set-environment-variables' describe('setEnvironmentVariablesServerTool', () => { diff --git a/apps/sim/lib/copilot/validation/selector-validator.test.ts b/apps/sim/lib/copilot/validation/selector-validator.test.ts index 7d068c03b20..6dbb96d81b1 100644 --- a/apps/sim/lib/copilot/validation/selector-validator.test.ts +++ b/apps/sim/lib/copilot/validation/selector-validator.test.ts @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index f4326b32035..5f070a01c06 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -3,14 +3,12 @@ */ import { randomFillSync } from 'node:crypto' -import { loggerMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({ fetchWorkspaceFileBuffer: vi.fn(), })) -vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer, })) diff --git a/apps/sim/lib/core/config/block-visibility.test.ts b/apps/sim/lib/core/config/block-visibility.test.ts index 55fabe28702..775a0990510 100644 --- a/apps/sim/lib/core/config/block-visibility.test.ts +++ b/apps/sim/lib/core/config/block-visibility.test.ts @@ -1,28 +1,22 @@ /** * @vitest-environment node */ -import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ +const { mockFetch, mockIsPlatformAdmin } = vi.hoisted(() => ({ mockFetch: vi.fn(), mockIsPlatformAdmin: vi.fn(), - envRef: { - APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, - APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, - }, })) +beforeAll(() => { + setEnv({ APPCONFIG_APPLICATION: 'sim-staging', APPCONFIG_ENVIRONMENT: 'staging' }) +}) + vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: mockFetch, })) -vi.mock('@/lib/core/config/env', () => ({ - get env() { - return envRef - }, -})) - vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mockIsPlatformAdmin, })) @@ -35,7 +29,10 @@ function withAppConfig(doc: unknown) { mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc))) } -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('getBlockVisibility', () => { beforeEach(() => { diff --git a/apps/sim/lib/core/config/env.test.ts b/apps/sim/lib/core/config/env.test.ts index 905b4b0b207..ea792ba42d8 100644 --- a/apps/sim/lib/core/config/env.test.ts +++ b/apps/sim/lib/core/config/env.test.ts @@ -1,9 +1,11 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { envNumber } from '@/lib/core/config/env' +vi.unmock('@/lib/core/config/env') + describe('envNumber', () => { it('can require integer env values for count-like settings', () => { expect(envNumber('5', 1, { min: 1, integer: true })).toBe(5) diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index 7cd527d8ad0..82a834fc93a 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -14,6 +14,7 @@ MockRedisConstructor.mockImplementation( } ) +vi.unmock('@/lib/core/config/redis') vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' })) vi.mock('ioredis', () => ({ default: MockRedisConstructor, diff --git a/apps/sim/lib/core/idempotency/cleanup.test.ts b/apps/sim/lib/core/idempotency/cleanup.test.ts index 3e460083168..1777b8b1ea9 100644 --- a/apps/sim/lib/core/idempotency/cleanup.test.ts +++ b/apps/sim/lib/core/idempotency/cleanup.test.ts @@ -2,11 +2,10 @@ * @vitest-environment node */ import { idempotencyKey } from '@sim/db/schema' -import { dbChainMock, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import { like, notLike } from 'drizzle-orm' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn() })) import { cleanupExpiredIdempotencyKeys } from '@/lib/core/idempotency/cleanup' diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts index 0c3390fbf95..7502ed5b8e8 100644 --- a/apps/sim/lib/core/outbox/service.test.ts +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -20,8 +20,6 @@ type OutboxRow = { processedAt: Date | null } -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-event-id'), })) diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts b/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts index d3d997d215a..0883618bebe 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts @@ -1,9 +1,7 @@ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { HostedKeyQueue } from './queue' -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - interface MockPipeline { rpush: Mock expire: Mock diff --git a/apps/sim/lib/core/rate-limiter/storage/factory.test.ts b/apps/sim/lib/core/rate-limiter/storage/factory.test.ts index 4827d142d9a..60507e1235d 100644 --- a/apps/sim/lib/core/rate-limiter/storage/factory.test.ts +++ b/apps/sim/lib/core/rate-limiter/storage/factory.test.ts @@ -1,4 +1,4 @@ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetStorageMethod, reconnectCallbacks } = vi.hoisted(() => { @@ -12,8 +12,6 @@ const { mockGetStorageMethod, reconnectCallbacks } = vi.hoisted(() => { const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient const mockOnRedisReconnect = redisConfigMockFns.mockOnRedisReconnect -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, })) diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index 7ccb5dc75cf..62753f61204 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -1,5 +1,5 @@ import { cn } from '@sim/emcn' -import { createEnvMock } from '@sim/testing' +import { resetEnvMock, setEnv } from '@sim/testing' import { formatDate, formatDateTime, @@ -7,7 +7,28 @@ import { formatTime, getTimezoneAbbreviation, } from '@sim/utils/formatting' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ + ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + OPENAI_API_KEY_1: 'test-openai-key-1', + OPENAI_API_KEY_2: 'test-openai-key-2', + OPENAI_API_KEY_3: 'test-openai-key-3', + ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', + ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', + ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', + GEMINI_API_KEY_1: 'test-gemini-key-1', + GEMINI_API_KEY_2: 'test-gemini-key-2', + GEMINI_API_KEY_3: 'test-gemini-key-3', + XAI_API_KEY_1: 'test-xai-key-1', + XAI_API_KEY_2: 'test-xai-key-2', + XAI_API_KEY_3: 'test-xai-key-3', + }) +}) + +afterAll(resetEnvMock) + import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { convertScheduleOptionsToCron } from '@/lib/core/utils/scheduling' @@ -31,24 +52,6 @@ vi.mock('crypto', () => ({ }), })) -vi.mock('@/lib/core/config/env', () => - createEnvMock({ - ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - OPENAI_API_KEY_1: 'test-openai-key-1', - OPENAI_API_KEY_2: 'test-openai-key-2', - OPENAI_API_KEY_3: 'test-openai-key-3', - ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', - ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', - ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', - GEMINI_API_KEY_1: 'test-gemini-key-1', - GEMINI_API_KEY_2: 'test-gemini-key-2', - GEMINI_API_KEY_3: 'test-gemini-key-3', - XAI_API_KEY_1: 'test-xai-key-1', - XAI_API_KEY_2: 'test-xai-key-2', - XAI_API_KEY_3: 'test-xai-key-3', - }) -) - afterEach(() => { vi.clearAllMocks() }) diff --git a/apps/sim/lib/core/utils/urls.test.ts b/apps/sim/lib/core/utils/urls.test.ts index 55cc21e71a5..b282a47f832 100644 --- a/apps/sim/lib/core/utils/urls.test.ts +++ b/apps/sim/lib/core/utils/urls.test.ts @@ -7,6 +7,7 @@ const { mockGetEnv } = vi.hoisted(() => ({ mockGetEnv: vi.fn<(key: string) => string | undefined>(), })) +vi.unmock('@/lib/core/utils/urls') vi.mock('@/lib/core/config/env', () => ({ env: {}, getEnv: mockGetEnv, diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index be6a158982e..93a5eae03ac 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' describe('getWorkspaceEnvKeyAdminAccess', () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts index 5e78de2f1d6..314da932186 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts @@ -1,15 +1,14 @@ /** * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnv } = vi.hoisted(() => ({ - mockEnv: { TRELLO_API_KEY: undefined as string | undefined }, -})) +beforeAll(() => { + setEnv({ TRELLO_API_KEY: undefined }) +}) -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, -})) +afterAll(resetEnvMock) import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello' @@ -31,7 +30,7 @@ describe('validateTrelloServiceAccount', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) - mockEnv.TRELLO_API_KEY = 'sim-api-key' + setEnv({ TRELLO_API_KEY: 'sim-api-key' }) }) afterEach(() => { @@ -119,7 +118,7 @@ describe('validateTrelloServiceAccount', () => { }) it('throws provider_unavailable without fetching when the API key is not configured', async () => { - mockEnv.TRELLO_API_KEY = undefined + setEnv({ TRELLO_API_KEY: undefined }) await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', diff --git a/apps/sim/lib/data-drains/dispatcher.test.ts b/apps/sim/lib/data-drains/dispatcher.test.ts index 17edc85de08..cb51659b0a5 100644 --- a/apps/sim/lib/data-drains/dispatcher.test.ts +++ b/apps/sim/lib/data-drains/dispatcher.test.ts @@ -1,17 +1,9 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockIsEnterprise, mockEnqueue, mockGetJobQueue } = vi.hoisted(() => { const mockEnqueue = vi.fn(async () => 'job-id') return { diff --git a/apps/sim/lib/data-drains/service.test.ts b/apps/sim/lib/data-drains/service.test.ts index 394b2d6d9f1..328ea105462 100644 --- a/apps/sim/lib/data-drains/service.test.ts +++ b/apps/sim/lib/data-drains/service.test.ts @@ -1,11 +1,9 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockGetSource, mockGetDestination, mockDecryptCredentials } = vi.hoisted(() => ({ mockGetSource: vi.fn(), mockGetDestination: vi.fn(), diff --git a/apps/sim/lib/execution/cancellation.test.ts b/apps/sim/lib/execution/cancellation.test.ts index eb440b2985e..2a59904326c 100644 --- a/apps/sim/lib/execution/cancellation.test.ts +++ b/apps/sim/lib/execution/cancellation.test.ts @@ -1,4 +1,4 @@ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({ @@ -9,7 +9,6 @@ const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({ const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient -vi.mock('@/lib/core/config/redis', () => redisConfigMock) vi.mock('@/lib/events/pubsub', () => ({ createPubSubChannel: () => ({ publish: mockPublish, diff --git a/apps/sim/lib/execution/e2b.test.ts b/apps/sim/lib/execution/e2b.test.ts index 0bc92f095f2..d5d45fbd03a 100644 --- a/apps/sim/lib/execution/e2b.test.ts +++ b/apps/sim/lib/execution/e2b.test.ts @@ -1,7 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ E2B_API_KEY: 'test-key' }) +}) + +afterAll(resetEnvMock) + import { CodeLanguage } from '@/lib/execution/languages' const { mockCreate, mockRunCode, mockCommandsRun, mockFilesWrite, mockKill } = vi.hoisted(() => ({ @@ -13,7 +21,6 @@ const { mockCreate, mockRunCode, mockCommandsRun, mockFilesWrite, mockKill } = v })) vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockCreate } })) -vi.mock('@/lib/core/config/env', () => ({ env: { E2B_API_KEY: 'test-key' } })) import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' diff --git a/apps/sim/lib/execution/event-buffer.test.ts b/apps/sim/lib/execution/event-buffer.test.ts index cd1753570ec..93dbd3bff07 100644 --- a/apps/sim/lib/execution/event-buffer.test.ts +++ b/apps/sim/lib/execution/event-buffer.test.ts @@ -1,11 +1,12 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' -const { mockGetRedisClient, mockRedis, persistedEntries } = vi.hoisted(() => { +const { mockRedis, persistedEntries } = vi.hoisted(() => { const persistedEntries: ExecutionEventEntry[] = [] const mockRedis = { get: vi.fn(), @@ -18,13 +19,12 @@ const { mockGetRedisClient, mockRedis, persistedEntries } = vi.hoisted(() => { pipeline: vi.fn(), eval: vi.fn(), } - const mockGetRedisClient = vi.fn(() => mockRedis) - return { mockGetRedisClient, mockRedis, persistedEntries } + return { mockRedis, persistedEntries } }) -vi.mock('@/lib/core/config/redis', () => ({ - getRedisClient: mockGetRedisClient, -})) +const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient + +afterAll(resetRedisConfigMock) import { createExecutionEventWriter, diff --git a/apps/sim/lib/execution/isolated-vm.test.ts b/apps/sim/lib/execution/isolated-vm.test.ts index 85c479b40b4..46ca743ae7f 100644 --- a/apps/sim/lib/execution/isolated-vm.test.ts +++ b/apps/sim/lib/execution/isolated-vm.test.ts @@ -2,12 +2,7 @@ * @vitest-environment node */ import { EventEmitter } from 'node:events' -import { - inputValidationMock, - inputValidationMockFns, - redisConfigMock, - redisConfigMockFns, -} from '@sim/testing' +import { inputValidationMock, inputValidationMockFns, redisConfigMockFns } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type MockProc = EventEmitter & { @@ -204,7 +199,6 @@ vi.mock('@/lib/core/utils/logging', () => ({ vi.mock('@/lib/core/config/env', () => ({ env: mockEnv, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) vi.mock('node:child_process', () => ({ execSync: mockExecSync, spawn: mockSpawn, diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index 6aff30c4ea0..3443e102939 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import { loggingSessionMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loggingSessionMock, workflowAuthzMockFns } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ADMISSION_ERROR_CODE } from '@/lib/core/admission/transient-failure' const { @@ -55,15 +55,6 @@ vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ })) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: vi.fn().mockResolvedValue({ - id: 'workflow-1', - userId: 'creator-1', - workspaceId: 'workspace-1', - isDeployed: true, - }), -})) - import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { preprocessExecution } from './preprocessing' @@ -88,7 +79,17 @@ const ORGANIZATION_ATTRIBUTION = { workspaceId: 'workspace-1', } +afterAll(() => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockReset() +}) + beforeEach(() => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + id: 'workflow-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + isDeployed: true, + }) mockResolveBillingAttribution.mockImplementation( ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => ({ ...ORGANIZATION_ATTRIBUTION, diff --git a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts index 4dc1b8bf9c0..fcbf013a774 100644 --- a/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts +++ b/apps/sim/lib/execution/preprocessing.webhook-correlation.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import { loggingSessionMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { loggingSessionMock, workflowAuthzMockFns } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockResolveSystemBillingAttribution } = vi.hoisted(() => ({ mockResolveSystemBillingAttribution: vi.fn(), @@ -29,17 +29,21 @@ vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ })) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: vi.fn().mockResolvedValue({ - id: 'workflow-1', - workspaceId: 'workspace-1', - isDeployed: true, - }), -})) - import { preprocessExecution } from './preprocessing' describe('preprocessExecution webhook correlation logging', () => { + beforeEach(() => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + id: 'workflow-1', + workspaceId: 'workspace-1', + isDeployed: true, + }) + }) + + afterAll(() => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockReset() + }) + it('preserves webhook correlation when logging preprocessing failures', async () => { mockResolveSystemBillingAttribution.mockRejectedValueOnce( new Error('Unable to resolve billing payer') diff --git a/apps/sim/lib/guardrails/mask-client.test.ts b/apps/sim/lib/guardrails/mask-client.test.ts index f8ee031545c..354adbefc0a 100644 --- a/apps/sim/lib/guardrails/mask-client.test.ts +++ b/apps/sim/lib/guardrails/mask-client.test.ts @@ -1,16 +1,19 @@ /** * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetUrlsMock, urlsMockFns } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockToken, mockBaseUrl, mockSleep } = vi.hoisted(() => ({ +afterAll(resetUrlsMock) + +const { mockToken, mockSleep } = vi.hoisted(() => ({ mockToken: vi.fn(), - mockBaseUrl: vi.fn(), mockSleep: vi.fn(), })) +const mockBaseUrl = urlsMockFns.mockGetInternalApiBaseUrl + vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) -vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: mockBaseUrl })) vi.mock('@sim/utils/helpers', () => ({ sleep: mockSleep })) import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client' diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index a3f258e0a02..0535f6632cd 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -37,8 +37,6 @@ const { mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/membership', () => ({ ensureUserInOrganizationTx: mockEnsureUserInOrganization, getUserOrganization: mockGetUserOrganization, diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts index 9eba697ffff..db987ac4603 100644 --- a/apps/sim/lib/invitations/direct-grant.test.ts +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - auditMock, - auditMockFns, - dbChainMock, - dbChainMockFns, - resetDbChainMock, -} from '@sim/testing' +import { auditMock, auditMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -26,7 +20,6 @@ const { mockWorkspaceMemberAdded: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/billing/organizations/membership', () => ({ diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts index a63fc27447a..819337b96e2 100644 --- a/apps/sim/lib/invitations/workspace-invitations.test.ts +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - auditMock, - createMockRequest, - dbChainMock, - dbChainMockFns, - resetDbChainMock, -} from '@sim/testing' +import { auditMock, createMockRequest, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -32,7 +26,6 @@ const { mockCaptureServerEvent: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/billing/organizations/membership', () => ({ diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index 81c5627e456..9ad039247b1 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteSync, mockIsTriggerAvailable, mockResolveTriggerRegion, mockTrigger } = @@ -12,7 +12,6 @@ const { mockExecuteSync, mockIsTriggerAvailable, mockResolveTriggerRegion, mockT mockTrigger: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTrigger } })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: mockResolveTriggerRegion, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index faa7fca4717..83a91635bab 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -1,11 +1,10 @@ /** * @vitest-environment node */ -import { authOAuthUtilsMock, dbChainMock, urlsMock } from '@sim/testing' +import { authOAuthUtilsMock } from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) vi.mock('drizzle-orm', () => ({ and: vi.fn(), eq: vi.fn(), @@ -13,7 +12,6 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn(), ne: vi.fn(), })) -vi.mock('@/lib/core/utils/urls', () => urlsMock) vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: vi.fn(), isTriggerAvailable: vi.fn(), diff --git a/apps/sim/lib/knowledge/documents/lock-order.test.ts b/apps/sim/lib/knowledge/documents/lock-order.test.ts index 92ecb99c566..27c6796dd4c 100644 --- a/apps/sim/lib/knowledge/documents/lock-order.test.ts +++ b/apps/sim/lib/knowledge/documents/lock-order.test.ts @@ -8,12 +8,10 @@ * a concurrent chunk edit of the same document. */ import { document, embedding } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { updateDocument } from '@/lib/knowledge/documents/service' -vi.mock('@sim/db', () => dbChainMock) - /** invocationCallOrder of the first `tx.update(table)` call. */ function updateOrderForTable(table: unknown): number { const { calls, invocationCallOrder } = dbChainMockFns.update.mock diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index 62de6d9d01d..5e090255f0a 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, defaultMockEnv, resetDbChainMock, @@ -17,7 +16,6 @@ const { mockBatchTrigger } = vi.hoisted(() => ({ mockBatchTrigger: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@trigger.dev/sdk', () => ({ tasks: { batchTrigger: mockBatchTrigger, diff --git a/apps/sim/lib/knowledge/documents/storage-billing.test.ts b/apps/sim/lib/knowledge/documents/storage-billing.test.ts index 6ff2a882f1c..f2e5e5b8a9a 100644 --- a/apps/sim/lib/knowledge/documents/storage-billing.test.ts +++ b/apps/sim/lib/knowledge/documents/storage-billing.test.ts @@ -24,8 +24,6 @@ const { mockGetFileMetadataByKeys: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/storage', () => ({ applyStorageUsageDeltasInTx: mockApplyStorageUsageDeltasInTx, checkStorageQuota: mockCheckStorageQuota, diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 8b825f0592e..8b9ca673f2d 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - permissionsMock, - permissionsMockFns, - resetDbChainMock, -} from '@sim/testing' +import { dbChainMockFns, permissionsMock, permissionsMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -24,7 +18,6 @@ const { mockResolveStorageBillingContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/billing/storage', () => ({ applyStorageUsageDeltasInTx: mockApplyStorageUsageDeltasInTx, diff --git a/apps/sim/lib/logs/execution/progress-markers.test.ts b/apps/sim/lib/logs/execution/progress-markers.test.ts index 2fc17b45d93..f04945d029f 100644 --- a/apps/sim/lib/logs/execution/progress-markers.test.ts +++ b/apps/sim/lib/logs/execution/progress-markers.test.ts @@ -1,21 +1,22 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionLastCompletedBlock, ExecutionLastStartedBlock } from '@/lib/logs/types' -const { mockGetRedisClient, mockRedis } = vi.hoisted(() => { +const { mockRedis } = vi.hoisted(() => { const mockRedis = { eval: vi.fn(), hgetall: vi.fn(), del: vi.fn(), } - return { mockGetRedisClient: vi.fn<[], typeof mockRedis | null>(() => mockRedis), mockRedis } + return { mockRedis } }) -vi.mock('@/lib/core/config/redis', () => ({ - getRedisClient: mockGetRedisClient, -})) +const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient + +afterAll(resetRedisConfigMock) vi.mock('@/lib/core/execution-limits', () => ({ getExecutionReservationTtlMs: () => 5_460_000, diff --git a/apps/sim/lib/mcp/connection-pool.test.ts b/apps/sim/lib/mcp/connection-pool.test.ts index 9359eceb92e..4683ce23d23 100644 --- a/apps/sim/lib/mcp/connection-pool.test.ts +++ b/apps/sim/lib/mcp/connection-pool.test.ts @@ -5,10 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { McpClient } from '@/lib/mcp/client' import { type AcquireParams, McpConnectionPool } from '@/lib/mcp/connection-pool' -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), -})) - interface FakeClient extends McpClient { __fireClose(): void __setConnected(connected: boolean): void diff --git a/apps/sim/lib/mcp/oauth/revoke.test.ts b/apps/sim/lib/mcp/oauth/revoke.test.ts index 73583eb5926..046671b269e 100644 --- a/apps/sim/lib/mcp/oauth/revoke.test.ts +++ b/apps/sim/lib/mcp/oauth/revoke.test.ts @@ -8,7 +8,7 @@ * raw `fetch`. */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/' @@ -49,7 +49,6 @@ vi.mock('@/lib/mcp/oauth/storage', () => ({ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) -vi.mock('@sim/db', () => dbChainMock) import { revokeMcpOauthTokens } from './revoke' diff --git a/apps/sim/lib/mcp/oauth/storage.test.ts b/apps/sim/lib/mcp/oauth/storage.test.ts index b9d07e779f7..ace0c16430f 100644 --- a/apps/sim/lib/mcp/oauth/storage.test.ts +++ b/apps/sim/lib/mcp/oauth/storage.test.ts @@ -2,29 +2,20 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, encryptionMock, encryptionMockFns, + redisConfigMockFns, resetDbChainMock, - schemaMock, + resetRedisConfigMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAcquireLock, mockReleaseLock, mockExtendLock } = vi.hoisted(() => ({ - mockAcquireLock: vi.fn(), - mockReleaseLock: vi.fn(), - mockExtendLock: vi.fn(), -})) +const { mockAcquireLock, mockReleaseLock, mockExtendLock } = redisConfigMockFns + +afterAll(resetRedisConfigMock) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => schemaMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) -vi.mock('@/lib/core/config/redis', () => ({ - acquireLock: mockAcquireLock, - releaseLock: mockReleaseLock, - extendLock: mockExtendLock, -})) import { getOrCreateOauthRow, diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 8b11a5ec4f7..29d746f3c7b 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -5,9 +5,7 @@ import { auditMock, dbChainMock, dbChainMockFns, - drizzleOrmMock, encryptionMock, - loggerMock, posthogServerMock, resetDbChainMock, schemaMock, @@ -36,9 +34,7 @@ vi.mock('@sim/db', () => ({ vi.mock('@sim/db/schema', () => ({ mcpServerOauth: schemaMock.mcpServerOauth, })) -vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn() })) -vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/mcp/domain-check', () => ({ McpDnsResolutionError: class extends Error {}, diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts index d2bbe589b79..f6437a111e8 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts @@ -21,7 +21,6 @@ vi.mock('@sim/db', () => ({ workflowMcpServer: schemaMock.workflowMcpServer, workflowMcpTool: schemaMock.workflowMcpTool, })) -vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => ({ and: vi.fn(), asc: vi.fn(), diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index ac334e22b41..f499ba99111 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -9,7 +9,7 @@ */ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { dbChainMock, dbChainMockFns, loggerMock, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -60,7 +60,6 @@ const { } }) -vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/mcp/connection-pool', () => ({ mcpConnectionPool: { acquire: mockAcquire, evictServer: vi.fn() }, })) @@ -87,7 +86,6 @@ const SERVER_ROW = { updatedAt: new Date('2026-01-01T00:00:00Z'), } -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/mcp/domain-check', () => ({ isMcpDomainAllowed: () => true, validateMcpDomain: () => {}, diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 04f33b2b4ff..d1cbb4bb538 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -3,7 +3,7 @@ */ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { dbChainMock, dbChainMockFns, loggerMock, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, loggerMock, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -73,8 +73,6 @@ const { } }) -vi.mock('@sim/db', () => dbChainMock) - /** * Routes every select chain to `mockGetWorkspaceServersRows`: `where(...)` * resolves the workspace's rows AND exposes `.limit()` for chains like diff --git a/apps/sim/lib/messaging/email/providers/gmail.test.ts b/apps/sim/lib/messaging/email/providers/gmail.test.ts index f7c79e0de5a..8147ab1e350 100644 --- a/apps/sim/lib/messaging/email/providers/gmail.test.ts +++ b/apps/sim/lib/messaging/email/providers/gmail.test.ts @@ -3,12 +3,14 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockJwtConstructor, mockGetAccessToken, mockEnv } = vi.hoisted(() => { +afterAll(resetEnvMock) + +const { mockJwtConstructor, mockGetAccessToken } = vi.hoisted(() => { const mockGetAccessToken = vi.fn() const jwtInstance = { getAccessToken: mockGetAccessToken } - const mockEnv: Record = {} return { mockJwtConstructor: vi.fn().mockImplementation( class { @@ -19,7 +21,6 @@ const { mockJwtConstructor, mockGetAccessToken, mockEnv } = vi.hoisted(() => { } ), mockGetAccessToken, - mockEnv, } }) @@ -27,11 +28,6 @@ vi.mock('google-auth-library', () => ({ JWT: mockJwtConstructor, })) -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, - getEnv: (key: string) => mockEnv[key], -})) - import { createGmailProvider } from '@/lib/messaging/email/providers/gmail' import type { ProcessedEmailData } from '@/lib/messaging/email/types' @@ -54,8 +50,8 @@ describe('Gmail mail provider', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) - mockEnv.GMAIL_SENDER = 'noreply@sim.example' - mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS + setEnv({ GMAIL_SENDER: 'noreply@sim.example' }) + setEnv({ GMAIL_CREDENTIALS_JSON: VALID_CREDENTIALS }) mockGetAccessToken.mockResolvedValue({ token: 'test-token' }) }) @@ -66,26 +62,26 @@ describe('Gmail mail provider', () => { describe('createGmailProvider', () => { it('returns null when neither GMAIL_SENDER nor GMAIL_CREDENTIALS_JSON is set', () => { - mockEnv.GMAIL_SENDER = undefined - mockEnv.GMAIL_CREDENTIALS_JSON = undefined + setEnv({ GMAIL_SENDER: undefined }) + setEnv({ GMAIL_CREDENTIALS_JSON: undefined }) expect(createGmailProvider()).toBeNull() }) it('returns null when only one of the two variables is set', () => { - mockEnv.GMAIL_CREDENTIALS_JSON = undefined + setEnv({ GMAIL_CREDENTIALS_JSON: undefined }) expect(createGmailProvider()).toBeNull() - mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS - mockEnv.GMAIL_SENDER = undefined + setEnv({ GMAIL_CREDENTIALS_JSON: VALID_CREDENTIALS }) + setEnv({ GMAIL_SENDER: undefined }) expect(createGmailProvider()).toBeNull() }) it('returns null for invalid or incomplete credentials JSON', () => { - mockEnv.GMAIL_CREDENTIALS_JSON = 'not-json' + setEnv({ GMAIL_CREDENTIALS_JSON: 'not-json' }) expect(createGmailProvider()).toBeNull() - mockEnv.GMAIL_CREDENTIALS_JSON = JSON.stringify({ client_email: 'x@y.iam' }) + setEnv({ GMAIL_CREDENTIALS_JSON: JSON.stringify({ client_email: 'x@y.iam' }) }) expect(createGmailProvider()).toBeNull() }) diff --git a/apps/sim/lib/messaging/email/unsubscribe.test.ts b/apps/sim/lib/messaging/email/unsubscribe.test.ts index d0b65c2fa26..f2eb98e8f67 100644 --- a/apps/sim/lib/messaging/email/unsubscribe.test.ts +++ b/apps/sim/lib/messaging/email/unsubscribe.test.ts @@ -1,5 +1,12 @@ -import { createEnvMock, databaseMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ BETTER_AUTH_SECRET: 'test-secret-key' }) +}) + +afterAll(resetEnvMock) + import type { EmailType } from '@/lib/messaging/email/mailer' vi.mock('drizzle-orm', () => ({ @@ -8,8 +15,6 @@ vi.mock('drizzle-orm', () => ({ const mockDb = databaseMock.db as Record> -vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' })) - import { generateUnsubscribeToken, getEmailPreferences, diff --git a/apps/sim/lib/messaging/email/utils.test.ts b/apps/sim/lib/messaging/email/utils.test.ts index c261c8966d0..c8ca121c837 100644 --- a/apps/sim/lib/messaging/email/utils.test.ts +++ b/apps/sim/lib/messaging/email/utils.test.ts @@ -1,5 +1,5 @@ -import { createEnvMock, urlsMock, urlsMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, resetUrlsMock, setEnv, urlsMockFns } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { EMAIL_HEADER_CONTROL_CHARS_REGEX, getFromEmailAddress, @@ -14,15 +14,17 @@ import { * environment configurations for email addresses. */ -// Set up mocks at module level - these will be used for all tests in this file -vi.mock('@/lib/core/config/env', () => - createEnvMock({ +beforeAll(() => { + setEnv({ FROM_EMAIL_ADDRESS: 'Sim ', EMAIL_DOMAIN: 'example.com', }) -) +}) -vi.mock('@/lib/core/utils/urls', () => urlsMock) +afterAll(() => { + resetEnvMock() + resetUrlsMock() +}) beforeEach(() => { urlsMockFns.mockGetEmailDomain.mockReturnValue('fallback.com') diff --git a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts index 3fd2787a1ea..9755c64b2d7 100644 --- a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts +++ b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - import { clearDeadFlag, getRecentTerminalError, diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 339b1a7694a..d6273e5af40 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -1,8 +1,8 @@ -import { createEnvMock, createMockFetch } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockFetch, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/config/env', () => - createEnvMock({ +beforeAll(() => { + setEnv({ GOOGLE_CLIENT_ID: 'google_client_id', GOOGLE_CLIENT_SECRET: 'google_client_secret', GITHUB_CLIENT_ID: 'github_client_id', @@ -54,7 +54,9 @@ vi.mock('@/lib/core/config/env', () => SPOTIFY_CLIENT_ID: 'spotify_client_id', SPOTIFY_CLIENT_SECRET: 'spotify_client_secret', }) -) +}) + +afterAll(resetEnvMock) import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' import { refreshOAuthToken } from '@/lib/oauth' diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 616eaae5f00..3ac741ff69d 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -2,11 +2,8 @@ * @vitest-environment node */ import { member } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { canOpenOrganizationSettingsSection, getOrganizationSettingsAccess, diff --git a/apps/sim/lib/table/__tests__/find-row-matches.test.ts b/apps/sim/lib/table/__tests__/find-row-matches.test.ts index 076a50686df..a0db90a5f82 100644 --- a/apps/sim/lib/table/__tests__/find-row-matches.test.ts +++ b/apps/sim/lib/table/__tests__/find-row-matches.test.ts @@ -6,13 +6,11 @@ * JS-side shaping (ordinal coercion, column rename, LIMIT+1 truncation), not * the query semantics — those need a real Postgres. */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ColumnDefinition, TableDefinition } from '@/lib/table/types' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/table/sql', () => ({ buildFilterClause: vi.fn(() => sql`true`), buildSortClause: vi.fn(() => sql`true`), diff --git a/apps/sim/lib/table/__tests__/lock-order.test.ts b/apps/sim/lib/table/__tests__/lock-order.test.ts index cca52fadcee..580584d438c 100644 --- a/apps/sim/lib/table/__tests__/lock-order.test.ts +++ b/apps/sim/lib/table/__tests__/lock-order.test.ts @@ -8,13 +8,11 @@ * concurrent inserts on the same table. */ import { userTableDefinitions } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { importAppendRows } from '@/lib/table/import-data' import type { TableDefinition } from '@/lib/table/types' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn().mockResolvedValue(false), })) diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index c0ade663506..8e19a08c84d 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -7,14 +7,12 @@ * timestamp for dates) are always available at the SQL builder layer — the * latent bug that PR #4657 was originally fixing. */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildFilterClause, buildSortClause } from '@/lib/table/sql' import type { ColumnDefinition, TableDefinition } from '@/lib/table/types' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/table/sql', () => ({ buildFilterClause: vi.fn(() => sql`true`), buildSortClause: vi.fn(() => sql`true`), diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index b516a7e0c5a..e606333026d 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { deleteColumn, renameColumn } from '@/lib/table/columns/service' import { @@ -14,8 +14,6 @@ import { import type { TableDefinition } from '@/lib/table/types' import { getUniqueColumns } from '@/lib/table/validation' -vi.mock('@sim/db', () => dbChainMock) - // Capacity is exercised in billing.test.ts; here it's a no-op so the timeout-scaling // suites can use large synthetic row counts without tripping the plan limit. vi.mock('@/lib/table/billing', () => ({ diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index fab52b73106..0f68850a39c 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { RowExecutionMetadata, TableDefinition, WorkflowGroup } from '@/lib/table/types' @@ -11,8 +11,6 @@ const { mockAppendTableEvent, mockUpdateRow, mockWriteExecutionsPatch } = vi.hoi mockWriteExecutionsPatch: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent, })) diff --git a/apps/sim/lib/table/dispatch-concurrency.test.ts b/apps/sim/lib/table/dispatch-concurrency.test.ts index 770be25bb1b..562ff0b0949 100644 --- a/apps/sim/lib/table/dispatch-concurrency.test.ts +++ b/apps/sim/lib/table/dispatch-concurrency.test.ts @@ -1,40 +1,24 @@ /** * @vitest-environment node */ -import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnv } = vi.hoisted(() => ({ - mockEnv: {} as Record, -})) - -vi.mock('@/lib/core/config/env', () => ({ - env: mockEnv, - envNumber: ( - value: number | string | undefined | null, - fallback: number, - options: { min?: number; integer?: boolean } = {} - ) => { - const parsed = Number(value) - const min = options.min ?? 0 - return Number.isFinite(parsed) && - parsed >= min && - (!options.integer || Number.isInteger(parsed)) - ? parsed - : fallback - }, -})) - +import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { getMaxTableDispatchConcurrency, getTableDispatchConcurrency, } from '@/lib/table/dispatch-concurrency' -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('getTableDispatchConcurrency', () => { beforeEach(() => { - for (const key of Object.keys(mockEnv)) delete mockEnv[key] + setEnv({ + TABLE_DISPATCH_CONCURRENCY_FREE: undefined, + TABLE_DISPATCH_CONCURRENCY_PAID: undefined, + }) setEnvFlags({ isBillingEnabled: true }) }) @@ -47,8 +31,8 @@ describe('getTableDispatchConcurrency', () => { }) it('applies env overrides', () => { - mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '5' - mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '200' + setEnv({ TABLE_DISPATCH_CONCURRENCY_FREE: '5' }) + setEnv({ TABLE_DISPATCH_CONCURRENCY_PAID: '200' }) expect(getTableDispatchConcurrency('free')).toBe(5) expect(getTableDispatchConcurrency('pro_6000')).toBe(200) @@ -59,20 +43,23 @@ describe('getTableDispatchConcurrency', () => { setEnvFlags({ isBillingEnabled: false }) expect(getTableDispatchConcurrency(null)).toBe(50) - mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '120' + setEnv({ TABLE_DISPATCH_CONCURRENCY_PAID: '120' }) expect(getTableDispatchConcurrency(null)).toBe(120) }) }) describe('getMaxTableDispatchConcurrency', () => { beforeEach(() => { - for (const key of Object.keys(mockEnv)) delete mockEnv[key] + setEnv({ + TABLE_DISPATCH_CONCURRENCY_FREE: undefined, + TABLE_DISPATCH_CONCURRENCY_PAID: undefined, + }) }) it('returns the highest configured value', () => { expect(getMaxTableDispatchConcurrency()).toBe(50) - mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '80' + setEnv({ TABLE_DISPATCH_CONCURRENCY_FREE: '80' }) expect(getMaxTableDispatchConcurrency()).toBe(80) }) }) diff --git a/apps/sim/lib/table/events.test.ts b/apps/sim/lib/table/events.test.ts index d3bdb6deca3..48c8a7bd61d 100644 --- a/apps/sim/lib/table/events.test.ts +++ b/apps/sim/lib/table/events.test.ts @@ -1,15 +1,14 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/config/redis', () => ({ - getRedisClient: () => null, -})) +beforeAll(() => { + setEnv({ REDIS_URL: undefined }) +}) -vi.mock('@/lib/core/config/env', () => ({ - env: { REDIS_URL: undefined }, -})) +afterAll(resetEnvMock) import type { TableEvent } from '@/lib/table/events' import { appendTableEvent, getLatestTableEventId, readTableEventsSince } from '@/lib/table/events' diff --git a/apps/sim/lib/table/rows/executions.test.ts b/apps/sim/lib/table/rows/executions.test.ts index 57dd8128a53..ebfc2c0e008 100644 --- a/apps/sim/lib/table/rows/executions.test.ts +++ b/apps/sim/lib/table/rows/executions.test.ts @@ -3,11 +3,8 @@ */ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RowExecutionMetadata } from '@/lib/table/types' - -vi.mock('@sim/db', () => dbChainMock) - import { writeExecutionsPatch } from '@/lib/table/rows/executions' +import type { RowExecutionMetadata } from '@/lib/table/types' const EXECUTION_STATE: RowExecutionMetadata = { status: 'running', diff --git a/apps/sim/lib/table/snapshot-cache.test.ts b/apps/sim/lib/table/snapshot-cache.test.ts index 35f5b4d1310..032970857ba 100644 --- a/apps/sim/lib/table/snapshot-cache.test.ts +++ b/apps/sim/lib/table/snapshot-cache.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockSelectExportRowPage, mockCreateMultipartUpload, mockHeadObject, mockDeleteFile } = @@ -12,7 +12,6 @@ const { mockSelectExportRowPage, mockCreateMultipartUpload, mockHeadObject, mock mockDeleteFile: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/table/jobs/service', () => ({ selectExportRowPage: mockSelectExportRowPage })) vi.mock('@/lib/uploads/core/storage-service', () => ({ createMultipartUpload: mockCreateMultipartUpload, diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 5c658928b55..ca103139be3 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -17,8 +17,6 @@ const { mockResolveStorageBillingContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/storage', () => ({ checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, decrementStorageUsageForBillingContext: mockDecrementStorageUsageForBillingContext, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index b106ce0aa54..1f51abd4b37 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { listWorkspaceFiles } from './workspace-file-manager' afterAll(resetDbChainMock) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index 13b2f30a628..9ac40d5587f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -26,8 +26,6 @@ const { mockUploadFile: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/storage', () => ({ decrementStorageUsageForBillingContextInTx: mockDecrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageForBillingContextInTx, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts index 8d016c7174c..eaf463774bc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -16,8 +16,6 @@ const { mockUploadFile: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/storage', () => ({ decrementStorageUsageForBillingContextInTx: vi.fn(), incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageForBillingContextInTx, diff --git a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts index 4e497204463..6c6cc70611a 100644 --- a/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts +++ b/apps/sim/lib/uploads/utils/user-file-base64.server.test.ts @@ -1,14 +1,15 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import type { UserFile } from '@/executor/types' -const { mockDownloadFile, mockGetRedisClient, mockRedis, mockVerifyFileAccess } = vi.hoisted(() => { +const { mockDownloadFile, mockRedis, mockVerifyFileAccess } = vi.hoisted(() => { const mockRedis = { get: vi.fn(), set: vi.fn(), @@ -22,15 +23,14 @@ const { mockDownloadFile, mockGetRedisClient, mockRedis, mockVerifyFileAccess } } return { mockDownloadFile: vi.fn(), - mockGetRedisClient: vi.fn(), mockRedis, mockVerifyFileAccess: vi.fn(), } }) -vi.mock('@/lib/core/config/redis', () => ({ - getRedisClient: mockGetRedisClient, -})) +const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient + +afterAll(resetRedisConfigMock) vi.mock('@/lib/uploads', () => ({ StorageService: { diff --git a/apps/sim/lib/webhooks/env-resolver.test.ts b/apps/sim/lib/webhooks/env-resolver.test.ts index 6da44a32fb3..fcbdb139daa 100644 --- a/apps/sim/lib/webhooks/env-resolver.test.ts +++ b/apps/sim/lib/webhooks/env-resolver.test.ts @@ -2,15 +2,12 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetEffectiveDecryptedEnv } = vi.hoisted(() => ({ - mockGetEffectiveDecryptedEnv: vi.fn(), -})) +const { mockGetEffectiveDecryptedEnv } = environmentUtilsMockFns -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, -})) +afterAll(resetEnvironmentUtilsMock) import { resolveWebhookProviderConfig, diff --git a/apps/sim/lib/webhooks/pending-verification.test.ts b/apps/sim/lib/webhooks/pending-verification.test.ts index d282aebb90d..370cd18b0a1 100644 --- a/apps/sim/lib/webhooks/pending-verification.test.ts +++ b/apps/sim/lib/webhooks/pending-verification.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - +import { redisConfigMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { clearPendingWebhookVerification, getPendingWebhookVerification, diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 9218ab35403..d189730573f 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -81,10 +81,6 @@ vi.mock('@sim/security/compare', () => ({ safeCompare: vi.fn().mockReturnValue(true), })) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: vi.fn().mockResolvedValue({}), -})) - vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.test.ts b/apps/sim/lib/webhooks/provider-subscriptions.test.ts index 336dc8ed3da..cdccc03b4a7 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.test.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.test.ts @@ -1,16 +1,17 @@ /** * @vitest-environment node */ + +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetEffectiveDecryptedEnv, mockGetProviderHandler } = vi.hoisted(() => ({ - mockGetEffectiveDecryptedEnv: vi.fn(), - mockGetProviderHandler: vi.fn(), -})) +const { mockGetEffectiveDecryptedEnv } = environmentUtilsMockFns -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +afterAll(resetEnvironmentUtilsMock) + +const { mockGetProviderHandler } = vi.hoisted(() => ({ + mockGetProviderHandler: vi.fn(), })) vi.mock('@/lib/webhooks/providers', () => ({ diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts index 9e3584cde9a..72490858e11 100644 --- a/apps/sim/lib/webhooks/providers/instantly.test.ts +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -1,3 +1,4 @@ +import { resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { instantlyHandler } from '@/lib/webhooks/providers/instantly' @@ -242,14 +243,14 @@ describe('Instantly webhook provider', () => { const fetchMock = vi.fn() beforeEach(() => { - vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test') + setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.test' }) vi.stubGlobal('fetch', fetchMock) fetchMock.mockReset() }) afterEach(() => { vi.unstubAllGlobals() - vi.unstubAllEnvs() + resetEnvMock() }) it('creates an Instantly webhook with the mapped event type', async () => { diff --git a/apps/sim/lib/webhooks/providers/revenuecat.test.ts b/apps/sim/lib/webhooks/providers/revenuecat.test.ts index b34cdfe6d33..0d832733111 100644 --- a/apps/sim/lib/webhooks/providers/revenuecat.test.ts +++ b/apps/sim/lib/webhooks/providers/revenuecat.test.ts @@ -1,3 +1,4 @@ +import { resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat' @@ -164,12 +165,12 @@ describe('RevenueCat webhook provider', () => { beforeEach(() => { vi.restoreAllMocks() - vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://sim.example.com') + setEnv({ NEXT_PUBLIC_APP_URL: 'https://sim.example.com' }) }) afterEach(() => { vi.restoreAllMocks() - vi.unstubAllEnvs() + resetEnvMock() }) it('creates the integration and returns externalId + generated authHeaderSecret', async () => { diff --git a/apps/sim/lib/webhooks/providers/rootly.test.ts b/apps/sim/lib/webhooks/providers/rootly.test.ts index 59a8e3a90f6..47f8114e262 100644 --- a/apps/sim/lib/webhooks/providers/rootly.test.ts +++ b/apps/sim/lib/webhooks/providers/rootly.test.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto' +import { resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rootlyHandler } from '@/lib/webhooks/providers/rootly' @@ -168,14 +169,14 @@ describe('Rootly webhook provider', () => { const fetchMock = vi.fn() beforeEach(() => { - vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test') + setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.test' }) vi.stubGlobal('fetch', fetchMock) fetchMock.mockReset() }) afterEach(() => { vi.unstubAllGlobals() - vi.unstubAllEnvs() + resetEnvMock() }) it('creates a Rootly endpoint with a generated secret and the mapped event type', async () => { diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts index 164538fb65d..b08db0ce3e9 100644 --- a/apps/sim/lib/workflows/deployment-outbox.test.ts +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -65,24 +65,11 @@ vi.mock('@sim/audit', () => ({ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) -vi.mock('@/lib/core/config/env', () => ({ - env: { INTERNAL_API_SECRET: 'secret' }, -})) - vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn(), processOutboxEventById: vi.fn(), })) -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: () => 'request-generated', -})) - -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'http://localhost:3000', - getSocketServerUrl: () => 'http://localhost:3002', -})) - vi.mock('@/lib/mcp/server-locks', () => ({ setWorkflowMcpTransactionLockTimeout: mockSetWorkflowMcpTransactionLockTimeout, })) diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index b5e2f77429a..a5f255bcf33 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1,13 +1,14 @@ import { + environmentUtilsMockFns, + resetEnvironmentUtilsMock, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - getPersonalAndWorkspaceEnvMock, mergeSubblockStateWithValuesMock, safeStartMock, safeCompleteMock, @@ -23,7 +24,6 @@ const { executorConstructorMock, findStartBlockMock, } = vi.hoisted(() => ({ - getPersonalAndWorkspaceEnvMock: vi.fn(), mergeSubblockStateWithValuesMock: vi.fn(), safeStartMock: vi.fn(), safeCompleteMock: vi.fn(), @@ -40,15 +40,15 @@ const { findStartBlockMock: vi.fn(), })) +const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + +afterAll(resetEnvironmentUtilsMock) + const loadWorkflowFromNormalizedTablesMock = workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables const loadDeployedWorkflowStateMock = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState const updateWorkflowRunCountsMock = workflowsUtilsMockFns.mockUpdateWorkflowRunCounts -vi.mock('@/lib/environment/utils', () => ({ - getPersonalAndWorkspaceEnv: getPersonalAndWorkspaceEnvMock, -})) - vi.mock('@/lib/execution/cancellation', () => ({ clearExecutionCancellation: clearExecutionCancellationMock, })) diff --git a/apps/sim/lib/workflows/executor/execution-id-claim.test.ts b/apps/sim/lib/workflows/executor/execution-id-claim.test.ts index c62b261df59..c442bf9a2f0 100644 --- a/apps/sim/lib/workflows/executor/execution-id-claim.test.ts +++ b/apps/sim/lib/workflows/executor/execution-id-claim.test.ts @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ mockGenerateId: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId, })) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index de567e13371..43403742eb0 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockReleaseExecutionSlot, mockReplaceLargeValueReferenceKeysWithClient } = vi.hoisted( @@ -11,8 +11,6 @@ const { mockReleaseExecutionSlot, mockReplaceLargeValueReferenceKeysWithClient } }) ) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ releaseExecutionSlot: mockReleaseExecutionSlot, })) diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts index 5749f6408c6..624880ba592 100644 --- a/apps/sim/lib/workflows/lifecycle.test.ts +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -2,17 +2,23 @@ * @vitest-environment node */ import { - createEnvMock, dbChainMock, dbChainMockFns, resetDbChainMock, + resetEnvMock, schemaMock, - urlsMock, + setEnv, urlsMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + setEnv({ SOCKET_SERVER_URL: 'http://socket.test', INTERNAL_API_SECRET: 'secret' }) +}) + +afterAll(resetEnvMock) const { mockCleanupExternalWebhook, mockWorkflowDeleted } = vi.hoisted(() => ({ mockCleanupExternalWebhook: vi.fn(), @@ -29,12 +35,6 @@ vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ cleanupExternalWebhook: (...args: unknown[]) => mockCleanupExternalWebhook(...args), })) -vi.mock('@/lib/core/config/env', () => - createEnvMock({ SOCKET_SERVER_URL: 'http://socket.test', INTERNAL_API_SECRET: 'secret' }) -) - -vi.mock('@/lib/core/utils/urls', () => urlsMock) - vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { workflowDeleted: (...args: unknown[]) => mockWorkflowDeleted(...args), diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index b9afcdd9fa7..e428dc4b48b 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -79,15 +79,6 @@ vi.mock('@/lib/workspace-events/emitter', () => ({ emitWorkflowUndeployedEvent: vi.fn(), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { INTERNAL_API_SECRET: 'secret' }, -})) - -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'http://localhost:3000', - getSocketServerUrl: () => 'http://localhost:3002', -})) - vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 683a973714c..404af18c04f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -1,14 +1,17 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { resetUrlsMock, urlsMockFns } from '@sim/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'https://sim.test', -})) +beforeAll(() => { + urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test') +}) + +afterAll(resetUrlsMock) const genericWebhookConfig = { type: 'generic_webhook', diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index 0936d440680..d0c8b88d474 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -13,19 +13,15 @@ import { createWorkflowRecord, expectWorkflowAccessDenied, expectWorkflowAccessGranted, + workflowAuthzMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockAuthorizeWorkflow } = vi.hoisted(() => ({ - mockAuthorizeWorkflow: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, - getActiveWorkflowContext: vi.fn(), - getActiveWorkflowRecord: vi.fn(), - assertActiveWorkflowContext: vi.fn(), -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow } = workflowAuthzMockFns + +afterAll(() => { + mockAuthorizeWorkflow.mockReset() +}) import { createHttpResponseFromBlock, validateWorkflowPermissions } from '@/lib/workflows/utils' diff --git a/apps/sim/lib/workspace-events/emitter.test.ts b/apps/sim/lib/workspace-events/emitter.test.ts index 096c79fb4d8..3072ece6ac7 100644 --- a/apps/sim/lib/workspace-events/emitter.test.ts +++ b/apps/sim/lib/workspace-events/emitter.test.ts @@ -1,17 +1,22 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { workflowAuthzMockFns } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetActiveWorkflowContext } = workflowAuthzMockFns + +afterAll(() => { + mockGetActiveWorkflowContext.mockReset() +}) const { - mockGetActiveWorkflowContext, mockFetchSubscriptions, mockEvaluateRule, mockReadLastFiredAt, mockClaimCooldown, mockProcessPolledWebhookEvent, } = vi.hoisted(() => ({ - mockGetActiveWorkflowContext: vi.fn(), mockFetchSubscriptions: vi.fn(), mockEvaluateRule: vi.fn(), mockReadLastFiredAt: vi.fn(), @@ -19,10 +24,6 @@ const { mockProcessPolledWebhookEvent: vi.fn(), })) -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowContext: mockGetActiveWorkflowContext, -})) - vi.mock('@/lib/workspace-events/subscriptions', () => ({ fetchSimTriggerSubscriptions: mockFetchSubscriptions, parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig), diff --git a/apps/sim/lib/workspace-events/no-activity.test.ts b/apps/sim/lib/workspace-events/no-activity.test.ts index bee1fec1bf2..220f0978cfa 100644 --- a/apps/sim/lib/workspace-events/no-activity.test.ts +++ b/apps/sim/lib/workspace-events/no-activity.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockDispatchSimEvent, mockReadLastFiredAt, mockClaimCooldown } = vi.hoisted(() => ({ @@ -10,8 +10,6 @@ const { mockDispatchSimEvent, mockReadLastFiredAt, mockClaimCooldown } = vi.hois mockClaimCooldown: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workspace-events/emitter', () => ({ dispatchSimEvent: mockDispatchSimEvent, })) diff --git a/apps/sim/lib/workspace-events/rules.test.ts b/apps/sim/lib/workspace-events/rules.test.ts index 5350c25c079..cfb3caea534 100644 --- a/apps/sim/lib/workspace-events/rules.test.ts +++ b/apps/sim/lib/workspace-events/rules.test.ts @@ -1,11 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { evaluateRule, excludeSimExecutionsCondition } from '@/lib/workspace-events/rules' import type { ExecutionEventContext, SimSubscriptionConfig } from '@/lib/workspace-events/types' diff --git a/apps/sim/lib/workspaces/admin-move.test.ts b/apps/sim/lib/workspaces/admin-move.test.ts index e77473ffa3a..d1e29fffbdc 100644 --- a/apps/sim/lib/workspaces/admin-move.test.ts +++ b/apps/sim/lib/workspaces/admin-move.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment node */ import { organization, workspace } from '@sim/db/schema' -import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { PgDialect } from 'drizzle-orm/pg-core' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceMoveError } from '@/lib/workspaces/admin-move' @@ -26,15 +26,11 @@ const { changeWorkspaceStoragePayerInTx: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/audit', () => ({ AuditAction: { WORKSPACE_UPDATED: 'workspace.updated', INVITATION_UPDATED: 'invitation.updated' }, AuditResourceType: { WORKSPACE: 'workspace' }, recordAudit, })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), -})) vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: vi.fn(), })) diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index 671e6c73df6..2102e84ae97 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, permissionsMock, permissionsMockFns, @@ -18,8 +17,6 @@ const { mockArchiveWorkflowsForWorkspace } = vi.hoisted(() => ({ const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/workflows/lifecycle', () => ({ archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args), })) diff --git a/apps/sim/lib/workspaces/organization-workspaces.test.ts b/apps/sim/lib/workspaces/organization-workspaces.test.ts index d84c3571cf7..2dd1bcb4800 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.test.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.test.ts @@ -1,13 +1,7 @@ /** * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -26,8 +20,6 @@ const { mockChangeWorkspaceStoragePayersInTx: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: mockAcquireOrganizationMutationLock, ensureUserInOrganizationTx: mockEnsureUserInOrganizationTx, diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index fc5f2f86b0e..0a3ca8d6e55 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -2,13 +2,7 @@ * @vitest-environment node */ import { member, workspace } from '@sim/db/schema' -import { - dbChainMock, - queueTableRows, - resetDbChainMock, - resetEnvFlagsMock, - setEnvFlags, -} from '@sim/testing' +import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -21,8 +15,6 @@ const { mockGetHighestPrioritySubscription: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/organizations/membership', () => ({ getUserOrganization: mockGetUserOrganization, })) diff --git a/apps/sim/lib/workspaces/utils.test.ts b/apps/sim/lib/workspaces/utils.test.ts index a1cd55d2ad7..33d9eec1d0d 100644 --- a/apps/sim/lib/workspaces/utils.test.ts +++ b/apps/sim/lib/workspaces/utils.test.ts @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockChangeWorkspaceStoragePayerInTx } = vi.hoisted(() => ({ mockChangeWorkspaceStoragePayerInTx: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/storage/payer-transfer', () => ({ changeWorkspaceStoragePayerInTx: mockChangeWorkspaceStoragePayerInTx, })) diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index d78c9bdac2b..0956aa0dac8 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRequest } from '@/providers/types' const { @@ -11,7 +12,6 @@ const { mockCreatePinnedFetch, mockExecuteAnthropic, sentinelFetch, - envState, } = vi.hoisted(() => { const anthropicArgs: Array> = [] const sentinelFetch = vi.fn() @@ -27,15 +27,10 @@ const { mockCreatePinnedFetch: vi.fn(() => sentinelFetch), mockExecuteAnthropic: vi.fn(), sentinelFetch, - envState: { - AZURE_ANTHROPIC_ENDPOINT: undefined as string | undefined, - AZURE_ANTHROPIC_API_VERSION: undefined as string | undefined, - }, } }) vi.mock('@anthropic-ai/sdk', () => ({ default: mockAnthropic })) -vi.mock('@/lib/core/config/env', () => ({ env: envState })) vi.mock('@/lib/core/security/input-validation.server', () => ({ validateUrlWithDNS: mockValidate, createPinnedFetch: mockCreatePinnedFetch, @@ -65,12 +60,13 @@ function buildClientOptions(): Record { return anthropicArgs[0] } +afterAll(resetEnvMock) + describe('azureAnthropicProvider — SSRF pinning', () => { beforeEach(() => { vi.clearAllMocks() anthropicArgs.length = 0 - envState.AZURE_ANTHROPIC_ENDPOINT = undefined - envState.AZURE_ANTHROPIC_API_VERSION = undefined + setEnv({ AZURE_ANTHROPIC_ENDPOINT: undefined, AZURE_ANTHROPIC_API_VERSION: undefined }) mockExecuteAnthropic.mockResolvedValue({ content: 'ok' }) }) @@ -87,7 +83,7 @@ describe('azureAnthropicProvider — SSRF pinning', () => { }) it('does not pin when the endpoint comes from trusted server env', async () => { - envState.AZURE_ANTHROPIC_ENDPOINT = 'https://trusted.services.ai.azure.com' + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://trusted.services.ai.azure.com' }) await azureAnthropicProvider.executeRequest(request({ azureEndpoint: undefined })) diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 15e4073e8b0..f58c72ddfd6 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRequest } from '@/providers/types' const { @@ -14,7 +15,6 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint, mockIsResponsesEndpoint, - envState, } = vi.hoisted(() => { const azureOpenAIArgs: Array> = [] const sentinelFetch = vi.fn() @@ -35,15 +35,10 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint: vi.fn(() => false), mockIsResponsesEndpoint: vi.fn(() => false), - envState: { - AZURE_OPENAI_ENDPOINT: undefined as string | undefined, - AZURE_OPENAI_API_VERSION: undefined as string | undefined, - }, } }) vi.mock('openai', () => ({ AzureOpenAI: mockAzureOpenAI })) -vi.mock('@/lib/core/config/env', () => ({ env: envState })) vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) vi.mock('@/lib/core/security/input-validation.server', () => ({ validateUrlWithDNS: mockValidate, @@ -96,12 +91,13 @@ function request(overrides: Partial): ProviderRequest { /** Config object passed to the Responses core on the Nth call. */ const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1] +afterAll(resetEnvMock) + describe('azureOpenAIProvider — SSRF pinning', () => { beforeEach(() => { vi.clearAllMocks() azureOpenAIArgs.length = 0 - envState.AZURE_OPENAI_ENDPOINT = undefined - envState.AZURE_OPENAI_API_VERSION = undefined + setEnv({ AZURE_OPENAI_ENDPOINT: undefined, AZURE_OPENAI_API_VERSION: undefined }) mockIsChatCompletionsEndpoint.mockReturnValue(false) mockIsResponsesEndpoint.mockReturnValue(false) mockExecuteResponses.mockResolvedValue({ content: 'ok' }) @@ -121,7 +117,7 @@ describe('azureOpenAIProvider — SSRF pinning', () => { }) it('passes no custom fetch when the endpoint comes from trusted server env', async () => { - envState.AZURE_OPENAI_ENDPOINT = 'https://trusted.openai.azure.com' + setEnv({ AZURE_OPENAI_ENDPOINT: 'https://trusted.openai.azure.com' }) await azureOpenAIProvider.executeRequest(request({ azureEndpoint: undefined })) @@ -178,8 +174,10 @@ describe('azureOpenAIProvider — SSRF pinning', () => { it('constructs the AzureOpenAI client without a custom fetch for a trusted env endpoint', async () => { mockIsChatCompletionsEndpoint.mockReturnValue(true) - envState.AZURE_OPENAI_ENDPOINT = - 'https://trusted.openai.azure.com/openai/deployments/gpt-4o/chat/completions' + setEnv({ + AZURE_OPENAI_ENDPOINT: + 'https://trusted.openai.azure.com/openai/deployments/gpt-4o/chat/completions', + }) mockChatCreate.mockResolvedValue({ choices: [{ message: { content: 'hi', tool_calls: undefined } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 8a6a2fa011d..04efada3fa3 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ mockCreate: vi.fn(), @@ -20,9 +21,11 @@ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) -vi.mock('@/lib/core/config/env', () => ({ - env: { LITELLM_BASE_URL: 'http://litellm.test', LITELLM_API_KEY: '' }, -})) +beforeAll(() => { + setEnv({ LITELLM_BASE_URL: 'http://litellm.test', LITELLM_API_KEY: '' }) +}) + +afterAll(resetEnvMock) vi.mock('@/stores/providers', () => ({ useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) }, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 3c811906826..00ec2b43c32 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -38,7 +38,6 @@ vi.mock('openai', () => { return { default: OpenAI } }) -vi.mock('@/lib/core/utils/urls', () => ({ getOllamaUrl: () => 'http://localhost:11434' })) vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) vi.mock('@/providers/attachments', () => ({ formatMessagesForProvider: (messages: unknown) => messages, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 925c61ca2d2..ed6fa3de8f1 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreate, @@ -14,7 +15,6 @@ const { mockValidateUrlWithDNS, mockCreatePinnedFetch, pinnedFetchFn, - envState, } = vi.hoisted(() => { const openAIArgs: Array> = [] const mockCreate = vi.fn() @@ -36,15 +36,10 @@ const { mockValidateUrlWithDNS: vi.fn(), mockCreatePinnedFetch: vi.fn(() => pinnedFetchFn), pinnedFetchFn, - envState: { - VLLM_BASE_URL: 'http://localhost:8000', - VLLM_API_KEY: undefined as string | undefined, - }, } }) vi.mock('openai', () => ({ default: mockOpenAI })) -vi.mock('@/lib/core/config/env', () => ({ env: envState })) vi.mock('@/lib/core/security/input-validation.server', () => ({ validateUrlWithDNS: mockValidateUrlWithDNS, createPinnedFetch: mockCreatePinnedFetch, @@ -115,13 +110,14 @@ const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({ /** Payload passed to the Nth `chat.completions.create` call. */ const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] +afterAll(resetEnvMock) + describe('vllmProvider', () => { beforeEach(() => { vi.clearAllMocks() clearProviderClientCacheForTests() openAIArgs.length = 0 - envState.VLLM_BASE_URL = 'http://localhost:8000' - envState.VLLM_API_KEY = undefined + setEnv({ VLLM_BASE_URL: 'http://localhost:8000', VLLM_API_KEY: undefined }) mockPrepareTools.mockReturnValue({ tools: [{ type: 'function', function: { name: 'myTool' } }], toolChoice: 'auto', @@ -386,7 +382,7 @@ describe('vllmProvider', () => { }) it('throws when no base URL is configured', async () => { - envState.VLLM_BASE_URL = '' + setEnv({ VLLM_BASE_URL: '' }) await expect( vllmProvider.executeRequest({ diff --git a/apps/sim/stores/workflow-diff/store.test.ts b/apps/sim/stores/workflow-diff/store.test.ts index c9bca038048..7eae99115f9 100644 --- a/apps/sim/stores/workflow-diff/store.test.ts +++ b/apps/sim/stores/workflow-diff/store.test.ts @@ -22,10 +22,6 @@ const { applyWorkflowStateToStores } = vi.hoisted(() => ({ applyWorkflowStateToStores: vi.fn(), })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), -})) - vi.mock('@/lib/workflows/diff', () => ({ WorkflowDiffEngine: class { clearDiff = vi.fn() diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 929eed52aff..7c4ab612bdc 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -11,10 +11,15 @@ import { createExecutionContext, createMockFetch, type ExecutionContext, + environmentUtilsMockFns, inputValidationMock, inputValidationMockFns, type MockFetchResponse, resetEnvFlagsMock, + resetEnvironmentUtilsMock, + resetEnvMock, + resetUrlsMock, + setEnv, setEnvFlags, } from '@sim/testing' import { sleep } from '@sim/utils/helpers' @@ -23,7 +28,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attr // Hoisted mock state - these are available to vi.mock factories const { - mockEnv, mockGetBYOKKey, mockGetToolAsync, mockRateLimiterFns, @@ -32,9 +36,7 @@ const { mockGetCustomToolByIdOrTitle, mockGenerateInternalToken, mockResolveWorkspaceFileReference, - mockGetEffectiveDecryptedEnv, } = vi.hoisted(() => ({ - mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, mockGetBYOKKey: vi.fn(), mockGetToolAsync: vi.fn(), mockRateLimiterFns: { @@ -47,21 +49,11 @@ const { mockGetCustomToolByIdOrTitle: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), - mockGetEffectiveDecryptedEnv: vi.fn(), })) const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS - -// Mock env config to control hosted key availability -vi.mock('@/lib/core/config/env', () => ({ - env: new Proxy({} as Record, { - get: (_target, prop: string) => mockEnv[prop], - }), - getEnv: (key: string) => mockEnv[key], - isTruthy: (val: unknown) => val === true || val === 'true' || val === '1', - isFalsy: (val: unknown) => val === false || val === 'false' || val === '0', -})) +const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv // Mock getBYOKKey vi.mock('@/lib/api-key/byok', () => ({ @@ -99,10 +91,6 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ getHostedKeyRateLimiter: () => mockRateLimiterFns, })) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args), -})) - vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), })) @@ -402,10 +390,19 @@ vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQu beforeEach(() => { vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQueryClient) + // Suites below call vi.resetAllMocks(), which wipes the shared env/urls mock + // implementations — restore their defaults and re-pin the base URL each test. + resetEnvMock() + resetUrlsMock() + resetEnvironmentUtilsMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) }) afterAll(() => { vi.mocked(getQueryClientModule.getQueryClient).mockRestore() + resetEnvMock() + resetUrlsMock() + resetEnvironmentUtilsMock() }) /** @@ -2550,7 +2547,7 @@ describe('Rate Limiting and Retry Logic', () => { }) vi.clearAllMocks() setEnvFlags({ isHosted: true }) - mockEnv.TEST_HOSTED_KEY = 'test-hosted-api-key' + setEnv({ TEST_HOSTED_KEY: 'test-hosted-api-key' }) mockGetBYOKKey.mockResolvedValue(null) // Set up throttler mock defaults mockRateLimiterFns.acquireKey.mockResolvedValue({ @@ -2568,7 +2565,7 @@ describe('Rate Limiting and Retry Logic', () => { vi.resetAllMocks() cleanupEnvVars() setEnvFlags({ isHosted: false }) - mockEnv.TEST_HOSTED_KEY = undefined + setEnv({ TEST_HOSTED_KEY: undefined }) }) it('should retry on 429 rate limit errors with exponential backoff', async () => { @@ -2934,7 +2931,7 @@ describe('Cost Field Handling', () => { }) vi.clearAllMocks() setEnvFlags({ isHosted: true }) - mockEnv.TEST_HOSTED_KEY = 'test-hosted-api-key' + setEnv({ TEST_HOSTED_KEY: 'test-hosted-api-key' }) mockGetBYOKKey.mockResolvedValue(null) // Set up throttler mock defaults mockRateLimiterFns.acquireKey.mockResolvedValue({ @@ -2951,7 +2948,7 @@ describe('Cost Field Handling', () => { vi.resetAllMocks() cleanupEnvVars() setEnvFlags({ isHosted: false }) - mockEnv.TEST_HOSTED_KEY = undefined + setEnv({ TEST_HOSTED_KEY: undefined }) }) it('should add cost to output when using hosted key with per_request pricing', async () => { diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index 4abf8e34bb2..fe9c7f1439d 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -3,13 +3,17 @@ import { databaseMock, drizzleOrmMock, envFlagsMock, + environmentUtilsMock, + envMock, hybridAuthMock, loggerMock, + redisConfigMock, requestUtilsMock, schemaMock, setupGlobalFetchMock, setupGlobalStorageMocks, terminalConsoleMock, + urlsMock, workflowAuthzMock, } from '@sim/testing' import { afterAll, vi } from 'vitest' @@ -27,6 +31,10 @@ vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +vi.mock('@/lib/core/config/env', () => envMock) +vi.mock('@/lib/core/utils/urls', () => urlsMock) +vi.mock('@/lib/core/config/redis', () => redisConfigMock) +vi.mock('@/lib/environment/utils', () => environmentUtilsMock) vi.mock('@/stores/console/store', () => ({ useConsoleStore: { diff --git a/packages/testing/src/mocks/env.mock.test.ts b/packages/testing/src/mocks/env.mock.test.ts new file mode 100644 index 00000000000..0270c23220e --- /dev/null +++ b/packages/testing/src/mocks/env.mock.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + defaultMockEnv, + envMock, + envMockFns, + mockEnvObject, + resetEnvMock, + setEnv, +} from './env.mock' + +describe('env mock', () => { + afterEach(() => { + resetEnvMock() + vi.unstubAllEnvs() + }) + + it('exposes the default state through env and getEnv', () => { + expect(envMock.env.NEXT_PUBLIC_APP_URL).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + expect(envMock.getEnv('DATABASE_URL')).toBe(defaultMockEnv.DATABASE_URL) + }) + + it('applies setEnv overrides to live reads', () => { + setEnv({ REDIS_URL: 'redis://localhost:6379' }) + expect(envMock.env.REDIS_URL).toBe('redis://localhost:6379') + expect(envMock.getEnv('REDIS_URL')).toBe('redis://localhost:6379') + }) + + it('supports direct property assignment on the env object', () => { + mockEnvObject.COPILOT_SOURCE_ENV = 'dev' + expect(envMock.env.COPILOT_SOURCE_ENV).toBe('dev') + }) + + it('falls back to process.env for keys not pinned in state', () => { + vi.stubEnv('SOME_UNPINNED_TEST_VAR', 'from-process-env') + expect(envMock.env.SOME_UNPINNED_TEST_VAR).toBe('from-process-env') + expect(envMock.getEnv('SOME_UNPINNED_TEST_VAR')).toBe('from-process-env') + }) + + it('pins explicitly-undefined overrides without process.env fallback', () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://shadowed.example.com') + setEnv({ NEXT_PUBLIC_APP_URL: undefined }) + expect(envMock.env.NEXT_PUBLIC_APP_URL).toBeUndefined() + expect(envMock.getEnv('NEXT_PUBLIC_APP_URL')).toBeUndefined() + }) + + it('resetEnvMock restores defaults and removes overrides', () => { + setEnv({ REDIS_URL: 'redis://localhost:6379', NEXT_PUBLIC_APP_URL: 'https://other.test' }) + envMockFns.getEnv.mockReturnValue('overridden') + resetEnvMock() + expect(envMock.env.REDIS_URL).toBeUndefined() + expect(envMock.env.NEXT_PUBLIC_APP_URL).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + expect(envMock.getEnv('NEXT_PUBLIC_APP_URL')).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + }) + + it('coercion helpers mirror the real module', () => { + expect(envMock.isTruthy('true')).toBe(true) + expect(envMock.isTruthy('0')).toBe(false) + expect(envMock.isFalsy('false')).toBe(true) + expect(envMock.isFalsy(undefined)).toBe(false) + expect(envMock.envBoolean('yes')).toBe(true) + expect(envMock.envBoolean('')).toBeUndefined() + expect(envMock.envNumber('5', 1, { min: 1, integer: true })).toBe(5) + expect(envMock.envNumber('5.5', 1, { min: 1, integer: true })).toBe(1) + }) +}) diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts index e6bbd09f7b4..26368b020f1 100644 --- a/packages/testing/src/mocks/env.mock.ts +++ b/packages/testing/src/mocks/env.mock.ts @@ -1,7 +1,15 @@ import { vi } from 'vitest' /** - * Default mock environment values for testing + * Value type for entries in the mocked env object. The real module runs + * `createEnv` with `skipValidation: true`, so values arrive as raw strings + * (or occasionally booleans/numbers when injected programmatically). + */ +export type EnvMockValue = string | boolean | number | undefined + +/** + * Default mock environment values for testing. These seed the shared stateful + * env mock and are restored by {@link resetEnvMock}. */ export const defaultMockEnv = { // Core @@ -22,23 +30,137 @@ export const defaultMockEnv = { } /** - * Creates a mock getEnv function that returns values from the provided env object + * Mutable state backing the shared env mock. Keys present here (even with an + * `undefined` value) shadow `process.env`; absent keys fall back to + * `process.env` so `vi.stubEnv`-driven tests keep working for variables the + * defaults do not pin. + */ +const envState: Record = { ...defaultMockEnv } + +function readEnvValue(key: string): EnvMockValue { + if (Object.hasOwn(envState, key)) return envState[key] + return process.env[key] +} + +/** + * Live env object for the shared `@/lib/core/config/env` mock. Property reads + * resolve against the mutable mock state first and `process.env` second; + * property writes land in the mock state (mirroring how tests mutate the real + * t3-env object under `skipValidation`). + */ +export const mockEnvObject: Record = new Proxy(envState, { + get: (_target, prop) => (typeof prop === 'string' ? readEnvValue(prop) : undefined), + set: (target, prop, value) => { + if (typeof prop === 'string') target[prop] = value as EnvMockValue + return true + }, + has: (target, prop) => + typeof prop === 'string' ? Object.hasOwn(target, prop) || prop in process.env : false, + deleteProperty: (target, prop) => { + if (typeof prop === 'string') delete target[prop] + return true + }, + ownKeys: (target) => Array.from(new Set([...Object.keys(target), ...Object.keys(process.env)])), + getOwnPropertyDescriptor: (_target, prop) => + typeof prop === 'string' + ? { enumerable: true, configurable: true, value: readEnvValue(prop) } + : undefined, +}) + +/** + * Applies per-test overrides to the shared env mock state. Passing an + * explicitly `undefined` value pins the variable as unset (it will NOT fall + * back to `process.env`). + * + * @example + * ```ts + * beforeEach(() => { + * setEnv({ REDIS_URL: 'redis://localhost:6379', NEXT_PUBLIC_APP_URL: undefined }) + * }) + * afterAll(resetEnvMock) + * ``` + */ +export function setEnv(overrides: Record): void { + Object.assign(envState, overrides) +} + +/** + * Restores the shared env mock to {@link defaultMockEnv} and reinstalls the + * default `getEnv` implementation. + */ +export function resetEnvMock(): void { + for (const key of Object.keys(envState)) delete envState[key] + Object.assign(envState, defaultMockEnv) + envMockFns.getEnv.mockReset().mockImplementation(getEnvDefaultImpl) +} + +function getEnvDefaultImpl(variable: string): string | undefined { + const value = readEnvValue(variable) + return value === undefined ? undefined : String(value) +} + +/** Mirrors the real `isTruthy` from `@/lib/core/config/env`. */ +export const isTruthyImpl = (value: string | boolean | number | undefined): boolean => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value) + +/** Mirrors the real `isFalsy` from `@/lib/core/config/env`. */ +export const isFalsyImpl = (value: string | boolean | number | undefined): boolean => + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false + +/** Mirrors the real `envBoolean` from `@/lib/core/config/env`. */ +export function envBooleanImpl(value: boolean | string | undefined | null): boolean | undefined { + if (typeof value === 'boolean') return value + if (value === undefined || value === null || value === '') return undefined + const normalized = String(value).trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +/** Mirrors the real `envNumber` from `@/lib/core/config/env`. */ +export function envNumberImpl( + value: number | string | undefined | null, + fallback: number, + options: { min?: number; integer?: boolean } = {} +): number { + const min = options.min ?? 0 + if ( + typeof value === 'number' && + Number.isFinite(value) && + value >= min && + (!options.integer || Number.isInteger(value)) + ) { + return value + } + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= min && (!options.integer || Number.isInteger(parsed)) + ? parsed + : fallback +} + +/** + * Controllable mock functions for the function exports of + * `@/lib/core/config/env`. `getEnv` defaults to reading the shared state (with + * `process.env` fallback); override per-test if needed. {@link resetEnvMock} + * restores the default implementation. + */ +export const envMockFns = { + getEnv: vi.fn<(variable: string) => string | undefined>(getEnvDefaultImpl), +} + +/** + * Creates a mock getEnv function that returns values from the provided env object. */ export function createMockGetEnv(envValues: Record = defaultMockEnv) { return vi.fn((key: string) => envValues[key]) } /** - * Creates a complete env mock object for use with vi.doMock + * Creates a standalone (non-shared) env mock module, for file-local factories + * that need a fully isolated env rather than the shared stateful mock. * * @example * ```ts - * vi.doMock('@/lib/core/config/env', () => createEnvMock()) - * - * // With custom values - * vi.doMock('@/lib/core/config/env', () => createEnvMock({ - * NEXT_PUBLIC_APP_URL: 'https://custom.example.com', - * })) + * vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' })) * ``` */ export function createEnvMock(overrides: Record = {}) { @@ -47,40 +169,30 @@ export function createEnvMock(overrides: Record = {} return { env: envValues, getEnv: createMockGetEnv(envValues), - isTruthy: (value: string | boolean | number | undefined) => - typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), - isFalsy: (value: string | boolean | number | undefined) => - typeof value === 'string' - ? value.toLowerCase() === 'false' || value === '0' - : value === false, - envBoolean: (value: boolean | string | undefined | null): boolean | undefined => { - if (typeof value === 'boolean') return value - if (value === undefined || value === null || value === '') return undefined - const normalized = String(value).trim().toLowerCase() - return ( - normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' - ) - }, - envNumber: ( - value: number | string | undefined | null, - fallback: number, - options: { min?: number } = {} - ): number => { - const min = options.min ?? 0 - if (typeof value === 'number' && Number.isFinite(value) && value >= min) return value - if (value === undefined || value === null || value === '') return fallback - const parsed = Number(value) - return Number.isFinite(parsed) && parsed >= min ? parsed : fallback - }, + isTruthy: isTruthyImpl, + isFalsy: isFalsyImpl, + envBoolean: envBooleanImpl, + envNumber: envNumberImpl, } } /** - * Pre-configured env mock for direct use with vi.mock + * Complete, stateful mock module for `@/lib/core/config/env`, installed + * globally in `apps/sim/vitest.setup.ts`. Every export of the real module is + * present. Reads through `env` and `getEnv` are live: override via + * {@link setEnv} (or direct property assignment on `envMock.env`) and restore + * with {@link resetEnvMock}. * * @example * ```ts * vi.mock('@/lib/core/config/env', () => envMock) * ``` */ -export const envMock = createEnvMock() +export const envMock = { + env: mockEnvObject, + getEnv: envMockFns.getEnv, + isTruthy: isTruthyImpl, + isFalsy: isFalsyImpl, + envBoolean: envBooleanImpl, + envNumber: envNumberImpl, +} diff --git a/packages/testing/src/mocks/environment-utils.mock.test.ts b/packages/testing/src/mocks/environment-utils.mock.test.ts new file mode 100644 index 00000000000..389a89fa3f6 --- /dev/null +++ b/packages/testing/src/mocks/environment-utils.mock.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + environmentUtilsMock, + environmentUtilsMockFns, + resetEnvironmentUtilsMock, +} from './environment-utils.mock' + +describe('environment-utils mock', () => { + afterEach(() => { + resetEnvironmentUtilsMock() + }) + + it('defaults model a user with no environment variables', async () => { + await expect(environmentUtilsMock.getEnvironmentVariableKeys('user-1')).resolves.toEqual({ + variableNames: [], + count: 0, + }) + await expect(environmentUtilsMock.getEffectiveDecryptedEnv('user-1')).resolves.toEqual({}) + await expect(environmentUtilsMock.getPersonalAndWorkspaceEnv('user-1')).resolves.toEqual({ + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) + await expect(environmentUtilsMock.upsertPersonalEnvVars('user-1', {})).resolves.toEqual({ + added: [], + updated: [], + }) + await expect( + environmentUtilsMock.upsertWorkspaceEnvVars('ws-1', {}, 'user-1') + ).resolves.toEqual([]) + }) + + it('resetEnvironmentUtilsMock restores defaults after overrides', async () => { + environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'k' }) + await expect(environmentUtilsMock.getEffectiveDecryptedEnv('user-1')).resolves.toEqual({ + API_KEY: 'k', + }) + resetEnvironmentUtilsMock() + await expect(environmentUtilsMock.getEffectiveDecryptedEnv('user-1')).resolves.toEqual({}) + }) +}) diff --git a/packages/testing/src/mocks/environment-utils.mock.ts b/packages/testing/src/mocks/environment-utils.mock.ts new file mode 100644 index 00000000000..40c07de5391 --- /dev/null +++ b/packages/testing/src/mocks/environment-utils.mock.ts @@ -0,0 +1,79 @@ +import { vi } from 'vitest' + +function emptyPersonalAndWorkspaceEnv(): { + personalEncrypted: Record + workspaceEncrypted: Record + personalDecrypted: Record + workspaceDecrypted: Record + conflicts: string[] + decryptionFailures: string[] +} { + return { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + } +} + +/** + * Controllable mock functions for `@/lib/environment/utils`. Defaults model a + * user/workspace with no environment variables. Override per-test and restore + * with {@link resetEnvironmentUtilsMock}. + * + * @example + * ```ts + * import { environmentUtilsMockFns } from '@sim/testing' + * + * environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'k' }) + * ``` + */ +export const environmentUtilsMockFns = { + mockInvalidateEffectiveDecryptedEnvCache: vi.fn(), + mockGetEnvironmentVariableKeys: vi.fn().mockResolvedValue({ variableNames: [], count: 0 }), + mockGetPersonalAndWorkspaceEnv: vi + .fn() + .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()), + mockUpsertPersonalEnvVars: vi.fn().mockResolvedValue({ added: [], updated: [] }), + mockUpsertWorkspaceEnvVars: vi.fn().mockResolvedValue([]), + mockGetEffectiveDecryptedEnv: vi.fn().mockResolvedValue({}), +} + +/** + * Restores every environment-utils mock function to its default behavior. + */ +export function resetEnvironmentUtilsMock(): void { + environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache.mockReset() + environmentUtilsMockFns.mockGetEnvironmentVariableKeys + .mockReset() + .mockResolvedValue({ variableNames: [], count: 0 }) + environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + .mockReset() + .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()) + environmentUtilsMockFns.mockUpsertPersonalEnvVars + .mockReset() + .mockResolvedValue({ added: [], updated: [] }) + environmentUtilsMockFns.mockUpsertWorkspaceEnvVars.mockReset().mockResolvedValue([]) + environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockReset().mockResolvedValue({}) +} + +/** + * Complete mock module for `@/lib/environment/utils`, installed globally in + * `apps/sim/vitest.setup.ts`. Every export of the real module is present. + * + * @example + * ```ts + * vi.mock('@/lib/environment/utils', () => environmentUtilsMock) + * ``` + */ +export const environmentUtilsMock = { + invalidateEffectiveDecryptedEnvCache: + environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache, + getEnvironmentVariableKeys: environmentUtilsMockFns.mockGetEnvironmentVariableKeys, + getPersonalAndWorkspaceEnv: environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv, + upsertPersonalEnvVars: environmentUtilsMockFns.mockUpsertPersonalEnvVars, + upsertWorkspaceEnvVars: environmentUtilsMockFns.mockUpsertWorkspaceEnvVars, + getEffectiveDecryptedEnv: environmentUtilsMockFns.mockGetEffectiveDecryptedEnv, +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index e3f6c2d6f62..91705b03f54 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -51,7 +51,17 @@ export { // Encryption mocks export { encryptionMock, encryptionMockFns } from './encryption.mock' // Env mocks -export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' +export { + createEnvMock, + createMockGetEnv, + defaultMockEnv, + type EnvMockValue, + envMock, + envMockFns, + mockEnvObject, + resetEnvMock, + setEnv, +} from './env.mock' // Env flag mocks export { type EnvFlagsMockState, @@ -60,6 +70,12 @@ export { resetEnvFlagsMock, setEnvFlags, } from './env-flags.mock' +// Environment utils mocks (for @/lib/environment/utils) +export { + environmentUtilsMock, + environmentUtilsMockFns, + resetEnvironmentUtilsMock, +} from './environment-utils.mock' // Execution preprocessing mocks (for @/lib/execution/preprocessing) export { executionPreprocessingMock, @@ -104,7 +120,11 @@ export { posthogServerMock, posthogServerMockFns } from './posthog-server.mock' // Redis client mocks (for Redis client objects) export { clearRedisMocks, createMockRedis, type MockRedis } from './redis.mock' // Redis config mocks (for @/lib/core/config/redis) -export { redisConfigMock, redisConfigMockFns } from './redis-config.mock' +export { + redisConfigMock, + redisConfigMockFns, + resetRedisConfigMock, +} from './redis-config.mock' // Request mocks export { createMockFormDataRequest, @@ -142,7 +162,7 @@ export { terminalConsoleMockFns, } from './terminal-console.mock' // URL mocks -export { urlsMock, urlsMockFns } from './urls.mock' +export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' // Workflow authz package mocks (for @sim/platform-authz/workflow) export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock' // Workflows API utils mocks (for @/app/api/workflows/utils) diff --git a/packages/testing/src/mocks/redis-config.mock.test.ts b/packages/testing/src/mocks/redis-config.mock.test.ts new file mode 100644 index 00000000000..65f86acf315 --- /dev/null +++ b/packages/testing/src/mocks/redis-config.mock.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { redisConfigMock, redisConfigMockFns, resetRedisConfigMock } from './redis-config.mock' + +describe('redis-config mock', () => { + afterEach(() => { + resetRedisConfigMock() + }) + + it('defaults to the Redis-unavailable behavior of the real module', async () => { + expect(redisConfigMock.getRedisClient()).toBeNull() + await expect(redisConfigMock.acquireLock('k', 'v', 10)).resolves.toBe(true) + await expect(redisConfigMock.releaseLock('k', 'v')).resolves.toBe(true) + await expect(redisConfigMock.extendLock('k', 'v', 10)).resolves.toBe(true) + await expect(redisConfigMock.closeRedisConnection()).resolves.toBeUndefined() + }) + + it('returns the real connection defaults shape', () => { + expect(redisConfigMock.getRedisConnectionDefaults('redis://localhost:6379')).toEqual({ + keepAlive: 1000, + connectTimeout: 10000, + enableOfflineQueue: true, + }) + }) + + it('resetRedisConfigMock restores defaults after overrides', async () => { + const fakeClient = { ping: () => 'PONG' } + redisConfigMockFns.mockGetRedisClient.mockReturnValue(fakeClient) + redisConfigMockFns.mockAcquireLock.mockResolvedValue(false) + expect(redisConfigMock.getRedisClient()).toBe(fakeClient) + await expect(redisConfigMock.acquireLock('k', 'v', 10)).resolves.toBe(false) + + resetRedisConfigMock() + expect(redisConfigMock.getRedisClient()).toBeNull() + await expect(redisConfigMock.acquireLock('k', 'v', 10)).resolves.toBe(true) + }) +}) diff --git a/packages/testing/src/mocks/redis-config.mock.ts b/packages/testing/src/mocks/redis-config.mock.ts index 7d90711a865..48d4fc9e01e 100644 --- a/packages/testing/src/mocks/redis-config.mock.ts +++ b/packages/testing/src/mocks/redis-config.mock.ts @@ -1,9 +1,55 @@ import { vi } from 'vitest' +import { envMockFns } from './env.mock' + +/** + * Mirrors the real `resolveRedisTlsOptions`: `rediss://` URLs targeting a raw + * IPv4 host require `REDIS_TLS_SERVERNAME` (cert hostname verification cannot + * match an IP) and yield a `tls.servername`; DNS hosts and plain `redis://` + * URLs add no TLS options. + */ +function resolveTlsOptionsImpl(url: string | undefined): { servername: string } | undefined { + if (!url) return undefined + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + if (parsed.protocol !== 'rediss:') return undefined + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname)) return undefined + const servername = envMockFns.getEnv('REDIS_TLS_SERVERNAME') + if (!servername) { + throw new Error( + 'REDIS_TLS_SERVERNAME must be set when REDIS_URL targets an IP over rediss://. ' + + 'TLS cert hostname verification cannot match an IP — set REDIS_TLS_SERVERNAME ' + + 'to the DNS name the cert was issued for (the ElastiCache primary endpoint).' + ) + } + return { servername } +} + +function getRedisConnectionDefaultsImpl(url?: string): { + keepAlive: number + connectTimeout: number + enableOfflineQueue: boolean + tls?: { servername: string } +} { + const tls = resolveTlsOptionsImpl(url) + return { + keepAlive: 1000, + connectTimeout: 10000, + enableOfflineQueue: true, + ...(tls ? { tls } : {}), + } +} /** * Controllable mock functions for `@/lib/core/config/redis`. - * Default: `getRedisClient` returns `null` (tests that need a client override it). - * `acquireLock` defaults to succeeding (`true`); `releaseLock` defaults to `true`. + * Default: `getRedisClient` returns `null` (tests that need a client override + * it), matching the real module's behavior when `REDIS_URL` is unset. + * `acquireLock`/`releaseLock`/`extendLock` default to succeeding (`true`), + * matching the real module's Redis-unavailable no-op path. + * {@link resetRedisConfigMock} restores the default behaviors. * * @example * ```ts @@ -14,6 +60,7 @@ import { vi } from 'vitest' */ export const redisConfigMockFns = { mockGetRedisClient: vi.fn().mockReturnValue(null), + mockGetRedisConnectionDefaults: vi.fn(getRedisConnectionDefaultsImpl), mockOnRedisReconnect: vi.fn(), mockAcquireLock: vi.fn().mockResolvedValue(true), mockReleaseLock: vi.fn().mockResolvedValue(true), @@ -23,7 +70,24 @@ export const redisConfigMockFns = { } /** - * Static mock module for `@/lib/core/config/redis`. + * Restores every redis-config mock function to its default behavior. + */ +export function resetRedisConfigMock(): void { + redisConfigMockFns.mockGetRedisClient.mockReset().mockReturnValue(null) + redisConfigMockFns.mockGetRedisConnectionDefaults + .mockReset() + .mockImplementation(getRedisConnectionDefaultsImpl) + redisConfigMockFns.mockOnRedisReconnect.mockReset() + redisConfigMockFns.mockAcquireLock.mockReset().mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockReset().mockResolvedValue(true) + redisConfigMockFns.mockExtendLock.mockReset().mockResolvedValue(true) + redisConfigMockFns.mockCloseRedisConnection.mockReset().mockResolvedValue(undefined) + redisConfigMockFns.mockResetForTesting.mockReset() +} + +/** + * Complete mock module for `@/lib/core/config/redis`, installed globally in + * `apps/sim/vitest.setup.ts`. Every export of the real module is present. * * @example * ```ts @@ -32,6 +96,7 @@ export const redisConfigMockFns = { */ export const redisConfigMock = { getRedisClient: redisConfigMockFns.mockGetRedisClient, + getRedisConnectionDefaults: redisConfigMockFns.mockGetRedisConnectionDefaults, onRedisReconnect: redisConfigMockFns.mockOnRedisReconnect, acquireLock: redisConfigMockFns.mockAcquireLock, releaseLock: redisConfigMockFns.mockReleaseLock, diff --git a/packages/testing/src/mocks/urls.mock.test.ts b/packages/testing/src/mocks/urls.mock.test.ts new file mode 100644 index 00000000000..d1e0501eae0 --- /dev/null +++ b/packages/testing/src/mocks/urls.mock.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { defaultMockEnv, resetEnvMock, setEnv } from './env.mock' +import { resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' + +describe('urls mock', () => { + afterEach(() => { + resetUrlsMock() + resetEnvMock() + }) + + it('derives getBaseUrl from the shared env mock state', () => { + expect(urlsMock.getBaseUrl()).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + setEnv({ NEXT_PUBLIC_APP_URL: 'https://custom.example.com' }) + expect(urlsMock.getBaseUrl()).toBe('https://custom.example.com') + }) + + it('throws from getBaseUrl when NEXT_PUBLIC_APP_URL is pinned unset', () => { + setEnv({ NEXT_PUBLIC_APP_URL: undefined }) + expect(() => urlsMock.getBaseUrl()).toThrow('NEXT_PUBLIC_APP_URL must be configured') + }) + + it('getInternalApiBaseUrl prefers INTERNAL_API_BASE_URL and falls back to base URL', () => { + expect(urlsMock.getInternalApiBaseUrl()).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + setEnv({ INTERNAL_API_BASE_URL: 'http://sim-app.default.svc.cluster.local:3000' }) + expect(urlsMock.getInternalApiBaseUrl()).toBe('http://sim-app.default.svc.cluster.local:3000') + }) + + it('ensureAbsoluteUrl prefixes relative paths with the base URL', () => { + expect(urlsMock.ensureAbsoluteUrl('/api/files/serve/x')).toBe( + `${defaultMockEnv.NEXT_PUBLIC_APP_URL}/api/files/serve/x` + ) + expect(urlsMock.ensureAbsoluteUrl('https://a.b/c')).toBe('https://a.b/c') + }) + + it('domain helpers derive from the base URL', () => { + setEnv({ NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' }) + expect(urlsMock.getBaseDomain()).toBe('www.sim.ai') + expect(urlsMock.getEmailDomain()).toBe('sim.ai') + }) + + it('pure helpers behave like the real module', () => { + expect(urlsMock.isLoopbackHostname('localhost')).toBe(true) + expect(urlsMock.isLoopbackHostname('sim.ai')).toBe(false) + expect(urlsMock.isLocalhostUrl('http://127.0.0.1:3000')).toBe(true) + expect(urlsMock.isSafeHttpUrl('javascript:alert(1)')).toBe(false) + expect(urlsMock.isSafeHttpUrl('https://sim.ai')).toBe(true) + expect( + urlsMock.parseOriginList('https://a.example.com/path, https://a.example.com, bad-url') + ).toEqual(['https://a.example.com']) + }) + + it('socket and ollama URLs read env with localhost fallbacks', () => { + expect(urlsMock.getSocketServerUrl()).toBe('http://localhost:3002') + expect(urlsMock.getOllamaUrl()).toBe('http://localhost:11434') + setEnv({ SOCKET_SERVER_URL: 'http://sockets:3002', OLLAMA_URL: 'http://ollama:11434' }) + expect(urlsMock.getSocketServerUrl()).toBe('http://sockets:3002') + expect(urlsMock.getOllamaUrl()).toBe('http://ollama:11434') + }) + + it('resetUrlsMock restores default implementations after overrides', () => { + urlsMockFns.mockGetBaseUrl.mockReturnValue('https://overridden.test') + expect(urlsMock.getBaseUrl()).toBe('https://overridden.test') + resetUrlsMock() + expect(urlsMock.getBaseUrl()).toBe(defaultMockEnv.NEXT_PUBLIC_APP_URL) + }) +}) diff --git a/packages/testing/src/mocks/urls.mock.ts b/packages/testing/src/mocks/urls.mock.ts index 49992cd278e..ab58ae5804a 100644 --- a/packages/testing/src/mocks/urls.mock.ts +++ b/packages/testing/src/mocks/urls.mock.ts @@ -1,7 +1,146 @@ import { vi } from 'vitest' +import { envMockFns, mockEnvObject } from './env.mock' +import { envFlagsMock } from './env-flags.mock' + +/** Mirrors the real `LOCALHOST_HOSTNAMES` from `@/lib/core/utils/urls`. */ +export const LOCALHOST_HOSTNAMES_MOCK: ReadonlySet = new Set([ + 'localhost', + '127.0.0.1', + '[::1]', + '::1', +]) + +const DEFAULT_SOCKET_URL = 'http://localhost:3002' +const DEFAULT_OLLAMA_URL = 'http://localhost:11434' + +function readEnv(key: string): string | undefined { + return envMockFns.getEnv(key) +} + +function hasHttpProtocol(url: string): boolean { + return /^https?:\/\//i.test(url) +} + +function getBaseUrlImpl(): string { + const baseUrl = readEnv('NEXT_PUBLIC_APP_URL')?.trim() + if (!baseUrl) { + throw new Error( + 'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly' + ) + } + // Mirrors the real module: protocol-less values get https:// under isProd. + const protocol = envFlagsMock.isProd ? 'https://' : 'http://' + return hasHttpProtocol(baseUrl) ? baseUrl : `${protocol}${baseUrl}` +} + +function getInternalApiBaseUrlImpl(): string { + const internalBaseUrl = readEnv('INTERNAL_API_BASE_URL')?.trim() + if (!internalBaseUrl) return getBaseUrlImpl() + if (!hasHttpProtocol(internalBaseUrl)) { + throw new Error( + 'INTERNAL_API_BASE_URL must include protocol (http:// or https://), e.g. http://sim-app.default.svc.cluster.local:3000' + ) + } + return internalBaseUrl +} + +function ensureAbsoluteUrlImpl(pathOrUrl: string): string { + if (!pathOrUrl) throw new Error('URL is required') + return pathOrUrl.startsWith('/') ? `${getBaseUrlImpl()}${pathOrUrl}` : pathOrUrl +} + +function getBaseDomainImpl(): string { + try { + return new URL(getBaseUrlImpl()).host + } catch { + const fallbackUrl = readEnv('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000' + try { + return new URL(fallbackUrl).host + } catch { + // Mirrors the real module's unparseable-URL fallback per environment. + return envFlagsMock.isProd ? 'sim.ai' : 'localhost:3000' + } + } +} + +function getEmailDomainImpl(): string { + const baseDomain = getBaseDomainImpl() + return baseDomain.startsWith('www.') ? baseDomain.substring(4) : baseDomain +} + +function isLoopbackHostnameImpl(hostname: string): boolean { + return LOCALHOST_HOSTNAMES_MOCK.has(hostname) +} + +function parseOriginListImpl( + raw: string | undefined | null, + onInvalid?: (value: string) => void +): string[] { + if (!raw) return [] + const seen = new Set() + const origins: string[] = [] + for (const candidate of raw.split(',')) { + const trimmed = candidate.trim() + if (!trimmed) continue + try { + const { origin } = new URL(trimmed) + if (!seen.has(origin)) { + seen.add(origin) + origins.push(origin) + } + } catch { + onInvalid?.(trimmed) + } + } + return origins +} + +function isLocalhostUrlImpl(url: string): boolean { + try { + return LOCALHOST_HOSTNAMES_MOCK.has(new URL(url).hostname) + } catch { + return false + } +} + +function getBrowserOriginImpl(): string | null { + return typeof window !== 'undefined' ? window.location.origin : null +} + +function isSafeHttpUrlImpl(url: string): boolean { + try { + const parsed = new URL(url, getBrowserOriginImpl() ?? undefined) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +function getSocketServerUrlImpl(): string { + const value = mockEnvObject.SOCKET_SERVER_URL + return (typeof value === 'string' && value) || DEFAULT_SOCKET_URL +} + +function getSocketUrlImpl(): string { + const explicit = readEnv('NEXT_PUBLIC_SOCKET_URL')?.trim() + if (explicit) return explicit + const browserOrigin = getBrowserOriginImpl() + if (browserOrigin && !LOCALHOST_HOSTNAMES_MOCK.has(new URL(browserOrigin).hostname)) { + return browserOrigin + } + return DEFAULT_SOCKET_URL +} + +function getOllamaUrlImpl(): string { + const value = mockEnvObject.OLLAMA_URL + return (typeof value === 'string' && value) || DEFAULT_OLLAMA_URL +} /** - * Controllable mock functions for `@/lib/core/utils/urls`. + * Controllable mock functions for `@/lib/core/utils/urls`. Each defaults to a + * faithful implementation of the real module that reads through the shared env + * mock (so `setEnv({ NEXT_PUBLIC_APP_URL: ... })` changes the derived URLs). + * Override per-test and restore with {@link resetUrlsMock}. * * @example * ```ts @@ -11,19 +150,44 @@ import { vi } from 'vitest' * ``` */ export const urlsMockFns = { - mockGetBaseUrl: vi.fn(), - mockGetInternalApiBaseUrl: vi.fn(), - mockEnsureAbsoluteUrl: vi.fn(), - mockGetBaseDomain: vi.fn(), - mockGetEmailDomain: vi.fn(), - mockGetSocketServerUrl: vi.fn(), - mockGetSocketUrl: vi.fn(), - mockGetOllamaUrl: vi.fn(), + mockGetBaseUrl: vi.fn(getBaseUrlImpl), + mockGetInternalApiBaseUrl: vi.fn(getInternalApiBaseUrlImpl), + mockEnsureAbsoluteUrl: vi.fn(ensureAbsoluteUrlImpl), + mockGetBaseDomain: vi.fn(getBaseDomainImpl), + mockGetEmailDomain: vi.fn(getEmailDomainImpl), + mockIsLoopbackHostname: vi.fn(isLoopbackHostnameImpl), + mockParseOriginList: vi.fn(parseOriginListImpl), + mockIsLocalhostUrl: vi.fn(isLocalhostUrlImpl), + mockGetBrowserOrigin: vi.fn(getBrowserOriginImpl), + mockIsSafeHttpUrl: vi.fn(isSafeHttpUrlImpl), + mockGetSocketServerUrl: vi.fn(getSocketServerUrlImpl), + mockGetSocketUrl: vi.fn(getSocketUrlImpl), + mockGetOllamaUrl: vi.fn(getOllamaUrlImpl), +} + +/** + * Restores every urls mock function to its default (real-behavior) + * implementation. + */ +export function resetUrlsMock(): void { + urlsMockFns.mockGetBaseUrl.mockReset().mockImplementation(getBaseUrlImpl) + urlsMockFns.mockGetInternalApiBaseUrl.mockReset().mockImplementation(getInternalApiBaseUrlImpl) + urlsMockFns.mockEnsureAbsoluteUrl.mockReset().mockImplementation(ensureAbsoluteUrlImpl) + urlsMockFns.mockGetBaseDomain.mockReset().mockImplementation(getBaseDomainImpl) + urlsMockFns.mockGetEmailDomain.mockReset().mockImplementation(getEmailDomainImpl) + urlsMockFns.mockIsLoopbackHostname.mockReset().mockImplementation(isLoopbackHostnameImpl) + urlsMockFns.mockParseOriginList.mockReset().mockImplementation(parseOriginListImpl) + urlsMockFns.mockIsLocalhostUrl.mockReset().mockImplementation(isLocalhostUrlImpl) + urlsMockFns.mockGetBrowserOrigin.mockReset().mockImplementation(getBrowserOriginImpl) + urlsMockFns.mockIsSafeHttpUrl.mockReset().mockImplementation(isSafeHttpUrlImpl) + urlsMockFns.mockGetSocketServerUrl.mockReset().mockImplementation(getSocketServerUrlImpl) + urlsMockFns.mockGetSocketUrl.mockReset().mockImplementation(getSocketUrlImpl) + urlsMockFns.mockGetOllamaUrl.mockReset().mockImplementation(getOllamaUrlImpl) } /** - * Static mock module for `@/lib/core/utils/urls`. - * Functions return sensible localhost defaults. + * Complete mock module for `@/lib/core/utils/urls`, installed globally in + * `apps/sim/vitest.setup.ts`. Every export of the real module is present. * * @example * ```ts @@ -32,11 +196,17 @@ export const urlsMockFns = { */ export const urlsMock = { SITE_URL: 'https://www.sim.ai', + LOCALHOST_HOSTNAMES: LOCALHOST_HOSTNAMES_MOCK, getBaseUrl: urlsMockFns.mockGetBaseUrl, getInternalApiBaseUrl: urlsMockFns.mockGetInternalApiBaseUrl, ensureAbsoluteUrl: urlsMockFns.mockEnsureAbsoluteUrl, getBaseDomain: urlsMockFns.mockGetBaseDomain, getEmailDomain: urlsMockFns.mockGetEmailDomain, + isLoopbackHostname: urlsMockFns.mockIsLoopbackHostname, + parseOriginList: urlsMockFns.mockParseOriginList, + isLocalhostUrl: urlsMockFns.mockIsLocalhostUrl, + getBrowserOrigin: urlsMockFns.mockGetBrowserOrigin, + isSafeHttpUrl: urlsMockFns.mockIsSafeHttpUrl, getSocketServerUrl: urlsMockFns.mockGetSocketServerUrl, getSocketUrl: urlsMockFns.mockGetSocketUrl, getOllamaUrl: urlsMockFns.mockGetOllamaUrl, From 227cd65f41bcb51bbaa57b18675cdbaf6f705721 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 22 Jul 2026 20:31:24 -0700 Subject: [PATCH 24/24] fix(mcp): bound and retry OAuth start so a transient stall recovers instead of a blank popup (#5874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): bound and retry OAuth start so a transient stall recovers instead of a blank popup Empirically root-caused a blank/stuck authorize popup: the provider (planetscale) and our guarded OAuth fetch are both fast (120/120 legs clean from staging), and /oauth/start uses local AES encryption with no Redis lock in its path — so the intermittent hang is the same transient headers-then-stalled-body class we've documented for CDN-fronted MCP hosts (a per-connection stall a fresh attempt dodges), which /oauth/start had no server-side bound against. - Bound every /oauth/start step with the shared timedStep helper (extracted from the callback route, now used by both) + an entry log, so a stalled step surfaces as a labeled error instead of hanging the request (and the browser popup) to the client's 30s timeout. - Retry mcpAuthGuarded once on a bounded 12s timeout: a fresh attempt gets a fresh connection and recovers from the transient stall automatically (two 12s attempts stay under the client's 30s deadline). McpOauthRedirectRequired (the success signal) and DCR-unsupported errors are never retried. Adds OauthStepTimeoutError + makeTimedStep to the shared oauth barrel and test mocks. * fix(mcp): drop the unsafe OAuth-start retry; fail fast without error-logging success Review fixes on the bound+retry change: - Removed the mcpAuthGuarded auto-retry. timedStep can't cancel the loser, so a lingering first attempt shares this server's OAuth row and could overwrite the retry's PKCE verifier / state after the client already got the second authorize URL, breaking the callback. Recovery is now fail-fast (504) → the user re-clicks, which is a clean fresh flow (fresh connection dodges the transient stall) with no shared-state race. - Catch McpOauthRedirectRequired (the success signal) INSIDE the bounded step and return it as a value, so a successful authorize is no longer error-logged as 'OAuth step failed'. - Tighten step budgets (5s DB x3 + 12s auth = 27s) to stay under the client's 30s /oauth/start deadline. * fix(mcp): route all bounded-step timeouts to the 504 handler Move OauthStepTimeoutError handling to the outer catch so a DB-step timeout (loadServer/getOrCreateOauthRow/loadPreregisteredClient) returns the same fast 504 'try again' as the auth step, not a generic 500. Documents that the fresh retry is race-safe: the callback correlates on the state nonce, so a lingering timed-out attempt overwriting the row's state only yields a clean invalid_state on the user's fresh authorize URL — never silent corruption. * fix(mcp): bound the setOauthRowUser write too so no step escapes the budget The user-stamp write was the one DB op left unbounded on the start path; wrap it in timedStep(DB_STEP_MS) so every step stays inside the sub-30s budget and its timeout routes to the same 504. * fix(mcp): shrink OAuth-start step budgets to fit the 30s client deadline with 4 DB steps Bounding setOauthRowUser added a fourth possible DB step, so 4x5+12=32s exceeded the client's 30s /oauth/start abort. Lower DB steps to 4s and auth to 10s: 4x4+10=26s worst case, leaving margin for middleware/network. Comment corrected. --- apps/sim/app/api/mcp/oauth/callback/route.ts | 42 +------ .../sim/app/api/mcp/oauth/start/route.test.ts | 49 ++++++++ apps/sim/app/api/mcp/oauth/start/route.ts | 107 +++++++++++++----- apps/sim/lib/mcp/oauth/index.ts | 1 + apps/sim/lib/mcp/oauth/timed-step.ts | 45 ++++++++ packages/testing/src/mocks/index.ts | 1 + packages/testing/src/mocks/mcp-oauth.mock.ts | 14 +++ 7 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 apps/sim/lib/mcp/oauth/timed-step.ts diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 79b7058ddea..9d63191fb32 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -16,55 +16,17 @@ import { loadOauthRowByState, loadPreregisteredClient, type McpOauthCallbackReason, + makeTimedStep, mcpAuthGuarded, SimMcpOauthProvider, } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' const logger = createLogger('McpOauthCallbackAPI') +const timedStep = makeTimedStep(logger) export const dynamic = 'force-dynamic' -class OauthCallbackStepTimeout extends Error { - constructor(step: string, ms: number) { - super(`MCP OAuth callback step "${step}" did not settle within ${ms}ms`) - this.name = 'OauthCallbackStepTimeout' - } -} - -/** - * Times and bounds one awaited step of the callback so a stalled operation - * surfaces as a labeled, logged error instead of hanging the request forever. - * The losing promise is not cancelled (a wedged DB/socket op can't be), so it - * settles in the background with its rejection swallowed; the point is that the - * request stops waiting on it and the logs name the exact step that stalled. - */ -async function timedStep(step: string, ms: number, fn: () => Promise): Promise { - const start = Date.now() - logger.info(`OAuth callback step start: ${step}`) - const work = Promise.resolve(fn()) - work.catch(() => {}) - let timer: ReturnType | undefined - try { - const value = await Promise.race([ - work, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new OauthCallbackStepTimeout(step, ms)), ms) - timer.unref?.() - }), - ]) - logger.info(`OAuth callback step done: ${step} (${Date.now() - start}ms)`) - return value - } catch (error) { - logger.error(`OAuth callback step failed: ${step} (${Date.now() - start}ms)`, { - error: toError(error).message, - }) - throw error - } finally { - clearTimeout(timer) - } -} - function escapeHtml(value: string): string { return value .replace(/&/g, '&') diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index 11bbc1af623..c9b09937fbe 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -7,6 +7,7 @@ import { McpOauthRedirectRequiredMock, mcpOauthMock, mcpOauthMockFns, + OauthStepTimeoutErrorMock, permissionsMock, permissionsMockFns, resetDbChainMock, @@ -75,6 +76,54 @@ describe('MCP OAuth start route', () => { ) }) + it('returns 504 (not a retry) when the auth step times out', async () => { + // The stall is intentionally NOT auto-retried — a lingering attempt shares the OAuth row and + // could corrupt the retry's PKCE/state. The bounded step fails fast; the user re-clicks. + mcpOauthMockFns.mockMcpAuthGuarded.mockImplementationOnce(() => { + throw new OauthStepTimeoutErrorMock('mcpAuthGuarded', 12_000) + }) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1) + expect(response.status).toBe(504) + }) + + it('returns 504 (not a generic 500) when a DB step times out', async () => { + // DB-step timeouts are bounded too; their OauthStepTimeoutError must reach the same + // 504 handler, not fall through to the generic 500. + mcpOauthMockFns.mockGetOrCreateOauthRow.mockImplementationOnce(() => { + throw new OauthStepTimeoutErrorMock('getOrCreateOauthRow', 5_000) + }) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(504) + expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled() + }) + + it('returns the authorize URL without error-logging the success redirect throw', async () => { + mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce( + new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize') + ) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1) + expect(response.status).toBe(200) + expect(body).toEqual({ status: 'redirect', authorizationUrl: 'https://mcp.exa.ai/authorize' }) + }) + it('requires workspace write permission via MCP auth middleware', async () => { const request = new NextRequest( 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 5bc08af493f..3a936167650 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -16,14 +16,31 @@ import { loadPreregisteredClient, McpOauthInsecureUrlError, McpOauthRedirectRequired, + makeTimedStep, mcpAuthGuarded, + OauthStepTimeoutError, SimMcpOauthProvider, setOauthRowUser, } from '@/lib/mcp/oauth' import { createMcpErrorResponse } from '@/lib/mcp/utils' const logger = createLogger('McpOauthStartAPI') +const timedStep = makeTimedStep(logger) const OAUTH_START_TTL_MS = 10 * 60 * 1000 +/** + * Per-step budgets, kept small so the whole request stays under the client's 30s `/oauth/start` + * deadline even in the worst case: up to four bounded DB steps (loadServer, getOrCreateOauthRow, + * setOauthRowUser, loadPreregisteredClient) + the auth step = 4×4 + 10 = 26s, leaving margin for + * middleware and network. OAuth discovery + DCR occasionally hits the transient + * headers-then-stalled-body class documented for CDN-fronted MCP hosts; the bound turns that into + * a fast, labeled failure so the popup closes with a clear error and the user can retry (a fresh + * click = a fresh connection that dodges the per-connection stall) rather than the popup hanging + * blank. We deliberately do NOT auto-retry here: `timedStep` can't cancel a wedged attempt, and a + * lingering first attempt sharing this server's OAuth row could overwrite the retry's PKCE + * verifier / state and break the callback. + */ +const DB_STEP_MS = 4_000 +const MCP_AUTH_STEP_MS = 10_000 const MAX_SURFACED_ERROR_LENGTH = 250 const DCR_UNSUPPORTED_MESSAGE = "This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead." @@ -81,18 +98,21 @@ export const GET = withRouteHandler( const parsed = await parseRequest(startMcpOauthContract, request, {}) if (!parsed.success) return parsed.response const { serverId } = parsed.data.query + logger.info(`Starting MCP OAuth flow for server ${serverId}`) - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) + const [server] = await timedStep('loadServer', DB_STEP_MS, () => + db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) ) - ) - .limit(1) + .limit(1) + ) if (!server) { return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) @@ -107,8 +127,9 @@ export const GET = withRouteHandler( if (!server.url) { return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400) } + const serverUrl = server.url try { - assertSafeOauthServerUrl(server.url) + assertSafeOauthServerUrl(serverUrl) } catch (e) { if (e instanceof McpOauthInsecureUrlError) { return createMcpErrorResponse( @@ -120,11 +141,13 @@ export const GET = withRouteHandler( throw e } - const row = await getOrCreateOauthRow({ - mcpServerId: server.id, - userId, - workspaceId, - }) + const row = await timedStep('getOrCreateOauthRow', DB_STEP_MS, () => + getOrCreateOauthRow({ + mcpServerId: server.id, + userId, + workspaceId, + }) + ) const hasActiveFlow = !!row.state && !!row.stateCreatedAt && @@ -137,17 +160,38 @@ export const GET = withRouteHandler( ) } if (row.userId !== userId) { - await setOauthRowUser(row.id, userId) + await timedStep('setOauthRowUser', DB_STEP_MS, () => setOauthRowUser(row.id, userId)) row.userId = userId } - const preregistered = await loadPreregisteredClient(server.id) + const preregistered = await timedStep('loadPreregisteredClient', DB_STEP_MS, () => + loadPreregisteredClient(server.id) + ) const provider = new SimMcpOauthProvider({ row, preregistered }) try { - const result = await mcpAuthGuarded(provider, { - serverUrl: server.url, + // OAuth discovery + DCR through the guarded fetch, bounded so a transient stall fails + // fast with a labeled log instead of hanging the popup. `McpOauthRedirectRequired` is + // the SUCCESS signal (a throw carrying the authorize URL), so we catch it INSIDE the + // bounded step and return it as a normal value — otherwise timedStep would error-log + // every successful authorize. Only a real error or a timeout escapes as a throw. + const authOutcome = await timedStep('mcpAuthGuarded', MCP_AUTH_STEP_MS, async () => { + try { + return { kind: 'result' as const, value: await mcpAuthGuarded(provider, { serverUrl }) } + } catch (e) { + if (e instanceof McpOauthRedirectRequired) { + return { kind: 'redirect' as const, authorizationUrl: e.authorizationUrl } + } + throw e + } }) - if (result === 'AUTHORIZED') { + if (authOutcome.kind === 'redirect') { + logger.info(`OAuth redirect for server ${serverId}`) + return NextResponse.json({ + status: 'redirect', + authorizationUrl: authOutcome.authorizationUrl, + }) + } + if (authOutcome.value === 'AUTHORIZED') { return NextResponse.json({ status: 'already_authorized' }) } return createMcpErrorResponse( @@ -156,19 +200,26 @@ export const GET = withRouteHandler( 500 ) } catch (e) { - if (e instanceof McpOauthRedirectRequired) { - logger.info(`OAuth redirect for server ${serverId}`) - return NextResponse.json({ - status: 'redirect', - authorizationUrl: e.authorizationUrl, - }) - } if (isDynamicClientRegistrationUnsupported(e)) { return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422) } throw e } } catch (error) { + // Any bounded step timing out (DB reads or the auth step) is a stall, not a bug — + // surface it as a fast 504 so the popup closes with a clear "try again" rather than a + // generic 500. A fresh retry is a clean flow: the callback correlates on the `state` + // nonce, so even if a lingering timed-out attempt later overwrites the row's state, the + // user's authorize URL (carrying the fresh nonce) simply fails `invalid_state` — a clean + // retry, never silent corruption. + if (error instanceof OauthStepTimeoutError) { + logger.warn('MCP OAuth start stalled') + return createMcpErrorResponse( + error, + 'Authorization is taking too long — please try again.', + 504 + ) + } logger.error('Error starting MCP OAuth flow:', error) // Only surface OAuth-flow errors verbatim; everything else (DB, decryption, // network) gets a generic message to avoid leaking internal details. diff --git a/apps/sim/lib/mcp/oauth/index.ts b/apps/sim/lib/mcp/oauth/index.ts index 5237b22fe16..7e89f72d7c3 100644 --- a/apps/sim/lib/mcp/oauth/index.ts +++ b/apps/sim/lib/mcp/oauth/index.ts @@ -28,4 +28,5 @@ export { setOauthRowUser, withMcpOauthRefreshLock, } from './storage' +export { makeTimedStep, OauthStepTimeoutError } from './timed-step' export { assertSafeOauthServerUrl, McpOauthInsecureUrlError } from './url-validation' diff --git a/apps/sim/lib/mcp/oauth/timed-step.ts b/apps/sim/lib/mcp/oauth/timed-step.ts new file mode 100644 index 00000000000..4429969b798 --- /dev/null +++ b/apps/sim/lib/mcp/oauth/timed-step.ts @@ -0,0 +1,45 @@ +import type { Logger } from '@sim/logger' +import { toError } from '@sim/utils/errors' + +/** Thrown when a `timedStep`-bounded operation doesn't settle within its budget. */ +export class OauthStepTimeoutError extends Error { + constructor(step: string, ms: number) { + super(`MCP OAuth step "${step}" did not settle within ${ms}ms`) + this.name = 'OauthStepTimeoutError' + } +} + +/** + * Times and bounds one awaited step of an OAuth route so a stalled operation surfaces + * as a labeled, logged error instead of hanging the request (and the browser popup + * waiting on it) forever. The losing promise is not cancelled — a wedged DB/socket op + * can't be — so it settles in the background with its rejection swallowed; the point is + * that the request stops waiting on it and the logs name the exact step that stalled. + */ +export function makeTimedStep(logger: Logger) { + return async function timedStep(step: string, ms: number, fn: () => Promise): Promise { + const start = Date.now() + logger.info(`OAuth step start: ${step}`) + const work = Promise.resolve(fn()) + work.catch(() => {}) + let timer: ReturnType | undefined + try { + const value = await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new OauthStepTimeoutError(step, ms)), ms) + timer.unref?.() + }), + ]) + logger.info(`OAuth step done: ${step} (${Date.now() - start}ms)`) + return value + } catch (error) { + logger.error(`OAuth step failed: ${step} (${Date.now() - start}ms)`, { + error: toError(error).message, + }) + throw error + } finally { + clearTimeout(timer) + } + } +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 91705b03f54..2c60725e703 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -112,6 +112,7 @@ export { McpOauthRedirectRequiredMock, mcpOauthMock, mcpOauthMockFns, + OauthStepTimeoutErrorMock, } from './mcp-oauth.mock' // Permission mocks export { permissionsMock, permissionsMockFns } from './permissions.mock' diff --git a/packages/testing/src/mocks/mcp-oauth.mock.ts b/packages/testing/src/mocks/mcp-oauth.mock.ts index 81dee8de9c4..e5318930a60 100644 --- a/packages/testing/src/mocks/mcp-oauth.mock.ts +++ b/packages/testing/src/mocks/mcp-oauth.mock.ts @@ -44,6 +44,13 @@ export class McpOauthInsecureUrlErrorMock extends Error { } } +export class OauthStepTimeoutErrorMock extends Error { + constructor(step: string, ms: number) { + super(`MCP OAuth step "${step}" did not settle within ${ms}ms`) + this.name = 'OauthStepTimeoutErrorMock' + } +} + /** * Returns the provider config back as the constructed instance, matching the * original identity passthrough. Declared as a named function (not an arrow) so @@ -82,5 +89,12 @@ export const mcpOauthMock = { withMcpOauthRefreshLock: mcpOauthMockFns.mockWithMcpOauthRefreshLock, McpOauthRedirectRequired: McpOauthRedirectRequiredMock, McpOauthInsecureUrlError: McpOauthInsecureUrlErrorMock, + OauthStepTimeoutError: OauthStepTimeoutErrorMock, SimMcpOauthProvider: vi.fn().mockImplementation(buildSimMcpOauthProvider), + // Pass-through: run the step immediately, no bounding, so route tests exercise real behavior. + // Wrap in Promise.resolve like the real helper so a mock returning a non-promise still chains. + makeTimedStep: + () => + (_step: string, _ms: number, fn: () => Promise): Promise => + Promise.resolve(fn()), }