diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 3a2bcd0898b..35535fa913a 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,16 +4,41 @@ * * @vitest-environment node */ -import { createEnvMock } from '@sim/testing' -import { mockNextFetchResponse } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockNextFetchResponse, setupGlobalFetchMock } from '@sim/testing/mocks' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' +import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' vi.mock('drizzle-orm') -vi.mock('@/lib/knowledge/documents/utils', () => ({ - retryWithExponentialBackoff: (fn: any) => fn(), -})) -vi.mock('@/lib/core/config/env', () => createEnvMock()) +/** + * 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, + * so patching the namespace is the only wiring that always applies. + */ +const retrySpy = vi + .spyOn(documentsUtilsModule, 'retryWithExponentialBackoff') + .mockImplementation(((fn: () => unknown) => fn()) as never) + +afterAll(() => { + retrySpy.mockRestore() +}) + +/** + * Under `isolate: false` the shared `@/lib/knowledge/embeddings` module may be + * cached bound to the REAL env module, so tests mutate the real `env` object + * (the tests below clear and assign it per case) instead of vi.mock'ing a + * file-local replacement that a cached consumer would never see. The snapshot + * restores whatever the worker started with after every test. + */ +const envSnapshot = { ...env } + +afterEach(() => { + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, envSnapshot) +}) import { generateSearchEmbedding, @@ -25,6 +50,11 @@ import { describe('Knowledge Search Utils', () => { beforeEach(() => { vi.clearAllMocks() + // The worker-level fetch stub from vitest.setup.ts is removed after the + // first test by `unstubGlobals: true`; re-stub it per test so + // mockNextFetchResponse always operates on a mocked fetch. + setupGlobalFetchMock({ json: {} }) + retrySpy.mockImplementation(((fn: () => unknown) => fn()) as never) }) describe('handleTagOnlySearch', () => { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 3bf7f5ce352..ad9e59113cf 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -6,8 +6,27 @@ * This file contains unit tests for the knowledge base utility functions, * including access checks, document processing, and embedding generation. */ -import { createEnvMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultMockEnv } 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' +import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' +import * as workspacesUtilsModule from '@/lib/workspaces/utils' + +const envSnapshot = { ...env } + +afterAll(() => { + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, envSnapshot) + retrySpy.mockRestore() + vi.mocked(workspacesUtilsModule.getWorkspaceBilledAccountUserId).mockRestore() + vi.mocked(billingAttributionModule.assertBillingAttributionSnapshot).mockRestore() + vi.mocked(billingAttributionModule.checkAttributedUsageLimits).mockRestore() + vi.mocked(billingAttributionModule.resolveBillingAttribution).mockRestore() + vi.mocked(billingAttributionModule.toBillingContext).mockRestore() +}) vi.mock('drizzle-orm', () => ({ and: (...args: any[]) => args, @@ -16,42 +35,54 @@ vi.mock('drizzle-orm', () => ({ sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }), })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-key' })) - -vi.mock('@/lib/knowledge/documents/utils', () => ({ - retryWithExponentialBackoff: (fn: any) => fn(), -})) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'), -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - assertBillingAttributionSnapshot: vi.fn((value) => value), - checkAttributedUsageLimits: vi.fn().mockResolvedValue({ +/** + * 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, + * so patching the namespace is the only wiring that always applies. + */ +const retrySpy = vi + .spyOn(documentsUtilsModule, 'retryWithExponentialBackoff') + .mockImplementation(((fn: () => unknown) => fn()) as never) + +const BILLING_ATTRIBUTION_FIXTURE = { + actorUserId: 'billing-user-1', + billedAccountUserId: 'billing-user-1', + billingEntity: { type: 'user', id: 'billing-user-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + organizationId: null, + payerSubscription: null, + workspaceId: 'workspace1', +} as never + +function applyBillingSpies() { + vi.spyOn(workspacesUtilsModule, 'getWorkspaceBilledAccountUserId').mockResolvedValue('user1') + vi.spyOn(billingAttributionModule, 'assertBillingAttributionSnapshot').mockImplementation( + ((value: unknown) => value) as never + ) + vi.spyOn(billingAttributionModule, 'checkAttributedUsageLimits').mockResolvedValue({ isExceeded: false, payerUsage: { currentUsage: 0, limit: 100 }, - }), - resolveBillingAttribution: vi.fn().mockResolvedValue({ - actorUserId: 'billing-user-1', - billedAccountUserId: 'billing-user-1', - billingEntity: { type: 'user', id: 'billing-user-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - organizationId: null, - payerSubscription: null, - workspaceId: 'workspace1', - }), - toBillingContext: vi.fn(() => ({ + } as never) + vi.spyOn(billingAttributionModule, 'resolveBillingAttribution').mockResolvedValue( + BILLING_ATTRIBUTION_FIXTURE + ) + vi.spyOn(billingAttributionModule, 'toBillingContext').mockImplementation((() => ({ billingEntity: { type: 'user', id: 'billing-user-1' }, billingPeriod: { start: new Date('2026-07-01T00:00:00.000Z'), end: new Date('2026-08-01T00:00:00.000Z'), }, - })), -})) + })) as never) +} + +/** + * Billing helpers are spied on the real namespaces (not vi.mock'd) for the + * same shared-consumer reason as the retry spy above. + */ +applyBillingSpies() vi.mock('@/lib/knowledge/documents/document-processor', () => ({ processDocument: vi.fn().mockResolvedValue({ @@ -99,9 +130,8 @@ function resetDatasets() { chunkRows = [] } -vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ +function createEmbeddingFetchMock() { + return vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [ @@ -111,7 +141,9 @@ vi.stubGlobal( usage: { prompt_tokens: 2, total_tokens: 2 }, }), }) -) +} + +vi.stubGlobal('fetch', createEmbeddingFetchMock()) vi.mock('@sim/db', async () => { const { schemaMock } = (await import('@sim/testing')) as typeof import('@sim/testing') @@ -261,10 +293,23 @@ describe('Knowledge Utils', () => { dbOps.updatePayloads.length = 0 resetDatasets() vi.clearAllMocks() + // `unstubGlobals: true` removes the module-scope fetch stub after the + // first test in the worker; re-stub it per test. + vi.stubGlobal('fetch', createEmbeddingFetchMock()) + // Under `isolate: false` the shared `@/lib/knowledge/embeddings` module may + // be cached bound to the REAL env module, so reset the real `env` object + // per test instead of vi.mock'ing a file-local replacement that a cached + // consumer would never see. + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, { ...defaultMockEnv, OPENAI_API_KEY: 'test-key' }) + retrySpy.mockImplementation(((fn: () => unknown) => fn()) as never) + applyBillingSpies() }) describe('processDocumentAsync', () => { - it.concurrent('should insert embeddings before updating document counters', async () => { + it('should insert embeddings before updating document counters', async () => { kbRows.push({ id: 'kb1', userId: 'user1', @@ -315,7 +360,7 @@ describe('Knowledge Utils', () => { }) describe('checkKnowledgeBaseAccess', () => { - it.concurrent('should return success for owner', async () => { + it('should return success for owner', async () => { kbRows.push({ id: 'kb1', userId: 'user1' }) const result = await checkKnowledgeBaseAccess('kb1', 'user1') @@ -331,7 +376,7 @@ describe('Knowledge Utils', () => { }) describe('checkDocumentAccess', () => { - it.concurrent('should return unauthorized when user mismatch', async () => { + it('should return unauthorized when user mismatch', async () => { kbRows.push({ id: 'kb1', userId: 'owner' }) const result = await checkDocumentAccess('kb1', 'doc1', 'intruder') @@ -343,7 +388,7 @@ describe('Knowledge Utils', () => { }) describe('checkChunkAccess', () => { - it.concurrent('should fail when document is not completed', async () => { + it('should fail when document is not completed', async () => { kbRows.push({ id: 'kb1', userId: 'user1' }) docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' }) @@ -370,7 +415,7 @@ describe('Knowledge Utils', () => { }) describe('generateEmbeddings', () => { - it.concurrent('should return same length as input', async () => { + it('should return same length as input', async () => { const result = await generateEmbeddings(['a', 'b']) expect(result.embeddings.length).toBe(2) 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 638e3c3a747..8076d88cf14 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 @@ -1,7 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * `@/lib/auth/auth-client` builds a Better Auth client at module scope, which + * throws when NEXT_PUBLIC_APP_URL is absent from the environment (and under + * `isolate: false` an earlier file may have imported the graph in a polluted + * env). These tests only exercise pure parsing/model helpers, so stub the + * client module out entirely. + */ +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + import { parseQuestionTagBody, parseSpecialTags, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 942ca3c5afe..60c7a5fbce9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -1,7 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * `@/lib/auth/auth-client` builds a Better Auth client at module scope, which + * throws when NEXT_PUBLIC_APP_URL is absent from the environment (and under + * `isolate: false` an earlier file may have imported the graph in a polluted + * env). These tests only exercise pure parsing/model helpers, so stub the + * client module out entirely. + */ +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index d9f4217280d..f252bf6cefc 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -132,6 +132,10 @@ function renderSso(organizationId: string) { describe('SSO organization transitions', () => { beforeEach(() => { + // The component reads getBaseUrl() during render; make sure the env var is + // present even when the suite runs without a local .env or after another + // test file mutated the environment (auto-restored via unstubEnvs). + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 0c0eafb57bb..f733c885944 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,4 +1,3 @@ -import { setupGlobalFetchMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getAllBlocks } from '@/blocks' import { BlockType, isMcpTool } from '@/executor/constants' @@ -108,13 +107,11 @@ vi.mock('@/lib/workflows/custom-tools/operations', () => ({ getCustomToolById: (...args: unknown[]) => mockGetCustomToolById(...args), })) -setupGlobalFetchMock() - const mockGetAllBlocks = getAllBlocks as Mock const mockExecuteTool = executeTool as Mock const mockGetProviderFromModel = getProviderFromModel as Mock const mockTransformBlockTool = transformBlockTool as Mock -const mockFetch = global.fetch as unknown as Mock +const mockFetch = vi.fn() const mockExecuteProviderRequest = executeProviderRequest as Mock describe('AgentBlockHandler', () => { @@ -126,6 +123,9 @@ describe('AgentBlockHandler', () => { handler = new AgentBlockHandler() vi.clearAllMocks() + // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here + vi.stubGlobal('fetch', mockFetch) + Object.defineProperty(global, 'window', { value: {}, writable: true, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 2f8993e0835..5afc6b640df 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -27,7 +27,7 @@ import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const mockGetProviderFromModel = getProviderFromModel as Mock -const mockFetch = global.fetch as unknown as Mock +const mockFetch = vi.fn() describe('EvaluatorBlockHandler', () => { let handler: EvaluatorBlockHandler @@ -69,6 +69,9 @@ describe('EvaluatorBlockHandler', () => { // Reset mocks using vi vi.clearAllMocks() + // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here + vi.stubGlobal('fetch', mockFetch) + // Default mock implementations authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'test-vertex-credential-id', diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 81c1a62ab55..299eaf6e8dc 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -30,7 +30,7 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' const mockGenerateRouterPrompt = generateRouterPrompt as Mock const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock const mockGetProviderFromModel = getProviderFromModel as Mock -const mockFetch = global.fetch as unknown as Mock +const mockFetch = vi.fn() describe('RouterBlockHandler', () => { let handler: RouterBlockHandler @@ -95,6 +95,9 @@ describe('RouterBlockHandler', () => { vi.clearAllMocks() + // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here + vi.stubGlobal('fetch', mockFetch) + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'test-vertex-credential-id', usedCredentialTable: false, @@ -394,6 +397,9 @@ describe('RouterBlockHandler V2', () => { vi.clearAllMocks() + // unstubGlobals removes any module-scope fetch stub before each test, so re-stub here + vi.stubGlobal('fetch', mockFetch) + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'test-vertex-credential-id', usedCredentialTable: false, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 2d2f142409a..50bc0a7abef 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -1,4 +1,3 @@ -import { setupGlobalFetchMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { @@ -109,9 +108,6 @@ vi.mock('@/executor/utils/http', () => ({ }), })) -// Mock fetch globally -setupGlobalFetchMock() - describe('WorkflowBlockHandler', () => { let handler: WorkflowBlockHandler let mockBlock: SerializedBlock @@ -119,14 +115,17 @@ describe('WorkflowBlockHandler', () => { let mockFetch: Mock beforeEach(() => { - // Mock window.location.origin for getBaseUrl() - ;(global as any).window = { + // Mock window.location.origin for getBaseUrl(); stubGlobal so unstubGlobals cleans it up + vi.stubGlobal('window', { location: { origin: 'http://localhost:3000', }, - } + }) handler = new WorkflowBlockHandler() - mockFetch = global.fetch as Mock + + // unstubGlobals removes any module-scope fetch stub before each test, so stub fresh here + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) mockBlock = { id: 'workflow-block-1', diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts index 71dca0258b8..2b0c37d1565 100644 --- a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts +++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts @@ -1,21 +1,22 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRequestJson } = vi.hoisted(() => ({ - mockRequestJson: vi.fn(), -})) - -vi.mock('@/lib/api/client/request', () => ({ - requestJson: mockRequestJson, -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as requestModule from '@/lib/api/client/request' +import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' +import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' -vi.mock('@/lib/api/contracts/workflows', () => ({ - getWorkflowStateContract: { __contract: 'getWorkflowState' }, -})) +/** + * Spy on the real module namespace instead of vi.mock: under `isolate: false` + * `@/hooks/queries/utils/fetch-workflow-envelope` may already be cached bound + * to the real request module, so patching the shared namespace is the only + * wiring that always applies. + */ +const mockRequestJson = vi.spyOn(requestModule, 'requestJson') -import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' +afterAll(() => { + mockRequestJson.mockRestore() +}) describe('fetchWorkflowEnvelope', () => { beforeEach(() => { @@ -42,9 +43,9 @@ describe('fetchWorkflowEnvelope', () => { await fetchWorkflowEnvelope('wf-2', controller.signal) expect(mockRequestJson).toHaveBeenCalledTimes(1) - expect(mockRequestJson).toHaveBeenCalledWith( - { __contract: 'getWorkflowState' }, - { params: { id: 'wf-2' }, signal: controller.signal } - ) + expect(mockRequestJson).toHaveBeenCalledWith(getWorkflowStateContract, { + params: { id: 'wf-2' }, + signal: controller.signal, + }) }) }) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts index 3db8179f1a7..7da713330a3 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.test.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -2,23 +2,30 @@ * @vitest-environment node */ import { QueryClient } from '@tanstack/react-query' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { getQueryDataMock } = vi.hoisted(() => ({ - getQueryDataMock: vi.fn(), -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: vi.fn(() => ({ - getQueryData: getQueryDataMock, - })), -})) - +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' import { getWorkflowById, getWorkflows, removeWorkflowFromActiveCache, } from '@/hooks/queries/utils/workflow-cache' + +const getQueryDataMock = vi.fn() + +/** + * Spy on the real module namespace instead of vi.mock: under `isolate: false` + * `@/hooks/queries/utils/workflow-cache` may already be cached bound to the + * real get-query-client module, so patching the shared namespace is the only + * wiring that always applies. + */ +const getQueryClientSpy = vi + .spyOn(getQueryClientModule, 'getQueryClient') + .mockImplementation(() => ({ getQueryData: getQueryDataMock }) as unknown as QueryClient) + +afterAll(() => { + getQueryClientSpy.mockRestore() +}) + import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -35,6 +42,9 @@ function workflow(id: string): WorkflowMetadata { describe('getWorkflows', () => { beforeEach(() => { vi.clearAllMocks() + getQueryClientSpy.mockImplementation( + () => ({ getQueryData: getQueryDataMock }) as unknown as QueryClient + ) }) it('reads the active workflow list from the cache', () => { diff --git a/apps/sim/hooks/selectors/registry.test.ts b/apps/sim/hooks/selectors/registry.test.ts index 08f58b05727..7114545606a 100644 --- a/apps/sim/hooks/selectors/registry.test.ts +++ b/apps/sim/hooks/selectors/registry.test.ts @@ -1,43 +1,65 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnsureQueryData, mockGetWorkflows, mockGetFolderMap } = vi.hoisted(() => ({ - mockEnsureQueryData: vi.fn().mockResolvedValue(undefined), - mockGetWorkflows: vi.fn(), - mockGetFolderMap: vi.fn().mockReturnValue({}), -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: vi.fn(() => ({ - ensureQueryData: mockEnsureQueryData, - })), -})) - -vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ - getWorkflows: mockGetWorkflows, - getWorkflowById: vi.fn((workspaceId: string, workflowId: string) => - mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) - ), -})) - -vi.mock('@/hooks/queries/utils/folder-cache', () => ({ - getFolderMap: mockGetFolderMap, -})) +import type { QueryClient } from '@tanstack/react-query' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' +import * as folderCacheModule from '@/hooks/queries/utils/folder-cache' +import * as workflowCacheModule from '@/hooks/queries/utils/workflow-cache' +import * as workflowListQueryModule from '@/hooks/queries/utils/workflow-list-query' +import { getSelectorDefinition } from '@/hooks/selectors/registry' -vi.mock('@/hooks/queries/utils/workflow-list-query', () => ({ - getWorkflowListQueryOptions: vi.fn((workspaceId: string) => ({ - queryKey: ['workflows', 'list', workspaceId, 'active'], - })), -})) +const mockEnsureQueryData = vi.fn().mockResolvedValue(undefined) -import { getSelectorDefinition } from '@/hooks/selectors/registry' +/** + * Spy on the real module namespaces instead of vi.mock: under `isolate: false` + * `@/hooks/selectors/registry` may already be cached bound to the real + * cache/query modules, so patching the shared namespaces is the only wiring + * that always applies. + */ +const getQueryClientSpy = vi + .spyOn(getQueryClientModule, 'getQueryClient') + .mockImplementation(() => ({ ensureQueryData: mockEnsureQueryData }) as unknown as QueryClient) +const mockGetWorkflows = vi.spyOn(workflowCacheModule, 'getWorkflows') +const getWorkflowByIdSpy = vi + .spyOn(workflowCacheModule, 'getWorkflowById') + .mockImplementation((workspaceId: string, workflowId: string) => + mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) + ) +const mockGetFolderMap = vi.spyOn(folderCacheModule, 'getFolderMap').mockReturnValue({}) +const getWorkflowListQueryOptionsSpy = vi + .spyOn(workflowListQueryModule, 'getWorkflowListQueryOptions') + .mockImplementation( + (workspaceId: string) => + ({ + queryKey: ['workflows', 'list', workspaceId, 'active'], + }) as unknown as ReturnType + ) + +afterAll(() => { + getQueryClientSpy.mockRestore() + mockGetWorkflows.mockRestore() + getWorkflowByIdSpy.mockRestore() + mockGetFolderMap.mockRestore() + getWorkflowListQueryOptionsSpy.mockRestore() +}) describe('sim.workflows selector', () => { beforeEach(() => { vi.clearAllMocks() mockEnsureQueryData.mockResolvedValue(undefined) + getQueryClientSpy.mockImplementation( + () => ({ ensureQueryData: mockEnsureQueryData }) as unknown as QueryClient + ) + getWorkflowByIdSpy.mockImplementation((workspaceId: string, workflowId: string) => + mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) + ) + getWorkflowListQueryOptionsSpy.mockImplementation( + (workspaceId: string) => + ({ + queryKey: ['workflows', 'list', workspaceId, 'active'], + }) as unknown as ReturnType + ) mockGetWorkflows.mockReturnValue([ { id: 'wf-1', name: 'Alpha Workflow', folderId: null }, { id: 'wf-2', name: 'Bravo Workflow', folderId: null }, diff --git a/apps/sim/hooks/use-settings-navigation.test.ts b/apps/sim/hooks/use-settings-navigation.test.ts index 93e52f9c6ed..e2839fe0a22 100644 --- a/apps/sim/hooks/use-settings-navigation.test.ts +++ b/apps/sim/hooks/use-settings-navigation.test.ts @@ -1,8 +1,20 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' + +/** + * `@/lib/auth/auth-client` builds a Better Auth client at module scope, which + * throws when NEXT_PUBLIC_APP_URL is absent from the environment (and under + * `isolate: false` an earlier file may have imported the graph in a polluted + * env). This test only exercises the pure `resolveSettingsHref`, so stub the + * client module out entirely. + */ +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + import { resolveSettingsHref } from '@/hooks/use-settings-navigation' const HOST_CONTEXT: WorkspaceHostContext = { diff --git a/apps/sim/lib/admin/dashboard-organizations.test.ts b/apps/sim/lib/admin/dashboard-organizations.test.ts index 6f9d2362ee9..00c87bc32cc 100644 --- a/apps/sim/lib/admin/dashboard-organizations.test.ts +++ b/apps/sim/lib/admin/dashboard-organizations.test.ts @@ -1,6 +1,8 @@ /** @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { Mock } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.unmock('drizzle-orm') @@ -16,34 +18,28 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: {}, recordAudit: vi.fn(), })) -vi.mock('@sim/db', () => { - 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 - } - - const db = { - select: vi.fn(() => { - const rows = mocks.queryRows[mocks.selectCalls] ?? [] - mocks.selectCalls += 1 - return queryChain(rows) - }), - selectDistinctOn: vi.fn(() => { - const rows = mocks.queryRows[mocks.selectCalls] ?? [] - mocks.selectCalls += 1 - mocks.selectDistinctOnCalls += 1 - return queryChain(rows) - }), - } - return { db } -}) +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 + * in this shared worker has clobbered NEXT_PUBLIC_APP_URL, which fails this + * file's collection under `isolate: false`. + */ +vi.mock('@/lib/workspaces/admin-move', () => ({ + moveWorkspaceToOrganization: vi.fn(), +})) +/** + * Keeps this suite from being the first loader of the real plan/usage modules + * in the shared worker. Under `isolate: false` the first import freezes a + * module's dependency bindings, which would break the dedicated plan/usage + * suites when they run later with their own dependency mocks. + */ +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPrioritySubscription: vi.fn(), +})) +vi.mock('@/lib/billing/core/usage', () => ({ + syncUsageLimitsFromSubscription: vi.fn(), +})) vi.mock('@/lib/billing/enterprise-provisioning', () => ({ getLatestEnterpriseProvisionings: vi.fn(async () => mocks.provisionings), })) @@ -69,6 +65,80 @@ 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', () => { it('converts the pending Stripe metadata intent without replacing applied values', () => { expect( @@ -102,6 +172,10 @@ describe('toDashboardConfigurationUpdate', () => { 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() diff --git a/apps/sim/lib/billing/core/plan.test.ts b/apps/sim/lib/billing/core/plan.test.ts index cf223952b0c..a9e855f4861 100644 --- a/apps/sim/lib/billing/core/plan.test.ts +++ b/apps/sim/lib/billing/core/plan.test.ts @@ -1,7 +1,28 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, organization, subscription } from '@sim/db/schema' +import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { Mock } from 'vitest' +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. + */ +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], + checkEnterprisePlan: (s: { plan?: string; status?: string } | null) => + s?.plan === 'enterprise' && ['active', 'past_due'].includes(s?.status ?? ''), + checkTeamPlan: (s: { plan?: string; status?: string } | null) => + s?.plan === 'team' && ['active', 'past_due'].includes(s?.status ?? ''), + checkProPlan: (s: { plan?: string; status?: string } | null) => + s?.plan === 'pro' && ['active', 'past_due'].includes(s?.status ?? ''), +})) + +import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' /** * Drizzle mock for `getHighestPrioritySubscription`. It issues up to four @@ -16,67 +37,77 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' * 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. * - * Table sentinels and shared mock state live inside `vi.hoisted` so the - * `vi.mock` factories (hoisted to the top of the file) can reference them. + * 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. */ -const { SUBSCRIPTION_TABLE, MEMBER_TABLE, ORGANIZATION_TABLE, resultsByTable, fromCalls, select } = - vi.hoisted(() => { - const SUBSCRIPTION_TABLE = { __table: 'subscription' } - const MEMBER_TABLE = { __table: 'member' } - const ORGANIZATION_TABLE = { __table: 'organization' } - - const resultsByTable: Record = { - subscription: [], - member: [], - organization: [], - } - const fromCalls: string[] = [] - - const select = vi.fn(() => ({ - from: (table: { __table: string }) => { - fromCalls.push(table.__table) - const where = () => { - const queue = resultsByTable[table.__table] - const next = queue.length > 0 ? queue.shift() : [] - return Promise.resolve(next ?? []) - } - return { where } - }, - })) - - return { - SUBSCRIPTION_TABLE, - MEMBER_TABLE, - ORGANIZATION_TABLE, - resultsByTable, - fromCalls, - select, - } - }) - -vi.mock('@sim/db', () => ({ - db: { select }, -})) - -vi.mock('@sim/db/schema', () => ({ - subscription: SUBSCRIPTION_TABLE, - member: MEMBER_TABLE, - organization: ORGANIZATION_TABLE, -})) +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 } + }, + } +} -/** - * Realistic plan-check predicates so `pickHighestPrioritySubscription` exercises - * the real Enterprise > Team > Pro priority ordering over the rows we feed it. - */ -vi.mock('@/lib/billing/subscriptions/utils', () => ({ - ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], - checkEnterprisePlan: (s: any) => - s?.plan === 'enterprise' && ['active', 'past_due'].includes(s?.status), - checkTeamPlan: (s: any) => s?.plan === 'team' && ['active', 'past_due'].includes(s?.status), - checkProPlan: (s: any) => s?.plan === 'pro' && ['active', 'past_due'].includes(s?.status), -})) +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)) + } +} -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +/** 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() + } +} interface SubRow { id: string @@ -93,17 +124,25 @@ function orgEnterprise(orgId: string): SubRow { return { id: 'sub-org-enterprise', referenceId: orgId, plan: 'enterprise', status: 'active' } } -function queue(table: 'subscription' | 'member' | 'organization', rows: unknown[]) { +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 () => { diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index ca6452f81f0..23206179b9f 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -1,7 +1,10 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usageLog } from '@sim/db/schema' +import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { Mock } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetHighestPrioritySubscription, @@ -23,31 +26,7 @@ const { mockUpdate: vi.fn(), })) -vi.mock('@sim/db', () => { - const instance = { insert: mockInsert, transaction: mockTransaction } - return { db: instance, dbReplica: instance } -}) - -vi.mock('@sim/db/schema', () => ({ - usageLog: { - billingEntityId: 'usageLog.billingEntityId', - billingEntityType: 'usageLog.billingEntityType', - billingPeriodEnd: 'usageLog.billingPeriodEnd', - billingPeriodStart: 'usageLog.billingPeriodStart', - category: 'usageLog.category', - cost: 'usageLog.cost', - createdAt: 'usageLog.createdAt', - description: 'usageLog.description', - eventKey: 'usageLog.eventKey', - executionId: 'usageLog.executionId', - id: 'usageLog.id', - metadata: 'usageLog.metadata', - source: 'usageLog.source', - userId: 'usageLog.userId', - workflowId: 'usageLog.workflowId', - workspaceId: 'usageLog.workspaceId', - }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, @@ -69,9 +48,66 @@ import { resolveCumulativeTopUp, } 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. + */ +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', () => { beforeEach(() => { vi.clearAllMocks() + installSharedDbMocks() mockReturning.mockResolvedValue([{ cost: '0.10' }, { cost: '0.20' }]) mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning }) mockValues.mockReturnValue({ @@ -121,9 +157,10 @@ describe('recordUsage', () => { expect(values[0].eventKey).toMatch(/^[a-f0-9]{64}$/) expect(values[1].eventKey).toMatch(/^[a-f0-9]{64}$/) expect(values[0].eventKey).not.toBe(values[1].eventKey) - expect(mockOnConflictDoNothing).toHaveBeenCalledWith( - expect.objectContaining({ target: 'usageLog.eventKey' }) - ) + expect(mockOnConflictDoNothing).toHaveBeenCalledTimes(1) + expect(mockOnConflictDoNothing.mock.calls[0][0]).toMatchObject({ + target: usageLog.eventKey, + }) expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() }) @@ -219,6 +256,7 @@ describe('recordCumulativeUsage', () => { beforeEach(() => { vi.clearAllMocks() + installSharedDbMocks() mockReturning.mockResolvedValue([{ cost: '0.3474447' }]) mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning }) mockValues.mockReturnValue({ onConflictDoNothing: mockOnConflictDoNothing }) diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index 7e46b6b8179..443cea29f7a 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -8,11 +8,57 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { Mock } from 'vitest' +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 { mockGetFreeTierLimit, mockGetHighestPrioritySubscription, @@ -84,6 +130,8 @@ const PRO_SUBSCRIPTION = { describe('getUserUsageLimit', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + delegateGlobalDbToChainMocks() mockIsOrgScopedSubscription.mockReturnValue(false) mockGetHighestPrioritySubscription.mockResolvedValue(null) }) @@ -164,6 +212,8 @@ describe('getUserUsageLimit', () => { describe('syncUsageLimitsFromSubscription', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + delegateGlobalDbToChainMocks() mockIsOrgScopedSubscription.mockReturnValue(false) }) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 8c9b95a7556..de017d9826c 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -37,7 +37,24 @@ vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mockIsPlatformAdmin, })) -import { getFeatureFlags, isFeatureEnabled } from '@/lib/core/config/feature-flags' +/** + * 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 + * files, so the plain specifier may already be cached with the real + * appconfig/env/env-flags bindings (mocks never reach an already-evaluated + * module) — and evaluating it here under this file's mocks would poison it for + * later files. The suffixed id is unique to this file, so it always evaluates + * fresh with the mocks above. + */ +declare module '@/lib/core/config/feature-flags?feature-flags-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/core/config/feature-flags' +} + +import { + getFeatureFlags, + isFeatureEnabled, +} from '@/lib/core/config/feature-flags?feature-flags-test' /** Make `getFeatureFlags` resolve to `doc` via the AppConfig path (also exercises parseConfig). */ function withAppConfig(doc: unknown) { diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts index 0b61b5a3efd..a166a9d7496 100644 --- a/apps/sim/lib/core/execution-limits/types.test.ts +++ b/apps/sim/lib/core/execution-limits/types.test.ts @@ -23,7 +23,25 @@ vi.mock('@/lib/core/config/env-flags', () => ({ }, })) -import { createTimeoutAbortController, getExecutionTimeout } from '@/lib/core/execution-limits' +/** + * 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 + * env into EXECUTION_TIMEOUTS). Under `isolate: false` the worker's module + * graph is shared across test files, so the plain specifier may already be + * cached with the real env/env-flags bindings (mocks never reach an + * already-evaluated module) — and evaluating it here under this file's mocks + * would poison it for later files. The suffixed id is unique to this file, so + * it always evaluates fresh with the mocks above. + */ +declare module '@/lib/core/execution-limits/types?execution-limits-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/core/execution-limits/types' +} + +import { + createTimeoutAbortController, + getExecutionTimeout, +} from '@/lib/core/execution-limits/types?execution-limits-test' describe('getExecutionTimeout', () => { beforeEach(() => { 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 93e482613fd..48d14f671d7 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -1,9 +1,34 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { RateLimiter } from './rate-limiter' -import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage' -import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types' + +/** + * Query-suffixed imports give this file private instances of the modules under + * test. Under `isolate: false` the worker's module graph is shared across test + * files, so the plain specifiers may already be cached with the real env-flags + * binding (`isBillingEnabled` false ⇒ unlimited limits; mocks never reach an + * already-evaluated module) — and evaluating them here under this file's mocks + * would poison them for later files. The suffixed ids are unique to this file, + * so they always evaluate fresh with the mocks below. The plain `./types` id is + * redirected to the same fresh instance so the `RateLimiter` under test and the + * assertions below share one `RATE_LIMITS`/`getRateLimit`. + */ +declare module '@/lib/core/rate-limiter/rate-limiter?rate-limiter-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/core/rate-limiter/rate-limiter' +} +declare module '@/lib/core/rate-limiter/types?rate-limiter-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + 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') +) + +import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter?rate-limiter-test' +import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage' +import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types' interface MockAdapter { consumeTokens: Mock 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 0ecfa22cb9e..292439034fe 100644 --- a/apps/sim/lib/core/security/pinned-fetch.server.test.ts +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -27,7 +27,20 @@ const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoi vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) -import { createPinnedFetch } from '@/lib/core/security/input-validation.server' +/** + * 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 + * files, so the plain specifier may already be cached with the real `undici` + * binding (mocks never reach an already-evaluated module) — and evaluating it + * here under this file's mocks would poison it for later files. The suffixed id + * is unique to this file, so it always evaluates fresh with the mocks above. + */ +declare module '@/lib/core/security/input-validation.server?pinned-fetch-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/core/security/input-validation.server' +} + +import { createPinnedFetch } from '@/lib/core/security/input-validation.server?pinned-fetch-test' type LookupCallback = (err: Error | null, address: string, family: number) => void type PinnedLookup = (hostname: string, options: { all?: boolean }, callback: LookupCallback) => void diff --git a/apps/sim/lib/core/security/ssrf-guarded-lookup.test.ts b/apps/sim/lib/core/security/ssrf-guarded-lookup.test.ts index b504017c435..c2b593ebbf1 100644 --- a/apps/sim/lib/core/security/ssrf-guarded-lookup.test.ts +++ b/apps/sim/lib/core/security/ssrf-guarded-lookup.test.ts @@ -7,7 +7,24 @@ const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn() })) vi.mock('dns/promises', () => ({ default: { lookup: mockDnsLookup } })) -import { createSsrfGuardedLookup } from '@/lib/core/security/input-validation.server' +/** + * 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 + * files, so the plain specifier may already be cached with the real + * `dns/promises` binding (mocks never reach an already-evaluated module) — and + * evaluating it here under this file's mocks would poison it for later files. + * The suffixed id is unique to this file, so it always evaluates fresh with + * the mock above. + */ +declare module '@/lib/core/security/input-validation.server?ssrf-guarded-lookup-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/core/security/input-validation.server' +} + +import { + createSsrfGuardedLookup, + followRedirectsGuarded, +} from '@/lib/core/security/input-validation.server?ssrf-guarded-lookup-test' type LookupResult = { address: string; family: number } @@ -96,8 +113,6 @@ describe('createSsrfGuardedLookup', () => { }) }) -import { followRedirectsGuarded } from '@/lib/core/security/input-validation.server' - function redirectTo(location: string, status = 302): Response { return new Response(null, { status, headers: { location } }) } diff --git a/apps/sim/lib/data-drains/destinations/bigquery.test.ts b/apps/sim/lib/data-drains/destinations/bigquery.test.ts index de1e6a2db3f..31f4d98092a 100644 --- a/apps/sim/lib/data-drains/destinations/bigquery.test.ts +++ b/apps/sim/lib/data-drains/destinations/bigquery.test.ts @@ -67,6 +67,7 @@ const meta = { beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue( new Response(JSON.stringify({}), { status: 200, diff --git a/apps/sim/lib/data-drains/destinations/datadog.test.ts b/apps/sim/lib/data-drains/destinations/datadog.test.ts index fe63bfdc9ee..bb19761dd56 100644 --- a/apps/sim/lib/data-drains/destinations/datadog.test.ts +++ b/apps/sim/lib/data-drains/destinations/datadog.test.ts @@ -23,6 +23,7 @@ const meta = (sequence: number) => ({ beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue(new Response(null, { status: 202 })) }) diff --git a/apps/sim/lib/data-drains/destinations/gcs.test.ts b/apps/sim/lib/data-drains/destinations/gcs.test.ts index 0003d2b8095..81d35034fe0 100644 --- a/apps/sim/lib/data-drains/destinations/gcs.test.ts +++ b/apps/sim/lib/data-drains/destinations/gcs.test.ts @@ -32,6 +32,7 @@ const credentials = { beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue(new Response(null, { status: 200 })) }) diff --git a/apps/sim/lib/data-drains/destinations/snowflake.test.ts b/apps/sim/lib/data-drains/destinations/snowflake.test.ts index 059eaf531d9..f4ea574c72a 100644 --- a/apps/sim/lib/data-drains/destinations/snowflake.test.ts +++ b/apps/sim/lib/data-drains/destinations/snowflake.test.ts @@ -37,6 +37,7 @@ const meta = { beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })) }) diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index 85a6e01f1c6..26944e5d52c 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -1,9 +1,10 @@ /** * @vitest-environment node */ -import { createEnvMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, defaultMockEnv, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { env } from '@/lib/core/config/env' const { mockBatchTrigger } = vi.hoisted(() => ({ mockBatchTrigger: vi.fn(), @@ -18,7 +19,20 @@ vi.mock('@trigger.dev/sdk', () => ({ vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ TRIGGER_SECRET_KEY: 'trigger-secret' })) +/** + * Under `isolate: false` the shared `@/lib/knowledge/embeddings` / + * `documents/service` modules may be cached bound to the REAL env module, so + * mutate the real `env` object per test (restored afterAll) instead of + * vi.mock'ing a file-local replacement a cached consumer would never see. + */ +const envSnapshot = { ...env } + +afterAll(() => { + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, envSnapshot) +}) vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: vi.fn().mockReturnValue(1), isTriggerDevEnabled: true, @@ -52,6 +66,10 @@ describe('processDocumentsWithQueue billing attribution', () => { vi.clearAllMocks() resetDbChainMock() mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' }) + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, { ...defaultMockEnv, TRIGGER_SECRET_KEY: 'trigger-secret' }) }) it('validates and preserves workspace attribution before enqueue', async () => { diff --git a/apps/sim/lib/knowledge/embeddings.test.ts b/apps/sim/lib/knowledge/embeddings.test.ts index 54d1c970ed5..63ecaa3c7df 100644 --- a/apps/sim/lib/knowledge/embeddings.test.ts +++ b/apps/sim/lib/knowledge/embeddings.test.ts @@ -1,74 +1,54 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockRecordUsage, - mockToBillingContext, - mockCheckAndBillPayerOverageThreshold, - mockCalculateCost, -} = vi.hoisted(() => ({ - mockRecordUsage: vi.fn(), - mockToBillingContext: vi.fn(), - mockCheckAndBillPayerOverageThreshold: vi.fn(), - mockCalculateCost: vi.fn(), -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - recordUsage: mockRecordUsage, -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - resolveBillingAttribution: vi.fn(), - toBillingContext: mockToBillingContext, -})) - -vi.mock('@/lib/billing/threshold-billing', () => ({ - checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold, -})) - -vi.mock('@/lib/api-key/byok', () => ({ - getBYOKKey: vi.fn(), -})) - -vi.mock('@/lib/core/config/api-keys', () => ({ - getRotatingApiKey: vi.fn(), -})) - -vi.mock('@/lib/core/config/env', () => ({ - env: {}, - getEnv: vi.fn(() => undefined), - envNumber: (_value: unknown, fallback: number) => fallback, -})) - -vi.mock('@/lib/knowledge/documents/utils', () => ({ - isRetryableError: vi.fn(), - retryWithExponentialBackoff: vi.fn(), -})) - -vi.mock('@/lib/knowledge/embedding-models', () => ({ - DEFAULT_EMBEDDING_MODEL: 'text-embedding-3-small', - EMBEDDING_DIMENSIONS: { 'text-embedding-3-small': 1536 }, - SUPPORTED_EMBEDDING_MODELS: ['text-embedding-3-small'], - getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })), -})) - -vi.mock('@/lib/tokenization', () => ({ - batchByTokenLimit: vi.fn(), - estimateTokenCount: vi.fn(() => ({ count: 100 })), -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as billingAttributionModule from '@/lib/billing/core/billing-attribution' +import * as usageLogModule from '@/lib/billing/core/usage-log' +import * as thresholdBillingModule from '@/lib/billing/threshold-billing' +import * as embeddingModelsModule from '@/lib/knowledge/embedding-models' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import * as tokenizationModule from '@/lib/tokenization' +import * as providersUtilsModule from '@/providers/utils' -vi.mock('@/providers/utils', () => ({ - calculateCost: mockCalculateCost, -})) +/** + * Spy on the real module namespaces instead of vi.mock: under `isolate: false` + * `@/lib/knowledge/embeddings` is a shared consumer cached across test files, + * so vi.mock here would bind this file's fixtures into it for every later + * file. Patching the real namespaces (and restoring afterAll) is the only + * wiring that composes. + */ +const mockRecordUsage = vi + .spyOn(usageLogModule, 'recordUsage') + .mockResolvedValue(undefined as never) +const mockToBillingContext = vi.spyOn(billingAttributionModule, 'toBillingContext') +const mockCheckAndBillPayerOverageThreshold = vi + .spyOn(thresholdBillingModule, 'checkAndBillPayerOverageThreshold') + .mockResolvedValue(undefined as never) +const mockCalculateCost = vi.spyOn(providersUtilsModule, 'calculateCost') +const estimateTokenCountSpy = vi + .spyOn(tokenizationModule, 'estimateTokenCount') + .mockReturnValue({ count: 100 } as never) +const getEmbeddingModelInfoSpy = vi + .spyOn(embeddingModelsModule, 'getEmbeddingModelInfo') + .mockReturnValue({ tokenizerProvider: 'openai' } as never) -import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +afterAll(() => { + mockRecordUsage.mockRestore() + mockToBillingContext.mockRestore() + mockCheckAndBillPayerOverageThreshold.mockRestore() + mockCalculateCost.mockRestore() + estimateTokenCountSpy.mockRestore() + getEmbeddingModelInfoSpy.mockRestore() +}) describe('recordSearchEmbeddingUsage', () => { beforeEach(() => { vi.clearAllMocks() - mockCalculateCost.mockReturnValue({ total: 0.01 }) + mockRecordUsage.mockResolvedValue(undefined as never) + mockCheckAndBillPayerOverageThreshold.mockResolvedValue(undefined as never) + estimateTokenCountSpy.mockReturnValue({ count: 100 } as never) + getEmbeddingModelInfoSpy.mockReturnValue({ tokenizerProvider: 'openai' } as never) + mockCalculateCost.mockReturnValue({ total: 0.01 } as never) mockToBillingContext.mockReturnValue({ billingEntity: { type: 'organization', id: 'org-1' }, billingPeriod: { diff --git a/apps/sim/lib/table/constants.test.ts b/apps/sim/lib/table/constants.test.ts index bc067916b7a..d473fa16816 100644 --- a/apps/sim/lib/table/constants.test.ts +++ b/apps/sim/lib/table/constants.test.ts @@ -24,7 +24,20 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) -import { getBillingDisabledTableLimits } from '@/lib/table/constants' +/** + * 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 + * files, so the plain specifier may already be cached with the real env binding + * (mocks never reach an already-evaluated module) — and evaluating it here + * under this file's mocks would poison it for later files. The suffixed id is + * unique to this file, so it always evaluates fresh with the mock above. + */ +declare module '@/lib/table/constants?constants-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + export * from '@/lib/table/constants' +} + +import { getBillingDisabledTableLimits } from '@/lib/table/constants?constants-test' describe('getBillingDisabledTableLimits', () => { beforeEach(() => { diff --git a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts index d8c2476da9a..6348ef9d240 100644 --- a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts @@ -1,28 +1,26 @@ /** * @vitest-environment node + * + * Uses vi.spyOn against the shared module instances instead of vi.mock: under + * `isolate: false` the module under test may already be cached from another + * test file, bound to whatever dependency instances were live at that time. + * Spying on the instance this file resolves patches the exact namespace the + * cached module reads at call time, so the tests behave identically whether + * the module graph is fresh or reused. */ -import { - inputValidationMock, - inputValidationMockFns, - permissionsMock, - permissionsMockFns, -} from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockUploadWorkspaceFile } = vi.hoisted(() => ({ - mockUploadWorkspaceFile: vi.fn(), -})) - -vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - uploadWorkspaceFile: mockUploadWorkspaceFile, -})) - +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as inputValidation from '@/lib/core/security/input-validation.server' import { ExternalUrlValidationError, fetchExternalUrlToWorkspace, } from '@/lib/uploads/contexts/workspace/fetch-external-url' +import * as workspaceFileManager from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import * as workspacePermissions from '@/lib/workspaces/permissions/utils' + +const validateUrlWithDNSSpy = vi.spyOn(inputValidation, 'validateUrlWithDNS') +const secureFetchWithPinnedIPSpy = vi.spyOn(inputValidation, 'secureFetchWithPinnedIP') +const getUserEntityPermissionsSpy = vi.spyOn(workspacePermissions, 'getUserEntityPermissions') +const uploadWorkspaceFileSpy = vi.spyOn(workspaceFileManager, 'uploadWorkspaceFile') function makeResponse(body: string, contentType = 'application/octet-stream'): Response { return new Response(body, { status: 200, headers: { 'content-type': contentType } }) @@ -30,27 +28,39 @@ function makeResponse(body: string, contentType = 'application/octet-stream'): R describe('fetchExternalUrlToWorkspace', () => { beforeEach(() => { - vi.clearAllMocks() - inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + validateUrlWithDNSSpy.mockReset() + secureFetchWithPinnedIPSpy.mockReset() + getUserEntityPermissionsSpy.mockReset() + uploadWorkspaceFileSpy.mockReset() + + validateUrlWithDNSSpy.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10', }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockUploadWorkspaceFile.mockImplementation( - async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => ({ - id: `wf_${fileName}`, - name: fileName, - size: 0, - type: 'application/octet-stream', - url: `/api/files/serve/${workspaceId}/${fileName}`, - key: `${workspaceId}/${fileName}`, - context: 'workspace', - }) + getUserEntityPermissionsSpy.mockResolvedValue('write') + uploadWorkspaceFileSpy.mockImplementation( + async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => + ({ + id: `wf_${fileName}`, + name: fileName, + size: 0, + type: 'application/octet-stream', + url: `/api/files/serve/${workspaceId}/${fileName}`, + key: `${workspaceId}/${fileName}`, + context: 'workspace', + }) as unknown as Awaited> ) }) + afterAll(() => { + validateUrlWithDNSSpy.mockRestore() + secureFetchWithPinnedIPSpy.mockRestore() + getUserEntityPermissionsSpy.mockRestore() + uploadWorkspaceFileSpy.mockRestore() + }) + it('downloads each URL independently — never dedups by path filename', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP + secureFetchWithPinnedIPSpy .mockResolvedValueOnce(makeResponse('first bytes', 'image/png')) .mockResolvedValueOnce(makeResponse('different second bytes', 'image/png')) @@ -69,12 +79,12 @@ describe('fetchExternalUrlToWorkspace', () => { expect(second.filename).toBe('image.png') expect(first.buffer.toString()).toBe('first bytes') expect(second.buffer.toString()).toBe('different second bytes') - expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2) - expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(2) + expect(secureFetchWithPinnedIPSpy).toHaveBeenCalledTimes(2) + expect(uploadWorkspaceFileSpy).toHaveBeenCalledTimes(2) }) it('throws ExternalUrlValidationError when SSRF validation fails', async () => { - inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + validateUrlWithDNSSpy.mockResolvedValue({ isValid: false, error: 'Blocked private IP', }) @@ -85,11 +95,11 @@ describe('fetchExternalUrlToWorkspace', () => { userId: 'user-1', }) ).rejects.toBeInstanceOf(ExternalUrlValidationError) - expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + expect(secureFetchWithPinnedIPSpy).not.toHaveBeenCalled() }) it('throws on non-2xx fetch responses', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + secureFetchWithPinnedIPSpy.mockResolvedValue( new Response('not found', { status: 404, statusText: 'Not Found' }) ) @@ -102,9 +112,7 @@ describe('fetchExternalUrlToWorkspace', () => { }) it('skips workspace save when saveToWorkspace is false', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/file.txt', @@ -114,14 +122,12 @@ describe('fetchExternalUrlToWorkspace', () => { }) expect(result.savedWorkspaceFile).toBeUndefined() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(uploadWorkspaceFileSpy).not.toHaveBeenCalled() + expect(getUserEntityPermissionsSpy).not.toHaveBeenCalled() }) it('skips workspace save when no workspaceId is provided', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/file.txt', @@ -129,14 +135,12 @@ describe('fetchExternalUrlToWorkspace', () => { }) expect(result.savedWorkspaceFile).toBeUndefined() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(uploadWorkspaceFileSpy).not.toHaveBeenCalled() }) it('skips workspace save when user lacks write permission', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) + getUserEntityPermissionsSpy.mockResolvedValue('read') const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/file.txt', @@ -145,14 +149,12 @@ describe('fetchExternalUrlToWorkspace', () => { }) expect(result.savedWorkspaceFile).toBeUndefined() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(uploadWorkspaceFileSpy).not.toHaveBeenCalled() }) it('returns parsed bytes but skips save when user is not a workspace member', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) + getUserEntityPermissionsSpy.mockResolvedValue(null) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/file.txt', @@ -162,13 +164,11 @@ describe('fetchExternalUrlToWorkspace', () => { expect(result.buffer.toString()).toBe('bytes') expect(result.savedWorkspaceFile).toBeUndefined() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(uploadWorkspaceFileSpy).not.toHaveBeenCalled() }) it('returns the saved workspace file when permission allows save', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/notes.txt', @@ -176,7 +176,7 @@ describe('fetchExternalUrlToWorkspace', () => { workspaceId: 'workspace-1', }) - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + expect(uploadWorkspaceFileSpy).toHaveBeenCalledWith( 'workspace-1', 'user-1', expect.any(Buffer), @@ -187,10 +187,8 @@ describe('fetchExternalUrlToWorkspace', () => { }) it('swallows workspace save errors so parsing can still proceed', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) - mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('disk full')) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) + uploadWorkspaceFileSpy.mockRejectedValueOnce(new Error('disk full')) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/file.txt', @@ -203,9 +201,7 @@ describe('fetchExternalUrlToWorkspace', () => { }) it('forwards custom headers to the fetch', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('bytes', 'text/plain') - ) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain')) await fetchExternalUrlToWorkspace({ url: 'https://files.slack.com/files-pri/T07/download/report.txt', @@ -213,7 +209,7 @@ describe('fetchExternalUrlToWorkspace', () => { headers: { Authorization: 'Bearer xoxb-test-token' }, }) - expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + expect(secureFetchWithPinnedIPSpy).toHaveBeenCalledWith( 'https://files.slack.com/files-pri/T07/download/report.txt', '203.0.113.10', expect.objectContaining({ @@ -223,9 +219,7 @@ describe('fetchExternalUrlToWorkspace', () => { }) it('uses content-type from response headers', async () => { - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - makeResponse('pdf bytes', 'application/pdf') - ) + secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('pdf bytes', 'application/pdf')) const result = await fetchExternalUrlToWorkspace({ url: 'https://example.com/report.pdf', diff --git a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts index 3159d5e7139..8251a6c7033 100644 --- a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts +++ b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts @@ -5,8 +5,18 @@ * across .env.example, helm/sim/values.yaml, and env.ts. * * @vitest-environment node + * + * Under `isolate: false` the storage-service module may already be cached from + * another test file, bound to the real `@/lib/uploads/config` namespace, so a + * per-file `vi.mock` of that path would never reach it. Instead this file + * patches the real config namespace in place (the `USE_*` flags are value + * exports read at call time) and restores it afterwards. The blob client and + * Azure SDK are pulled in via dynamic `import()` at call time, so regular + * `vi.mock` registrations still apply to them. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as uploadsConfig from '@/lib/uploads/config' +import { generatePresignedUploadUrl, headObject } from '@/lib/uploads/core/storage-service' const CONNECTION_STRING = 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' @@ -18,19 +28,6 @@ const { mockHeadBlobObject, mockGetBlobServiceClient, mockGenerateBlobSASQueryPa mockGenerateBlobSASQueryParameters: vi.fn(() => ({ toString: () => 'sig=fake' })), })) -vi.mock('@/lib/uploads/config', () => ({ - USE_S3_STORAGE: false, - USE_BLOB_STORAGE: true, - USE_GCS_STORAGE: false, - // Connection-string-only: accountName/accountKey intentionally absent. - getStorageConfig: () => ({ - containerName: 'workspace-files', - accountName: undefined, - accountKey: undefined, - connectionString: CONNECTION_STRING, - }), -})) - vi.mock('@/lib/uploads/providers/blob/client', () => ({ headBlobObject: mockHeadBlobObject, getBlobServiceClient: mockGetBlobServiceClient, @@ -48,11 +45,46 @@ vi.mock('@azure/storage-blob', () => ({ generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters, })) -import { generatePresignedUploadUrl, headObject } from '@/lib/uploads/core/storage-service' +const STORAGE_FLAGS = ['USE_S3_STORAGE', 'USE_BLOB_STORAGE', 'USE_GCS_STORAGE'] as const + +const originalFlagValues = STORAGE_FLAGS.map( + (flag) => [flag, uploadsConfig[flag]] as [string, boolean] +) + +function setFlag(flag: (typeof STORAGE_FLAGS)[number], value: boolean): void { + Object.defineProperty(uploadsConfig, flag, { value, configurable: true }) +} + +setFlag('USE_S3_STORAGE', false) +setFlag('USE_BLOB_STORAGE', true) +setFlag('USE_GCS_STORAGE', false) + +const getStorageConfigSpy = vi + .spyOn(uploadsConfig, 'getStorageConfig') + // Connection-string-only: accountName/accountKey intentionally absent. + .mockReturnValue({ + containerName: 'workspace-files', + accountName: undefined, + accountKey: undefined, + connectionString: CONNECTION_STRING, + }) + +afterAll(() => { + for (const [flag, value] of originalFlagValues) { + Object.defineProperty(uploadsConfig, flag, { value, configurable: true }) + } + getStorageConfigSpy.mockRestore() +}) describe('Azure Blob storage — connection-string-only auth', () => { beforeEach(() => { vi.clearAllMocks() + getStorageConfigSpy.mockReturnValue({ + containerName: 'workspace-files', + accountName: undefined, + accountKey: undefined, + connectionString: CONNECTION_STRING, + }) mockHeadBlobObject.mockResolvedValue({ size: 42, contentType: 'text/plain' }) mockGetBlobServiceClient.mockResolvedValue({ getContainerClient: () => ({ diff --git a/apps/sim/lib/webhooks/pending-verification.test.ts b/apps/sim/lib/webhooks/pending-verification.test.ts index 9e2ff26ca8a..d282aebb90d 100644 --- a/apps/sim/lib/webhooks/pending-verification.test.ts +++ b/apps/sim/lib/webhooks/pending-verification.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { redisConfigMock } from '@sim/testing' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/redis', () => redisConfigMock) @@ -15,6 +15,10 @@ import { } from '@/lib/webhooks/pending-verification' describe('pending webhook verification', () => { + beforeEach(() => { + redisConfigMockFns.mockGetRedisClient.mockReturnValue(null) + }) + afterEach(async () => { await clearPendingWebhookVerification('grain-path-1') await clearPendingWebhookVerification('grain-path-2') diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index fe70871bcb9..ff521750ba5 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -1,13 +1,28 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import type { BlockState } from '@/stores/workflows/workflow/types' vi.unmock('@/blocks/registry') +import * as blocksBarrel from '@/blocks' +import { getBlock as getRealBlock } from '@/blocks/registry' import { backfillCanonicalModes, migrateSubblockIds } from './subblock-migrations' +/** + * Under `isolate: false` the module under test may already be cached from an + * earlier test file, bound to the global `@/blocks/registry` mock through the + * `@/blocks` barrel. `vi.unmock` alone cannot rebind that cached instance, so + * route the barrel's `getBlock` to the real registry via a spy on the shared + * barrel namespace — it patches whichever instance the cached module reads. + */ +const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock) + +afterAll(() => { + getBlockSpy.mockRestore() +}) + function makeBlock(overrides: Partial & { type: string }): BlockState { return { id: 'block-1', diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index 041bf4bbc5a..f38093ca6fd 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInsert, @@ -11,10 +11,6 @@ const { mockOnConflictDoUpdate, mockValues, mockWhere, - mockGenerateCronExpression, - mockCalculateNextRunTime, - mockValidateCronExpression, - mockGetScheduleTimeValues, mockRandomUUID, mockTransaction, mockSelect, @@ -25,10 +21,6 @@ const { mockOnConflictDoUpdate: vi.fn(), mockValues: vi.fn(), mockWhere: vi.fn(), - mockGenerateCronExpression: vi.fn(), - mockCalculateNextRunTime: vi.fn(), - mockValidateCronExpression: vi.fn(), - mockGetScheduleTimeValues: vi.fn(), mockRandomUUID: vi.fn(), mockTransaction: vi.fn(), mockSelect: vi.fn(), @@ -61,29 +53,40 @@ vi.mock('@/lib/webhooks/deploy', () => ({ cleanupWebhooksForWorkflow: vi.fn().mockResolvedValue(undefined), })) -vi.mock('./utils', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - generateCronExpression: mockGenerateCronExpression, - calculateNextRunTime: mockCalculateNextRunTime, - validateCronExpression: mockValidateCronExpression, - getScheduleTimeValues: mockGetScheduleTimeValues, - } -}) - -vi.stubGlobal('crypto', { - randomUUID: mockRandomUUID, -}) - import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from './deploy' import type { BlockState } from './utils' +import * as scheduleUtils from './utils' import { findScheduleBlocks, validateScheduleBlock, validateWorkflowSchedules } from './validation' +/** + * Spy on the shared `./utils` namespace instead of `vi.mock`: under + * `isolate: false` the modules under test may already be cached from another + * test file, bound to the real utils instance, which a per-file `vi.mock` + * factory could never rebind. Patching the resolved namespace covers both + * fresh and reused module graphs. + */ +const mockGenerateCronExpression = vi.spyOn(scheduleUtils, 'generateCronExpression') +const mockCalculateNextRunTime = vi.spyOn(scheduleUtils, 'calculateNextRunTime') +const mockValidateCronExpression = vi.spyOn(scheduleUtils, 'validateCronExpression') +const mockGetScheduleTimeValues = vi.spyOn(scheduleUtils, 'getScheduleTimeValues') + +afterAll(() => { + mockGenerateCronExpression.mockRestore() + mockCalculateNextRunTime.mockRestore() + mockValidateCronExpression.mockRestore() + mockGetScheduleTimeValues.mockRestore() +}) + describe('Schedule Deploy Utilities', () => { beforeEach(() => { vi.clearAllMocks() + /** + * Re-stub per test: `unstubGlobals: true` unstubs all globals before each + * test, so a top-level stub would not survive past collection. + */ + vi.stubGlobal('crypto', { randomUUID: mockRandomUUID }) + mockRandomUUID.mockReturnValue('test-uuid') mockGenerateCronExpression.mockReturnValue('0 9 * * *') mockCalculateNextRunTime.mockReturnValue(new Date('2025-04-15T09:00:00Z')) @@ -128,6 +131,7 @@ describe('Schedule Deploy Utilities', () => { afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() }) describe('findScheduleBlocks', () => { diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 7103152601e..dbe15aa294e 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -1,14 +1,28 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') -import { getAllBlocks } from '@/blocks/registry' +import * as blocksBarrel from '@/blocks' +import { getAllBlocks, getBlock as getRealBlock } from '@/blocks/registry' import { buildSelectorContextFromBlock, SELECTOR_CONTEXT_FIELDS } from './context' import { buildCanonicalIndex, isCanonicalPair } from './visibility' +/** + * Under `isolate: false` the module under test may already be cached from an + * earlier test file, bound to the global `@/blocks/registry` mock through the + * `@/blocks` barrel. `vi.unmock` alone cannot rebind that cached instance, so + * route the barrel's `getBlock` to the real registry via a spy on the shared + * barrel namespace — it patches whichever instance the cached module reads. + */ +const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock) + +afterAll(() => { + getBlockSpy.mockRestore() +}) + describe('buildSelectorContextFromBlock', () => { it('should extract knowledgeBaseId from knowledgeBaseSelector via canonical mapping', () => { const ctx = buildSelectorContextFromBlock('knowledge', { diff --git a/apps/sim/lib/workspaces/utils.test.ts b/apps/sim/lib/workspaces/utils.test.ts index 7a7fc851519..9cbf74c2c3e 100644 --- a/apps/sim/lib/workspaces/utils.test.ts +++ b/apps/sim/lib/workspaces/utils.test.ts @@ -1,16 +1,15 @@ /** * @vitest-environment node */ -import { db } from '@sim/db' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import type { Mock } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockChangeWorkspaceStoragePayerInTx } = vi.hoisted(() => ({ mockChangeWorkspaceStoragePayerInTx: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { select: vi.fn(), transaction: vi.fn() }, -})) +vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/storage/payer-transfer', () => ({ changeWorkspaceStoragePayerInTx: mockChangeWorkspaceStoragePayerInTx, @@ -22,11 +21,57 @@ import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, } from '@/lib/workspaces/utils' -const mockDb = db as unknown as { - select: ReturnType - transaction: ReturnType +/** + * `@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) { const chain: any = {} chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult)) @@ -68,6 +113,8 @@ function createUpdateChain(result: unknown) { describe('reassignBilledAccountForUser', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + delegateGlobalDbToChainMocks() }) it('routes each resolved workspace through the payer helper in its own transaction', async () => { @@ -180,6 +227,8 @@ describe('reassignBilledAccountForUser', () => { describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + delegateGlobalDbToChainMocks() }) it('reassigns departing member workflows to the workspace billed account', async () => { @@ -277,6 +326,8 @@ describe('reassignWorkflowOwnershipForWorkspaceMemberRemovalTx', () => { 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 () => { diff --git a/apps/sim/serializer/tests/dual-validation.test.ts b/apps/sim/serializer/tests/dual-validation.test.ts index 2450682811d..4704040e0af 100644 --- a/apps/sim/serializer/tests/dual-validation.test.ts +++ b/apps/sim/serializer/tests/dual-validation.test.ts @@ -8,11 +8,32 @@ * 2. Late validation (tool execution) - user-or-llm required fields */ import { blocksMock } from '@sim/testing/mocks' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' +import * as subblockVisibility from '@/lib/workflows/subblocks/visibility' import { Serializer } from '@/serializer/index' vi.mock('@/blocks', () => blocksMock) +/** + * Under `isolate: false` another test file may have cached a partial + * `@/lib/core/config/env-flags` mock (one without `isHosted`) that + * `isSubBlockHidden`'s cached module instance still reads at call time, + * throwing "No isHosted export is defined". Passing `hosted` explicitly + * short-circuits that read (`options?.hosted ?? isHosted`) while keeping the + * original implementation, so behavior is identical to the real test env + * (`isHosted` is false there) in both fresh and reused module graphs. + */ +const originalIsSubBlockHidden = subblockVisibility.isSubBlockHidden +const isSubBlockHiddenSpy = vi + .spyOn(subblockVisibility, 'isSubBlockHidden') + .mockImplementation((subBlock, options) => + originalIsSubBlockHidden(subBlock, { hosted: false, ...options }) + ) + +afterAll(() => { + isSubBlockHiddenSpy.mockRestore() +}) + /** * Validates required parameters after user and LLM parameter merge. * This checks user-or-llm visibility fields that should have been provided by either source. diff --git a/apps/sim/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts index 4392fab735f..5dc7979e858 100644 --- a/apps/sim/stores/workflows/registry/store.test.ts +++ b/apps/sim/stores/workflows/registry/store.test.ts @@ -8,21 +8,12 @@ * variables / deployment stores, guarding against superseded responses. */ import { QueryClient } from '@tanstack/react-query' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({ - mockRequestJson: vi.fn(), +const { sharedQueryClient } = vi.hoisted(() => ({ sharedQueryClient: { current: null as unknown }, })) -vi.mock('@/lib/api/client/request', () => ({ - requestJson: mockRequestJson, -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: () => sharedQueryClient.current as QueryClient, -})) - const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted( () => ({ replaceWorkflowState: vi.fn(), @@ -79,9 +70,28 @@ vi.mock('@/hooks/queries/deployments', () => ({ }, })) +import * as requestModule from '@/lib/api/client/request' +import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +/** + * Spy on the real module namespaces instead of vi.mock: under `isolate: false` + * shared consumers (`@/hooks/queries/utils/fetch-workflow-envelope`, the + * registry store) may be cached across test files, so patching the real + * namespaces is the only wiring that composes — and vi.mock here would leak + * this file's fixtures into later files that share those consumers. + */ +const mockRequestJson = vi.spyOn(requestModule, 'requestJson') +const getQueryClientSpy = vi + .spyOn(getQueryClientModule, 'getQueryClient') + .mockImplementation(() => sharedQueryClient.current as QueryClient) + +afterAll(() => { + mockRequestJson.mockRestore() + getQueryClientSpy.mockRestore() +}) + function makeEnvelope(overrides: Record = {}) { return { id: 'wf-1', @@ -102,6 +112,7 @@ function makeEnvelope(overrides: Record = {}) { describe('registry store loadWorkflowState (collapsed cache)', () => { beforeEach(() => { vi.clearAllMocks() + getQueryClientSpy.mockImplementation(() => sharedQueryClient.current as QueryClient) // The store dispatches an `active-workflow-changed` CustomEvent on the // window; provide a minimal stub under the node environment. vi.stubGlobal('window', { dispatchEvent: vi.fn() }) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ccf6b097253..f16e5927eeb 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -16,7 +16,7 @@ import { type MockFetchResponse, } from '@sim/testing' import { sleep } from '@sim/utils/helpers' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, 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 @@ -119,247 +119,214 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ // Mock the tools registry to avoid loading the full 4500+ line registry file. // Only the tools actually exercised in tests are provided. -vi.mock('@/tools/registry', () => { - const mockTools: Record = { - http_request: { - id: 'http_request', - name: 'HTTP Request', - description: 'Make HTTP requests', - version: '1.0.0', - params: { - url: { type: 'string', required: true }, - method: { type: 'string', default: 'GET' }, - headers: { type: 'object' }, - body: { type: 'object' }, - params: { type: 'object' }, - pathParams: { type: 'object' }, - formData: { type: 'object' }, - timeout: { type: 'number' }, - retries: { type: 'number' }, - retryDelayMs: { type: 'number' }, - retryMaxDelayMs: { type: 'number' }, - retryNonIdempotent: { type: 'boolean' }, - }, - request: { - url: (p: any) => p.url || '/api/test', - method: (p: any) => p.method || 'GET', - headers: (p: any) => p.headers || { 'Content-Type': 'application/json' }, - body: (p: any) => p.body, - retry: { - enabled: true, - maxRetries: 0, - initialDelayMs: 500, - maxDelayMs: 30000, - retryIdempotentOnly: true, - }, - }, - transformResponse: async (response: any) => { - const contentType = response.headers?.get?.('content-type') || '' - const headers: Record = {} - if (response.headers?.forEach) { - response.headers.forEach((value: string, key: string) => { - headers[key] = value - }) - } - const data = await (contentType.includes('application/json') - ? response.json() - : response.text()) - return { - success: response.ok, - output: { data, status: response.status, headers }, - } - }, - outputs: { - data: { type: 'json', description: 'Response data' }, - status: { type: 'number', description: 'HTTP status code' }, - headers: { type: 'object', description: 'Response headers' }, - }, +const mockRegistryTools: Record = { + http_request: { + id: 'http_request', + name: 'HTTP Request', + description: 'Make HTTP requests', + version: '1.0.0', + params: { + url: { type: 'string', required: true }, + method: { type: 'string', default: 'GET' }, + headers: { type: 'object' }, + body: { type: 'object' }, + params: { type: 'object' }, + pathParams: { type: 'object' }, + formData: { type: 'object' }, + timeout: { type: 'number' }, + retries: { type: 'number' }, + retryDelayMs: { type: 'number' }, + retryMaxDelayMs: { type: 'number' }, + retryNonIdempotent: { type: 'boolean' }, }, - function_execute: { - id: 'function_execute', - name: 'Function Execute', - description: 'Execute JavaScript code', - version: '1.0.0', - params: { - code: { type: 'string', required: true }, - language: { type: 'string', required: false }, - timeout: { type: 'number', required: false }, - }, - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: (p: any) => ({ - code: Array.isArray(p.code) ? p.code.map((c: any) => c.content).join('\n') : p.code, - language: p.language || 'javascript', - timeout: p.timeout || 30000, - }), - }, - transformResponse: async (response: any) => { - const data = await response.json() - return { success: true, output: data } - }, - outputs: { - result: { type: 'json', description: 'Execution result' }, + request: { + url: (p: any) => p.url || '/api/test', + method: (p: any) => p.method || 'GET', + headers: (p: any) => p.headers || { 'Content-Type': 'application/json' }, + body: (p: any) => p.body, + retry: { + enabled: true, + maxRetries: 0, + initialDelayMs: 500, + maxDelayMs: 30000, + retryIdempotentOnly: true, }, }, - gmail_read: { - id: 'gmail_read', - name: 'Gmail Read', - description: 'Read Gmail messages', - version: '1.0.0', - oauth: { required: true, provider: 'google-email' }, - params: {}, - request: { url: '/api/tools/gmail/read', method: 'GET' }, + transformResponse: async (response: any) => { + const contentType = response.headers?.get?.('content-type') || '' + const headers: Record = {} + if (response.headers?.forEach) { + response.headers.forEach((value: string, key: string) => { + headers[key] = value + }) + } + const data = await (contentType.includes('application/json') + ? response.json() + : response.text()) + return { + success: response.ok, + output: { data, status: response.status, headers }, + } }, - gmail_send: { - id: 'gmail_send', - name: 'Gmail Send', - description: 'Send Gmail messages', - version: '1.0.0', - oauth: { required: true, provider: 'google-email' }, - params: {}, - request: { url: '/api/tools/gmail/send', method: 'POST' }, + outputs: { + data: { type: 'json', description: 'Response data' }, + status: { type: 'number', description: 'HTTP status code' }, + headers: { type: 'object', description: 'Response headers' }, }, - test_single_file_tool: { - id: 'test_single_file_tool', - name: 'Test Single File Tool', - description: 'Accepts a single file parameter', - version: '1.0.0', - params: { - attachment: { type: 'file', required: true }, - }, - request: { - url: '/api/tools/test/single-file', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: (p: any) => ({ attachment: p.attachment }), - }, - transformResponse: async (response: any) => { - const data = await response.json() - return { success: true, output: data } - }, + }, + function_execute: { + id: 'function_execute', + name: 'Function Execute', + description: 'Execute JavaScript code', + version: '1.0.0', + params: { + code: { type: 'string', required: true }, + language: { type: 'string', required: false }, + timeout: { type: 'number', required: false }, }, - test_env_ref_tool: { - id: 'test_env_ref_tool', - name: 'Test Env Reference Tool', - description: 'Accepts a user-only API key and an llm-writable note', - version: '1.0.0', - params: { - apiKey: { type: 'string', required: true, visibility: 'user-only' }, - note: { type: 'string', required: false, visibility: 'user-or-llm' }, - }, - request: { - url: '/api/tools/test/env-ref', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: (p: any) => ({ apiKey: p.apiKey, note: p.note }), - }, - transformResponse: async (response: any) => { - const data = await response.json() - return { success: true, output: data } - }, + request: { + url: '/api/function/execute', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ + code: Array.isArray(p.code) ? p.code.map((c: any) => c.content).join('\n') : p.code, + language: p.language || 'javascript', + timeout: p.timeout || 30000, + }), }, - test_file_array_tool: { - id: 'test_file_array_tool', - name: 'Test File Array Tool', - description: 'Accepts an array of file parameters', - version: '1.0.0', - params: { - attachments: { type: 'file[]', required: true }, - }, - request: { - url: '/api/tools/test/file-array', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: (p: any) => ({ attachments: p.attachments }), - }, - transformResponse: async (response: any) => { - const data = await response.json() - return { success: true, output: data } - }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } }, - google_drive_list: { - id: 'google_drive_list', - name: 'Google Drive List', - description: 'List Google Drive files', - version: '1.0.0', - params: {}, - request: { url: '/api/tools/google-drive/list', method: 'GET' }, + outputs: { + result: { type: 'json', description: 'Execution result' }, }, - serper_search: { - id: 'serper_search', - name: 'Serper Search', - description: 'Search via Serper', - version: '1.0.0', - params: {}, - request: { url: '/api/tools/serper/search', method: 'GET' }, + }, + gmail_read: { + id: 'gmail_read', + name: 'Gmail Read', + description: 'Read Gmail messages', + version: '1.0.0', + oauth: { required: true, provider: 'google-email' }, + params: {}, + request: { url: '/api/tools/gmail/read', method: 'GET' }, + }, + gmail_send: { + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send Gmail messages', + version: '1.0.0', + oauth: { required: true, provider: 'google-email' }, + params: {}, + request: { url: '/api/tools/gmail/send', method: 'POST' }, + }, + test_single_file_tool: { + id: 'test_single_file_tool', + name: 'Test Single File Tool', + description: 'Accepts a single file parameter', + version: '1.0.0', + params: { + attachment: { type: 'file', required: true }, }, - notion_add_database_row: { - id: 'notion_add_database_row', - name: 'Add Notion Database Row', - description: 'Add a new row to a Notion database with specified properties', - version: '1.0.0', - params: {}, - request: { url: 'https://api.notion.com/v1/pages', method: 'POST' }, + request: { + url: '/api/tools/test/single-file', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ attachment: p.attachment }), }, - notion_add_database_row_v2: { - id: 'notion_add_database_row_v2', - name: 'Add Notion Database Row', - description: 'Add a new row to a Notion database with specified properties', - version: '2.0.0', - params: {}, - request: { url: 'https://api.notion.com/v1/pages', method: 'POST' }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } }, - notion_update_page: { - id: 'notion_update_page', - name: 'Notion Page Updater', - description: 'Update properties of a Notion page', - version: '1.0.0', - params: {}, - request: { url: 'https://api.notion.com/v1/pages/x', method: 'PATCH' }, + }, + test_env_ref_tool: { + id: 'test_env_ref_tool', + name: 'Test Env Reference Tool', + description: 'Accepts a user-only API key and an llm-writable note', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + note: { type: 'string', required: false, visibility: 'user-or-llm' }, }, - notion_update_page_v2: { - id: 'notion_update_page_v2', - name: 'Notion Page Updater', - description: 'Update properties of a Notion page', - version: '2.0.0', - params: {}, - request: { url: 'https://api.notion.com/v1/pages/x', method: 'PATCH' }, + request: { + url: '/api/tools/test/env-ref', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ apiKey: p.apiKey, note: p.note }), }, - } - return { tools: mockTools } -}) - -// Mock query client for custom tool cache reads -vi.mock('@/app/_shell/providers/get-query-client', () => { - const mockCustomTool = { - id: 'custom-tool-123', - title: 'Custom Weather Tool', - code: 'return { result: "Weather data" }', - schema: { - function: { - description: 'Get weather information', - parameters: { - type: 'object', - properties: { - location: { type: 'string', description: 'City name' }, - unit: { type: 'string', description: 'Unit (metric/imperial)' }, - }, - required: ['location'], - }, - }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } }, - } - return { - getQueryClient: () => ({ - getQueryData: (key: string[]) => { - if (key[0] === 'customTools') return [mockCustomTool] - return undefined - }, - }), - } -}) + }, + test_file_array_tool: { + id: 'test_file_array_tool', + name: 'Test File Array Tool', + description: 'Accepts an array of file parameters', + version: '1.0.0', + params: { + attachments: { type: 'file[]', required: true }, + }, + request: { + url: '/api/tools/test/file-array', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ attachments: p.attachments }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, + google_drive_list: { + id: 'google_drive_list', + name: 'Google Drive List', + description: 'List Google Drive files', + version: '1.0.0', + params: {}, + request: { url: '/api/tools/google-drive/list', method: 'GET' }, + }, + serper_search: { + id: 'serper_search', + name: 'Serper Search', + description: 'Search via Serper', + version: '1.0.0', + params: {}, + request: { url: '/api/tools/serper/search', method: 'GET' }, + }, + notion_add_database_row: { + id: 'notion_add_database_row', + name: 'Add Notion Database Row', + description: 'Add a new row to a Notion database with specified properties', + version: '1.0.0', + params: {}, + request: { url: 'https://api.notion.com/v1/pages', method: 'POST' }, + }, + notion_add_database_row_v2: { + id: 'notion_add_database_row_v2', + name: 'Add Notion Database Row', + description: 'Add a new row to a Notion database with specified properties', + version: '2.0.0', + params: {}, + request: { url: 'https://api.notion.com/v1/pages', method: 'POST' }, + }, + notion_update_page: { + id: 'notion_update_page', + name: 'Notion Page Updater', + description: 'Update properties of a Notion page', + version: '1.0.0', + params: {}, + request: { url: 'https://api.notion.com/v1/pages/x', method: 'PATCH' }, + }, + notion_update_page_v2: { + id: 'notion_update_page_v2', + name: 'Notion Page Updater', + description: 'Update properties of a Notion page', + version: '2.0.0', + params: {}, + request: { url: 'https://api.notion.com/v1/pages/x', method: 'PATCH' }, + }, +} vi.mock('@/lib/workflows/custom-tools/operations', () => ({ getCustomToolById: mockGetCustomToolById, @@ -376,11 +343,81 @@ vi.mock('@/tools/utils.server', async (importOriginal) => { } }) +import type { QueryClient } from '@tanstack/react-query' +import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' import { executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' import { getTool } from '@/tools/utils' import { getToolAsync } from '@/tools/utils.server' +/** + * Overlay the mock tools onto the REAL registry object instead of vi.mock: + * under `isolate: false` shared consumers (`@/tools/utils`, `@/tools`) may be + * cached across test files bound to the real registry namespace, so mutating + * the one real `tools` object (and restoring it afterAll) is the only wiring + * that applies in every ordering. + */ +const replacedRegistryEntries = new Map() +for (const [id, tool] of Object.entries(mockRegistryTools)) { + replacedRegistryEntries.set(id, (tools as Record)[id]) + ;(tools as Record)[id] = tool +} + +afterAll(() => { + for (const [id, original] of replacedRegistryEntries) { + if (original === undefined) { + delete (tools as Record)[id] + } else { + ;(tools as Record)[id] = original + } + } +}) + +const mockCustomTool = { + id: 'custom-tool-123', + title: 'Custom Weather Tool', + code: 'return { result: "Weather data" }', + schema: { + function: { + description: 'Get weather information', + parameters: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' }, + unit: { type: 'string', description: 'Unit (metric/imperial)' }, + }, + required: ['location'], + }, + }, + }, +} + +function createMockQueryClient(): QueryClient { + return { + getQueryData: (key: readonly unknown[]) => { + if (key[0] === 'customTools') return [mockCustomTool] + return undefined + }, + } as unknown as QueryClient +} + +/** + * Spy on the real get-query-client namespace instead of vi.mock: under + * `isolate: false` the shared `@/tools/utils` module may be cached across test + * files, so patching the real namespace is the only wiring that composes. + * Re-applied in beforeEach because suites below call vi.resetAllMocks() / + * vi.restoreAllMocks(). + */ +vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQueryClient) + +beforeEach(() => { + vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQueryClient) +}) + +afterAll(() => { + vi.mocked(getQueryClientModule.getQueryClient).mockRestore() +}) + /** * Sets up global fetch mock with Next.js preconnect support. */ diff --git a/apps/sim/tools/managed_agent/run_session.test.ts b/apps/sim/tools/managed_agent/run_session.test.ts index 9abbb72ceb3..991fbdc998c 100644 --- a/apps/sim/tools/managed_agent/run_session.test.ts +++ b/apps/sim/tools/managed_agent/run_session.test.ts @@ -1,17 +1,22 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runManagedAgentSession } = vi.hoisted(() => ({ - runManagedAgentSession: vi.fn(), -})) - -vi.mock('@/lib/managed-agents/run-session', () => ({ runManagedAgentSession })) - +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as runSessionModule from '@/lib/managed-agents/run-session' import { managedAgentRunSessionTool } from '@/tools/managed_agent/run_session' import type { ManagedAgentRunSessionParams } from '@/tools/managed_agent/types' +/** + * Spy on the real module namespace instead of vi.mock: under `isolate: false` + * the tool module may already be cached bound to the real run-session module, + * so patching the shared namespace is the only wiring that always applies. + */ +const runManagedAgentSession = vi.spyOn(runSessionModule, 'runManagedAgentSession') + +afterAll(() => { + runManagedAgentSession.mockRestore() +}) + const run = (params: Partial) => managedAgentRunSessionTool.directExecution!({ credential: 'cred_1', diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 734969cd8eb..ec912708eec 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { createExecutionToolSchema, createLLMToolSchema, @@ -15,6 +15,7 @@ import { validateToolParameters, } from '@/tools/params' import type { HttpMethod, ParameterVisibility } from '@/tools/types' +import * as toolsUtils from '@/tools/utils' const mockToolConfig = { id: 'test_tool', @@ -55,23 +56,31 @@ const mockToolConfig = { }, } -vi.mock('@/tools/utils', () => ({ - getTool: vi.fn((toolId: string) => { - if (toolId === 'test_tool') { - return mockToolConfig - } - if (toolId === 'workflow_executor') { - return { - id: 'workflow_executor', - name: 'Workflow Executor', - description: '', - version: '1.0.0', - params: {}, - } +/** + * Spy on the real module namespace instead of vi.mock: under `isolate: false` + * `@/tools/params` may already be cached bound to the real `@/tools/utils` + * module, so patching the shared namespace is the only wiring that always + * applies. + */ +const getToolSpy = vi.spyOn(toolsUtils, 'getTool').mockImplementation(((toolId: string) => { + if (toolId === 'test_tool') { + return mockToolConfig + } + if (toolId === 'workflow_executor') { + return { + id: 'workflow_executor', + name: 'Workflow Executor', + description: '', + version: '1.0.0', + params: {}, } - return null - }), -})) + } + return null +}) as unknown as typeof toolsUtils.getTool) + +afterAll(() => { + getToolSpy.mockRestore() +}) describe('Tool Parameters Utils', () => { describe('getToolParametersConfig', () => { diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index cf90b5ded2f..e5e88ff20e6 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -1,5 +1,7 @@ import { createMockResponse, inputValidationMock, inputValidationMockFns } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { QueryClient } from '@tanstack/react-query' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' import { @@ -13,19 +15,28 @@ import { executeRequest } from '@/tools/utils.server' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) -const { mockGetQueryData } = vi.hoisted(() => ({ - mockGetQueryData: vi.fn(), -})) +const mockGetQueryData = vi.fn() -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: () => ({ - getQueryData: mockGetQueryData, - }), -})) +/** + * Spy on the real module namespace instead of vi.mock: under `isolate: false` + * `@/tools/utils` may already be cached bound to the real get-query-client + * module, so patching the shared namespace is the only wiring that always + * applies. + */ +const getQueryClientSpy = vi + .spyOn(getQueryClientModule, 'getQueryClient') + .mockImplementation(() => ({ getQueryData: mockGetQueryData }) as unknown as QueryClient) + +afterAll(() => { + getQueryClientSpy.mockRestore() +}) const originalWindow = global.window beforeEach(() => { global.window = {} as any + getQueryClientSpy.mockImplementation( + () => ({ getQueryData: mockGetQueryData }) as unknown as QueryClient + ) mockGetQueryData.mockReturnValue({ API_KEY: { key: 'API_KEY', value: 'mock-api-key' }, BASE_URL: { key: 'BASE_URL', value: 'https://example.com' }, diff --git a/apps/sim/vitest.config.ts b/apps/sim/vitest.config.ts index a966ad5233e..cd5eea87377 100644 --- a/apps/sim/vitest.config.ts +++ b/apps/sim/vitest.config.ts @@ -20,6 +20,8 @@ export default defineConfig({ setupFiles: ['./vitest.setup.ts'], pool: 'threads', isolate: true, + unstubEnvs: true, + unstubGlobals: true, fileParallelism: true, maxConcurrency: 10, testTimeout: 10000, diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 3c832d79652..f88ac43e5f1 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -290,6 +290,9 @@ export function createMockDb() { 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([])),