diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 9c229c22718..1765f7732fb 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -532,6 +532,41 @@ outputs: { } ``` +### Typed JSON Outputs + +When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. For well-known, stable objects, use nested output definitions instead: + +```typescript +outputs: { + // BAD: Opaque json with no info about what's inside + plan: { type: 'json', description: 'Zone plan information' }, + + // GOOD: Describe the known fields in the description + plan: { + type: 'json', + description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)', + }, + + // BEST: Use nested output definition when the shape is stable and well-known + plan: { + id: { type: 'string', description: 'Plan identifier' }, + name: { type: 'string', description: 'Plan name' }, + price: { type: 'number', description: 'Plan price' }, + currency: { type: 'string', description: 'Price currency' }, + }, +} +``` + +Use the nested pattern when: +- The object has a small, stable set of fields (< 10) +- Downstream blocks will commonly access specific properties +- The API response shape is well-documented and unlikely to change + +Use `type: 'json'` with a descriptive string when: +- The object has many fields or a dynamic shape +- It represents a list/array of items +- The shape varies by operation + ## V2 Block Pattern When creating V2 blocks (alongside legacy V1): @@ -695,6 +730,62 @@ Please provide the SVG and I'll convert it to a React component. You can usually find this in the service's brand/press kit page, or copy it from their website. ``` +## Advanced Mode for Optional Fields + +Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: +- Pagination tokens +- Time range filters (start/end time) +- Sort order options +- Reply settings +- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID) +- Max results / limits + +```typescript +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: ['search', 'list'] }, + mode: 'advanced', // Rarely used, hide from basic view +} +``` + +## WandConfig for Complex Inputs + +Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience. + +```typescript +// Timestamps - use generationType: 'timestamp' to inject current date context +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, +} + +// Comma-separated lists - simple prompt without generationType +{ + id: 'mediaIds', + title: 'Media IDs', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.', + }, +} +``` + +## Naming Convention + +All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase. + ## Checklist Before Finishing - [ ] All subBlocks have `id`, `title` (except switch), and `type` @@ -702,9 +793,24 @@ You can usually find this in the service's brand/press kit page, or copy it from - [ ] DependsOn set for fields that need other values - [ ] Required fields marked correctly (boolean or condition) - [ ] OAuth inputs have correct `serviceId` -- [ ] Tools.access lists all tool IDs -- [ ] Tools.config.tool returns correct tool ID +- [ ] Tools.access lists all tool IDs (snake_case) +- [ ] Tools.config.tool returns correct tool ID (snake_case) - [ ] Outputs match tool outputs - [ ] Block registered in registry.ts - [ ] If icon missing: asked user to provide SVG - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread +- [ ] Optional/rarely-used fields set to `mode: 'advanced'` +- [ ] Timestamps and complex inputs have `wandConfig` enabled + +## Final Validation (Required) + +After creating the block, you MUST validate it against every tool it references: + +1. **Read every tool definition** that appears in `tools.access` — do not skip any +2. **For each tool, verify the block has correct:** + - SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation) + - SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings) + - `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ) + - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) +3. **Verify block outputs** cover the key fields returned by all tools +4. **Verify conditions** — each subBlock should only show for the operations that actually use it diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index c3221b2e9cc..aaa4cb857ce 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -102,6 +102,7 @@ export const {service}{Action}Tool: ToolConfig = { - Always use `?? []` for optional array fields - Set `optional: true` for outputs that may not exist - Never output raw JSON dumps - extract meaningful fields +- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic ## Step 3: Create Block @@ -436,6 +437,12 @@ If creating V2 versions (API-aligned outputs): - [ ] Ran `bun run scripts/generate-docs.ts` - [ ] Verified docs file created +### Final Validation (Required) +- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs +- [ ] Verified block subBlocks cover all required tool params with correct conditions +- [ ] Verified block outputs match what the tools actually return +- [ ] Verified `tools.config.params` correctly maps and coerces all param types + ## Example Command When the user asks to add an integration: @@ -685,13 +692,40 @@ return NextResponse.json({ | `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects | | `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation | +### Advanced Mode for Optional Fields + +Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings. + +### WandConfig for Complex Inputs + +Use `wandConfig` for fields that are hard to fill out manually: +- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt +- **JSON arrays**: Use `generationType: 'json-object'` for structured data +- **Complex queries**: Use a descriptive prompt explaining the expected format + +```typescript +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, +} +``` + ### Common Gotchas 1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration -2. **Tool IDs are snake_case** - `stripe_create_payment`, not `stripeCreatePayment` +2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values 3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` 4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted 5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true 6. **DependsOn clears options** - When a dependency changes, selector options are refetched 7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility 8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility +9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields +10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md index c83a95b1ba3..fb7bfdc55db 100644 --- a/.claude/commands/add-tools.md +++ b/.claude/commands/add-tools.md @@ -147,9 +147,18 @@ closedAt: { }, ``` -### Nested Properties -For complex outputs, define nested structure: +### Typed JSON Outputs + +When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available: + ```typescript +// BAD: Opaque json with no info about what's inside +metadata: { + type: 'json', + description: 'Response metadata', +}, + +// GOOD: Define the known properties metadata: { type: 'json', description: 'Response metadata', @@ -159,7 +168,10 @@ metadata: { count: { type: 'number', description: 'Total count' }, }, }, +``` +For arrays of objects, define the item structure: +```typescript items: { type: 'array', description: 'List of items', @@ -173,6 +185,8 @@ items: { }, ``` +Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown. + ## Critical Rules for transformResponse ### Handle Nullable Fields @@ -272,8 +286,13 @@ If creating V2 tools (API-aligned outputs), use `_v2` suffix: - Version: `'2.0.0'` - Outputs: Flat, API-aligned (no content/metadata wrapper) +## Naming Convention + +All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs. + ## Checklist Before Finishing +- [ ] All tool IDs use snake_case - [ ] All params have explicit `required: true` or `required: false` - [ ] All params have appropriate `visibility` - [ ] All nullable response fields use `?? null` @@ -281,4 +300,22 @@ If creating V2 tools (API-aligned outputs), use `_v2` suffix: - [ ] No raw JSON dumps in outputs - [ ] Types file has all interfaces - [ ] Index.ts exports all tools -- [ ] Tool IDs use snake_case + +## Final Validation (Required) + +After creating all tools, you MUST validate every tool before finishing: + +1. **Read every tool file** you created — do not skip any +2. **Cross-reference with the API docs** to verify: + - All required params are marked `required: true` + - All optional params are marked `required: false` + - Param types match the API (string, number, boolean, json) + - Request URL, method, headers, and body match the API spec + - `transformResponse` extracts the correct fields from the API response + - All output fields match what the API actually returns + - No fields are missing from outputs that the API provides + - No extra fields are defined in outputs that the API doesn't return +3. **Verify consistency** across tools: + - Shared types in `types.ts` match all tools that use them + - Tool IDs in the barrel export match the tool file definitions + - Error handling is consistent (error checks, meaningful messages) diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md new file mode 100644 index 00000000000..77091b2b9dd --- /dev/null +++ b/.claude/commands/validate-integration.md @@ -0,0 +1,283 @@ +--- +description: Validate an existing Sim integration (tools, block, registry) against the service's API docs +argument-hint: [api-docs-url] +--- + +# Validate Integration Skill + +You are an expert auditor for Sim integrations. Your job is to thoroughly validate that an existing integration is correct, complete, and follows all conventions. + +## Your Task + +When the user asks you to validate an integration: +1. Read the service's API documentation (via WebFetch or Context7) +2. Read every tool, the block, and registry entries +3. Cross-reference everything against the API docs and Sim conventions +4. Report all issues found, grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the integration — do not skip any: + +``` +apps/sim/tools/{service}/ # All tool files, types.ts, index.ts +apps/sim/blocks/blocks/{service}.ts # Block definition +apps/sim/tools/registry.ts # Tool registry entries for this service +apps/sim/blocks/registry.ts # Block registry entry for this service +apps/sim/components/icons.tsx # Icon definition +apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service) +apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service) +``` + +## Step 2: Pull API Documentation + +Fetch the official API docs for the service. This is the **source of truth** for: +- Endpoint URLs, HTTP methods, and auth headers +- Required vs optional parameters +- Parameter types and allowed values +- Response shapes and field names +- Pagination patterns (which param name, which response field) +- Rate limits and error formats + +## Step 3: Validate Tools + +For **every** tool file, check: + +### Tool ID and Naming +- [ ] Tool ID uses `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`) +- [ ] Tool `name` is human-readable (e.g., `'X Create Tweet'`) +- [ ] Tool `description` is a concise one-liner describing what it does +- [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2) + +### Params +- [ ] All required API params are marked `required: true` +- [ ] All optional API params are marked `required: false` +- [ ] Every param has explicit `required: true` or `required: false` — never omitted +- [ ] Param types match the API (`'string'`, `'number'`, `'boolean'`, `'json'`) +- [ ] Visibility is correct: + - `'hidden'` — ONLY for OAuth access tokens and system-injected params + - `'user-only'` — for API keys, credentials, and account-specific IDs the user must provide + - `'user-or-llm'` — for everything else (search queries, content, filters, IDs that could come from other blocks) +- [ ] Every param has a `description` that explains what it does + +### Request +- [ ] URL matches the API endpoint exactly (correct base URL, path segments, path params) +- [ ] HTTP method matches the API spec (GET, POST, PUT, PATCH, DELETE) +- [ ] Headers include correct auth pattern: + - OAuth: `Authorization: Bearer ${params.accessToken}` + - API Key: correct header name and format per the service's docs +- [ ] `Content-Type` header is set for POST/PUT/PATCH requests +- [ ] Body sends all required fields and only includes optional fields when provided +- [ ] For GET requests with query params: URL is constructed correctly with query string +- [ ] ID fields in URL paths are `.trim()`-ed to prevent copy-paste whitespace errors +- [ ] Path params use template literals correctly: `` `https://api.service.com/v1/${params.id.trim()}` `` + +### Response / transformResponse +- [ ] Correctly parses the API response (`await response.json()`) +- [ ] Extracts the right fields from the response structure (e.g., `data.data` vs `data` vs `data.results`) +- [ ] All nullable fields use `?? null` +- [ ] All optional arrays use `?? []` +- [ ] Error cases are handled: checks for missing/empty data and returns meaningful error +- [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields + +### Outputs +- [ ] All output fields match what the API actually returns +- [ ] No fields are missing that the API provides and users would commonly need +- [ ] No phantom fields defined that the API doesn't return +- [ ] `optional: true` is set on fields that may not exist in all responses +- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields +- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` +- [ ] Field descriptions are accurate and helpful + +### Types (types.ts) +- [ ] Has param interfaces for every tool (e.g., `XCreateTweetParams`) +- [ ] Has response interfaces for every tool (extending `ToolResponse`) +- [ ] Optional params use `?` in the interface (e.g., `replyTo?: string`) +- [ ] Field names in types match actual API field names +- [ ] Shared response types are properly reused (e.g., `XTweetResponse` shared across tweet tools) + +### Barrel Export (index.ts) +- [ ] Every tool is exported +- [ ] All types are re-exported (`export * from './types'`) +- [ ] No orphaned exports (tools that don't exist) + +### Tool Registry (tools/registry.ts) +- [ ] Every tool is imported and registered +- [ ] Registry keys use snake_case and match tool IDs exactly +- [ ] Entries are in alphabetical order within the file + +## Step 4: Validate Block + +### Block ↔ Tool Alignment (CRITICAL) + +This is the most important validation — the block must be perfectly aligned with every tool it references. + +For **each tool** in `tools.access`: +- [ ] The operation dropdown has an option whose ID matches the tool ID (or the `tools.config.tool` function correctly maps to it) +- [ ] Every **required** tool param (except `accessToken`) has a corresponding subBlock input that is: + - Shown when that operation is selected (correct `condition`) + - Marked as `required: true` (or conditionally required) +- [ ] Every **optional** tool param has a corresponding subBlock input (or is intentionally omitted if truly never needed) +- [ ] SubBlock `id` values are unique across the entire block — no duplicates even across different conditions +- [ ] The `tools.config.tool` function returns the correct tool ID for every possible operation value +- [ ] The `tools.config.params` function correctly maps subBlock IDs to tool param names when they differ + +### SubBlocks +- [ ] Operation dropdown lists ALL tool operations available in `tools.access` +- [ ] Dropdown option labels are human-readable and descriptive +- [ ] Conditions use correct syntax: + - Single value: `{ field: 'operation', value: 'x_create_tweet' }` + - Multiple values (OR): `{ field: 'operation', value: ['x_create_tweet', 'x_delete_tweet'] }` + - Negation: `{ field: 'operation', value: 'delete', not: true }` + - Compound: `{ field: 'op', value: 'send', and: { field: 'type', value: 'dm' } }` +- [ ] Condition arrays include ALL operations that use that field — none missing +- [ ] `dependsOn` is set for fields that need other values (selectors depending on credential, cascading dropdowns) +- [ ] SubBlock types match tool param types: + - Enum/fixed options → `dropdown` + - Free text → `short-input` + - Long text/content → `long-input` + - True/false → `dropdown` with Yes/No options (not `switch` unless purely UI toggle) + - Credentials → `oauth-input` with correct `serviceId` +- [ ] Dropdown `value: () => 'default'` is set for dropdowns with a sensible default + +### Advanced Mode +- [ ] Optional, rarely-used fields are set to `mode: 'advanced'`: + - Pagination tokens / next tokens + - Time range filters (start/end time) + - Sort order / direction options + - Max results / per page limits + - Reply settings / threading options + - Rarely used IDs (reply-to, quote-tweet, etc.) + - Exclude filters +- [ ] **Required** fields are NEVER set to `mode: 'advanced'` +- [ ] Fields that users fill in most of the time are NOT set to `mode: 'advanced'` + +### WandConfig +- [ ] Timestamp fields have `wandConfig` with `generationType: 'timestamp'` +- [ ] Comma-separated list fields have `wandConfig` with a descriptive prompt +- [ ] Complex filter/query fields have `wandConfig` with format examples in the prompt +- [ ] All `wandConfig` prompts end with "Return ONLY the [format] - no explanations, no extra text." +- [ ] `wandConfig.placeholder` describes what to type in natural language + +### Tools Config +- [ ] `tools.access` lists **every** tool ID the block can use — none missing +- [ ] `tools.config.tool` returns the correct tool ID for each operation +- [ ] Type coercions are in `tools.config.params` (runs at execution time), NOT in `tools.config.tool` (runs at serialization time before variable resolution) +- [ ] `tools.config.params` handles: + - `Number()` conversion for numeric params that come as strings from inputs + - `Boolean` / string-to-boolean conversion for toggle params + - Empty string → `undefined` conversion for optional dropdown values + - Any subBlock ID → tool param name remapping +- [ ] No `Number()`, `JSON.parse()`, or other coercions in `tools.config.tool` — these would destroy dynamic references like `` + +### Block Outputs +- [ ] Outputs cover the key fields returned by ALL tools (not just one operation) +- [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`) +- [ ] `type: 'json'` outputs either: + - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'` + - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }` +- [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'` +- [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them + +### Block Metadata +- [ ] `type` is snake_case (e.g., `'x'`, `'cloudflare'`) +- [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`) +- [ ] `description` is a concise one-liner +- [ ] `longDescription` provides detail for docs +- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'` +- [ ] `category` is `'tools'` +- [ ] `bgColor` uses the service's brand color hex +- [ ] `icon` references the correct icon component from `@/components/icons` +- [ ] `authMode` is set correctly (`AuthMode.OAuth` or `AuthMode.ApiKey`) +- [ ] Block is registered in `blocks/registry.ts` alphabetically + +### Block Inputs +- [ ] `inputs` section lists all subBlock params that the block accepts +- [ ] Input types match the subBlock types +- [ ] When using `canonicalParamId`, inputs list the canonical ID (not the raw subBlock IDs) + +## Step 5: Validate OAuth Scopes (if OAuth service) + +- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration +- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes +- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes +- [ ] No excess scopes that aren't needed by any tool +- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS` + +## Step 6: Validate Pagination Consistency + +If any tools support pagination: +- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`) +- [ ] Different API endpoints that use different pagination param names have separate subBlocks in the block +- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs +- [ ] Pagination subBlocks are set to `mode: 'advanced'` + +## Step 7: Validate Error Handling + +- [ ] `transformResponse` checks for error conditions before accessing data +- [ ] Error responses include meaningful messages (not just generic "failed") +- [ ] HTTP error status codes are handled (check `response.ok` or status codes) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (will cause runtime errors or incorrect behavior): +- Wrong endpoint URL or HTTP method +- Missing required params or wrong `required` flag +- Incorrect response field mapping (accessing wrong path in response) +- Missing error handling that would cause crashes +- Tool ID mismatch between tool file, registry, and block `tools.access` +- OAuth scopes missing in `auth.ts` that tools need +- `tools.config.tool` returning wrong tool ID for an operation +- Type coercions in `tools.config.tool` instead of `tools.config.params` + +**Warning** (follows conventions incorrectly or has usability issues): +- Optional field not set to `mode: 'advanced'` +- Missing `wandConfig` on timestamp/complex fields +- Wrong `visibility` on params (e.g., `'hidden'` instead of `'user-or-llm'`) +- Missing `optional: true` on nullable outputs +- Opaque `type: 'json'` without property descriptions +- Missing `.trim()` on ID fields in request URLs +- Missing `?? null` on nullable response fields +- Block condition array missing an operation that uses that field +- Missing scope description in `oauth-required-modal.tsx` + +**Suggestion** (minor improvements): +- Better description text +- Inconsistent naming across tools +- Missing `longDescription` or `docsLink` +- Pagination fields that could benefit from `wandConfig` + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run lint` passes with no fixes needed +2. TypeScript compiles clean (no type errors) +3. Re-read all modified files to verify fixes are correct + +## Checklist Summary + +- [ ] Read ALL tool files, block, types, index, and registries +- [ ] Pulled and read official API documentation +- [ ] Validated every tool's ID, params, request, response, outputs, and types against API docs +- [ ] Validated block ↔ tool alignment (every tool param has a subBlock, every condition is correct) +- [ ] Validated advanced mode on optional/rarely-used fields +- [ ] Validated wandConfig on timestamps and complex inputs +- [ ] Validated tools.config mapping, tool selector, and type coercions +- [ ] Validated block outputs match what tools return, with typed JSON where possible +- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth) +- [ ] Validated pagination consistency across tools and block +- [ ] Validated error handling (error checks, meaningful messages) +- [ ] Validated registry entries (tools and block, alphabetical, correct imports) +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] Ran `bun run lint` after fixes +- [ ] Verified TypeScript compiles clean diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md index 1f17125a3e2..85a7554637b 100644 --- a/.claude/rules/sim-testing.md +++ b/.claude/rules/sim-testing.md @@ -8,51 +8,210 @@ paths: Use Vitest. Test files: `feature.ts` → `feature.test.ts` +## Global Mocks (vitest.setup.ts) + +These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: + +- `@sim/db` → `databaseMock` +- `drizzle-orm` → `drizzleOrmMock` +- `@sim/logger` → `loggerMock` +- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` +- `@/blocks/registry` +- `@trigger.dev/sdk` + ## Structure ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET, POST } from '@/app/api/my-route/route' -describe('myFunction', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('isolated tests run in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('returns data', async () => { + const req = createMockRequest('GET') + const res = await GET(req) + expect(res.status).toBe(200) + }) }) ``` -## @sim/testing Package +## Performance Rules (Critical) -Always prefer over local mocks. +### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` -| Category | Utilities | -|----------|-----------| -| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +This is the #1 cause of slow tests. It forces complete module re-evaluation per test. + +```typescript +// BAD — forces module re-evaluation every test (~50-100ms each) +beforeEach(() => { + vi.resetModules() + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) +}) +it('test', async () => { + const { GET } = await import('./route') // slow dynamic import +}) + +// GOOD — module loaded once, mocks reconfigured per test (~1ms each) +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { vi.clearAllMocks() }) +it('test', () => { + mockGetSession.mockResolvedValue({ user: { id: '1' } }) +}) +``` + +**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. + +### NEVER use `vi.importActual()` + +This defeats the purpose of mocking by loading the real module and all its dependencies. + +```typescript +// BAD — loads real module + all transitive deps +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { ...actual, myFn: vi.fn() } +}) + +// GOOD — mock everything, only implement what tests need +vi.mock('@/lib/workspaces/utils', () => ({ + myFn: vi.fn(), + otherFn: vi.fn(), +})) +``` + +### NEVER use `mockAuth()`, `mockConsoleLogger()`, or `setupCommonApiMocks()` from `@sim/testing` + +These helpers internally use `vi.doMock()` which is slow. Use direct `vi.hoisted()` + `vi.mock()` instead. + +### Mock heavy transitive dependencies + +If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: + +```typescript +vi.mock('@/blocks', () => ({ + getBlock: () => null, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) +``` + +### Use `@vitest-environment node` unless DOM is needed + +Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. + +### Avoid real timers in tests + +```typescript +// BAD +await new Promise(r => setTimeout(r, 500)) + +// GOOD — use minimal delays or fake timers +await new Promise(r => setTimeout(r, 1)) +// or +vi.useFakeTimers() +``` + +## Mock Pattern Reference + +### Auth mocking (API routes) + +```typescript +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +// In tests: +mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }) +mockGetSession.mockResolvedValue(null) // unauthenticated +``` + +### Hybrid auth mocking -## Rules +```typescript +const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), +})) -1. `@vitest-environment node` directive at file top -2. `vi.mock()` calls before importing mocked modules -3. `@sim/testing` utilities over local mocks -4. `it.concurrent` for isolated tests (no shared mutable state) -5. `beforeEach(() => vi.clearAllMocks())` to reset state +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) -## Hoisted Mocks +// In tests: +mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, userId: 'user-1', authType: 'session', +}) +``` -For mutable mock references: +### Database chain mocking ```typescript -const mockFn = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/module', () => ({ myFunction: mockFn })) -mockFn.mockResolvedValue({ data: 'test' }) +const { mockSelect, mockFrom, mockWhere } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect }, +})) + +beforeEach(() => { + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockWhere.mockResolvedValue([{ id: '1', name: 'test' }]) +}) ``` + +## @sim/testing Package + +Always prefer over local test data. + +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +| **Requests** | `createMockRequest()`, `createEnvMock()` | + +## Rules Summary + +1. `@vitest-environment node` unless DOM is required +2. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports +3. `vi.mock()` calls before importing mocked modules +4. `@sim/testing` utilities over local mocks +5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` +6. No `vi.importActual()` — mock everything explicitly +7. No `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` — use direct mocks +8. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +9. Use absolute imports in test files +10. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index 8bf0d74f107..ec140388e84 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -7,51 +7,210 @@ globs: ["apps/sim/**/*.test.ts", "apps/sim/**/*.test.tsx"] Use Vitest. Test files: `feature.ts` → `feature.test.ts` +## Global Mocks (vitest.setup.ts) + +These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: + +- `@sim/db` → `databaseMock` +- `drizzle-orm` → `drizzleOrmMock` +- `@sim/logger` → `loggerMock` +- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` +- `@/blocks/registry` +- `@trigger.dev/sdk` + ## Structure ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET, POST } from '@/app/api/my-route/route' -describe('myFunction', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('isolated tests run in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('returns data', async () => { + const req = createMockRequest('GET') + const res = await GET(req) + expect(res.status).toBe(200) + }) }) ``` -## @sim/testing Package +## Performance Rules (Critical) -Always prefer over local mocks. +### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` -| Category | Utilities | -|----------|-----------| -| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +This is the #1 cause of slow tests. It forces complete module re-evaluation per test. + +```typescript +// BAD — forces module re-evaluation every test (~50-100ms each) +beforeEach(() => { + vi.resetModules() + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) +}) +it('test', async () => { + const { GET } = await import('./route') // slow dynamic import +}) + +// GOOD — module loaded once, mocks reconfigured per test (~1ms each) +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { vi.clearAllMocks() }) +it('test', () => { + mockGetSession.mockResolvedValue({ user: { id: '1' } }) +}) +``` + +**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. + +### NEVER use `vi.importActual()` + +This defeats the purpose of mocking by loading the real module and all its dependencies. + +```typescript +// BAD — loads real module + all transitive deps +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { ...actual, myFn: vi.fn() } +}) + +// GOOD — mock everything, only implement what tests need +vi.mock('@/lib/workspaces/utils', () => ({ + myFn: vi.fn(), + otherFn: vi.fn(), +})) +``` + +### NEVER use `mockAuth()`, `mockConsoleLogger()`, or `setupCommonApiMocks()` from `@sim/testing` + +These helpers internally use `vi.doMock()` which is slow. Use direct `vi.hoisted()` + `vi.mock()` instead. + +### Mock heavy transitive dependencies + +If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: + +```typescript +vi.mock('@/blocks', () => ({ + getBlock: () => null, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) +``` + +### Use `@vitest-environment node` unless DOM is needed + +Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. + +### Avoid real timers in tests + +```typescript +// BAD +await new Promise(r => setTimeout(r, 500)) + +// GOOD — use minimal delays or fake timers +await new Promise(r => setTimeout(r, 1)) +// or +vi.useFakeTimers() +``` + +## Mock Pattern Reference + +### Auth mocking (API routes) + +```typescript +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +// In tests: +mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }) +mockGetSession.mockResolvedValue(null) // unauthenticated +``` + +### Hybrid auth mocking -## Rules +```typescript +const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), +})) -1. `@vitest-environment node` directive at file top -2. `vi.mock()` calls before importing mocked modules -3. `@sim/testing` utilities over local mocks -4. `it.concurrent` for isolated tests (no shared mutable state) -5. `beforeEach(() => vi.clearAllMocks())` to reset state +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) -## Hoisted Mocks +// In tests: +mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, userId: 'user-1', authType: 'session', +}) +``` -For mutable mock references: +### Database chain mocking ```typescript -const mockFn = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/module', () => ({ myFunction: mockFn })) -mockFn.mockResolvedValue({ data: 'test' }) +const { mockSelect, mockFrom, mockWhere } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect }, +})) + +beforeEach(() => { + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockWhere.mockResolvedValue([{ id: '1', name: 'test' }]) +}) ``` + +## @sim/testing Package + +Always prefer over local test data. + +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +| **Requests** | `createMockRequest()`, `createEnvMock()` | + +## Rules Summary + +1. `@vitest-environment node` unless DOM is required +2. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports +3. `vi.mock()` calls before importing mocked modules +4. `@sim/testing` utilities over local mocks +5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` +6. No `vi.importActual()` — mock everything explicitly +7. No `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` — use direct mocks +8. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +9. Use absolute imports in test files +10. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73e5e852fb6..e3a1bdb3b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 10dd6f0b012..456472576fb 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -10,7 +10,7 @@ permissions: jobs: test-build: name: Test and Build - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout code @@ -38,6 +38,20 @@ jobs: key: ${{ github.repository }}-node-modules path: ./node_modules + - name: Mount Turbo cache (Sticky Disk) + uses: useblacksmith/stickydisk@v1 + with: + key: ${{ github.repository }}-turbo-cache + path: ./.turbo + + - name: Restore Next.js build cache + uses: actions/cache@v4 + with: + path: ./apps/sim/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-nextjs- + - name: Install dependencies run: bun install --frozen-lockfile @@ -85,6 +99,7 @@ jobs: NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only + TURBO_CACHE_DIR: .turbo run: bun run test - name: Check schema and migrations are in sync @@ -110,6 +125,7 @@ jobs: RESEND_API_KEY: 'dummy_key_for_ci_only' AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only + TURBO_CACHE_DIR: .turbo run: bunx turbo run build --filter=sim - name: Upload coverage to Codecov diff --git a/CLAUDE.md b/CLAUDE.md index edc351d71da..229fd25b559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,27 +167,51 @@ Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA ## Testing -Use Vitest. Test files: `feature.ts` → `feature.test.ts` +Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/sim-testing.mdc` for full details. + +### Global Mocks (vitest.setup.ts) + +`@sim/db`, `drizzle-orm`, `@sim/logger`, `@/blocks/registry`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. + +### Standard Test Pattern ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET } from '@/app/api/my-route/route' -describe('feature', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('runs in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + it('returns data', async () => { ... }) }) ``` -Use `@sim/testing` mocks/factories over local test data. See `.cursor/rules/sim-testing.mdc` for details. +### Performance Rules + +- **NEVER** use `vi.resetModules()` + `vi.doMock()` + `await import()` — use `vi.hoisted()` + `vi.mock()` + static imports +- **NEVER** use `vi.importActual()` — mock everything explicitly +- **NEVER** use `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` from `@sim/testing` — they use `vi.doMock()` internally +- **Mock heavy deps** (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +- **Use `@vitest-environment node`** unless DOM APIs are needed (`window`, `document`, `FormData`) +- **Avoid real timers** — use 1ms delays or `vi.useFakeTimers()` + +Use `@sim/testing` mocks/factories over local test data. ## Utils Rules diff --git a/apps/docs/components/docs-layout/toc-footer.tsx b/apps/docs/components/docs-layout/toc-footer.tsx index 01eb5d81a17..eaf29088f23 100644 --- a/apps/docs/components/docs-layout/toc-footer.tsx +++ b/apps/docs/components/docs-layout/toc-footer.tsx @@ -13,7 +13,7 @@ export function TOCFooter() {
Start building today
-
Trusted by over 60,000 builders.
+
Trusted by over 70,000 builders.
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9e68974089e..51cd709bb71 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -526,6 +526,17 @@ export function SlackMonoIcon(props: SVGProps) { ) } +export function GammaIcon(props: SVGProps) { + return ( + + + + ) +} + export function GithubIcon(props: SVGProps) { return ( @@ -1254,6 +1265,20 @@ export function GoogleSlidesIcon(props: SVGProps) { ) } +export function GoogleContactsIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function GoogleCalendarIcon(props: SVGProps) { return ( ) { ) } +export function AshbyIcon(props: SVGProps) { + return ( + + + + ) +} + export function ArxivIcon(props: SVGProps) { return ( @@ -3956,6 +3994,28 @@ export function IntercomIcon(props: SVGProps) { ) } +export function LoopsIcon(props: SVGProps) { + return ( + + + + ) +} + +export function LumaIcon(props: SVGProps) { + return ( + + + + ) +} + export function MailchimpIcon(props: SVGProps) { return ( ) { ) } +export function DatabricksIcon(props: SVGProps) { + return ( + + + + ) +} + export function DatadogIcon(props: SVGProps) { return ( @@ -4825,6 +4896,17 @@ export function CirclebackIcon(props: SVGProps) { ) } +export function GreenhouseIcon(props: SVGProps) { + return ( + + + + ) +} + export function GreptileIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 822ce48aeb1..a69b0d90f5b 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -13,6 +13,7 @@ import { ApolloIcon, ArxivIcon, AsanaIcon, + AshbyIcon, AttioIcon, BrainIcon, BrowserUseIcon, @@ -24,6 +25,7 @@ import { CloudflareIcon, ConfluenceIcon, CursorIcon, + DatabricksIcon, DatadogIcon, DevinIcon, DiscordIcon, @@ -39,6 +41,7 @@ import { EyeIcon, FirecrawlIcon, FirefliesIcon, + GammaIcon, GithubIcon, GitLabIcon, GmailIcon, @@ -46,6 +49,7 @@ import { GoogleBigQueryIcon, GoogleBooksIcon, GoogleCalendarIcon, + GoogleContactsIcon, GoogleDocsIcon, GoogleDriveIcon, GoogleFormsIcon, @@ -59,6 +63,7 @@ import { GoogleVaultIcon, GrafanaIcon, GrainIcon, + GreenhouseIcon, GreptileIcon, HexIcon, HubspotIcon, @@ -76,6 +81,8 @@ import { LinearIcon, LinkedInIcon, LinkupIcon, + LoopsIcon, + LumaIcon, MailchimpIcon, MailgunIcon, MailServerIcon, @@ -164,6 +171,7 @@ export const blockTypeToIconMap: Record = { apollo: ApolloIcon, arxiv: ArxivIcon, asana: AsanaIcon, + ashby: AshbyIcon, attio: AttioIcon, browser_use: BrowserUseIcon, calcom: CalComIcon, @@ -174,6 +182,7 @@ export const blockTypeToIconMap: Record = { cloudflare: CloudflareIcon, confluence_v2: ConfluenceIcon, cursor_v2: CursorIcon, + databricks: DatabricksIcon, datadog: DatadogIcon, devin: DevinIcon, discord: DiscordIcon, @@ -188,6 +197,7 @@ export const blockTypeToIconMap: Record = { file_v3: DocumentIcon, firecrawl: FirecrawlIcon, fireflies_v2: FirefliesIcon, + gamma: GammaIcon, github_v2: GithubIcon, gitlab: GitLabIcon, gmail_v2: GmailIcon, @@ -195,6 +205,7 @@ export const blockTypeToIconMap: Record = { google_bigquery: GoogleBigQueryIcon, google_books: GoogleBooksIcon, google_calendar_v2: GoogleCalendarIcon, + google_contacts: GoogleContactsIcon, google_docs: GoogleDocsIcon, google_drive: GoogleDriveIcon, google_forms: GoogleFormsIcon, @@ -208,6 +219,7 @@ export const blockTypeToIconMap: Record = { google_vault: GoogleVaultIcon, grafana: GrafanaIcon, grain: GrainIcon, + greenhouse: GreenhouseIcon, greptile: GreptileIcon, hex: HexIcon, hubspot: HubspotIcon, @@ -227,6 +239,8 @@ export const blockTypeToIconMap: Record = { linear: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, + loops: LoopsIcon, + luma: LumaIcon, mailchimp: MailchimpIcon, mailgun: MailgunIcon, mem0: Mem0Icon, diff --git a/apps/docs/content/docs/en/tools/ashby.mdx b/apps/docs/content/docs/en/tools/ashby.mdx new file mode 100644 index 00000000000..72c050beac7 --- /dev/null +++ b/apps/docs/content/docs/en/tools/ashby.mdx @@ -0,0 +1,473 @@ +--- +title: Ashby +description: Manage candidates, jobs, and applications in Ashby +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Ashby](https://www.ashbyhq.com/) is an all-in-one recruiting platform that combines an applicant tracking system (ATS), CRM, scheduling, and analytics to help teams hire more effectively. + +With Ashby, you can: + +- **List and search candidates**: Browse your full candidate pipeline or search by name and email to quickly find specific people +- **Create candidates**: Add new candidates to your Ashby organization with contact details +- **View candidate details**: Retrieve full candidate profiles including tags, email, phone, and timestamps +- **Add notes to candidates**: Attach notes to candidate records to capture feedback, context, or follow-up items +- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info +- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination + +In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Ashby into the workflow. Can list, search, create, and update candidates, list and get job details, create notes, list notes, list and get applications, create applications, and list offers. + + + +## Tools + +### `ashby_create_application` + +Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | The UUID of the candidate to consider for the job | +| `jobId` | string | Yes | The UUID of the job to consider the candidate for | +| `interviewPlanId` | string | No | UUID of the interview plan to use \(defaults to the job default plan\) | +| `interviewStageId` | string | No | UUID of the interview stage to place the application in \(defaults to first Lead stage\) | +| `sourceId` | string | No | UUID of the source to set on the application | +| `creditedToUserId` | string | No | UUID of the user the application is credited to | +| `createdAt` | string | No | ISO 8601 timestamp to set as the application creation date \(defaults to now\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created application UUID | +| `status` | string | Application status \(Active, Hired, Archived, Lead\) | +| `candidate` | object | Associated candidate | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Candidate name | +| `job` | object | Associated job | +| ↳ `id` | string | Job UUID | +| ↳ `title` | string | Job title | +| `currentInterviewStage` | object | Current interview stage | +| ↳ `id` | string | Stage UUID | +| ↳ `title` | string | Stage title | +| ↳ `type` | string | Stage type | +| `source` | object | Application source | +| ↳ `id` | string | Source UUID | +| ↳ `title` | string | Source title | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 last update timestamp | + +### `ashby_create_candidate` + +Creates a new candidate record in Ashby. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `name` | string | Yes | The candidate full name | +| `email` | string | No | Primary email address for the candidate | +| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) | +| `phoneNumber` | string | No | Primary phone number for the candidate | +| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) | +| `linkedInUrl` | string | No | LinkedIn profile URL | +| `githubUrl` | string | No | GitHub profile URL | +| `sourceId` | string | No | UUID of the source to attribute the candidate to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created candidate UUID | +| `name` | string | Full name | +| `primaryEmailAddress` | object | Primary email contact info | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary email | +| `primaryPhoneNumber` | object | Primary phone contact info | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| `createdAt` | string | ISO 8601 creation timestamp | + +### `ashby_create_note` + +Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | The UUID of the candidate to add the note to | +| `note` | string | Yes | The note content. If noteType is text/html, supports: <b>, <i>, <u>, <a>, <ul>, <ol>, <li>, <code>, <pre> | +| `noteType` | string | No | Content type of the note: text/plain \(default\) or text/html | +| `sendNotifications` | boolean | No | Whether to send notifications to subscribed users \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created note UUID | +| `content` | string | Note content as stored | +| `author` | object | Note author | +| ↳ `id` | string | Author user UUID | +| ↳ `firstName` | string | First name | +| ↳ `lastName` | string | Last name | +| ↳ `email` | string | Email address | +| `createdAt` | string | ISO 8601 creation timestamp | + +### `ashby_get_application` + +Retrieves full details about a single application by its ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `applicationId` | string | Yes | The UUID of the application to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Application UUID | +| `status` | string | Application status \(Active, Hired, Archived, Lead\) | +| `candidate` | object | Associated candidate | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Candidate name | +| `job` | object | Associated job | +| ↳ `id` | string | Job UUID | +| ↳ `title` | string | Job title | +| `currentInterviewStage` | object | Current interview stage | +| ↳ `id` | string | Stage UUID | +| ↳ `title` | string | Stage title | +| ↳ `type` | string | Stage type | +| `source` | object | Application source | +| ↳ `id` | string | Source UUID | +| ↳ `title` | string | Source title | +| `archiveReason` | object | Reason for archival | +| ↳ `id` | string | Reason UUID | +| ↳ `text` | string | Reason text | +| ↳ `reasonType` | string | Reason type | +| `archivedAt` | string | ISO 8601 archive timestamp | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 last update timestamp | + +### `ashby_get_candidate` + +Retrieves full details about a single candidate by their ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | The UUID of the candidate to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Candidate UUID | +| `name` | string | Full name | +| `primaryEmailAddress` | object | Primary email contact info | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary email | +| `primaryPhoneNumber` | object | Primary phone contact info | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| `profileUrl` | string | URL to the candidate Ashby profile | +| `position` | string | Current position or title | +| `company` | string | Current company | +| `linkedInUrl` | string | LinkedIn profile URL | +| `githubUrl` | string | GitHub profile URL | +| `tags` | array | Tags applied to the candidate | +| ↳ `id` | string | Tag UUID | +| ↳ `title` | string | Tag title | +| `applicationIds` | array | IDs of associated applications | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 last update timestamp | + +### `ashby_get_job` + +Retrieves full details about a single job by its ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `jobId` | string | Yes | The UUID of the job to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Job UUID | +| `title` | string | Job title | +| `status` | string | Job status \(Open, Closed, Draft, Archived, On Hold\) | +| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | +| `departmentId` | string | Department UUID | +| `locationId` | string | Location UUID | +| `descriptionPlain` | string | Job description in plain text | +| `isArchived` | boolean | Whether the job is archived | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 last update timestamp | + +### `ashby_list_applications` + +Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `perPage` | number | No | Number of results per page \(default 100\) | +| `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead | +| `jobId` | string | No | Filter applications by a specific job UUID | +| `candidateId` | string | No | Filter applications by a specific candidate UUID | +| `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `applications` | array | List of applications | +| ↳ `id` | string | Application UUID | +| ↳ `status` | string | Application status \(Active, Hired, Archived, Lead\) | +| ↳ `candidate` | object | Associated candidate | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Candidate name | +| ↳ `job` | object | Associated job | +| ↳ `id` | string | Job UUID | +| ↳ `title` | string | Job title | +| ↳ `currentInterviewStage` | object | Current interview stage | +| ↳ `id` | string | Stage UUID | +| ↳ `title` | string | Stage title | +| ↳ `type` | string | Stage type | +| ↳ `source` | object | Application source | +| ↳ `id` | string | Source UUID | +| ↳ `title` | string | Source title | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | + +### `ashby_list_candidates` + +Lists all candidates in an Ashby organization with cursor-based pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `perPage` | number | No | Number of results per page \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | array | List of candidates | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Full name | +| ↳ `primaryEmailAddress` | object | Primary email contact info | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary email | +| ↳ `primaryPhoneNumber` | object | Primary phone contact info | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | + +### `ashby_list_jobs` + +Lists all jobs in an Ashby organization. By default returns Open, Closed, and Archived jobs. Specify status to filter. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `perPage` | number | No | Number of results per page \(default 100\) | +| `status` | string | No | Filter by job status: Open, Closed, Archived, or Draft | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `jobs` | array | List of jobs | +| ↳ `id` | string | Job UUID | +| ↳ `title` | string | Job title | +| ↳ `status` | string | Job status \(Open, Closed, Archived, Draft\) | +| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | +| ↳ `departmentId` | string | Department UUID | +| ↳ `locationId` | string | Location UUID | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | + +### `ashby_list_notes` + +Lists all notes on a candidate with pagination support. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | The UUID of the candidate to list notes for | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `perPage` | number | No | Number of results per page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | array | List of notes on the candidate | +| ↳ `id` | string | Note UUID | +| ↳ `content` | string | Note content | +| ↳ `author` | object | Note author | +| ↳ `id` | string | Author user UUID | +| ↳ `firstName` | string | First name | +| ↳ `lastName` | string | Last name | +| ↳ `email` | string | Email address | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | + +### `ashby_list_offers` + +Lists all offers with their latest version in an Ashby organization. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `perPage` | number | No | Number of results per page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `offers` | array | List of offers | +| ↳ `id` | string | Offer UUID | +| ↳ `status` | string | Offer status | +| ↳ `candidate` | object | Associated candidate | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Candidate name | +| ↳ `job` | object | Associated job | +| ↳ `id` | string | Job UUID | +| ↳ `title` | string | Job title | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | + +### `ashby_search_candidates` + +Searches for candidates by name and/or email with AND logic. Results are limited to 100 matches. Use candidate.list for full pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `name` | string | No | Candidate name to search for \(combined with email using AND logic\) | +| `email` | string | No | Candidate email to search for \(combined with name using AND logic\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | array | Matching candidates \(max 100 results\) | +| ↳ `id` | string | Candidate UUID | +| ↳ `name` | string | Full name | +| ↳ `primaryEmailAddress` | object | Primary email contact info | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary email | +| ↳ `primaryPhoneNumber` | object | Primary phone contact info | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary phone | + +### `ashby_update_candidate` + +Updates an existing candidate record in Ashby. Only provided fields are changed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | The UUID of the candidate to update | +| `name` | string | No | Updated full name | +| `email` | string | No | Updated primary email address | +| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) | +| `phoneNumber` | string | No | Updated primary phone number | +| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) | +| `linkedInUrl` | string | No | LinkedIn profile URL | +| `githubUrl` | string | No | GitHub profile URL | +| `websiteUrl` | string | No | Personal website URL | +| `sourceId` | string | No | UUID of the source to attribute the candidate to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Candidate UUID | +| `name` | string | Full name | +| `primaryEmailAddress` | object | Primary email contact info | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary email | +| `primaryPhoneNumber` | object | Primary phone contact info | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Contact type \(Personal, Work, Other\) | +| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| `profileUrl` | string | URL to the candidate Ashby profile | +| `position` | string | Current position or title | +| `company` | string | Current company | +| `linkedInUrl` | string | LinkedIn profile URL | +| `githubUrl` | string | GitHub profile URL | +| `tags` | array | Tags applied to the candidate | +| ↳ `id` | string | Tag UUID | +| ↳ `title` | string | Tag title | +| `applicationIds` | array | IDs of associated applications | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 last update timestamp | + + diff --git a/apps/docs/content/docs/en/tools/databricks.mdx b/apps/docs/content/docs/en/tools/databricks.mdx new file mode 100644 index 00000000000..a1ded4fab96 --- /dev/null +++ b/apps/docs/content/docs/en/tools/databricks.mdx @@ -0,0 +1,267 @@ +--- +title: Databricks +description: Run SQL queries and manage jobs on Databricks +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Databricks](https://www.databricks.com/) is a unified data analytics platform built on Apache Spark, providing a collaborative environment for data engineering, data science, and machine learning. Databricks combines data warehousing, ETL, and AI workloads into a single lakehouse architecture, with support for SQL analytics, job orchestration, and cluster management across major cloud providers. + +With the Databricks integration in Sim, you can: + +- **Execute SQL queries**: Run SQL statements against Databricks SQL warehouses with support for parameterized queries and Unity Catalog +- **Manage jobs**: List, trigger, and monitor Databricks job runs programmatically +- **Track run status**: Get detailed run information including timing, state, and output results +- **Control clusters**: List and inspect cluster configurations, states, and resource details +- **Retrieve run outputs**: Access notebook results, error messages, and logs from completed job runs + +In Sim, the Databricks integration enables your agents to interact with your data lakehouse as part of automated workflows. Agents can query large-scale datasets, orchestrate ETL pipelines by triggering jobs, monitor job execution, and retrieve results—all without leaving the workflow canvas. This is ideal for automated reporting, data pipeline management, scheduled analytics, and building AI-driven data workflows that react to query results or job outcomes. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect to Databricks to execute SQL queries against SQL warehouses, trigger and monitor job runs, manage clusters, and retrieve run outputs. Requires a Personal Access Token and workspace host URL. + + + +## Tools + +### `databricks_execute_sql` + +Execute a SQL statement against a Databricks SQL warehouse and return results inline. Supports parameterized queries and Unity Catalog. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `warehouseId` | string | Yes | The ID of the SQL warehouse to execute against | +| `statement` | string | Yes | The SQL statement to execute \(max 16 MiB\) | +| `catalog` | string | No | Unity Catalog name \(equivalent to USE CATALOG\) | +| `schema` | string | No | Schema name \(equivalent to USE SCHEMA\) | +| `rowLimit` | number | No | Maximum number of rows to return | +| `waitTimeout` | string | No | How long to wait for results \(e.g., "50s"\). Range: "0s" or "5s" to "50s". Default: "50s" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `statementId` | string | Unique identifier for the executed statement | +| `status` | string | Execution status \(SUCCEEDED, PENDING, RUNNING, FAILED, CANCELED, CLOSED\) | +| `columns` | array | Column schema of the result set | +| ↳ `name` | string | Column name | +| ↳ `position` | number | Column position \(0-based\) | +| ↳ `typeName` | string | Column type \(STRING, INT, LONG, DOUBLE, BOOLEAN, TIMESTAMP, DATE, DECIMAL, etc.\) | +| `data` | array | Result rows as a 2D array of strings where each inner array is a row of column values | +| `totalRows` | number | Total number of rows in the result | +| `truncated` | boolean | Whether the result set was truncated due to row_limit or byte_limit | + +### `databricks_list_jobs` + +List all jobs in a Databricks workspace with optional filtering by name. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `limit` | number | No | Maximum number of jobs to return \(range 1-100, default 20\) | +| `offset` | number | No | Offset for pagination | +| `name` | string | No | Filter jobs by exact name \(case-insensitive\) | +| `expandTasks` | boolean | No | Include task and cluster details in the response \(max 100 elements\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `jobs` | array | List of jobs in the workspace | +| ↳ `jobId` | number | Unique job identifier | +| ↳ `name` | string | Job name | +| ↳ `createdTime` | number | Job creation timestamp \(epoch ms\) | +| ↳ `creatorUserName` | string | Email of the job creator | +| ↳ `maxConcurrentRuns` | number | Maximum number of concurrent runs | +| ↳ `format` | string | Job format \(SINGLE_TASK or MULTI_TASK\) | +| `hasMore` | boolean | Whether more jobs are available for pagination | +| `nextPageToken` | string | Token for fetching the next page of results | + +### `databricks_run_job` + +Trigger an existing Databricks job to run immediately with optional job-level or notebook parameters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `jobId` | number | Yes | The ID of the job to trigger | +| `jobParameters` | string | No | Job-level parameter overrides as a JSON object \(e.g., \{"key": "value"\}\) | +| `notebookParams` | string | No | Notebook task parameters as a JSON object \(e.g., \{"param1": "value1"\}\) | +| `idempotencyToken` | string | No | Idempotency token to prevent duplicate runs \(max 64 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | number | The globally unique ID of the triggered run | +| `numberInJob` | number | The sequence number of this run among all runs of the job | + +### `databricks_get_run` + +Get the status, timing, and details of a Databricks job run by its run ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `runId` | number | Yes | The canonical identifier of the run | +| `includeHistory` | boolean | No | Include repair history in the response | +| `includeResolvedValues` | boolean | No | Include resolved parameter values in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | number | The run ID | +| `jobId` | number | The job ID this run belongs to | +| `runName` | string | Name of the run | +| `runType` | string | Type of run \(JOB_RUN, WORKFLOW_RUN, SUBMIT_RUN\) | +| `attemptNumber` | number | Retry attempt number \(0 for initial attempt\) | +| `state` | object | Run state information | +| ↳ `lifeCycleState` | string | Lifecycle state \(QUEUED, PENDING, RUNNING, TERMINATING, TERMINATED, SKIPPED, INTERNAL_ERROR, BLOCKED, WAITING_FOR_RETRY\) | +| ↳ `resultState` | string | Result state \(SUCCESS, FAILED, TIMEDOUT, CANCELED, SUCCESS_WITH_FAILURES, UPSTREAM_FAILED, UPSTREAM_CANCELED, EXCLUDED\) | +| ↳ `stateMessage` | string | Descriptive message for the current state | +| ↳ `userCancelledOrTimedout` | boolean | Whether the run was cancelled by user or timed out | +| `startTime` | number | Run start timestamp \(epoch ms\) | +| `endTime` | number | Run end timestamp \(epoch ms, 0 if still running\) | +| `setupDuration` | number | Cluster setup duration \(ms\) | +| `executionDuration` | number | Execution duration \(ms\) | +| `cleanupDuration` | number | Cleanup duration \(ms\) | +| `queueDuration` | number | Time spent in queue before execution \(ms\) | +| `runPageUrl` | string | URL to the run detail page in Databricks UI | +| `creatorUserName` | string | Email of the user who triggered the run | + +### `databricks_list_runs` + +List job runs in a Databricks workspace with optional filtering by job, status, and time range. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `jobId` | number | No | Filter runs by job ID. Omit to list runs across all jobs | +| `activeOnly` | boolean | No | Only include active runs \(PENDING, RUNNING, or TERMINATING\) | +| `completedOnly` | boolean | No | Only include completed runs | +| `limit` | number | No | Maximum number of runs to return \(range 1-24, default 20\) | +| `offset` | number | No | Offset for pagination | +| `runType` | string | No | Filter by run type \(JOB_RUN, WORKFLOW_RUN, SUBMIT_RUN\) | +| `startTimeFrom` | number | No | Filter runs started at or after this timestamp \(epoch ms\) | +| `startTimeTo` | number | No | Filter runs started at or before this timestamp \(epoch ms\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runs` | array | List of job runs | +| ↳ `runId` | number | Unique run identifier | +| ↳ `jobId` | number | Job this run belongs to | +| ↳ `runName` | string | Run name | +| ↳ `runType` | string | Run type \(JOB_RUN, WORKFLOW_RUN, SUBMIT_RUN\) | +| ↳ `state` | object | Run state information | +| ↳ `lifeCycleState` | string | Lifecycle state \(QUEUED, PENDING, RUNNING, TERMINATING, TERMINATED, SKIPPED, INTERNAL_ERROR, BLOCKED, WAITING_FOR_RETRY\) | +| ↳ `resultState` | string | Result state \(SUCCESS, FAILED, TIMEDOUT, CANCELED, SUCCESS_WITH_FAILURES, UPSTREAM_FAILED, UPSTREAM_CANCELED, EXCLUDED\) | +| ↳ `stateMessage` | string | Descriptive state message | +| ↳ `userCancelledOrTimedout` | boolean | Whether the run was cancelled by user or timed out | +| ↳ `startTime` | number | Run start timestamp \(epoch ms\) | +| ↳ `endTime` | number | Run end timestamp \(epoch ms\) | +| `hasMore` | boolean | Whether more runs are available for pagination | +| `nextPageToken` | string | Token for fetching the next page of results | + +### `databricks_cancel_run` + +Cancel a running or pending Databricks job run. Cancellation is asynchronous; poll the run status to confirm termination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `runId` | number | Yes | The canonical identifier of the run to cancel | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the cancel request was accepted | + +### `databricks_get_run_output` + +Get the output of a completed Databricks job run, including notebook results, error messages, and logs. For multi-task jobs, use the task run ID (not the parent run ID). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | +| `runId` | number | Yes | The run ID to get output for. For multi-task jobs, use the task run ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notebookOutput` | object | Notebook task output \(from dbutils.notebook.exit\(\)\) | +| ↳ `result` | string | Value passed to dbutils.notebook.exit\(\) \(max 5 MB\) | +| ↳ `truncated` | boolean | Whether the result was truncated | +| `error` | string | Error message if the run failed or output is unavailable | +| `errorTrace` | string | Error stack trace if available | +| `logs` | string | Log output \(last 5 MB\) from spark_jar, spark_python, or python_wheel tasks | +| `logsTruncated` | boolean | Whether the log output was truncated | + +### `databricks_list_clusters` + +List all clusters in a Databricks workspace including their state, configuration, and resource details. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Databricks workspace host \(e.g., dbc-abc123.cloud.databricks.com\) | +| `apiKey` | string | Yes | Databricks Personal Access Token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `clusters` | array | List of clusters in the workspace | +| ↳ `clusterId` | string | Unique cluster identifier | +| ↳ `clusterName` | string | Cluster display name | +| ↳ `state` | string | Current state \(PENDING, RUNNING, RESTARTING, RESIZING, TERMINATING, TERMINATED, ERROR, UNKNOWN\) | +| ↳ `stateMessage` | string | Human-readable state description | +| ↳ `creatorUserName` | string | Email of the cluster creator | +| ↳ `sparkVersion` | string | Spark runtime version \(e.g., 13.3.x-scala2.12\) | +| ↳ `nodeTypeId` | string | Worker node type identifier | +| ↳ `driverNodeTypeId` | string | Driver node type identifier | +| ↳ `numWorkers` | number | Number of worker nodes \(for fixed-size clusters\) | +| ↳ `autoscale` | object | Autoscaling configuration \(null for fixed-size clusters\) | +| ↳ `minWorkers` | number | Minimum number of workers | +| ↳ `maxWorkers` | number | Maximum number of workers | +| ↳ `clusterSource` | string | Origin \(API, UI, JOB, MODELS, PIPELINE, PIPELINE_MAINTENANCE, SQL\) | +| ↳ `autoterminationMinutes` | number | Minutes of inactivity before auto-termination \(0 = disabled\) | +| ↳ `startTime` | number | Cluster start timestamp \(epoch ms\) | + + diff --git a/apps/docs/content/docs/en/tools/gamma.mdx b/apps/docs/content/docs/en/tools/gamma.mdx new file mode 100644 index 00000000000..f62b2232a5e --- /dev/null +++ b/apps/docs/content/docs/en/tools/gamma.mdx @@ -0,0 +1,165 @@ +--- +title: Gamma +description: Generate presentations, documents, and webpages with AI +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Gamma](https://gamma.app/) is an AI-powered platform for creating presentations, documents, webpages, and social posts. Gamma's API lets you programmatically generate polished, visually rich content from text prompts, adapt existing templates, and manage workspace assets like themes and folders. + +With Gamma, you can: + +- **Generate presentations and documents:** Create slide decks, documents, webpages, and social posts from text input with full control over format, tone, and image sourcing. +- **Create from templates:** Adapt existing Gamma templates with custom prompts to quickly produce tailored content. +- **Check generation status:** Poll for completion of async generation jobs and retrieve the final Gamma URL. +- **Browse themes and folders:** List available workspace themes and folders to organize and style your generated content. + +In Sim, the Gamma integration enables your agents to automatically generate presentations and documents, create content from templates, and manage workspace assets directly within your workflows. This allows you to automate content creation pipelines, batch-produce slide decks, and integrate AI-generated presentations into broader business automation scenarios. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Gamma into the workflow. Can generate presentations, documents, webpages, and social posts from text, create from templates, check generation status, and browse themes and folders. + + + +## Tools + +### `gamma_generate` + +Generate a new Gamma presentation, document, webpage, or social post from text input. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Gamma API key | +| `inputText` | string | Yes | Text and image URLs used to generate your gamma \(1-100,000 tokens\) | +| `textMode` | string | Yes | How to handle input text: generate \(AI expands\), condense \(AI summarizes\), or preserve \(keep as-is\) | +| `format` | string | No | Output format: presentation, document, webpage, or social \(default: presentation\) | +| `themeId` | string | No | Custom Gamma workspace theme ID \(use List Themes to find available themes\) | +| `numCards` | number | No | Number of cards/slides to generate \(1-60 for Pro, 1-75 for Ultra; default: 10\) | +| `cardSplit` | string | No | How to split content into cards: auto or inputTextBreaks \(default: auto\) | +| `cardDimensions` | string | No | Card aspect ratio. Presentation: fluid, 16x9, 4x3. Document: fluid, pageless, letter, a4. Social: 1x1, 4x5, 9x16 | +| `additionalInstructions` | string | No | Additional instructions for the AI generation \(max 2000 chars\) | +| `exportAs` | string | No | Automatically export the generated gamma as pdf or pptx | +| `folderIds` | string | No | Comma-separated folder IDs to store the generated gamma in | +| `textAmount` | string | No | Amount of text per card: brief, medium, detailed, or extensive | +| `textTone` | string | No | Tone of the generated text, e.g. "professional", "casual" \(max 500 chars\) | +| `textAudience` | string | No | Target audience for the generated text, e.g. "executives", "students" \(max 500 chars\) | +| `textLanguage` | string | No | Language code for the generated text \(default: en\) | +| `imageSource` | string | No | Where to source images: aiGenerated, pictographic, unsplash, webAllImages, webFreeToUse, webFreeToUseCommercially, giphy, placeholder, or noImages | +| `imageModel` | string | No | AI image generation model to use when imageSource is aiGenerated | +| `imageStyle` | string | No | Style directive for AI-generated images, e.g. "watercolor", "photorealistic" \(max 500 chars\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `generationId` | string | The ID of the generation job. Use with Check Status to poll for completion. | + +### `gamma_generate_from_template` + +Generate a new Gamma by adapting an existing template with a prompt. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Gamma API key | +| `gammaId` | string | Yes | The ID of the template gamma to adapt | +| `prompt` | string | Yes | Instructions for how to adapt the template \(1-100,000 tokens\) | +| `themeId` | string | No | Custom Gamma workspace theme ID to apply | +| `exportAs` | string | No | Automatically export the generated gamma as pdf or pptx | +| `folderIds` | string | No | Comma-separated folder IDs to store the generated gamma in | +| `imageModel` | string | No | AI image generation model to use when imageSource is aiGenerated | +| `imageStyle` | string | No | Style directive for AI-generated images, e.g. "watercolor", "photorealistic" \(max 500 chars\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `generationId` | string | The ID of the generation job. Use with Check Status to poll for completion. | + +### `gamma_check_status` + +Check the status of a Gamma generation job. Returns the gamma URL when completed, or error details if failed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Gamma API key | +| `generationId` | string | Yes | The generation ID returned by the Generate or Generate from Template tool | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `generationId` | string | The generation ID that was checked | +| `status` | string | Generation status: pending, completed, or failed | +| `gammaUrl` | string | URL of the generated gamma \(only present when status is completed\) | +| `credits` | object | Credit usage information \(only present when status is completed\) | +| ↳ `deducted` | number | Number of credits deducted for this generation | +| ↳ `remaining` | number | Remaining credits in the account | +| `error` | object | Error details \(only present when status is failed\) | +| ↳ `message` | string | Human-readable error message | +| ↳ `statusCode` | number | HTTP status code of the error | + +### `gamma_list_themes` + +List available themes in your Gamma workspace. Returns theme IDs, names, and keywords for styling. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Gamma API key | +| `query` | string | No | Search query to filter themes by name \(case-insensitive\) | +| `limit` | number | No | Maximum number of themes to return per page \(max 50\) | +| `after` | string | No | Pagination cursor from a previous response \(nextCursor\) to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `themes` | array | List of available themes | +| ↳ `id` | string | Theme ID \(use with themeId parameter\) | +| ↳ `name` | string | Theme display name | +| ↳ `type` | string | Theme type: standard or custom | +| ↳ `colorKeywords` | array | Color descriptors for this theme | +| ↳ `toneKeywords` | array | Tone descriptors for this theme | +| `hasMore` | boolean | Whether more results are available on the next page | +| `nextCursor` | string | Pagination cursor to pass as the after parameter for the next page | + +### `gamma_list_folders` + +List available folders in your Gamma workspace. Returns folder IDs and names for organizing generated content. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Gamma API key | +| `query` | string | No | Search query to filter folders by name \(case-sensitive\) | +| `limit` | number | No | Maximum number of folders to return per page \(max 50\) | +| `after` | string | No | Pagination cursor from a previous response \(nextCursor\) to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folders` | array | List of available folders | +| ↳ `id` | string | Folder ID \(use with folderIds parameter\) | +| ↳ `name` | string | Folder display name | +| `hasMore` | boolean | Whether more results are available on the next page | +| `nextCursor` | string | Pagination cursor to pass as the after parameter for the next page | + + diff --git a/apps/docs/content/docs/en/tools/gmail.mdx b/apps/docs/content/docs/en/tools/gmail.mdx index 8eb78c6deba..261699d8875 100644 --- a/apps/docs/content/docs/en/tools/gmail.mdx +++ b/apps/docs/content/docs/en/tools/gmail.mdx @@ -11,19 +11,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Gmail](https://mail.google.com/) is one of the world’s most popular and reliable email services, trusted by individuals and organizations to send, receive, and manage messages. Gmail offers a secure, intuitive interface with advanced organization and search capabilities, making it a top choice for personal and professional communication. +[Gmail](https://mail.google.com/) is one of the world’s most popular email services, trusted by individuals and organizations to send, receive, and manage messages securely. -Gmail provides a comprehensive suite of features for efficient email management, message filtering, and workflow integration. With its powerful API, Gmail enables developers and platforms to automate common email-related tasks, integrate mailbox activities into broader workflows, and enhance productivity by reducing manual effort. +With the Gmail integration in Sim, you can: -Key features of Gmail include: +- **Send emails**: Compose and send emails with support for recipients, CC, BCC, subject, body, and attachments +- **Create drafts**: Save email drafts for later review and sending +- **Read emails**: Retrieve email messages by ID with full content and metadata +- **Search emails**: Find emails using Gmail’s powerful search query syntax +- **Move emails**: Move messages between folders or labels +- **Manage read status**: Mark emails as read or unread +- **Archive and unarchive**: Archive messages to clean up your inbox or restore them +- **Delete emails**: Remove messages from your mailbox +- **Manage labels**: Add or remove labels from emails for organization -- Email Sending and Receiving: Compose, send, and receive emails reliably and securely -- Message Search and Organization: Advanced search, labels, and filters to easily find and categorize messages -- Conversation Threading: Keeps related messages grouped together for better conversation tracking -- Attachments and Formatting: Support for file attachments, rich formatting, and embedded media -- Integration and Automation: Robust API for integrating with other tools and automating email workflows - -In Sim, the Gmail integration allows your agents to interact with your emails programmatically—sending, receiving, searching, and organizing messages as part of powerful AI workflows. Agents can draft emails, trigger processes based on new email arrivals, and automate repetitive email tasks, freeing up time and reducing manual labor. By connecting Sim with Gmail, you can build intelligent agents to manage communications, automate follow-ups, and maintain organized inboxes within your workflows. +In Sim, the Gmail integration enables your agents to interact with your inbox programmatically as part of automated workflows. Agents can send notifications, search for specific emails, organize messages, and trigger actions based on email content—enabling intelligent email automation and communication workflows. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_bigquery.mdx b/apps/docs/content/docs/en/tools/google_bigquery.mdx index 38cc4edaad7..609a6928559 100644 --- a/apps/docs/content/docs/en/tools/google_bigquery.mdx +++ b/apps/docs/content/docs/en/tools/google_bigquery.mdx @@ -11,9 +11,16 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Google BigQuery](https://cloud.google.com/bigquery) is Google Cloud's fully managed, serverless data warehouse designed for large-scale data analytics. BigQuery lets you run fast SQL queries on massive datasets, making it ideal for business intelligence, data exploration, and machine learning pipelines. It supports standard SQL, streaming inserts, and integrates with the broader Google Cloud ecosystem. +[Google BigQuery](https://cloud.google.com/bigquery) is Google Cloud's fully managed, serverless data warehouse designed for large-scale data analytics. BigQuery lets you run fast SQL queries on massive datasets, making it ideal for business intelligence, data exploration, and machine learning pipelines. -In Sim, the Google BigQuery integration allows your agents to query datasets, list tables, inspect schemas, and insert rows as part of automated workflows. This enables use cases such as automated reporting, data pipeline orchestration, real-time data ingestion, and analytics-driven decision making. By connecting Sim with BigQuery, your agents can pull insights from petabytes of data, write results back to tables, and keep your analytics workflows running without manual intervention. +With the Google BigQuery integration in Sim, you can: + +- **Run SQL queries**: Execute queries against your BigQuery datasets and retrieve results for analysis or downstream processing +- **List datasets**: Browse available datasets within a Google Cloud project +- **List and inspect tables**: Enumerate tables within a dataset and retrieve detailed schema information +- **Insert rows**: Stream new rows into BigQuery tables for real-time data ingestion + +In Sim, the Google BigQuery integration enables your agents to query datasets, inspect schemas, and insert rows as part of automated workflows. This is ideal for automated reporting, data pipeline orchestration, real-time data ingestion, and analytics-driven decision making. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_books.mdx b/apps/docs/content/docs/en/tools/google_books.mdx index 8e817dbc0db..543dd6f1252 100644 --- a/apps/docs/content/docs/en/tools/google_books.mdx +++ b/apps/docs/content/docs/en/tools/google_books.mdx @@ -11,9 +11,14 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Google Books](https://books.google.com) is Google's comprehensive book discovery and metadata service, providing access to millions of books from publishers, libraries, and digitized collections worldwide. The Google Books API enables programmatic search and retrieval of detailed book information including titles, authors, descriptions, ratings, and publication details. +[Google Books](https://books.google.com) is Google's comprehensive book discovery and metadata service, providing access to millions of books from publishers, libraries, and digitized collections worldwide. -In Sim, the Google Books integration allows your agents to search for books and retrieve volume details as part of automated workflows. This enables use cases such as content research, reading list curation, bibliographic data enrichment, ISBN lookups, and knowledge gathering from published works. By connecting Sim with Google Books, your agents can discover and analyze book metadata, filter by availability or format, and incorporate literary references into their outputs—all without manual research. +With the Google Books integration in Sim, you can: + +- **Search for books**: Find volumes by title, author, ISBN, or keyword across the entire Google Books catalog +- **Retrieve volume details**: Get detailed metadata for a specific book including title, authors, description, ratings, and publication details + +In Sim, the Google Books integration allows your agents to search for books and retrieve volume details as part of automated workflows. This enables use cases such as content research, reading list curation, bibliographic data enrichment, and knowledge gathering from published works. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_contacts.mdx b/apps/docs/content/docs/en/tools/google_contacts.mdx new file mode 100644 index 00000000000..b68c303a578 --- /dev/null +++ b/apps/docs/content/docs/en/tools/google_contacts.mdx @@ -0,0 +1,144 @@ +--- +title: Google Contacts +description: Manage Google Contacts +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Google Contacts into the workflow. Can create, read, update, delete, list, and search contacts. + + + +## Tools + +### `google_contacts_create` + +Create a new contact in Google Contacts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `givenName` | string | Yes | First name of the contact | +| `familyName` | string | No | Last name of the contact | +| `email` | string | No | Email address of the contact | +| `emailType` | string | No | Email type: home, work, or other | +| `phone` | string | No | Phone number of the contact | +| `phoneType` | string | No | Phone type: mobile, home, work, or other | +| `organization` | string | No | Organization/company name | +| `jobTitle` | string | No | Job title at the organization | +| `notes` | string | No | Notes or biography for the contact | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Contact creation confirmation message | +| `metadata` | json | Created contact metadata including resource name and details | + +### `google_contacts_get` + +Get a specific contact from Google Contacts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `resourceName` | string | Yes | Resource name of the contact \(e.g., people/c1234567890\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Contact retrieval confirmation message | +| `metadata` | json | Contact details including name, email, phone, and organization | + +### `google_contacts_list` + +List contacts from Google Contacts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `pageSize` | number | No | Number of contacts to return \(1-1000, default 100\) | +| `pageToken` | string | No | Page token from a previous list request for pagination | +| `sortOrder` | string | No | Sort order for contacts | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Summary of found contacts count | +| `metadata` | json | List of contacts with pagination tokens | + +### `google_contacts_search` + +Search contacts in Google Contacts by name, email, phone, or organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search query to match against contact names, emails, phones, and organizations | +| `pageSize` | number | No | Number of results to return \(default 10, max 30\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Summary of search results count | +| `metadata` | json | Search results with matching contacts | + +### `google_contacts_update` + +Update an existing contact in Google Contacts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `resourceName` | string | Yes | Resource name of the contact \(e.g., people/c1234567890\) | +| `etag` | string | Yes | ETag from a previous get request \(required for concurrency control\) | +| `givenName` | string | No | Updated first name | +| `familyName` | string | No | Updated last name | +| `email` | string | No | Updated email address | +| `emailType` | string | No | Email type: home, work, or other | +| `phone` | string | No | Updated phone number | +| `phoneType` | string | No | Phone type: mobile, home, work, or other | +| `organization` | string | No | Updated organization/company name | +| `jobTitle` | string | No | Updated job title | +| `notes` | string | No | Updated notes or biography | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Contact update confirmation message | +| `metadata` | json | Updated contact metadata | + +### `google_contacts_delete` + +Delete a contact from Google Contacts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `resourceName` | string | Yes | Resource name of the contact to delete \(e.g., people/c1234567890\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Contact deletion confirmation message | +| `metadata` | json | Deletion details including resource name | + + diff --git a/apps/docs/content/docs/en/tools/google_search.mdx b/apps/docs/content/docs/en/tools/google_search.mdx index 962c041deeb..480ea4a1313 100644 --- a/apps/docs/content/docs/en/tools/google_search.mdx +++ b/apps/docs/content/docs/en/tools/google_search.mdx @@ -11,9 +11,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Google Search](https://www.google.com) is the world's most widely used web search engine, making it easy to find information, discover new content, and answer questions in real time. With advanced search algorithms, Google Search helps you quickly locate web pages, images, news, and more using simple or complex queries. +[Google Search](https://www.google.com) is the world's most widely used web search engine, making it easy to find information, discover new content, and answer questions in real time. -In Sim, the Google Search integration allows your agents to search the web and retrieve live information as part of automated workflows. This enables powerful use cases such as automated research, fact-checking, knowledge synthesis, and dynamic content discovery. By connecting Sim with Google Search, your agents can perform queries, process and analyze web results, and incorporate the latest information into their decisions—without manual effort. Enhance your workflows with always up-to-date knowledge from across the internet. +With the Google Search integration in Sim, you can: + +- **Search the web**: Perform queries using Google's Custom Search API and retrieve structured search results with titles, snippets, and URLs + +In Sim, the Google Search integration allows your agents to search the web and retrieve live information as part of automated workflows. This enables use cases such as automated research, fact-checking, knowledge synthesis, and dynamic content discovery. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_sheets.mdx b/apps/docs/content/docs/en/tools/google_sheets.mdx index bf034e01600..ac2f470a04b 100644 --- a/apps/docs/content/docs/en/tools/google_sheets.mdx +++ b/apps/docs/content/docs/en/tools/google_sheets.mdx @@ -11,18 +11,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Google Sheets](https://www.google.com/sheets/about/) is a powerful, cloud-based spreadsheet platform that allows teams and individuals to create, edit, and collaborate on spreadsheets in real-time. Widely used for data tracking, reporting, and lightweight database needs, Google Sheets seamlessly integrates with many tools and services to empower workflow automation and data-driven operations. +[Google Sheets](https://www.google.com/sheets/about/) is a cloud-based spreadsheet platform that allows teams and individuals to create, edit, and collaborate on spreadsheets in real-time. Widely used for data tracking, reporting, and lightweight database needs, Google Sheets integrates with many tools and services. -Google Sheets offers an extensive feature set for managing and analyzing tabular data, supporting everything from basic calculations to complex reporting and collaborative editing. Its robust API and integration capabilities enable automated access, updates, and reporting from agents and external services. +With the Google Sheets integration in Sim, you can: -Key features of Google Sheets include: +- **Read data**: Retrieve cell values from specific ranges in a spreadsheet +- **Write data**: Write values to specific cell ranges +- **Update data**: Modify existing cell values in a spreadsheet +- **Append rows**: Add new rows of data to the end of a sheet +- **Clear ranges**: Remove data from specific cell ranges +- **Manage spreadsheets**: Create new spreadsheets or retrieve metadata about existing ones +- **Batch operations**: Perform batch read, update, and clear operations across multiple ranges +- **Copy sheets**: Duplicate sheets within or between spreadsheets -- Real-Time Collaboration: Multiple users can edit and view spreadsheets simultaneously from anywhere. -- Rich Data Manipulation: Support for formulas, charts, pivots, and add-ons to analyze and visualize data. -- Easy Data Import/Export: Ability to connect and sync data from various sources using integrations and APIs. -- Powerful Permissions: Fine-grained sharing, access controls, and version history for team management. - -In Sim, the Google Sheets integration empowers your agents to automate reading from, writing to, and updating specific sheets within spreadsheets. Agents can interact programmatically with Google Sheets to retrieve or modify data, manage collaborative documents, and automate reporting or record-keeping as part of your AI workflows. By connecting Sim with Google Sheets, you can build intelligent agents that manage, analyze, and update your data dynamically—streamlining operations, enhancing productivity, and ensuring up-to-date data access across your organization. +In Sim, the Google Sheets integration enables your agents to read from, write to, and manage spreadsheets as part of automated workflows. This is ideal for automated reporting, data synchronization, record-keeping, and building data pipelines that use spreadsheets as a collaborative data layer. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_tasks.mdx b/apps/docs/content/docs/en/tools/google_tasks.mdx index 745b04c1e52..d18f69defc6 100644 --- a/apps/docs/content/docs/en/tools/google_tasks.mdx +++ b/apps/docs/content/docs/en/tools/google_tasks.mdx @@ -11,9 +11,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Google Tasks](https://support.google.com/tasks) is Google's lightweight task management service, integrated into Gmail, Google Calendar, and the standalone Google Tasks app. It provides a simple way to create, organize, and track to-do items with support for due dates, subtasks, and task lists. As part of Google Workspace, Google Tasks keeps your action items synchronized across all your devices. +[Google Tasks](https://support.google.com/tasks) is Google's lightweight task management service, integrated into Gmail, Google Calendar, and the standalone Google Tasks app. It provides a simple way to create, organize, and track to-do items with support for due dates, subtasks, and task lists. -In Sim, the Google Tasks integration allows your agents to create, read, update, delete, and list tasks and task lists as part of automated workflows. This enables use cases such as automated task creation from incoming data, to-do list management based on workflow triggers, task status tracking, and deadline monitoring. By connecting Sim with Google Tasks, your agents can manage action items programmatically, keep teams organized, and ensure nothing falls through the cracks. +With the Google Tasks integration in Sim, you can: + +- **Create tasks**: Add new to-do items to any task list with titles, notes, and due dates +- **List tasks**: Retrieve all tasks from a specific task list +- **Get task details**: Fetch detailed information about a specific task by ID +- **Update tasks**: Modify task titles, notes, due dates, or completion status +- **Delete tasks**: Remove tasks from a task list +- **List task lists**: Browse all available task lists in a Google account + +In Sim, the Google Tasks integration allows your agents to manage to-do items programmatically as part of automated workflows. This enables use cases such as automated task creation from incoming data, deadline monitoring, and workflow-triggered task management. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_translate.mdx b/apps/docs/content/docs/en/tools/google_translate.mdx index 2e890b1149b..8c0bd6bebb7 100644 --- a/apps/docs/content/docs/en/tools/google_translate.mdx +++ b/apps/docs/content/docs/en/tools/google_translate.mdx @@ -10,6 +10,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#E0E0E0" /> +{/* MANUAL-CONTENT-START:intro */} +[Google Translate](https://translate.google.com/) is Google's powerful translation service, supporting over 100 languages for text, documents, and websites. Backed by advanced neural machine translation, Google Translate delivers fast and accurate translations for a wide range of use cases. + +With the Google Translate integration in Sim, you can: + +- **Translate text**: Convert text between over 100 languages using Google Cloud Translation +- **Detect languages**: Automatically identify the language of a given text input + +In Sim, the Google Translate integration allows your agents to translate text and detect languages as part of automated workflows. This enables use cases such as localization, multilingual support, content translation, and language detection at scale. +{/* MANUAL-CONTENT-END */} + + ## Usage Instructions Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language. diff --git a/apps/docs/content/docs/en/tools/greenhouse.mdx b/apps/docs/content/docs/en/tools/greenhouse.mdx new file mode 100644 index 00000000000..a96605a0325 --- /dev/null +++ b/apps/docs/content/docs/en/tools/greenhouse.mdx @@ -0,0 +1,506 @@ +--- +title: Greenhouse +description: Manage candidates, jobs, and applications in Greenhouse +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Greenhouse](https://www.greenhouse.com/) is a leading applicant tracking system (ATS) and hiring platform designed to help companies optimize their recruiting processes. Greenhouse provides structured hiring workflows, candidate management, interview scheduling, and analytics to help organizations make better hiring decisions at scale. + +With the Greenhouse integration in Sim, you can: + +- **Manage candidates**: List and retrieve detailed candidate profiles including contact information, tags, and application history +- **Track jobs**: List and view job postings with details on hiring teams, openings, and confidentiality settings +- **Monitor applications**: List and retrieve applications with status, source, and interview stage information +- **Access user data**: List and look up Greenhouse users including recruiters, coordinators, and hiring managers +- **Browse organizational data**: List departments, offices, and job stages to understand your hiring pipeline structure + +In Sim, the Greenhouse integration enables your agents to interact with your recruiting data as part of automated workflows. Agents can pull candidate information, monitor application pipelines, track job openings, and cross-reference hiring team data—all programmatically. This is ideal for building automated recruiting reports, candidate pipeline monitoring, hiring analytics dashboards, and workflows that react to changes in your talent pipeline. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Greenhouse into the workflow. List and retrieve candidates, jobs, applications, users, departments, offices, and job stages from your Greenhouse ATS account. + + + +## Tools + +### `greenhouse_list_candidates` + +Lists candidates from Greenhouse with optional filtering by date, job, or email + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | +| `created_after` | string | No | Return only candidates created at or after this ISO 8601 timestamp | +| `created_before` | string | No | Return only candidates created before this ISO 8601 timestamp | +| `updated_after` | string | No | Return only candidates updated at or after this ISO 8601 timestamp | +| `updated_before` | string | No | Return only candidates updated before this ISO 8601 timestamp | +| `job_id` | string | No | Filter to candidates who applied to this job ID \(excludes prospects\) | +| `email` | string | No | Filter to candidates with this email address | +| `candidate_ids` | string | No | Comma-separated candidate IDs to retrieve \(max 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | array | List of candidates | +| ↳ `id` | number | Candidate ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `company` | string | Current employer | +| ↳ `title` | string | Current job title | +| ↳ `is_private` | boolean | Whether candidate is private | +| ↳ `can_email` | boolean | Whether candidate can be emailed | +| ↳ `email_addresses` | array | Email addresses | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Email type \(personal, work, other\) | +| ↳ `tags` | array | Candidate tags | +| ↳ `application_ids` | array | Associated application IDs | +| ↳ `created_at` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `last_activity` | string | Last activity timestamp \(ISO 8601\) | +| `count` | number | Number of candidates returned | + +### `greenhouse_get_candidate` + +Retrieves a specific candidate by ID with full details including contact info, education, and employment history + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `candidateId` | string | Yes | The ID of the candidate to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Candidate ID | +| `first_name` | string | First name | +| `last_name` | string | Last name | +| `company` | string | Current employer | +| `title` | string | Current job title | +| `is_private` | boolean | Whether candidate is private | +| `can_email` | boolean | Whether candidate can be emailed | +| `created_at` | string | Creation timestamp \(ISO 8601\) | +| `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| `last_activity` | string | Last activity timestamp \(ISO 8601\) | +| `email_addresses` | array | Email addresses | +| ↳ `value` | string | Email address | +| ↳ `type` | string | Type \(personal, work, other\) | +| `phone_numbers` | array | Phone numbers | +| ↳ `value` | string | Phone number | +| ↳ `type` | string | Type \(home, work, mobile, skype, other\) | +| `addresses` | array | Addresses | +| ↳ `value` | string | Address | +| ↳ `type` | string | Type \(home, work, other\) | +| `website_addresses` | array | Website addresses | +| ↳ `value` | string | URL | +| ↳ `type` | string | Type \(personal, company, portfolio, blog, other\) | +| `social_media_addresses` | array | Social media profiles | +| ↳ `value` | string | URL or handle | +| `tags` | array | Tags | +| `application_ids` | array | Associated application IDs | +| `recruiter` | object | Assigned recruiter | +| ↳ `id` | number | User ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `name` | string | Full name | +| ↳ `employee_id` | string | Employee ID | +| `coordinator` | object | Assigned coordinator | +| ↳ `id` | number | User ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `name` | string | Full name | +| ↳ `employee_id` | string | Employee ID | +| `attachments` | array | File attachments \(URLs expire after 7 days\) | +| ↳ `filename` | string | File name | +| ↳ `url` | string | Download URL \(expires after 7 days\) | +| ↳ `type` | string | Type \(resume, cover_letter, offer_packet, other\) | +| ↳ `created_at` | string | Upload timestamp | +| `educations` | array | Education history | +| ↳ `id` | number | Education record ID | +| ↳ `school_name` | string | School name | +| ↳ `degree` | string | Degree type | +| ↳ `discipline` | string | Field of study | +| ↳ `start_date` | string | Start date \(ISO 8601\) | +| ↳ `end_date` | string | End date \(ISO 8601\) | +| `employments` | array | Employment history | +| ↳ `id` | number | Employment record ID | +| ↳ `company_name` | string | Company name | +| ↳ `title` | string | Job title | +| ↳ `start_date` | string | Start date \(ISO 8601\) | +| ↳ `end_date` | string | End date \(ISO 8601\) | +| `custom_fields` | object | Custom field values | + +### `greenhouse_list_jobs` + +Lists jobs from Greenhouse with optional filtering by status, department, or office + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | +| `status` | string | No | Filter by job status \(open, closed, draft\) | +| `created_after` | string | No | Return only jobs created at or after this ISO 8601 timestamp | +| `created_before` | string | No | Return only jobs created before this ISO 8601 timestamp | +| `updated_after` | string | No | Return only jobs updated at or after this ISO 8601 timestamp | +| `updated_before` | string | No | Return only jobs updated before this ISO 8601 timestamp | +| `department_id` | string | No | Filter to jobs in this department ID | +| `office_id` | string | No | Filter to jobs in this office ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `jobs` | array | List of jobs | +| ↳ `id` | number | Job ID | +| ↳ `name` | string | Job title | +| ↳ `status` | string | Job status \(open, closed, draft\) | +| ↳ `confidential` | boolean | Whether the job is confidential | +| ↳ `departments` | array | Associated departments | +| ↳ `id` | number | Department ID | +| ↳ `name` | string | Department name | +| ↳ `offices` | array | Associated offices | +| ↳ `id` | number | Office ID | +| ↳ `name` | string | Office name | +| ↳ `opened_at` | string | Date job was opened \(ISO 8601\) | +| ↳ `closed_at` | string | Date job was closed \(ISO 8601\) | +| ↳ `created_at` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| `count` | number | Number of jobs returned | + +### `greenhouse_get_job` + +Retrieves a specific job by ID with full details including hiring team, openings, and custom fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `jobId` | string | Yes | The ID of the job to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Job ID | +| `name` | string | Job title | +| `requisition_id` | string | External requisition ID | +| `status` | string | Job status \(open, closed, draft\) | +| `confidential` | boolean | Whether the job is confidential | +| `created_at` | string | Creation timestamp \(ISO 8601\) | +| `opened_at` | string | Date job was opened \(ISO 8601\) | +| `closed_at` | string | Date job was closed \(ISO 8601\) | +| `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| `is_template` | boolean | Whether this is a job template | +| `notes` | string | Hiring plan notes \(may contain HTML\) | +| `departments` | array | Associated departments | +| ↳ `id` | number | Department ID | +| ↳ `name` | string | Department name | +| ↳ `parent_id` | number | Parent department ID | +| `offices` | array | Associated offices | +| ↳ `id` | number | Office ID | +| ↳ `name` | string | Office name | +| ↳ `location` | object | Office location | +| ↳ `name` | string | Location name | +| `hiring_team` | object | Hiring team members | +| ↳ `hiring_managers` | array | Hiring managers | +| ↳ `recruiters` | array | Recruiters \(includes responsible flag\) | +| ↳ `coordinators` | array | Coordinators \(includes responsible flag\) | +| ↳ `sourcers` | array | Sourcers | +| `openings` | array | Job openings/slots | +| ↳ `id` | number | Opening internal ID | +| ↳ `opening_id` | string | Custom opening identifier | +| ↳ `status` | string | Opening status \(open, closed\) | +| ↳ `opened_at` | string | Date opened \(ISO 8601\) | +| ↳ `closed_at` | string | Date closed \(ISO 8601\) | +| ↳ `application_id` | number | Hired application ID | +| ↳ `close_reason` | object | Reason for closing | +| ↳ `id` | number | Close reason ID | +| ↳ `name` | string | Close reason name | +| `custom_fields` | object | Custom field values | + +### `greenhouse_list_applications` + +Lists applications from Greenhouse with optional filtering by job, status, or date + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | +| `job_id` | string | No | Filter applications by job ID | +| `status` | string | No | Filter by status \(active, converted, hired, rejected\) | +| `created_after` | string | No | Return only applications created at or after this ISO 8601 timestamp | +| `created_before` | string | No | Return only applications created before this ISO 8601 timestamp | +| `last_activity_after` | string | No | Return only applications with activity at or after this ISO 8601 timestamp | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `applications` | array | List of applications | +| ↳ `id` | number | Application ID | +| ↳ `candidate_id` | number | Associated candidate ID | +| ↳ `prospect` | boolean | Whether this is a prospect application | +| ↳ `status` | string | Status \(active, converted, hired, rejected\) | +| ↳ `current_stage` | object | Current interview stage | +| ↳ `id` | number | Stage ID | +| ↳ `name` | string | Stage name | +| ↳ `jobs` | array | Associated jobs | +| ↳ `id` | number | Job ID | +| ↳ `name` | string | Job name | +| ↳ `applied_at` | string | Application date \(ISO 8601\) | +| ↳ `rejected_at` | string | Rejection date \(ISO 8601\) | +| ↳ `last_activity_at` | string | Last activity date \(ISO 8601\) | +| `count` | number | Number of applications returned | + +### `greenhouse_get_application` + +Retrieves a specific application by ID with full details including source, stage, answers, and attachments + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `applicationId` | string | Yes | The ID of the application to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | Application ID | +| `candidate_id` | number | Associated candidate ID | +| `prospect` | boolean | Whether this is a prospect application | +| `status` | string | Status \(active, converted, hired, rejected\) | +| `applied_at` | string | Application date \(ISO 8601\) | +| `rejected_at` | string | Rejection date \(ISO 8601\) | +| `last_activity_at` | string | Last activity date \(ISO 8601\) | +| `location` | object | Candidate location | +| ↳ `address` | string | Location address | +| `source` | object | Application source | +| ↳ `id` | number | Source ID | +| ↳ `public_name` | string | Source name | +| `credited_to` | object | User credited for the application | +| ↳ `id` | number | User ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `name` | string | Full name | +| ↳ `employee_id` | string | Employee ID | +| `recruiter` | object | Assigned recruiter | +| ↳ `id` | number | User ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `name` | string | Full name | +| ↳ `employee_id` | string | Employee ID | +| `coordinator` | object | Assigned coordinator | +| ↳ `id` | number | User ID | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `name` | string | Full name | +| ↳ `employee_id` | string | Employee ID | +| `current_stage` | object | Current interview stage \(null when hired\) | +| ↳ `id` | number | Stage ID | +| ↳ `name` | string | Stage name | +| `rejection_reason` | object | Rejection reason | +| ↳ `id` | number | Rejection reason ID | +| ↳ `name` | string | Rejection reason name | +| ↳ `type` | object | Rejection reason type | +| ↳ `id` | number | Type ID | +| ↳ `name` | string | Type name | +| `jobs` | array | Associated jobs | +| ↳ `id` | number | Job ID | +| ↳ `name` | string | Job name | +| `job_post_id` | number | Job post ID | +| `answers` | array | Application question answers | +| ↳ `question` | string | Question text | +| ↳ `answer` | string | Answer text | +| `attachments` | array | File attachments \(URLs expire after 7 days\) | +| ↳ `filename` | string | File name | +| ↳ `url` | string | Download URL \(expires after 7 days\) | +| ↳ `type` | string | Type \(resume, cover_letter, offer_packet, other\) | +| ↳ `created_at` | string | Upload timestamp | +| `custom_fields` | object | Custom field values | + +### `greenhouse_list_users` + +Lists Greenhouse users (recruiters, hiring managers, admins) with optional filtering + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | +| `created_after` | string | No | Return only users created at or after this ISO 8601 timestamp | +| `created_before` | string | No | Return only users created before this ISO 8601 timestamp | +| `updated_after` | string | No | Return only users updated at or after this ISO 8601 timestamp | +| `updated_before` | string | No | Return only users updated before this ISO 8601 timestamp | +| `email` | string | No | Filter by email address | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of Greenhouse users | +| ↳ `id` | number | User ID | +| ↳ `name` | string | Full name | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `primary_email_address` | string | Primary email | +| ↳ `disabled` | boolean | Whether the user is disabled | +| ↳ `site_admin` | boolean | Whether the user is a site admin | +| ↳ `emails` | array | All email addresses | +| ↳ `employee_id` | string | Employee ID | +| ↳ `linked_candidate_ids` | array | IDs of candidates linked to this user | +| ↳ `created_at` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| `count` | number | Number of users returned | + +### `greenhouse_get_user` + +Retrieves a specific Greenhouse user by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `userId` | string | Yes | The ID of the user to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | User ID | +| `name` | string | Full name | +| `first_name` | string | First name | +| `last_name` | string | Last name | +| `primary_email_address` | string | Primary email address | +| `disabled` | boolean | Whether the user is disabled | +| `site_admin` | boolean | Whether the user is a site admin | +| `emails` | array | All email addresses | +| `employee_id` | string | Employee ID | +| `linked_candidate_ids` | array | IDs of candidates linked to this user | +| `created_at` | string | Creation timestamp \(ISO 8601\) | +| `updated_at` | string | Last updated timestamp \(ISO 8601\) | + +### `greenhouse_list_departments` + +Lists all departments configured in Greenhouse + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `departments` | array | List of departments | +| ↳ `id` | number | Department ID | +| ↳ `name` | string | Department name | +| ↳ `parent_id` | number | Parent department ID | +| ↳ `child_ids` | array | Child department IDs | +| ↳ `external_id` | string | External system ID | +| `count` | number | Number of departments returned | + +### `greenhouse_list_offices` + +Lists all offices configured in Greenhouse + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `offices` | array | List of offices | +| ↳ `id` | number | Office ID | +| ↳ `name` | string | Office name | +| ↳ `location` | object | Office location | +| ↳ `name` | string | Location name | +| ↳ `primary_contact_user_id` | number | Primary contact user ID | +| ↳ `parent_id` | number | Parent office ID | +| ↳ `child_ids` | array | Child office IDs | +| ↳ `external_id` | string | External system ID | +| `count` | number | Number of offices returned | + +### `greenhouse_list_job_stages` + +Lists all interview stages for a specific job in Greenhouse + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Greenhouse Harvest API key | +| `jobId` | string | Yes | The job ID to list stages for | +| `per_page` | number | No | Number of results per page \(1-500, default 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | List of job stages in order | +| ↳ `id` | number | Stage ID | +| ↳ `name` | string | Stage name | +| ↳ `created_at` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updated_at` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `job_id` | number | Associated job ID | +| ↳ `priority` | number | Stage order priority | +| ↳ `active` | boolean | Whether the stage is active | +| ↳ `interviews` | array | Interview steps in this stage | +| ↳ `id` | number | Interview ID | +| ↳ `name` | string | Interview name | +| ↳ `schedulable` | boolean | Whether the interview is schedulable | +| ↳ `estimated_minutes` | number | Estimated duration in minutes | +| ↳ `default_interviewer_users` | array | Default interviewers | +| ↳ `id` | number | User ID | +| ↳ `name` | string | Full name | +| ↳ `first_name` | string | First name | +| ↳ `last_name` | string | Last name | +| ↳ `employee_id` | string | Employee ID | +| ↳ `interview_kit` | object | Interview kit details | +| ↳ `id` | number | Kit ID | +| ↳ `content` | string | Kit content \(HTML\) | +| ↳ `questions` | array | Interview kit questions | +| ↳ `id` | number | Question ID | +| ↳ `question` | string | Question text | +| `count` | number | Number of stages returned | + + diff --git a/apps/docs/content/docs/en/tools/hubspot.mdx b/apps/docs/content/docs/en/tools/hubspot.mdx index 695ccb44fa0..b4cfe86af81 100644 --- a/apps/docs/content/docs/en/tools/hubspot.mdx +++ b/apps/docs/content/docs/en/tools/hubspot.mdx @@ -11,20 +11,16 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[HubSpot](https://www.hubspot.com) is a comprehensive CRM platform that provides a full suite of marketing, sales, and customer service tools to help businesses grow better. With its powerful automation capabilities and extensive API, HubSpot has become one of the world's leading CRM platforms, serving businesses of all sizes across industries. +[HubSpot](https://www.hubspot.com) is a comprehensive CRM platform that provides a full suite of marketing, sales, and customer service tools to help businesses grow. With powerful automation capabilities and an extensive API, HubSpot serves businesses of all sizes across industries. -HubSpot CRM offers a complete solution for managing customer relationships, from initial contact through to long-term customer success. The platform combines contact management, deal tracking, marketing automation, and customer service tools into a unified system that helps teams stay aligned and focused on customer success. +With the HubSpot integration in Sim, you can: -Key features of HubSpot CRM include: +- **Manage contacts**: List, get, create, update, and search contacts in your CRM +- **Manage companies**: List, get, create, update, and search company records +- **Track deals**: List deals in your sales pipeline +- **Access users**: Retrieve user information from your HubSpot account -- Contact & Company Management: Comprehensive database for storing and organizing customer and prospect information -- Deal Pipeline: Visual sales pipeline for tracking opportunities through customizable stages -- Marketing Events: Track and manage marketing campaigns and events with detailed attribution -- Ticket Management: Customer support ticketing system for tracking and resolving customer issues -- Quotes & Line Items: Create and manage sales quotes with detailed product line items -- User & Team Management: Organize teams, assign ownership, and track user activity across the platform - -In Sim, the HubSpot integration enables your AI agents to seamlessly interact with your CRM data and automate key business processes. This creates powerful opportunities for intelligent lead qualification, automated contact enrichment, deal management, customer support automation, and data synchronization across your tech stack. The integration allows agents to create, retrieve, update, and search across all major HubSpot objects, enabling sophisticated workflows that can respond to CRM events, maintain data quality, and ensure your team has the most up-to-date customer information. By connecting Sim with HubSpot, you can build AI agents that automatically qualify leads, route support tickets, update deal stages based on customer interactions, generate quotes, and keep your CRM data synchronized with other business systems—ultimately increasing team productivity and improving customer experiences. +In Sim, the HubSpot integration enables your agents to interact with your CRM data as part of automated workflows. Agents can qualify leads, enrich contact records, track deals, and synchronize data across your tech stack—enabling intelligent sales and marketing automation. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/huggingface.mdx b/apps/docs/content/docs/en/tools/huggingface.mdx index 8a5fce3caaa..88b3b09e6c9 100644 --- a/apps/docs/content/docs/en/tools/huggingface.mdx +++ b/apps/docs/content/docs/en/tools/huggingface.mdx @@ -11,16 +11,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[HuggingFace](https://huggingface.co/) is a leading AI platform that provides access to thousands of pre-trained machine learning models and powerful inference capabilities. With its extensive model hub and robust API, HuggingFace offers comprehensive tools for both research and production AI applications. -With HuggingFace, you can: +[Hugging Face](https://huggingface.co/) is a leading AI platform that provides access to thousands of pre-trained machine learning models and powerful inference capabilities. With its extensive model hub and robust API, Hugging Face offers comprehensive tools for both research and production AI applications. -Access pre-trained models: Utilize models for text generation, translation, image processing, and more -Generate AI completions: Create content using state-of-the-art language models through the Inference API -Natural language processing: Process and analyze text with specialized NLP models -Deploy at scale: Host and serve models for production applications -Customize models: Fine-tune existing models for specific use cases +With the Hugging Face integration in Sim, you can: -In Sim, the HuggingFace integration enables your agents to programmatically generate completions using the HuggingFace Inference API. This allows for powerful automation scenarios such as content generation, text analysis, code completion, and creative writing. Your agents can generate completions with natural language prompts, access specialized models for different tasks, and integrate AI-generated content into workflows. This integration bridges the gap between your AI workflows and machine learning capabilities, enabling seamless AI-powered automation with one of the world's most comprehensive ML platforms. +- **Generate completions**: Create text content using state-of-the-art language models through the Hugging Face Inference API, with support for custom prompts and model selection + +In Sim, the Hugging Face integration enables your agents to generate AI completions as part of automated workflows. This allows for content generation, text analysis, code completion, and creative writing using models from the Hugging Face model hub. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/jira.mdx b/apps/docs/content/docs/en/tools/jira.mdx index df97c0957c3..5a89a6173b6 100644 --- a/apps/docs/content/docs/en/tools/jira.mdx +++ b/apps/docs/content/docs/en/tools/jira.mdx @@ -11,18 +11,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Jira](https://www.atlassian.com/jira) is a leading project management and issue tracking platform that helps teams plan, track, and manage agile software development projects effectively. As part of the Atlassian suite, Jira has become the industry standard for software development teams and project management professionals worldwide. - -Jira provides a comprehensive set of tools for managing complex projects through its flexible and customizable workflow system. With its robust API and integration capabilities, Jira enables teams to streamline their development processes and maintain clear visibility of project progress. - -Key features of Jira include: - -- Agile Project Management: Support for Scrum and Kanban methodologies with customizable boards and workflows -- Issue Tracking: Sophisticated tracking system for bugs, stories, epics, and tasks with detailed reporting -- Workflow Automation: Powerful automation rules to streamline repetitive tasks and processes -- Advanced Search: JQL (Jira Query Language) for complex issue filtering and reporting - -In Sim, the Jira integration allows your agents to seamlessly interact with your project management workflow. This creates opportunities for automated issue creation, updates, and tracking as part of your AI workflows. The integration enables agents to create, retrieve, and update Jira issues programmatically, facilitating automated project management tasks and ensuring that important information is properly tracked and documented. By connecting Sim with Jira, you can build intelligent agents that maintain project visibility while automating routine project management tasks, enhancing team productivity and ensuring consistent project tracking. +[Jira](https://www.atlassian.com/jira) is a leading project management and issue tracking platform from Atlassian that helps teams plan, track, and manage agile software development projects. Jira supports Scrum and Kanban methodologies with customizable boards, workflows, and advanced reporting. + +With the Jira integration in Sim, you can: + +- **Manage issues**: Create, retrieve, update, delete, and bulk-read issues in your Jira projects +- **Transition issues**: Move issues through workflow stages programmatically +- **Assign issues**: Set or change issue assignees +- **Search issues**: Use JQL (Jira Query Language) to find and filter issues +- **Manage comments**: Add, retrieve, update, and delete comments on issues +- **Handle attachments**: Upload, retrieve, and delete file attachments on issues +- **Track work**: Add, retrieve, update, and delete worklogs for time tracking +- **Link issues**: Create and delete issue links to establish relationships between issues +- **Manage watchers**: Add or remove watchers from issues +- **Access users**: Retrieve user information from your Jira instance + +In Sim, the Jira integration enables your agents to interact with your project management workflow as part of automated processes. Agents can create issues from external triggers, update statuses, track progress, and manage project data—enabling intelligent project management automation. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/knowledge.mdx b/apps/docs/content/docs/en/tools/knowledge.mdx index bc3b93e5c55..0721060dfed 100644 --- a/apps/docs/content/docs/en/tools/knowledge.mdx +++ b/apps/docs/content/docs/en/tools/knowledge.mdx @@ -11,19 +11,15 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -Sim's Knowledge Base is a powerful native feature that enables you to create, manage, and query custom knowledge bases directly within the platform. Using advanced AI embeddings and vector search technology, the Knowledge Base block allows you to build intelligent search capabilities into your workflows, making it easy to find and utilize relevant information across your organization. +Sim's Knowledge Base is a native feature that enables you to create, manage, and query custom knowledge bases directly within the platform. Using advanced AI embeddings and vector search, the Knowledge Base block allows you to build intelligent search capabilities into your workflows. -The Knowledge Base system provides a comprehensive solution for managing organizational knowledge through its flexible and scalable architecture. With its built-in vector search capabilities, teams can perform semantic searches that understand meaning and context, going beyond traditional keyword matching. +With the Knowledge Base in Sim, you can: -Key features of the Knowledge Base include: +- **Search knowledge**: Perform semantic searches across your custom knowledge bases using AI-powered vector similarity matching +- **Upload chunks**: Add text chunks with metadata to a knowledge base for indexing +- **Create documents**: Add new documents to a knowledge base for searchable content -- Semantic Search: Advanced AI-powered search that understands meaning and context, not just keywords -- Vector Embeddings: Automatic conversion of text into high-dimensional vectors for intelligent similarity matching -- Custom Knowledge Bases: Create and manage multiple knowledge bases for different purposes or departments -- Flexible Content Types: Support for various document formats and content types -- Real-time Updates: Immediate indexing of new content for instant searchability - -In Sim, the Knowledge Base block enables your agents to perform intelligent semantic searches across your custom knowledge bases. This creates opportunities for automated information retrieval, content recommendations, and knowledge discovery as part of your AI workflows. The integration allows agents to search and retrieve relevant information programmatically, facilitating automated knowledge management tasks and ensuring that important information is easily accessible. By leveraging the Knowledge Base block, you can build intelligent agents that enhance information discovery while automating routine knowledge management tasks, improving team efficiency and ensuring consistent access to organizational knowledge. +In Sim, the Knowledge Base block enables your agents to perform intelligent semantic searches across your organizational knowledge as part of automated workflows. This is ideal for information retrieval, content recommendations, FAQ automation, and grounding agent responses in your own data. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/linear.mdx b/apps/docs/content/docs/en/tools/linear.mdx index 7d472afce06..2e6c87677e7 100644 --- a/apps/docs/content/docs/en/tools/linear.mdx +++ b/apps/docs/content/docs/en/tools/linear.mdx @@ -11,18 +11,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Linear](https://linear.app) is a leading project management and issue tracking platform that helps teams plan, track, and manage their work effectively. As a modern project management tool, Linear has become increasingly popular among software development teams and project management professionals for its streamlined interface and powerful features. +[Linear](https://linear.app) is a modern project management and issue tracking platform that helps teams plan, track, and manage their work with a streamlined interface. Linear supports agile methodologies with customizable workflows, cycles, and project milestones. -Linear provides a comprehensive set of tools for managing complex projects through its flexible and customizable workflow system. With its robust API and integration capabilities, Linear enables teams to streamline their development processes and maintain clear visibility of project progress. +With the Linear integration in Sim, you can: -Key features of Linear include: +- **Manage issues**: Create, read, update, search, archive, unarchive, and delete issues +- **Manage labels**: Add or remove labels from issues, and create, update, or archive labels +- **Comment on issues**: Create, update, delete, and list comments on issues +- **Manage projects**: List, get, create, update, archive, and delete projects with milestones, labels, and statuses +- **Track cycles**: List, get, and create cycles, and retrieve the active cycle +- **Handle attachments**: Create, list, update, and delete attachments on issues +- **Manage issue relations**: Create, list, and delete relationships between issues +- **Access team data**: List users, teams, workflow states, notifications, and favorites +- **Manage customers**: Create, update, delete, list, and merge customers with statuses, tiers, and requests -- Agile Project Management: Support for Scrum and Kanban methodologies with customizable boards and workflows -- Issue Tracking: Sophisticated tracking system for bugs, stories, epics, and tasks with detailed reporting -- Workflow Automation: Powerful automation rules to streamline repetitive tasks and processes -- Advanced Search: Complex filtering and reporting capabilities for efficient issue management - -In Sim, the Linear integration allows your agents to seamlessly interact with your project management workflow. This creates opportunities for automated issue creation, updates, and tracking as part of your AI workflows. The integration enables agents to read existing issues and create new ones programmatically, facilitating automated project management tasks and ensuring that important information is properly tracked and documented. By connecting Sim with Linear, you can build intelligent agents that maintain project visibility while automating routine project management tasks, enhancing team productivity and ensuring consistent project tracking. +In Sim, the Linear integration enables your agents to interact with your project management workflow as part of automated processes. Agents can create issues from external triggers, update statuses, manage projects and cycles, and synchronize data—enabling intelligent project management automation at scale. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/loops.mdx b/apps/docs/content/docs/en/tools/loops.mdx new file mode 100644 index 00000000000..aa45836d20a --- /dev/null +++ b/apps/docs/content/docs/en/tools/loops.mdx @@ -0,0 +1,273 @@ +--- +title: Loops +description: Manage contacts and send emails with Loops +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Loops](https://loops.so/) is an email platform built for modern SaaS companies, offering transactional emails, marketing campaigns, and event-driven automations through a clean API. This integration connects Loops directly into Sim workflows. + +With Loops in Sim, you can: + +- **Manage contacts**: Create, update, find, and delete contacts in your Loops audience +- **Send transactional emails**: Trigger templated transactional emails with dynamic data variables +- **Fire events**: Send events to Loops to trigger automated email sequences and workflows +- **Manage subscriptions**: Control mailing list subscriptions and contact properties programmatically +- **Enrich contact data**: Attach custom properties, user groups, and mailing list memberships to contacts + +In Sim, the Loops integration enables your agents to manage email operations as part of their workflows. Supported operations include: + +- **Create Contact**: Add a new contact to your Loops audience with email, name, and custom properties. +- **Update Contact**: Update an existing contact or create one if no match exists (upsert behavior). +- **Find Contact**: Look up a contact by email address or userId. +- **Delete Contact**: Remove a contact from your audience. +- **Send Transactional Email**: Send a templated transactional email to a recipient with dynamic data variables. +- **Send Event**: Trigger a Loops event to start automated email sequences for a contact. + +Configure the Loops block with your API key from the Loops dashboard (Settings > API), select an operation, and provide the required parameters. Your agents can then manage contacts and send emails as part of any workflow. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Loops into the workflow. Create and manage contacts, send transactional emails, and trigger event-based automations. + + + +## Tools + +### `loops_create_contact` + +Create a new contact in your Loops audience with an email address and optional properties like name, user group, and mailing list subscriptions. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | Yes | The email address for the new contact | +| `firstName` | string | No | The contact first name | +| `lastName` | string | No | The contact last name | +| `source` | string | No | Custom source value replacing the default "API" | +| `subscribed` | boolean | No | Whether the contact receives campaign emails \(defaults to true\) | +| `userGroup` | string | No | Group to segment the contact into \(one group per contact\) | +| `userId` | string | No | Unique user identifier from your application | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | +| `customProperties` | json | No | Custom contact properties as key-value pairs \(string, number, boolean, or date values\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was created successfully | +| `id` | string | The Loops-assigned ID of the created contact | + +### `loops_update_contact` + +Update an existing contact in Loops by email or userId. Creates a new contact if no match is found (upsert). Can update name, subscription status, user group, mailing lists, and custom properties. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId \(at least one of email or userId is required\) | +| `firstName` | string | No | The contact first name | +| `lastName` | string | No | The contact last name | +| `source` | string | No | Custom source value replacing the default "API" | +| `subscribed` | boolean | No | Whether the contact receives campaign emails \(sending true re-subscribes unsubscribed contacts\) | +| `userGroup` | string | No | Group to segment the contact into \(one group per contact\) | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | +| `customProperties` | json | No | Custom contact properties as key-value pairs \(send null to reset a property\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was updated successfully | +| `id` | string | The Loops-assigned ID of the updated or created contact | + +### `loops_find_contact` + +Find a contact in Loops by email address or userId. Returns an array of matching contacts with all their properties including name, subscription status, user group, and mailing lists. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to search for \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to search for \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contacts` | array | Array of matching contact objects \(empty array if no match found\) | +| ↳ `id` | string | Loops-assigned contact ID | +| ↳ `email` | string | Contact email address | +| ↳ `firstName` | string | Contact first name | +| ↳ `lastName` | string | Contact last name | +| ↳ `source` | string | Source the contact was created from | +| ↳ `subscribed` | boolean | Whether the contact receives campaign emails | +| ↳ `userGroup` | string | Contact user group | +| ↳ `userId` | string | External user identifier | +| ↳ `mailingLists` | object | Mailing list IDs mapped to subscription status | +| ↳ `optInStatus` | string | Double opt-in status: pending, accepted, rejected, or null | + +### `loops_delete_contact` + +Delete a contact from Loops by email address or userId. At least one identifier must be provided. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The email address of the contact to delete \(at least one of email or userId is required\) | +| `userId` | string | No | The userId of the contact to delete \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was deleted successfully | +| `message` | string | Status message from the API | + +### `loops_send_transactional_email` + +Send a transactional email to a recipient using a Loops template. Supports dynamic data variables for personalization and optionally adds the recipient to your audience. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | Yes | The email address of the recipient | +| `transactionalId` | string | Yes | The ID of the transactional email template to send | +| `dataVariables` | json | No | Template data variables as key-value pairs \(string or number values\) | +| `addToAudience` | boolean | No | Whether to create the recipient as a contact if they do not already exist \(default: false\) | +| `attachments` | json | No | Array of file attachments. Each object must have filename \(string\), contentType \(MIME type string\), and data \(base64-encoded string\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the transactional email was sent successfully | + +### `loops_send_event` + +Send an event to Loops to trigger automated email sequences for a contact. Identify the contact by email or userId and include optional event properties and mailing list changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The email address of the contact \(at least one of email or userId is required\) | +| `userId` | string | No | The userId of the contact \(at least one of email or userId is required\) | +| `eventName` | string | Yes | The name of the event to trigger | +| `eventProperties` | json | No | Event data as key-value pairs \(string, number, boolean, or date values\) | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the event was sent successfully | + +### `loops_list_mailing_lists` + +Retrieve all mailing lists from your Loops account. Returns each list with its ID, name, description, and public/private status. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `mailingLists` | array | Array of mailing list objects | +| ↳ `id` | string | The mailing list ID | +| ↳ `name` | string | The mailing list name | +| ↳ `description` | string | The mailing list description \(null if not set\) | +| ↳ `isPublic` | boolean | Whether the list is public or private | + +### `loops_list_transactional_emails` + +Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `perPage` | string | No | Number of results per page \(10-50, default: 20\) | +| `cursor` | string | No | Pagination cursor from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionalEmails` | array | Array of published transactional email templates | +| ↳ `id` | string | The transactional email template ID | +| ↳ `name` | string | The template name | +| ↳ `lastUpdated` | string | Last updated timestamp | +| ↳ `dataVariables` | array | Template data variable names | +| `pagination` | object | Pagination information | +| ↳ `totalResults` | number | Total number of results | +| ↳ `returnedResults` | number | Number of results returned | +| ↳ `perPage` | number | Results per page | +| ↳ `totalPages` | number | Total number of pages | +| ↳ `nextCursor` | string | Cursor for next page \(null if no more pages\) | +| ↳ `nextPage` | string | URL for next page \(null if no more pages\) | + +### `loops_create_contact_property` + +Create a new custom contact property in your Loops account. The property name must be in camelCase format. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `name` | string | Yes | The property name in camelCase format \(e.g., "favoriteColor"\) | +| `type` | string | Yes | The property data type \(e.g., "string", "number", "boolean", "date"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact property was created successfully | + +### `loops_list_contact_properties` + +Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `list` | string | No | Filter type: "all" for all properties \(default\) or "custom" for custom properties only | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `properties` | array | Array of contact property objects | +| ↳ `key` | string | The property key \(camelCase identifier\) | +| ↳ `label` | string | The property display label | +| ↳ `type` | string | The property data type \(string, number, boolean, date\) | + + diff --git a/apps/docs/content/docs/en/tools/luma.mdx b/apps/docs/content/docs/en/tools/luma.mdx new file mode 100644 index 00000000000..fcde4adacad --- /dev/null +++ b/apps/docs/content/docs/en/tools/luma.mdx @@ -0,0 +1,284 @@ +--- +title: Luma +description: Manage events and guests on Luma +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Luma](https://lu.ma/) is an event management platform that makes it easy to create, manage, and share events with your community. + +With Luma integrated into Sim, your agents can: + +- **Create events**: Set up new events with name, time, timezone, description, and visibility settings. +- **Update events**: Modify existing event details like name, time, description, and visibility. +- **Get event details**: Retrieve full details for any event by its ID. +- **List calendar events**: Browse your calendar's events with date range filtering and pagination. +- **Manage guest lists**: View attendees for an event, filtered by approval status. +- **Add guests**: Invite new guests to events programmatically. + +By connecting Sim with Luma, you can automate event operations within your agent workflows. Automatically create events based on triggers, sync guest lists, monitor registrations, and manage your event calendar—all handled directly by your agents via the Luma API. + +Whether you're running community meetups, conferences, or internal team events, the Luma tool makes it easy to coordinate event management within your Sim workflows. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Luma into the workflow. Can create events, update events, get event details, list calendar events, get guest lists, and add guests to events. + + + +## Tools + +### `luma_get_event` + +Retrieve details of a Luma event including name, time, location, hosts, and visibility settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `eventId` | string | Yes | Event ID \(starts with evt-\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | object | Event details | +| ↳ `id` | string | Event ID | +| ↳ `name` | string | Event name | +| ↳ `startAt` | string | Event start time \(ISO 8601\) | +| ↳ `endAt` | string | Event end time \(ISO 8601\) | +| ↳ `timezone` | string | Event timezone \(IANA\) | +| ↳ `durationInterval` | string | Event duration \(ISO 8601 interval, e.g. PT2H\) | +| ↳ `createdAt` | string | Event creation timestamp \(ISO 8601\) | +| ↳ `description` | string | Event description \(plain text\) | +| ↳ `descriptionMd` | string | Event description \(Markdown\) | +| ↳ `coverUrl` | string | Event cover image URL | +| ↳ `url` | string | Event page URL on lu.ma | +| ↳ `visibility` | string | Event visibility \(public, members-only, private\) | +| ↳ `meetingUrl` | string | Virtual meeting URL | +| ↳ `geoAddressJson` | json | Structured location/address data | +| ↳ `geoLatitude` | string | Venue latitude coordinate | +| ↳ `geoLongitude` | string | Venue longitude coordinate | +| ↳ `calendarId` | string | Associated calendar ID | +| `hosts` | array | Event hosts | +| ↳ `id` | string | Host ID | +| ↳ `name` | string | Host display name | +| ↳ `firstName` | string | Host first name | +| ↳ `lastName` | string | Host last name | +| ↳ `email` | string | Host email address | +| ↳ `avatarUrl` | string | Host avatar image URL | + +### `luma_create_event` + +Create a new event on Luma with a name, start time, timezone, and optional details like description, location, and visibility. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `name` | string | Yes | Event name/title | +| `startAt` | string | Yes | Event start time in ISO 8601 format \(e.g., 2025-03-15T18:00:00Z\) | +| `timezone` | string | Yes | IANA timezone \(e.g., America/New_York, Europe/London\) | +| `endAt` | string | No | Event end time in ISO 8601 format \(e.g., 2025-03-15T20:00:00Z\) | +| `durationInterval` | string | No | Event duration as ISO 8601 interval \(e.g., PT2H for 2 hours, PT30M for 30 minutes\). Used if endAt is not provided. | +| `descriptionMd` | string | No | Event description in Markdown format | +| `meetingUrl` | string | No | Virtual meeting URL for online events \(e.g., Zoom, Google Meet link\) | +| `visibility` | string | No | Event visibility: public, members-only, or private \(defaults to public\) | +| `coverUrl` | string | No | Cover image URL \(must be a Luma CDN URL from images.lumacdn.com\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | object | Created event details | +| ↳ `id` | string | Event ID | +| ↳ `name` | string | Event name | +| ↳ `startAt` | string | Event start time \(ISO 8601\) | +| ↳ `endAt` | string | Event end time \(ISO 8601\) | +| ↳ `timezone` | string | Event timezone \(IANA\) | +| ↳ `durationInterval` | string | Event duration \(ISO 8601 interval, e.g. PT2H\) | +| ↳ `createdAt` | string | Event creation timestamp \(ISO 8601\) | +| ↳ `description` | string | Event description \(plain text\) | +| ↳ `descriptionMd` | string | Event description \(Markdown\) | +| ↳ `coverUrl` | string | Event cover image URL | +| ↳ `url` | string | Event page URL on lu.ma | +| ↳ `visibility` | string | Event visibility \(public, members-only, private\) | +| ↳ `meetingUrl` | string | Virtual meeting URL | +| ↳ `geoAddressJson` | json | Structured location/address data | +| ↳ `geoLatitude` | string | Venue latitude coordinate | +| ↳ `geoLongitude` | string | Venue longitude coordinate | +| ↳ `calendarId` | string | Associated calendar ID | +| `hosts` | array | Event hosts | +| ↳ `id` | string | Host ID | +| ↳ `name` | string | Host display name | +| ↳ `firstName` | string | Host first name | +| ↳ `lastName` | string | Host last name | +| ↳ `email` | string | Host email address | +| ↳ `avatarUrl` | string | Host avatar image URL | + +### `luma_update_event` + +Update an existing Luma event. Only the fields you provide will be changed; all other fields remain unchanged. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `eventId` | string | Yes | Event ID to update \(starts with evt-\) | +| `name` | string | No | New event name/title | +| `startAt` | string | No | New start time in ISO 8601 format \(e.g., 2025-03-15T18:00:00Z\) | +| `timezone` | string | No | New IANA timezone \(e.g., America/New_York, Europe/London\) | +| `endAt` | string | No | New end time in ISO 8601 format \(e.g., 2025-03-15T20:00:00Z\) | +| `durationInterval` | string | No | New duration as ISO 8601 interval \(e.g., PT2H for 2 hours\). Used if endAt is not provided. | +| `descriptionMd` | string | No | New event description in Markdown format | +| `meetingUrl` | string | No | New virtual meeting URL \(e.g., Zoom, Google Meet link\) | +| `visibility` | string | No | New visibility: public, members-only, or private | +| `coverUrl` | string | No | New cover image URL \(must be a Luma CDN URL from images.lumacdn.com\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | object | Updated event details | +| ↳ `id` | string | Event ID | +| ↳ `name` | string | Event name | +| ↳ `startAt` | string | Event start time \(ISO 8601\) | +| ↳ `endAt` | string | Event end time \(ISO 8601\) | +| ↳ `timezone` | string | Event timezone \(IANA\) | +| ↳ `durationInterval` | string | Event duration \(ISO 8601 interval, e.g. PT2H\) | +| ↳ `createdAt` | string | Event creation timestamp \(ISO 8601\) | +| ↳ `description` | string | Event description \(plain text\) | +| ↳ `descriptionMd` | string | Event description \(Markdown\) | +| ↳ `coverUrl` | string | Event cover image URL | +| ↳ `url` | string | Event page URL on lu.ma | +| ↳ `visibility` | string | Event visibility \(public, members-only, private\) | +| ↳ `meetingUrl` | string | Virtual meeting URL | +| ↳ `geoAddressJson` | json | Structured location/address data | +| ↳ `geoLatitude` | string | Venue latitude coordinate | +| ↳ `geoLongitude` | string | Venue longitude coordinate | +| ↳ `calendarId` | string | Associated calendar ID | +| `hosts` | array | Event hosts | +| ↳ `id` | string | Host ID | +| ↳ `name` | string | Host display name | +| ↳ `firstName` | string | Host first name | +| ↳ `lastName` | string | Host last name | +| ↳ `email` | string | Host email address | +| ↳ `avatarUrl` | string | Host avatar image URL | + +### `luma_list_events` + +List events from your Luma calendar with optional date range filtering, sorting, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `after` | string | No | Return events after this ISO 8601 datetime \(e.g., 2025-01-01T00:00:00Z\) | +| `before` | string | No | Return events before this ISO 8601 datetime \(e.g., 2025-12-31T23:59:59Z\) | +| `paginationLimit` | number | No | Maximum number of events to return per page | +| `paginationCursor` | string | No | Pagination cursor from a previous response \(next_cursor\) to fetch the next page of results | +| `sortColumn` | string | No | Column to sort by \(only start_at is supported\) | +| `sortDirection` | string | No | Sort direction: asc, desc, asc nulls last, or desc nulls last | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `events` | array | List of calendar events | +| ↳ `id` | string | Event ID | +| ↳ `name` | string | Event name | +| ↳ `startAt` | string | Event start time \(ISO 8601\) | +| ↳ `endAt` | string | Event end time \(ISO 8601\) | +| ↳ `timezone` | string | Event timezone \(IANA\) | +| ↳ `durationInterval` | string | Event duration \(ISO 8601 interval, e.g. PT2H\) | +| ↳ `createdAt` | string | Event creation timestamp \(ISO 8601\) | +| ↳ `description` | string | Event description \(plain text\) | +| ↳ `descriptionMd` | string | Event description \(Markdown\) | +| ↳ `coverUrl` | string | Event cover image URL | +| ↳ `url` | string | Event page URL on lu.ma | +| ↳ `visibility` | string | Event visibility \(public, members-only, private\) | +| ↳ `meetingUrl` | string | Virtual meeting URL | +| ↳ `geoAddressJson` | json | Structured location/address data | +| ↳ `geoLatitude` | string | Venue latitude coordinate | +| ↳ `geoLongitude` | string | Venue longitude coordinate | +| ↳ `calendarId` | string | Associated calendar ID | +| `hasMore` | boolean | Whether more results are available for pagination | +| `nextCursor` | string | Cursor to pass as paginationCursor to fetch the next page | + +### `luma_get_guests` + +Retrieve the guest list for a Luma event with optional filtering by approval status, sorting, and pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `eventId` | string | Yes | Event ID \(starts with evt-\) | +| `approvalStatus` | string | No | Filter by approval status: approved, session, pending_approval, invited, declined, or waitlist | +| `paginationLimit` | number | No | Maximum number of guests to return per page | +| `paginationCursor` | string | No | Pagination cursor from a previous response \(next_cursor\) to fetch the next page of results | +| `sortColumn` | string | No | Column to sort by: name, email, created_at, registered_at, or checked_in_at | +| `sortDirection` | string | No | Sort direction: asc, desc, asc nulls last, or desc nulls last | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `guests` | array | List of event guests | +| ↳ `id` | string | Guest ID | +| ↳ `email` | string | Guest email address | +| ↳ `name` | string | Guest full name | +| ↳ `firstName` | string | Guest first name | +| ↳ `lastName` | string | Guest last name | +| ↳ `approvalStatus` | string | Guest approval status \(approved, session, pending_approval, invited, declined, waitlist\) | +| ↳ `registeredAt` | string | Registration timestamp \(ISO 8601\) | +| ↳ `invitedAt` | string | Invitation timestamp \(ISO 8601\) | +| ↳ `joinedAt` | string | Join timestamp \(ISO 8601\) | +| ↳ `checkedInAt` | string | Check-in timestamp \(ISO 8601\) | +| ↳ `phoneNumber` | string | Guest phone number | +| `hasMore` | boolean | Whether more results are available for pagination | +| `nextCursor` | string | Cursor to pass as paginationCursor to fetch the next page | + +### `luma_add_guests` + +Add guests to a Luma event by email. Guests are added with Going (approved) status and receive one ticket of the default ticket type. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Luma API key | +| `eventId` | string | Yes | Event ID \(starts with evt-\) | +| `guests` | string | Yes | JSON array of guest objects. Each guest requires an "email" field and optionally "name", "first_name", "last_name". Example: \[\{"email": "user@example.com", "name": "John Doe"\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `guests` | array | List of added guests with their assigned status and ticket info | +| ↳ `id` | string | Guest ID | +| ↳ `email` | string | Guest email address | +| ↳ `name` | string | Guest full name | +| ↳ `firstName` | string | Guest first name | +| ↳ `lastName` | string | Guest last name | +| ↳ `approvalStatus` | string | Guest approval status \(approved, session, pending_approval, invited, declined, waitlist\) | +| ↳ `registeredAt` | string | Registration timestamp \(ISO 8601\) | +| ↳ `invitedAt` | string | Invitation timestamp \(ISO 8601\) | +| ↳ `joinedAt` | string | Join timestamp \(ISO 8601\) | +| ↳ `checkedInAt` | string | Check-in timestamp \(ISO 8601\) | +| ↳ `phoneNumber` | string | Guest phone number | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index a089247e2e7..3a1917fc19a 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -10,6 +10,7 @@ "apollo", "arxiv", "asana", + "ashby", "attio", "browser_use", "calcom", @@ -20,6 +21,7 @@ "cloudflare", "confluence", "cursor", + "databricks", "datadog", "devin", "discord", @@ -34,6 +36,7 @@ "file", "firecrawl", "fireflies", + "gamma", "github", "gitlab", "gmail", @@ -41,6 +44,7 @@ "google_bigquery", "google_books", "google_calendar", + "google_contacts", "google_docs", "google_drive", "google_forms", @@ -54,6 +58,7 @@ "google_vault", "grafana", "grain", + "greenhouse", "greptile", "hex", "hubspot", @@ -73,6 +78,8 @@ "linear", "linkedin", "linkup", + "loops", + "luma", "mailchimp", "mailgun", "mem0", diff --git a/apps/docs/content/docs/en/tools/pipedrive.mdx b/apps/docs/content/docs/en/tools/pipedrive.mdx index 40ad2d1b430..cf4f2dc8194 100644 --- a/apps/docs/content/docs/en/tools/pipedrive.mdx +++ b/apps/docs/content/docs/en/tools/pipedrive.mdx @@ -11,19 +11,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Pipedrive](https://www.pipedrive.com) is a powerful sales-focused CRM platform designed to help sales teams manage leads, track deals, and optimize their sales pipeline. Built with simplicity and effectiveness in mind, Pipedrive has become a favorite among sales professionals and growing businesses worldwide for its intuitive visual pipeline management and actionable sales insights. +[Pipedrive](https://www.pipedrive.com) is a sales-focused CRM platform designed to help sales teams manage leads, track deals, and optimize their sales pipeline. Built with simplicity and effectiveness in mind, Pipedrive provides intuitive visual pipeline management and actionable sales insights. -Pipedrive provides a comprehensive suite of tools for managing the entire sales process from lead capture to deal closure. With its robust API and extensive integration capabilities, Pipedrive enables sales teams to automate repetitive tasks, maintain data consistency, and focus on what matters most—closing deals. +With the Pipedrive integration in Sim, you can: -Key features of Pipedrive include: +- **Manage deals**: List, get, create, and update deals in your sales pipeline +- **Manage leads**: Get, create, update, and delete leads for prospect tracking +- **Track activities**: Get, create, and update sales activities such as calls, meetings, and tasks +- **Manage projects**: List and create projects for post-sale delivery tracking +- **Access pipelines**: Get pipeline configurations and list deals within specific pipelines +- **Retrieve files**: Access files attached to deals, contacts, or other records +- **Access email**: Get mail messages and threads linked to CRM records -- Visual Sales Pipeline: Intuitive drag-and-drop interface for managing deals through customizable sales stages -- Lead Management: Comprehensive lead inbox for capturing, qualifying, and converting potential opportunities -- Activity Tracking: Sophisticated system for scheduling and tracking calls, meetings, emails, and tasks -- Project Management: Built-in project tracking capabilities for post-sale customer success and delivery -- Email Integration: Native mailbox integration for seamless communication tracking within the CRM - -In Sim, the Pipedrive integration allows your AI agents to seamlessly interact with your sales workflow. This creates opportunities for automated lead qualification, deal creation and updates, activity scheduling, and pipeline management as part of your AI-powered sales processes. The integration enables agents to create, retrieve, update, and manage deals, leads, activities, and projects programmatically, facilitating intelligent sales automation and ensuring that critical customer information is properly tracked and acted upon. By connecting Sim with Pipedrive, you can build AI agents that maintain sales pipeline visibility, automate routine CRM tasks, qualify leads intelligently, and ensure no opportunities slip through the cracks—enhancing sales team productivity and driving consistent revenue growth. +In Sim, the Pipedrive integration enables your agents to interact with your sales workflow as part of automated processes. Agents can qualify leads, manage deals through pipeline stages, schedule activities, and keep CRM data synchronized—enabling intelligent sales automation. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/resend.mdx b/apps/docs/content/docs/en/tools/resend.mdx index 8ddc4e933df..be7f6bfdf1d 100644 --- a/apps/docs/content/docs/en/tools/resend.mdx +++ b/apps/docs/content/docs/en/tools/resend.mdx @@ -1,6 +1,6 @@ --- title: Resend -description: Send emails with Resend. +description: Send emails and manage contacts with Resend. --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -27,7 +27,7 @@ In Sim, the Resend integration allows your agents to programmatically send email ## Usage Instructions -Integrate Resend into the workflow. Can send emails. Requires API Key. +Integrate Resend into your workflow. Send emails, retrieve email status, manage contacts, and view domains. Requires API Key. @@ -46,6 +46,11 @@ Send an email using your own Resend API key and from address | `subject` | string | Yes | Email subject line | | `body` | string | Yes | Email body content \(plain text or HTML based on contentType\) | | `contentType` | string | No | Content type for the email body: "text" for plain text or "html" for HTML content | +| `cc` | string | No | Carbon copy recipient email address | +| `bcc` | string | No | Blind carbon copy recipient email address | +| `replyTo` | string | No | Reply-to email address | +| `scheduledAt` | string | No | Schedule email to be sent later in ISO 8601 format | +| `tags` | string | No | Comma-separated key:value pairs for email tags \(e.g., "category:welcome,type:onboarding"\) | | `resendApiKey` | string | Yes | Resend API key for sending emails | #### Output @@ -53,8 +58,152 @@ Send an email using your own Resend API key and from address | Parameter | Type | Description | | --------- | ---- | ----------- | | `success` | boolean | Whether the email was sent successfully | +| `id` | string | Email ID returned by Resend | | `to` | string | Recipient email address | | `subject` | string | Email subject | | `body` | string | Email body content | +### `resend_get_email` + +Retrieve details of a previously sent email by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `emailId` | string | Yes | The ID of the email to retrieve | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Email ID | +| `from` | string | Sender email address | +| `to` | json | Recipient email addresses | +| `subject` | string | Email subject | +| `html` | string | HTML email content | +| `text` | string | Plain text email content | +| `cc` | json | CC email addresses | +| `bcc` | json | BCC email addresses | +| `replyTo` | json | Reply-to email addresses | +| `lastEvent` | string | Last event status \(e.g., delivered, bounced\) | +| `createdAt` | string | Email creation timestamp | +| `scheduledAt` | string | Scheduled send timestamp | +| `tags` | json | Email tags as name-value pairs | + +### `resend_create_contact` + +Create a new contact in Resend + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `email` | string | Yes | Email address of the contact | +| `firstName` | string | No | First name of the contact | +| `lastName` | string | No | Last name of the contact | +| `unsubscribed` | boolean | No | Whether the contact is unsubscribed from all broadcasts | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created contact ID | + +### `resend_list_contacts` + +List all contacts in Resend + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contacts` | json | Array of contacts with id, email, first_name, last_name, created_at, unsubscribed | +| `hasMore` | boolean | Whether there are more contacts to retrieve | + +### `resend_get_contact` + +Retrieve details of a contact by ID or email + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `contactId` | string | Yes | The contact ID or email address to retrieve | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Contact ID | +| `email` | string | Contact email address | +| `firstName` | string | Contact first name | +| `lastName` | string | Contact last name | +| `createdAt` | string | Contact creation timestamp | +| `unsubscribed` | boolean | Whether the contact is unsubscribed | + +### `resend_update_contact` + +Update an existing contact in Resend + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `contactId` | string | Yes | The contact ID or email address to update | +| `firstName` | string | No | Updated first name | +| `lastName` | string | No | Updated last name | +| `unsubscribed` | boolean | No | Whether the contact should be unsubscribed from all broadcasts | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Updated contact ID | + +### `resend_delete_contact` + +Delete a contact from Resend by ID or email + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `contactId` | string | Yes | The contact ID or email address to delete | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted contact ID | +| `deleted` | boolean | Whether the contact was successfully deleted | + +### `resend_list_domains` + +List all verified domains in your Resend account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `resendApiKey` | string | Yes | Resend API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domains` | json | Array of domains with id, name, status, region, and createdAt | +| `hasMore` | boolean | Whether there are more domains to retrieve | + diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index c51fea7ff2c..22a06d413dd 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -13,39 +13,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} [Slack](https://www.slack.com/) is a business communication platform that offers teams a unified place for messaging, tools, and files. -With Slack, you can: +With the Slack integration in Sim, you can: -- **Automate agent notifications**: Send real-time updates from your Sim agents to any Slack channel -- **Create webhook endpoints**: Configure Slack bots as webhooks to trigger Sim workflows from Slack activities -- **Enhance agent workflows**: Integrate Slack messaging into your agents to deliver results, alerts, and status updates -- **Create and share Slack canvases**: Programmatically generate collaborative documents (canvases) in Slack channels -- **Read messages from channels**: Retrieve and process recent messages from any Slack channel for monitoring or workflow triggers -- **Manage bot messages**: Update, delete, and add reactions to messages sent by your bot - -In Sim, the Slack integration enables your agents to programmatically interact with Slack with full message management capabilities as part of their workflows: - -- **Send messages**: Agents can send formatted messages to any Slack channel or user, supporting Slack's mrkdwn syntax for rich formatting +- **Send messages**: Send formatted messages to any Slack channel or user, supporting Slack's mrkdwn syntax for rich formatting +- **Send ephemeral messages**: Send temporary messages visible only to a specific user in a channel - **Update messages**: Edit previously sent bot messages to correct information or provide status updates - **Delete messages**: Remove bot messages when they're no longer needed or contain errors - **Add reactions**: Express sentiment or acknowledgment by adding emoji reactions to any message -- **Create canvases**: Create and share Slack canvases (collaborative documents) directly in channels, enabling richer content sharing and documentation -- **Read messages**: Read recent messages from channels, allowing for monitoring, reporting, or triggering further actions based on channel activity +- **Create canvases**: Create and share Slack canvases (collaborative documents) directly in channels +- **Read messages**: Retrieve recent messages from channels or DMs, with filtering by time range +- **Manage channels and users**: List channels, members, and users in your Slack workspace - **Download files**: Retrieve files shared in Slack channels for processing or archival -This allows for powerful automation scenarios such as sending notifications with dynamic updates, managing conversational flows with editable status messages, acknowledging important messages with reactions, and maintaining clean channels by removing outdated bot messages. Your agents can deliver timely information, update messages as workflows progress, create collaborative documents, or alert team members when attention is needed. This integration bridges the gap between your AI workflows and your team's communication, ensuring everyone stays informed with accurate, up-to-date information. By connecting Sim with Slack, you can create agents that keep your team updated with relevant information at the right time, enhance collaboration by sharing and updating insights automatically, and reduce the need for manual status updates—all while leveraging your existing Slack workspace where your team already communicates. - -## Getting Started - -To connect Slack to your Sim workflows: - -1. Sign up or log in at [sim.ai](https://sim.ai) -2. Create a new workflow or open an existing one -3. Drag a **Slack** block onto your canvas -4. Click the credential selector and choose **Connect** -5. Authorize Sim to access your Slack workspace -6. Select your target channel or user - -Once connected, you can use any of the Slack operations listed below. +In Sim, the Slack integration enables your agents to programmatically interact with Slack as part of their workflows. This allows for automation scenarios such as sending notifications with dynamic updates, managing conversational flows with editable status messages, acknowledging important messages with reactions, and maintaining clean channels by removing outdated bot messages. The integration can also be used in trigger mode to start a workflow when a message is sent to a channel. ## AI-Generated Content diff --git a/apps/docs/content/docs/en/tools/telegram.mdx b/apps/docs/content/docs/en/tools/telegram.mdx index 26f7ae7e784..685cf2fb3de 100644 --- a/apps/docs/content/docs/en/tools/telegram.mdx +++ b/apps/docs/content/docs/en/tools/telegram.mdx @@ -11,11 +11,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[Telegram](https://telegram.org) is a secure, cloud-based messaging platform that enables fast and reliable communication across devices and platforms. With over 700 million monthly active users, Telegram has established itself as one of the world's leading messaging services, known for its security, speed, and powerful API capabilities. +[Telegram](https://telegram.org) is a secure, cloud-based messaging platform that enables fast and reliable communication across devices. With its powerful Bot API, Telegram provides a robust framework for automated messaging and integration. -Telegram's Bot API provides a robust framework for creating automated messaging solutions and integrating communication features into applications. With support for rich media, inline keyboards, and custom commands, Telegram bots can facilitate sophisticated interaction patterns and automated workflows. +With the Telegram integration in Sim, you can: -Learn how to create a webhook trigger in Sim that seamlessly initiates workflows from Telegram messages. This tutorial walks you through setting up a webhook, configuring it with Telegram's bot API, and triggering automated actions in real-time. Perfect for streamlining tasks directly from your chat! +- **Send messages**: Send text messages to Telegram chats, groups, or channels +- **Delete messages**: Remove previously sent messages from a chat +- **Send photos**: Share images with optional captions +- **Send videos**: Share video files with optional captions +- **Send audio**: Share audio files with optional captions +- **Send animations**: Share GIF animations with optional captions +- **Send documents**: Share files of any type with optional captions + +In Sim, the Telegram integration enables your agents to send messages and rich media to Telegram chats as part of automated workflows. This is ideal for automated notifications, alerts, content distribution, and interactive bot experiences. + +Learn how to create a webhook trigger in Sim that seamlessly initiates workflows from Telegram messages. This tutorial walks you through setting up a webhook, configuring it with Telegram's bot API, and triggering automated actions in real-time. -Learn how to use the Telegram Tool in Sim to seamlessly automate message delivery to any Telegram group. This tutorial walks you through integrating the tool into your workflow, configuring group messaging, and triggering automated updates in real-time. Perfect for enhancing communication directly from your workspace! +Learn how to use the Telegram Tool in Sim to seamlessly automate message delivery to any Telegram group. This tutorial walks you through integrating the tool into your workflow, configuring group messaging, and triggering automated updates in real-time. - -Key features of Telegram include: - -- Secure Communication: End-to-end encryption and secure cloud storage for messages and media -- Bot Platform: Powerful bot API for creating automated messaging solutions and interactive experiences -- Rich Media Support: Send and receive messages with text formatting, images, files, and interactive elements -- Global Reach: Connect with users worldwide with support for multiple languages and platforms - -In Sim, the Telegram integration enables your agents to leverage these powerful messaging capabilities as part of their workflows. This creates opportunities for automated notifications, alerts, and interactive conversations through Telegram's secure messaging platform. The integration allows agents to send messages programmatically to individuals or channels, enabling timely communication and updates. By connecting Sim with Telegram, you can build intelligent agents that engage users through a secure and widely-adopted messaging platform, perfect for delivering notifications, updates, and interactive communications. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/wordpress.mdx b/apps/docs/content/docs/en/tools/wordpress.mdx index a6e853b53a2..38f6688f005 100644 --- a/apps/docs/content/docs/en/tools/wordpress.mdx +++ b/apps/docs/content/docs/en/tools/wordpress.mdx @@ -11,11 +11,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[WordPress](https://wordpress.org/) is the world’s leading open-source content management system, making it easy to publish and manage websites, blogs, and all types of online content. With WordPress, you can create and update posts or pages, organize your content with categories and tags, manage media files, moderate comments, and handle user accounts—allowing you to run everything from personal blogs to complex business sites. +[WordPress](https://wordpress.org/) is the world’s leading open-source content management system, powering websites, blogs, and online stores of all sizes. WordPress provides a flexible platform for publishing and managing content with extensive plugin and theme support. -Sim’s integration with WordPress lets your agents automate essential website tasks. You can programmatically create new blog posts with specific titles, content, categories, tags, and featured images. Updating existing posts—such as changing their content, title, or publishing status—is straightforward. You can also publish or save content as drafts, manage static pages, work with media uploads, oversee comments, and assign content to relevant organizational taxonomies. +With the WordPress integration in Sim, you can: -By connecting WordPress to your automations, Sim empowers your agents to streamline content publishing, editorial workflows, and everyday site management—helping you keep your website fresh, organized, and secure without manual effort. +- **Manage posts**: Create, update, delete, get, and list blog posts with full control over content, status, categories, and tags +- **Manage pages**: Create, update, delete, get, and list static pages +- **Handle media**: Upload, get, list, and delete media files such as images, videos, and documents +- **Moderate comments**: Create, list, update, and delete comments on posts and pages +- **Organize content**: Create and list categories and tags for content taxonomy +- **Manage users**: Get the current user, list users, and retrieve user details +- **Search content**: Search across all content types on the WordPress site + +In Sim, the WordPress integration enables your agents to automate content publishing and site management as part of automated workflows. Agents can create and publish posts, manage media assets, moderate comments, and organize content—keeping your website fresh and organized without manual effort. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/x.mdx b/apps/docs/content/docs/en/tools/x.mdx index 234dc3649c2..7ab8a64d080 100644 --- a/apps/docs/content/docs/en/tools/x.mdx +++ b/apps/docs/content/docs/en/tools/x.mdx @@ -29,113 +29,814 @@ In Sim, the X integration enables sophisticated social media automation scenario ## Usage Instructions -Integrate X into the workflow. Can post a new tweet, get tweet details, search tweets, and get user profile. +Integrate X into the workflow. Search tweets, manage bookmarks, follow/block/mute users, like and retweet, view trends, and more. ## Tools -### `x_write` +### `x_create_tweet` -Post new tweets, reply to tweets, or create polls on X (Twitter) +Create a new tweet, reply, or quote tweet on X #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `text` | string | Yes | The text content of your tweet \(max 280 characters\) | -| `replyTo` | string | No | ID of the tweet to reply to \(e.g., 1234567890123456789\) | -| `mediaIds` | array | No | Array of media IDs to attach to the tweet | -| `poll` | object | No | Poll configuration for the tweet | +| `text` | string | Yes | The text content of the tweet \(max 280 characters\) | +| `replyToTweetId` | string | No | Tweet ID to reply to | +| `quoteTweetId` | string | No | Tweet ID to quote | +| `mediaIds` | string | No | Comma-separated media IDs to attach \(up to 4\) | +| `replySettings` | string | No | Who can reply: "mentionedUsers", "following", "subscribers", or "verified" | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `tweet` | object | The newly created tweet data | +| `id` | string | The ID of the created tweet | +| `text` | string | The text of the created tweet | + +### `x_delete_tweet` + +Delete a tweet authored by the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tweetId` | string | Yes | The ID of the tweet to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the tweet was successfully deleted | + +### `x_search_tweets` + +Search for recent tweets using keywords, hashtags, or advanced query operators + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search query \(supports operators like "from:", "to:", "#hashtag", "has:images", "is:retweet", "lang:"\) | +| `maxResults` | number | No | Maximum number of results \(10-100, default 10\) | +| `startTime` | string | No | Oldest UTC timestamp in ISO 8601 format \(e.g., 2024-01-01T00:00:00Z\) | +| `endTime` | string | No | Newest UTC timestamp in ISO 8601 format | +| `sinceId` | string | No | Returns tweets with ID greater than this | +| `untilId` | string | No | Returns tweets with ID less than this | +| `sortOrder` | string | No | Sort order: "recency" or "relevancy" | +| `nextToken` | string | No | Pagination token for next page of results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of tweets matching the search query | | ↳ `id` | string | Tweet ID | -| ↳ `text` | string | Tweet content text | +| ↳ `text` | string | Tweet text content | | ↳ `createdAt` | string | Tweet creation timestamp | -| ↳ `authorId` | string | ID of the tweet author | +| ↳ `authorId` | string | Author user ID | | ↳ `conversationId` | string | Conversation thread ID | -| ↳ `attachments` | object | Media or poll attachments | -| ↳ `mediaKeys` | array | Media attachment keys | -| ↳ `pollId` | string | Poll ID if poll attached | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Search metadata including result count and pagination tokens | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `newestId` | string | ID of the newest tweet | +| ↳ `oldestId` | string | ID of the oldest tweet | +| ↳ `nextToken` | string | Pagination token for next page | + +### `x_get_tweets_by_ids` + +Look up multiple tweets by their IDs (up to 100) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `ids` | string | Yes | Comma-separated tweet IDs \(up to 100\) | -### `x_read` +#### Output -Read tweet details, including replies and conversation context +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of tweets matching the provided IDs | +| ↳ `id` | string | Tweet ID | +| ↳ `text` | string | Tweet text content | +| ↳ `createdAt` | string | Tweet creation timestamp | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | + +### `x_get_quote_tweets` + +Get tweets that quote a specific tweet #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `tweetId` | string | Yes | ID of the tweet to read \(e.g., 1234567890123456789\) | -| `includeReplies` | boolean | No | Whether to include replies to the tweet | +| `tweetId` | string | Yes | The tweet ID to get quote tweets for | +| `maxResults` | number | No | Maximum number of results \(10-100, default 10\) | +| `paginationToken` | string | No | Pagination token for next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `tweet` | object | The main tweet data | +| `tweets` | array | Array of quote tweets | | ↳ `id` | string | Tweet ID | -| ↳ `text` | string | Tweet content text | +| ↳ `text` | string | Tweet text content | | ↳ `createdAt` | string | Tweet creation timestamp | -| ↳ `authorId` | string | ID of the tweet author | -| `context` | object | Conversation context including parent and root tweets | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | -### `x_search` +### `x_hide_reply` -Search for tweets using keywords, hashtags, or advanced queries +Hide or unhide a reply to a tweet authored by the authenticated user #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `query` | string | Yes | Search query \(e.g., "AI news", "#technology", "from:username"\). Supports X search operators | -| `maxResults` | number | No | Maximum number of results to return \(e.g., 10, 25, 50\). Default: 10, max: 100 | -| `startTime` | string | No | Start time for search \(ISO 8601 format\) | -| `endTime` | string | No | End time for search \(ISO 8601 format\) | -| `sortOrder` | string | No | Sort order for results \(recency or relevancy\) | +| `tweetId` | string | Yes | The reply tweet ID to hide or unhide | +| `hidden` | boolean | Yes | Set to true to hide the reply, false to unhide | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `tweets` | array | Array of tweets matching the search query | +| `hidden` | boolean | Whether the reply is now hidden | + +### `x_get_user_tweets` + +Get tweets authored by a specific user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The user ID whose tweets to retrieve | +| `maxResults` | number | No | Maximum number of results \(5-100, default 10\) | +| `paginationToken` | string | No | Pagination token for next page of results | +| `startTime` | string | No | Oldest UTC timestamp in ISO 8601 format | +| `endTime` | string | No | Newest UTC timestamp in ISO 8601 format | +| `sinceId` | string | No | Returns tweets with ID greater than this | +| `untilId` | string | No | Returns tweets with ID less than this | +| `exclude` | string | No | Comma-separated types to exclude: "retweets", "replies" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of tweets by the user | +| ↳ `id` | string | Tweet ID | +| ↳ `text` | string | Tweet text content | +| ↳ `createdAt` | string | Tweet creation timestamp | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `newestId` | string | ID of the newest tweet | +| ↳ `oldestId` | string | ID of the oldest tweet | +| ↳ `nextToken` | string | Token for next page | +| ↳ `previousToken` | string | Token for previous page | + +### `x_get_user_mentions` + +Get tweets that mention a specific user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The user ID whose mentions to retrieve | +| `maxResults` | number | No | Maximum number of results \(5-100, default 10\) | +| `paginationToken` | string | No | Pagination token for next page of results | +| `startTime` | string | No | Oldest UTC timestamp in ISO 8601 format | +| `endTime` | string | No | Newest UTC timestamp in ISO 8601 format | +| `sinceId` | string | No | Returns tweets with ID greater than this | +| `untilId` | string | No | Returns tweets with ID less than this | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of tweets mentioning the user | +| ↳ `id` | string | Tweet ID | +| ↳ `text` | string | Tweet text content | +| ↳ `createdAt` | string | Tweet creation timestamp | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `newestId` | string | ID of the newest tweet | +| ↳ `oldestId` | string | ID of the oldest tweet | +| ↳ `nextToken` | string | Token for next page | +| ↳ `previousToken` | string | Token for previous page | + +### `x_get_user_timeline` + +Get the reverse chronological home timeline for the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `maxResults` | number | No | Maximum number of results \(1-100, default 10\) | +| `paginationToken` | string | No | Pagination token for next page of results | +| `startTime` | string | No | Oldest UTC timestamp in ISO 8601 format | +| `endTime` | string | No | Newest UTC timestamp in ISO 8601 format | +| `sinceId` | string | No | Returns tweets with ID greater than this | +| `untilId` | string | No | Returns tweets with ID less than this | +| `exclude` | string | No | Comma-separated types to exclude: "retweets", "replies" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of timeline tweets | +| ↳ `id` | string | Tweet ID | +| ↳ `text` | string | Tweet text content | +| ↳ `createdAt` | string | Tweet creation timestamp | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `newestId` | string | ID of the newest tweet | +| ↳ `oldestId` | string | ID of the oldest tweet | +| ↳ `nextToken` | string | Token for next page | +| ↳ `previousToken` | string | Token for previous page | + +### `x_manage_like` + +Like or unlike a tweet on X + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `tweetId` | string | Yes | The tweet ID to like or unlike | +| `action` | string | Yes | Action to perform: "like" or "unlike" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `liked` | boolean | Whether the tweet is now liked | + +### `x_manage_retweet` + +Retweet or unretweet a tweet on X + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `tweetId` | string | Yes | The tweet ID to retweet or unretweet | +| `action` | string | Yes | Action to perform: "retweet" or "unretweet" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `retweeted` | boolean | Whether the tweet is now retweeted | + +### `x_get_liked_tweets` + +Get tweets liked by a specific user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The user ID whose liked tweets to retrieve | +| `maxResults` | number | No | Maximum number of results \(5-100\) | +| `paginationToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of liked tweets | | ↳ `id` | string | Tweet ID | | ↳ `text` | string | Tweet content | | ↳ `createdAt` | string | Creation timestamp | | ↳ `authorId` | string | Author user ID | -| `includes` | object | Additional data including user profiles and media | -| `meta` | object | Search metadata including result count and pagination tokens | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_get_liking_users` + +Get the list of users who liked a specific tweet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tweetId` | string | Yes | The tweet ID to get liking users for | +| `maxResults` | number | No | Maximum number of results \(1-100, default 100\) | +| `paginationToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Array of users who liked the tweet | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_get_retweeted_by` + +Get the list of users who retweeted a specific tweet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `tweetId` | string | Yes | The tweet ID to get retweeters for | +| `maxResults` | number | No | Maximum number of results \(1-100, default 100\) | +| `paginationToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Array of users who retweeted the tweet | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_get_bookmarks` + +Get bookmarked tweets for the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `maxResults` | number | No | Maximum number of results \(1-100\) | +| `paginationToken` | string | No | Pagination token for next page of results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tweets` | array | Array of bookmarked tweets | +| ↳ `id` | string | Tweet ID | +| ↳ `text` | string | Tweet text content | +| ↳ `createdAt` | string | Tweet creation timestamp | +| ↳ `authorId` | string | Author user ID | +| ↳ `conversationId` | string | Conversation thread ID | +| ↳ `inReplyToUserId` | string | User ID being replied to | +| ↳ `publicMetrics` | object | Engagement metrics | +| ↳ `retweetCount` | number | Number of retweets | +| ↳ `replyCount` | number | Number of replies | +| ↳ `likeCount` | number | Number of likes | +| ↳ `quoteCount` | number | Number of quotes | +| `includes` | object | Additional data including user profiles | +| ↳ `users` | array | Array of user objects referenced in tweets | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | | ↳ `resultCount` | number | Number of results returned | | ↳ `newestId` | string | ID of the newest tweet | | ↳ `oldestId` | string | ID of the oldest tweet | +| ↳ `nextToken` | string | Token for next page | +| ↳ `previousToken` | string | Token for previous page | + +### `x_create_bookmark` + +Bookmark a tweet for the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `tweetId` | string | Yes | The tweet ID to bookmark | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmarked` | boolean | Whether the tweet was successfully bookmarked | + +### `x_delete_bookmark` + +Remove a tweet from the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `tweetId` | string | Yes | The tweet ID to remove from bookmarks | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmarked` | boolean | Whether the tweet is still bookmarked \(should be false after deletion\) | + +### `x_get_me` + +Get the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | Authenticated user profile | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | + +### `x_search_users` + +Search for X users by name, username, or bio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search keyword \(1-50 chars, matches name, username, or bio\) | +| `maxResults` | number | No | Maximum number of results \(1-1000, default 100\) | +| `nextToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Array of users matching the search query | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Search metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Pagination token for next page | + +### `x_get_followers` + +Get the list of followers for a user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The user ID whose followers to retrieve | +| `maxResults` | number | No | Maximum number of results \(1-1000, default 100\) | +| `paginationToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Array of follower user profiles | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_get_following` + +Get the list of users that a user is following + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The user ID whose following list to retrieve | +| `maxResults` | number | No | Maximum number of results \(1-1000, default 100\) | +| `paginationToken` | string | No | Pagination token for next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Array of users being followed | +| ↳ `id` | string | User ID | +| ↳ `username` | string | Username without @ symbol | +| ↳ `name` | string | Display name | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | +| ↳ `verified` | boolean | Whether the user is verified | +| ↳ `metrics` | object | User statistics | +| ↳ `followersCount` | number | Number of followers | +| ↳ `followingCount` | number | Number of users following | +| ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_manage_follow` + +Follow or unfollow a user on X -### `x_user` +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `targetUserId` | string | Yes | The user ID to follow or unfollow | +| `action` | string | Yes | Action to perform: "follow" or "unfollow" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `following` | boolean | Whether you are now following the user | +| `pendingFollow` | boolean | Whether the follow request is pending \(for protected accounts\) | + +### `x_manage_block` + +Block or unblock a user on X + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `targetUserId` | string | Yes | The user ID to block or unblock | +| `action` | string | Yes | Action to perform: "block" or "unblock" | + +#### Output -Get user profile information +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `blocking` | boolean | Whether you are now blocking the user | + +### `x_get_blocking` + +Get the list of users blocked by the authenticated user #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `username` | string | Yes | Username to look up without @ symbol \(e.g., elonmusk, openai\) | +| `userId` | string | Yes | The authenticated user ID | +| `maxResults` | number | No | Maximum number of results \(1-1000\) | +| `paginationToken` | string | No | Pagination token for next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `user` | object | X user profile information | +| `users` | array | Array of blocked user profiles | | ↳ `id` | string | User ID | | ↳ `username` | string | Username without @ symbol | | ↳ `name` | string | Display name | -| ↳ `description` | string | User bio/description | +| ↳ `description` | string | User bio | +| ↳ `profileImageUrl` | string | Profile image URL | | ↳ `verified` | boolean | Whether the user is verified | | ↳ `metrics` | object | User statistics | | ↳ `followersCount` | number | Number of followers | | ↳ `followingCount` | number | Number of users following | | ↳ `tweetCount` | number | Total number of tweets | +| `meta` | object | Pagination metadata | +| ↳ `resultCount` | number | Number of results returned | +| ↳ `nextToken` | string | Token for next page | + +### `x_manage_mute` + +Mute or unmute a user on X + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The authenticated user ID | +| `targetUserId` | string | Yes | The user ID to mute or unmute | +| `action` | string | Yes | Action to perform: "mute" or "unmute" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `muting` | boolean | Whether you are now muting the user | + +### `x_get_trends_by_woeid` + +Get trending topics for a specific location by WOEID (e.g., 1 for worldwide, 23424977 for US) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `woeid` | string | Yes | Yahoo Where On Earth ID \(e.g., "1" for worldwide, "23424977" for US, "23424975" for UK\) | +| `maxTrends` | number | No | Maximum number of trends to return \(1-50, default 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trends` | array | Array of trending topics | +| ↳ `trendName` | string | Name of the trending topic | +| ↳ `tweetCount` | number | Number of tweets for this trend | + +### `x_get_personalized_trends` + +Get personalized trending topics for the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trends` | array | Array of personalized trending topics | +| ↳ `trendName` | string | Name of the trending topic | +| ↳ `postCount` | number | Number of posts for this trend | +| ↳ `category` | string | Category of the trend | +| ↳ `trendingSince` | string | ISO 8601 timestamp of when the topic started trending | + +### `x_get_usage` + +Get the API usage data for your X project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `days` | number | No | Number of days of usage data to return \(1-90, default 7\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `capResetDay` | number | Day of month when usage cap resets | +| `projectId` | string | The project ID | +| `projectCap` | number | The project tweet consumption cap | +| `projectUsage` | number | Total tweets consumed in current period | +| `dailyProjectUsage` | array | Daily project usage breakdown | +| ↳ `date` | string | Usage date in ISO 8601 format | +| ↳ `usage` | number | Number of tweets consumed | +| `dailyClientAppUsage` | array | Daily per-app usage breakdown | +| ↳ `clientAppId` | string | Client application ID | +| ↳ `usage` | array | Daily usage entries for this app | +| ↳ `date` | string | Usage date in ISO 8601 format | +| ↳ `usage` | number | Number of tweets consumed | diff --git a/apps/sim/app/(landing)/components/structured-data.tsx b/apps/sim/app/(landing)/components/structured-data.tsx index ba3d9e704e4..71127996db3 100644 --- a/apps/sim/app/(landing)/components/structured-data.tsx +++ b/apps/sim/app/(landing)/components/structured-data.tsx @@ -38,7 +38,7 @@ export default function StructuredData() { url: 'https://sim.ai', name: 'Sim - AI Agent Workflow Builder', description: - 'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.', + 'Open-source AI agent workflow builder. 70,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.', publisher: { '@id': 'https://sim.ai/#organization', }, @@ -87,7 +87,7 @@ export default function StructuredData() { '@id': 'https://sim.ai/#software', name: 'Sim - AI Agent Workflow Builder', description: - 'Open-source AI agent workflow builder used by 60,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.', + 'Open-source AI agent workflow builder used by 70,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.', applicationCategory: 'DeveloperApplication', applicationSubCategory: 'AI Development Tools', operatingSystem: 'Web, Windows, macOS, Linux', @@ -187,7 +187,7 @@ export default function StructuredData() { name: 'What is Sim?', acceptedAnswer: { '@type': 'Answer', - text: 'Sim is an open-source AI agent workflow builder used by 60,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.', + text: 'Sim is an open-source AI agent workflow builder used by 70,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.', }, }, { diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index d10ef0eea2e..f865e2c3961 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -19,6 +19,7 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import { @@ -630,9 +631,11 @@ async function handleMessageStream( } const encoder = new TextEncoder() + let messageStreamDecremented = false const stream = new ReadableStream({ async start(controller) { + incrementSSEConnections('a2a-message') const sendEvent = (event: string, data: unknown) => { try { const jsonRpcResponse = { @@ -841,9 +844,19 @@ async function handleMessageStream( }) } finally { await releaseLock(lockKey, lockValue) + if (!messageStreamDecremented) { + messageStreamDecremented = true + decrementSSEConnections('a2a-message') + } controller.close() } }, + cancel() { + if (!messageStreamDecremented) { + messageStreamDecremented = true + decrementSSEConnections('a2a-message') + } + }, }) return new NextResponse(stream, { @@ -1016,16 +1029,34 @@ async function handleTaskResubscribe( let pollTimeoutId: ReturnType | null = null const abortSignal = request.signal - abortSignal.addEventListener('abort', () => { + abortSignal.addEventListener( + 'abort', + () => { + isCancelled = true + if (pollTimeoutId) { + clearTimeout(pollTimeoutId) + pollTimeoutId = null + } + }, + { once: true } + ) + + let sseDecremented = false + const cleanup = () => { isCancelled = true if (pollTimeoutId) { clearTimeout(pollTimeoutId) pollTimeoutId = null } - }) + if (!sseDecremented) { + sseDecremented = true + decrementSSEConnections('a2a-resubscribe') + } + } const stream = new ReadableStream({ async start(controller) { + incrementSSEConnections('a2a-resubscribe') const sendEvent = (event: string, data: unknown): boolean => { if (isCancelled || abortSignal.aborted) return false try { @@ -1041,14 +1072,6 @@ async function handleTaskResubscribe( } } - const cleanup = () => { - isCancelled = true - if (pollTimeoutId) { - clearTimeout(pollTimeoutId) - pollTimeoutId = null - } - } - if ( !sendEvent('status', { kind: 'status', @@ -1160,11 +1183,7 @@ async function handleTaskResubscribe( poll() }, cancel() { - isCancelled = true - if (pollTimeoutId) { - clearTimeout(pollTimeoutId) - pollTimeoutId = null - } + cleanup() }, }) diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 6d049612e9b..37167b82a48 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { createMockRequest, setupCommonApiMocks } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlerMocks = vi.hoisted(() => ({ @@ -14,6 +14,7 @@ const handlerMocks = vi.hoisted(() => ({ session: { id: 'anon-session' }, }, })), + isAuthDisabled: false, })) vi.mock('better-auth/next-js', () => ({ @@ -32,18 +33,22 @@ vi.mock('@/lib/auth/anonymous', () => ({ createAnonymousGetSessionResponse: handlerMocks.createAnonymousGetSessionResponse, })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isAuthDisabled() { + return handlerMocks.isAuthDisabled + }, +})) + +import { GET } from '@/app/api/auth/[...all]/route' + describe('auth catch-all route (DISABLE_AUTH get-session)', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - handlerMocks.betterAuthGET.mockReset() - handlerMocks.betterAuthPOST.mockReset() - handlerMocks.ensureAnonymousUserExists.mockReset() - handlerMocks.createAnonymousGetSessionResponse.mockClear() + vi.clearAllMocks() + handlerMocks.isAuthDisabled = false }) it('returns anonymous session in better-auth response envelope when auth is disabled', async () => { - vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: true })) + handlerMocks.isAuthDisabled = true const req = createMockRequest( 'GET', @@ -51,7 +56,6 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { {}, 'http://localhost:3000/api/auth/get-session' ) - const { GET } = await import('@/app/api/auth/[...all]/route') const res = await GET(req as any) const json = await res.json() @@ -67,10 +71,11 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { }) it('delegates to better-auth handler when auth is enabled', async () => { - vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: false })) + handlerMocks.isAuthDisabled = false + const { NextResponse } = await import('next/server') handlerMocks.betterAuthGET.mockResolvedValueOnce( - new (await import('next/server')).NextResponse(JSON.stringify({ data: { ok: true } }), { + new NextResponse(JSON.stringify({ data: { ok: true } }), { headers: { 'content-type': 'application/json' }, }) as any ) @@ -81,7 +86,6 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { {}, 'http://localhost:3000/api/auth/get-session' ) - const { GET } = await import('@/app/api/auth/[...all]/route') const res = await GET(req as any) const json = await res.json() diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index 7f08c76e3e3..7bffef74e6a 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -3,63 +3,45 @@ * * @vitest-environment node */ -import { - createMockRequest, - mockConsoleLogger, - mockCryptoUuid, - mockDrizzleOrm, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'https://app.example.com'), -})) - -/** Setup auth API mocks for testing authentication routes */ -function setupAuthApiMocks( - options: { - operations?: { - forgetPassword?: { success?: boolean; error?: string } - resetPassword?: { success?: boolean; error?: string } - } - } = {} -) { - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - mockConsoleLogger() - mockDrizzleOrm() - - const { operations = {} } = options - const defaultOperations = { - forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword }, - resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword }, +const { mockForgetPassword, mockLogger } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), } - - const createAuthMethod = (config: { success?: boolean; error?: string }) => { - return vi.fn().mockImplementation(() => { - if (config.success) { - return Promise.resolve() - } - return Promise.reject(new Error(config.error)) - }) + return { + mockForgetPassword: vi.fn(), + mockLogger: logger, } +}) - vi.doMock('@/lib/auth', () => ({ - auth: { - api: { - forgetPassword: createAuthMethod(defaultOperations.forgetPassword), - resetPassword: createAuthMethod(defaultOperations.resetPassword), - }, +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: vi.fn(() => 'https://app.example.com'), +})) +vi.mock('@/lib/auth', () => ({ + auth: { + api: { + forgetPassword: mockForgetPassword, }, - })) -} + }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) + +import { POST } from '@/app/api/auth/forget-password/route' describe('Forget Password API Route', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() + mockForgetPassword.mockResolvedValue(undefined) }) afterEach(() => { @@ -67,27 +49,18 @@ describe('Forget Password API Route', () => { }) it('should send password reset email successfully with same-origin redirectTo', async () => { - setupAuthApiMocks({ - operations: { - forgetPassword: { success: true }, - }, - }) - const req = createMockRequest('POST', { email: 'test@example.com', redirectTo: 'https://app.example.com/reset', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) - const auth = await import('@/lib/auth') - expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({ + expect(mockForgetPassword).toHaveBeenCalledWith({ body: { email: 'test@example.com', redirectTo: 'https://app.example.com/reset', @@ -97,50 +70,32 @@ describe('Forget Password API Route', () => { }) it('should reject external redirectTo URL', async () => { - setupAuthApiMocks({ - operations: { - forgetPassword: { success: true }, - }, - }) - const req = createMockRequest('POST', { email: 'test@example.com', redirectTo: 'https://evil.com/phishing', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Redirect URL must be a valid same-origin URL') - const auth = await import('@/lib/auth') - expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled() + expect(mockForgetPassword).not.toHaveBeenCalled() }) it('should send password reset email without redirectTo', async () => { - setupAuthApiMocks({ - operations: { - forgetPassword: { success: true }, - }, - }) - const req = createMockRequest('POST', { email: 'test@example.com', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) - const auth = await import('@/lib/auth') - expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({ + expect(mockForgetPassword).toHaveBeenCalledWith({ body: { email: 'test@example.com', redirectTo: undefined, @@ -150,97 +105,64 @@ describe('Forget Password API Route', () => { }) it('should handle missing email', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', {}) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Email is required') - const auth = await import('@/lib/auth') - expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled() + expect(mockForgetPassword).not.toHaveBeenCalled() }) it('should handle empty email', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', { email: '', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Please provide a valid email address') - const auth = await import('@/lib/auth') - expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled() + expect(mockForgetPassword).not.toHaveBeenCalled() }) it('should handle auth service error with message', async () => { const errorMessage = 'User not found' - setupAuthApiMocks({ - operations: { - forgetPassword: { - success: false, - error: errorMessage, - }, - }, - }) + mockForgetPassword.mockRejectedValue(new Error(errorMessage)) const req = createMockRequest('POST', { email: 'nonexistent@example.com', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(500) expect(data.message).toBe(errorMessage) - const logger = await import('@sim/logger') - const mockLogger = logger.createLogger('ForgetPasswordTest') expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', { error: expect.any(Error), }) }) it('should handle unknown error', async () => { - setupAuthApiMocks() - - vi.doMock('@/lib/auth', () => ({ - auth: { - api: { - forgetPassword: vi.fn().mockRejectedValue('Unknown error'), - }, - }, - })) + mockForgetPassword.mockRejectedValue('Unknown error') const req = createMockRequest('POST', { email: 'test@example.com', }) - const { POST } = await import('@/app/api/auth/forget-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(500) expect(data.message).toBe('Failed to send password reset email. Please try again later.') - const logger = await import('@sim/logger') - const mockLogger = logger.createLogger('ForgetPasswordTest') expect(mockLogger.error).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 688f72edc72..eab4ecbc32c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -3,52 +3,81 @@ * * @vitest-environment node */ -import { createMockLogger, createMockRequest } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('OAuth Connections API Route', () => { - const mockGetSession = vi.fn() - const mockDb = { +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockDb, + mockLogger, + mockParseProvider, + mockEvaluateScopeCoverage, + mockJwtDecode, + mockEq, +} = vi.hoisted(() => { + const db = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), limit: vi.fn(), } - const mockLogger = createMockLogger() - const mockParseProvider = vi.fn() - const mockEvaluateScopeCoverage = vi.fn() + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockDb: db, + mockLogger: logger, + mockParseProvider: vi.fn(), + mockEvaluateScopeCoverage: vi.fn(), + mockJwtDecode: vi.fn(), + mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + } +}) - const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) - beforeEach(() => { - vi.resetModules() +vi.mock('@sim/db', () => ({ + db: mockDb, + account: { userId: 'userId', providerId: 'providerId' }, + user: { email: 'email', id: 'id' }, + eq: mockEq, +})) - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue(mockUUID), - }) +vi.mock('drizzle-orm', () => ({ + eq: mockEq, +})) - vi.doMock('@/lib/auth', () => ({ - getSession: mockGetSession, - })) +vi.mock('jwt-decode', () => ({ + jwtDecode: mockJwtDecode, +})) - vi.doMock('@sim/db', () => ({ - db: mockDb, - account: { userId: 'userId', providerId: 'providerId' }, - user: { email: 'email', id: 'id' }, - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) +vi.mock('@/lib/oauth/utils', () => ({ + parseProvider: mockParseProvider, + evaluateScopeCoverage: mockEvaluateScopeCoverage, +})) - vi.doMock('jwt-decode', () => ({ - jwtDecode: vi.fn(), - })) +import { GET } from '@/app/api/auth/oauth/connections/route' - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - })) +describe('OAuth Connections API Route', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockDb.select.mockReturnThis() + mockDb.from.mockReturnThis() + mockDb.where.mockReturnThis() mockParseProvider.mockImplementation((providerId: string) => ({ baseProvider: providerId.split('-')[0] || providerId, @@ -64,15 +93,6 @@ describe('OAuth Connections API Route', () => { requiresReauthorization: false, }) ) - - vi.doMock('@/lib/oauth/utils', () => ({ - parseProvider: mockParseProvider, - evaluateScopeCoverage: mockEvaluateScopeCoverage, - })) - }) - - afterEach(() => { - vi.clearAllMocks() }) it('should return connections successfully', async () => { @@ -111,7 +131,6 @@ describe('OAuth Connections API Route', () => { mockDb.limit.mockResolvedValueOnce(mockUserRecord) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/auth/oauth/connections/route') const response = await GET(req) const data = await response.json() @@ -136,7 +155,6 @@ describe('OAuth Connections API Route', () => { mockGetSession.mockResolvedValueOnce(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/auth/oauth/connections/route') const response = await GET(req) const data = await response.json() @@ -161,7 +179,6 @@ describe('OAuth Connections API Route', () => { mockDb.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/auth/oauth/connections/route') const response = await GET(req) const data = await response.json() @@ -180,7 +197,6 @@ describe('OAuth Connections API Route', () => { mockDb.where.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/auth/oauth/connections/route') const response = await GET(req) const data = await response.json() @@ -191,9 +207,6 @@ describe('OAuth Connections API Route', () => { }) it('should decode ID token for display name', async () => { - const { jwtDecode } = await import('jwt-decode') - const mockJwtDecode = jwtDecode as any - mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, }) @@ -224,7 +237,6 @@ describe('OAuth Connections API Route', () => { mockDb.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/auth/oauth/connections/route') const response = await GET(req) const data = await response.json() diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 0e40d18de67..bfae3a81789 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -4,66 +4,89 @@ * @vitest-environment node */ -import { createMockLogger } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('OAuth Credentials API Route', () => { - const mockGetSession = vi.fn() - const mockParseProvider = vi.fn() - const mockEvaluateScopeCoverage = vi.fn() - const mockDb = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), } - const mockLogger = createMockLogger() + return { + mockCheckSessionOrInternalAuth: vi.fn(), + mockEvaluateScopeCoverage: vi.fn(), + mockLogger: logger, + } +}) - const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/oauth', () => ({ + evaluateScopeCoverage: mockEvaluateScopeCoverage, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('mock-request-id'), +})) + +vi.mock('@/lib/credentials/oauth', () => ({ + syncWorkspaceOAuthCredentialsForUser: vi.fn(), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: vi.fn(), +})) + +vi.mock('@sim/db/schema', () => ({ + account: { + userId: 'userId', + providerId: 'providerId', + id: 'id', + scope: 'scope', + updatedAt: 'updatedAt', + }, + credential: { + id: 'id', + workspaceId: 'workspaceId', + type: 'type', + displayName: 'displayName', + providerId: 'providerId', + accountId: 'accountId', + }, + credentialMember: { + id: 'id', + credentialId: 'credentialId', + userId: 'userId', + status: 'status', + }, + user: { email: 'email', id: 'id' }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) + +import { GET } from '@/app/api/auth/oauth/credentials/route' +describe('OAuth Credentials API Route', () => { function createMockRequestWithQuery(method = 'GET', queryParams = ''): NextRequest { const url = `http://localhost:3000/api/auth/oauth/credentials${queryParams}` return new NextRequest(new URL(url), { method }) } beforeEach(() => { - vi.resetModules() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue(mockUUID), - }) - - vi.doMock('@/lib/auth', () => ({ - getSession: mockGetSession, - })) - - vi.doMock('@/lib/oauth/utils', () => ({ - parseProvider: mockParseProvider, - evaluateScopeCoverage: mockEvaluateScopeCoverage, - })) - - vi.doMock('@sim/db', () => ({ - db: mockDb, - })) - - vi.doMock('@sim/db/schema', () => ({ - account: { userId: 'userId', providerId: 'providerId' }, - user: { email: 'email', id: 'id' }, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) - - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - })) - - mockParseProvider.mockImplementation((providerId: string) => ({ - baseProvider: providerId.split('-')[0] || providerId, - })) + vi.clearAllMocks() mockEvaluateScopeCoverage.mockImplementation( (_providerId: string, grantedScopes: string[]) => ({ @@ -76,17 +99,14 @@ describe('OAuth Credentials API Route', () => { ) }) - afterEach(() => { - vi.clearAllMocks() - }) - it('should handle unauthenticated user', async () => { - mockGetSession.mockResolvedValueOnce(null) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'Authentication required', + }) const req = createMockRequestWithQuery('GET', '?provider=google') - const { GET } = await import('@/app/api/auth/oauth/credentials/route') - const response = await GET(req) const data = await response.json() @@ -96,14 +116,14 @@ describe('OAuth Credentials API Route', () => { }) it('should handle missing provider parameter', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'user-123' }, + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', }) const req = createMockRequestWithQuery('GET') - const { GET } = await import('@/app/api/auth/oauth/credentials/route') - const response = await GET(req) const data = await response.json() @@ -113,22 +133,14 @@ describe('OAuth Credentials API Route', () => { }) it('should handle no credentials found', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'user-123' }, - }) - - mockParseProvider.mockReturnValueOnce({ - baseProvider: 'github', + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', }) - mockDb.select.mockReturnValueOnce(mockDb) - mockDb.from.mockReturnValueOnce(mockDb) - mockDb.where.mockResolvedValueOnce([]) - const req = createMockRequestWithQuery('GET', '?provider=github') - const { GET } = await import('@/app/api/auth/oauth/credentials/route') - const response = await GET(req) const data = await response.json() @@ -137,14 +149,14 @@ describe('OAuth Credentials API Route', () => { }) it('should return empty credentials when no workspace context', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'user-123' }, + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', }) const req = createMockRequestWithQuery('GET', '?provider=google-email') - const { GET } = await import('@/app/api/auth/oauth/credentials/route') - const response = await GET(req) const data = await response.json() diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 2105b837061..35bec861936 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -3,76 +3,102 @@ * * @vitest-environment node */ -import { auditMock, createMockLogger, createMockRequest } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockDb, mockSelectChain, mockLogger, mockSyncAllWebhooksForCredentialSet } = + vi.hoisted(() => { + const selectChain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + } + const db = { + delete: vi.fn().mockReturnThis(), + where: vi.fn(), + select: vi.fn().mockReturnValue(selectChain), + } + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockDb: db, + mockSelectChain: selectChain, + mockLogger: logger, + mockSyncAllWebhooksForCredentialSet: vi.fn().mockResolvedValue({}), + } + }) -describe('OAuth Disconnect API Route', () => { - const mockGetSession = vi.fn() - const mockSelectChain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), - } - const mockDb = { - delete: vi.fn().mockReturnThis(), - where: vi.fn(), - select: vi.fn().mockReturnValue(mockSelectChain), - } - const mockLogger = createMockLogger() - const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({}) - - const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: mockDb, +})) + +vi.mock('@sim/db/schema', () => ({ + account: { userId: 'userId', providerId: 'providerId' }, + credentialSetMember: { + id: 'id', + credentialSetId: 'credentialSetId', + userId: 'userId', + status: 'status', + }, + credentialSet: { id: 'id', providerId: 'providerId' }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), + or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/webhooks/utils.server', () => ({ + syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, +})) + +vi.mock('@/lib/audit/log', () => ({ + recordAudit: vi.fn(), + AuditAction: { + CREDENTIAL_SET_CREATED: 'credential_set.created', + CREDENTIAL_SET_UPDATED: 'credential_set.updated', + CREDENTIAL_SET_DELETED: 'credential_set.deleted', + OAUTH_CONNECTED: 'oauth.connected', + OAUTH_DISCONNECTED: 'oauth.disconnected', + }, + AuditResourceType: { + CREDENTIAL_SET: 'credential_set', + OAUTH_CONNECTION: 'oauth_connection', + }, +})) + +import { POST } from '@/app/api/auth/oauth/disconnect/route' +describe('OAuth Disconnect API Route', () => { beforeEach(() => { - vi.resetModules() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue(mockUUID), - }) - - vi.doMock('@/lib/auth', () => ({ - getSession: mockGetSession, - })) - - vi.doMock('@sim/db', () => ({ - db: mockDb, - })) - - vi.doMock('@sim/db/schema', () => ({ - account: { userId: 'userId', providerId: 'providerId' }, - credentialSetMember: { - id: 'id', - credentialSetId: 'credentialSetId', - userId: 'userId', - status: 'status', - }, - credentialSet: { id: 'id', providerId: 'providerId' }, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - like: vi.fn((field, value) => ({ field, value, type: 'like' })), - or: vi.fn((...conditions) => ({ conditions, type: 'or' })), - })) - - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - })) - - vi.doMock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn().mockReturnValue('test-request-id'), - })) - - vi.doMock('@/lib/webhooks/utils.server', () => ({ - syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, - })) - - vi.doMock('@/lib/audit/log', () => auditMock) - }) - - afterEach(() => { vi.clearAllMocks() + + mockDb.delete.mockReturnThis() + mockSelectChain.from.mockReturnThis() + mockSelectChain.innerJoin.mockReturnThis() + mockSelectChain.where.mockResolvedValue([]) }) it('should disconnect provider successfully', async () => { @@ -87,8 +113,6 @@ describe('OAuth Disconnect API Route', () => { provider: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - const response = await POST(req) const data = await response.json() @@ -110,8 +134,6 @@ describe('OAuth Disconnect API Route', () => { providerId: 'google-email', }) - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - const response = await POST(req) const data = await response.json() @@ -127,8 +149,6 @@ describe('OAuth Disconnect API Route', () => { provider: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - const response = await POST(req) const data = await response.json() @@ -144,8 +164,6 @@ describe('OAuth Disconnect API Route', () => { const req = createMockRequest('POST', {}) - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - const response = await POST(req) const data = await response.json() @@ -166,8 +184,6 @@ describe('OAuth Disconnect API Route', () => { provider: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - const response = await POST(req) const data = await response.json() diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index d9f563b89ff..7054576c3c5 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -3,48 +3,63 @@ * * @vitest-environment node */ -import { createMockLogger, createMockRequest, mockHybridAuth } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('OAuth Token API Routes', () => { - const mockGetUserId = vi.fn() - const mockGetCredential = vi.fn() - const mockRefreshTokenIfNeeded = vi.fn() - const mockGetOAuthToken = vi.fn() - const mockAuthorizeCredentialUse = vi.fn() - let mockCheckSessionOrInternalAuth: ReturnType - - const mockLogger = createMockLogger() - - const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' - const mockRequestId = mockUUID.slice(0, 8) +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetUserId, + mockGetCredential, + mockRefreshTokenIfNeeded, + mockGetOAuthToken, + mockAuthorizeCredentialUse, + mockCheckSessionOrInternalAuth, + mockLogger, +} = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetUserId: vi.fn(), + mockGetCredential: vi.fn(), + mockRefreshTokenIfNeeded: vi.fn(), + mockGetOAuthToken: vi.fn(), + mockAuthorizeCredentialUse: vi.fn(), + mockCheckSessionOrInternalAuth: vi.fn(), + mockLogger: logger, + } +}) - beforeEach(() => { - vi.resetModules() +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getUserId: mockGetUserId, + getCredential: mockGetCredential, + refreshTokenIfNeeded: mockRefreshTokenIfNeeded, + getOAuthToken: mockGetOAuthToken, +})) - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue(mockUUID), - }) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) - vi.doMock('@/app/api/auth/oauth/utils', () => ({ - getUserId: mockGetUserId, - getCredential: mockGetCredential, - refreshTokenIfNeeded: mockRefreshTokenIfNeeded, - getOAuthToken: mockGetOAuthToken, - })) +vi.mock('@/lib/auth/credential-access', () => ({ + authorizeCredentialUse: mockAuthorizeCredentialUse, +})) - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - })) +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn(), + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, + checkInternalAuth: vi.fn(), +})) - vi.doMock('@/lib/auth/credential-access', () => ({ - authorizeCredentialUse: mockAuthorizeCredentialUse, - })) +import { GET, POST } from '@/app/api/auth/oauth/token/route' - ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) - }) - - afterEach(() => { +describe('OAuth Token API Routes', () => { + beforeEach(() => { vi.clearAllMocks() }) @@ -75,8 +90,6 @@ describe('OAuth Token API Routes', () => { credentialId: 'credential-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -112,8 +125,6 @@ describe('OAuth Token API Routes', () => { workflowId: 'workflow-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -127,8 +138,6 @@ describe('OAuth Token API Routes', () => { it('should handle missing credentialId', async () => { const req = createMockRequest('POST', {}) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -150,8 +159,6 @@ describe('OAuth Token API Routes', () => { credentialId: 'credential-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -167,8 +174,6 @@ describe('OAuth Token API Routes', () => { workflowId: 'nonexistent-workflow-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -188,8 +193,6 @@ describe('OAuth Token API Routes', () => { credentialId: 'nonexistent-credential-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -217,8 +220,6 @@ describe('OAuth Token API Routes', () => { credentialId: 'credential-id', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -238,8 +239,6 @@ describe('OAuth Token API Routes', () => { providerId: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -260,8 +259,6 @@ describe('OAuth Token API Routes', () => { providerId: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -282,8 +279,6 @@ describe('OAuth Token API Routes', () => { providerId: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -305,8 +300,6 @@ describe('OAuth Token API Routes', () => { providerId: 'google', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -328,8 +321,6 @@ describe('OAuth Token API Routes', () => { providerId: 'nonexistent-provider', }) - const { POST } = await import('@/app/api/auth/oauth/token/route') - const response = await POST(req) const data = await response.json() @@ -366,8 +357,6 @@ describe('OAuth Token API Routes', () => { 'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id' ) - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() @@ -382,8 +371,6 @@ describe('OAuth Token API Routes', () => { it('should handle missing credentialId', async () => { const req = new Request('http://localhost:3000/api/auth/oauth/token') - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() @@ -402,8 +389,6 @@ describe('OAuth Token API Routes', () => { 'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id' ) - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() @@ -424,8 +409,6 @@ describe('OAuth Token API Routes', () => { 'http://localhost:3000/api/auth/oauth/token?credentialId=nonexistent-credential-id' ) - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() @@ -451,8 +434,6 @@ describe('OAuth Token API Routes', () => { 'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id' ) - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() @@ -480,8 +461,6 @@ describe('OAuth Token API Routes', () => { 'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id' ) - const { GET } = await import('@/app/api/auth/oauth/token/route') - const response = await GET(req as any) const data = await response.json() diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index 18c44044400..ee969fe9067 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -3,59 +3,42 @@ * * @vitest-environment node */ -import { - createMockRequest, - mockConsoleLogger, - mockCryptoUuid, - mockDrizzleOrm, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -/** Setup auth API mocks for testing authentication routes */ -function setupAuthApiMocks( - options: { - operations?: { - forgetPassword?: { success?: boolean; error?: string } - resetPassword?: { success?: boolean; error?: string } - } - } = {} -) { - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - mockConsoleLogger() - mockDrizzleOrm() - - const { operations = {} } = options - const defaultOperations = { - forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword }, - resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword }, +const { mockResetPassword, mockLogger } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), } - - const createAuthMethod = (config: { success?: boolean; error?: string }) => { - return vi.fn().mockImplementation(() => { - if (config.success) { - return Promise.resolve() - } - return Promise.reject(new Error(config.error)) - }) + return { + mockResetPassword: vi.fn(), + mockLogger: logger, } +}) - vi.doMock('@/lib/auth', () => ({ - auth: { - api: { - forgetPassword: createAuthMethod(defaultOperations.forgetPassword), - resetPassword: createAuthMethod(defaultOperations.resetPassword), - }, +vi.mock('@/lib/auth', () => ({ + auth: { + api: { + resetPassword: mockResetPassword, }, - })) -} + }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) + +import { POST } from '@/app/api/auth/reset-password/route' describe('Reset Password API Route', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() + mockResetPassword.mockResolvedValue(undefined) }) afterEach(() => { @@ -63,27 +46,18 @@ describe('Reset Password API Route', () => { }) it('should reset password successfully', async () => { - setupAuthApiMocks({ - operations: { - resetPassword: { success: true }, - }, - }) - const req = createMockRequest('POST', { token: 'valid-reset-token', newPassword: 'newSecurePassword123!', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) - const auth = await import('@/lib/auth') - expect(auth.auth.api.resetPassword).toHaveBeenCalledWith({ + expect(mockResetPassword).toHaveBeenCalledWith({ body: { token: 'valid-reset-token', newPassword: 'newSecurePassword123!', @@ -93,133 +67,92 @@ describe('Reset Password API Route', () => { }) it('should handle missing token', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', { newPassword: 'newSecurePassword123', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Token is required') - const auth = await import('@/lib/auth') - expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() + expect(mockResetPassword).not.toHaveBeenCalled() }) it('should handle missing new password', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', { token: 'valid-reset-token', }) - const { POST } = await import('./route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Password is required') - const auth = await import('@/lib/auth') - expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() + expect(mockResetPassword).not.toHaveBeenCalled() }) it('should handle empty token', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', { token: '', newPassword: 'newSecurePassword123', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Token is required') - const auth = await import('@/lib/auth') - expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() + expect(mockResetPassword).not.toHaveBeenCalled() }) it('should handle empty new password', async () => { - setupAuthApiMocks() - const req = createMockRequest('POST', { token: 'valid-reset-token', newPassword: '', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(400) expect(data.message).toBe('Password must be at least 8 characters long') - const auth = await import('@/lib/auth') - expect(auth.auth.api.resetPassword).not.toHaveBeenCalled() + expect(mockResetPassword).not.toHaveBeenCalled() }) it('should handle auth service error with message', async () => { const errorMessage = 'Invalid or expired token' - setupAuthApiMocks({ - operations: { - resetPassword: { - success: false, - error: errorMessage, - }, - }, - }) + mockResetPassword.mockRejectedValue(new Error(errorMessage)) const req = createMockRequest('POST', { token: 'invalid-token', newPassword: 'newSecurePassword123!', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(500) expect(data.message).toBe(errorMessage) - const logger = await import('@sim/logger') - const mockLogger = logger.createLogger('PasswordResetAPI') expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', { error: expect.any(Error), }) }) it('should handle unknown error', async () => { - setupAuthApiMocks() - - vi.doMock('@/lib/auth', () => ({ - auth: { - api: { - resetPassword: vi.fn().mockRejectedValue('Unknown error'), - }, - }, - })) + mockResetPassword.mockRejectedValue('Unknown error') const req = createMockRequest('POST', { token: 'valid-reset-token', newPassword: 'newSecurePassword123!', }) - const { POST } = await import('@/app/api/auth/reset-password/route') - const response = await POST(req) const data = await response.json() @@ -228,8 +161,6 @@ describe('Reset Password API Route', () => { 'Failed to reset password. Please try again or request a new reset link.' ) - const logger = await import('@sim/logger') - const mockLogger = logger.createLogger('PasswordResetAPI') expect(mockLogger.error).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index b16818f1795..cd8af919c7c 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -6,21 +6,38 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Chat OTP API Route', () => { - const mockEmail = 'test@example.com' - const mockChatId = 'chat-123' - const mockIdentifier = 'test-chat' - const mockOTP = '123456' - +const { + mockRedisSet, + mockRedisGet, + mockRedisDel, + mockGetRedisClient, + mockRedisClient, + mockDbSelect, + mockDbInsert, + mockDbDelete, + mockSendEmail, + mockRenderOTPEmail, + mockAddCorsHeaders, + mockCreateSuccessResponse, + mockCreateErrorResponse, + mockSetChatAuthCookie, + mockGenerateRequestId, + mockGetStorageMethod, + mockZodParse, + mockGetEnv, +} = vi.hoisted(() => { const mockRedisSet = vi.fn() const mockRedisGet = vi.fn() const mockRedisDel = vi.fn() + const mockRedisClient = { + set: mockRedisSet, + get: mockRedisGet, + del: mockRedisDel, + } const mockGetRedisClient = vi.fn() - const mockDbSelect = vi.fn() const mockDbInsert = vi.fn() const mockDbDelete = vi.fn() - const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() const mockAddCorsHeaders = vi.fn() @@ -28,11 +45,152 @@ describe('Chat OTP API Route', () => { const mockCreateErrorResponse = vi.fn() const mockSetChatAuthCookie = vi.fn() const mockGenerateRequestId = vi.fn() + const mockGetStorageMethod = vi.fn() + const mockZodParse = vi.fn() + const mockGetEnv = vi.fn() + + return { + mockRedisSet, + mockRedisGet, + mockRedisDel, + mockGetRedisClient, + mockRedisClient, + mockDbSelect, + mockDbInsert, + mockDbDelete, + mockSendEmail, + mockRenderOTPEmail, + mockAddCorsHeaders, + mockCreateSuccessResponse, + mockCreateErrorResponse, + mockSetChatAuthCookie, + mockGenerateRequestId, + mockGetStorageMethod, + mockZodParse, + mockGetEnv, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: mockGetRedisClient, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + insert: mockDbInsert, + delete: mockDbDelete, + transaction: vi.fn(async (callback: (tx: Record) => unknown) => { + return callback({ + select: mockDbSelect, + insert: mockDbInsert, + delete: mockDbDelete, + }) + }), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + chat: { + id: 'id', + authType: 'authType', + allowedEmails: 'allowedEmails', + title: 'title', + }, + verification: { + id: 'id', + identifier: 'identifier', + value: 'value', + expiresAt: 'expiresAt', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })), + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), + lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), +})) + +vi.mock('@/lib/core/storage', () => ({ + getStorageMethod: mockGetStorageMethod, +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + sendEmail: mockSendEmail, +})) + +vi.mock('@/components/emails/render-email', () => ({ + renderOTPEmail: mockRenderOTPEmail, +})) + +vi.mock('@/app/api/chat/utils', () => ({ + addCorsHeaders: mockAddCorsHeaders, + setChatAuthCookie: mockSetChatAuthCookie, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + createSuccessResponse: mockCreateSuccessResponse, + createErrorResponse: mockCreateErrorResponse, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + NODE_ENV: 'test', + }, + getEnv: mockGetEnv, + isTruthy: vi.fn().mockReturnValue(false), + isFalsy: vi.fn().mockReturnValue(true), +})) + +vi.mock('zod', () => { + class ZodError extends Error { + errors: Array<{ message: string }> + constructor(issues: Array<{ message: string }>) { + super('ZodError') + this.errors = issues + } + } + const mockStringReturnValue = { + email: vi.fn().mockReturnThis(), + length: vi.fn().mockReturnThis(), + } + return { + z: { + object: vi.fn().mockReturnValue({ + parse: mockZodParse, + }), + string: vi.fn().mockReturnValue(mockStringReturnValue), + ZodError, + }, + } +}) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: mockGenerateRequestId, +})) - let storageMethod: 'redis' | 'database' = 'redis' +import { POST, PUT } from './route' + +describe('Chat OTP API Route', () => { + const mockEmail = 'test@example.com' + const mockChatId = 'chat-123' + const mockIdentifier = 'test-chat' + const mockOTP = '123456' beforeEach(() => { - vi.resetModules() vi.clearAllMocks() vi.spyOn(Math, 'random').mockReturnValue(0.123456) @@ -43,21 +201,12 @@ describe('Chat OTP API Route', () => { randomUUID: vi.fn().mockReturnValue('test-uuid-1234'), }) - const mockRedisClient = { - set: mockRedisSet, - get: mockRedisGet, - del: mockRedisDel, - } mockGetRedisClient.mockReturnValue(mockRedisClient) mockRedisSet.mockResolvedValue('OK') mockRedisGet.mockResolvedValue(null) mockRedisDel.mockResolvedValue(1) - vi.doMock('@/lib/core/config/redis', () => ({ - getRedisClient: mockGetRedisClient, - })) - - const createDbChain = (result: any) => ({ + const createDbChain = (result: unknown) => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(result), @@ -73,110 +222,26 @@ describe('Chat OTP API Route', () => { where: vi.fn().mockResolvedValue(undefined), })) - vi.doMock('@sim/db', () => ({ - db: { - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - transaction: vi.fn(async (callback) => { - return callback({ - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - }) - }), - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - chat: { - id: 'id', - authType: 'authType', - allowedEmails: 'allowedEmails', - title: 'title', - }, - verification: { - id: 'id', - identifier: 'identifier', - value: 'value', - expiresAt: 'expiresAt', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - gt: vi.fn((field, value) => ({ field, value, type: 'gt' })), - lt: vi.fn((field, value) => ({ field, value, type: 'lt' })), - })) - - vi.doMock('@/lib/core/storage', () => ({ - getStorageMethod: vi.fn(() => storageMethod), - })) + mockGetStorageMethod.mockReturnValue('redis') mockSendEmail.mockResolvedValue({ success: true }) mockRenderOTPEmail.mockResolvedValue('OTP Email') - vi.doMock('@/lib/messaging/email/mailer', () => ({ - sendEmail: mockSendEmail, - })) - - vi.doMock('@/components/emails/render-email', () => ({ - renderOTPEmail: mockRenderOTPEmail, - })) - - mockAddCorsHeaders.mockImplementation((response) => response) - mockCreateSuccessResponse.mockImplementation((data) => ({ + mockAddCorsHeaders.mockImplementation((response: unknown) => response) + mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, })) - mockCreateErrorResponse.mockImplementation((message, status) => ({ + mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({ json: () => Promise.resolve({ error: message }), status, })) - vi.doMock('@/app/api/chat/utils', () => ({ - addCorsHeaders: mockAddCorsHeaders, - setChatAuthCookie: mockSetChatAuthCookie, - })) - - vi.doMock('@/app/api/workflows/utils', () => ({ - createSuccessResponse: mockCreateSuccessResponse, - createErrorResponse: mockCreateErrorResponse, - })) - - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock() - }) + mockGenerateRequestId.mockReturnValue('req-123') - vi.doMock('zod', () => ({ - z: { - object: vi.fn().mockReturnValue({ - parse: vi.fn().mockImplementation((data) => data), - }), - string: vi.fn().mockReturnValue({ - email: vi.fn().mockReturnThis(), - length: vi.fn().mockReturnThis(), - }), - }, - })) + mockZodParse.mockImplementation((data: unknown) => data) - mockGenerateRequestId.mockReturnValue('req-123') - vi.doMock('@/lib/core/utils/request', () => ({ - generateRequestId: mockGenerateRequestId, - })) + mockGetEnv.mockReturnValue('http://localhost:3000') }) afterEach(() => { @@ -185,12 +250,10 @@ describe('Chat OTP API Route', () => { describe('POST - Store OTP (Redis path)', () => { beforeEach(() => { - storageMethod = 'redis' + mockGetStorageMethod.mockReturnValue('redis') }) it('should store OTP in Redis when storage method is redis', async () => { - const { POST } = await import('./route') - mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -226,13 +289,11 @@ describe('Chat OTP API Route', () => { describe('POST - Store OTP (Database path)', () => { beforeEach(() => { - storageMethod = 'database' + mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should store OTP in database when storage method is database', async () => { - const { POST } = await import('./route') - mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -283,13 +344,11 @@ describe('Chat OTP API Route', () => { describe('PUT - Verify OTP (Redis path)', () => { beforeEach(() => { - storageMethod = 'redis' + mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(mockOTP) }) it('should retrieve OTP from Redis and verify successfully', async () => { - const { PUT } = await import('./route') - mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -320,13 +379,11 @@ describe('Chat OTP API Route', () => { describe('PUT - Verify OTP (Database path)', () => { beforeEach(() => { - storageMethod = 'database' + mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should retrieve OTP from database and verify successfully', async () => { - const { PUT } = await import('./route') - let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ @@ -373,8 +430,6 @@ describe('Chat OTP API Route', () => { }) it('should reject expired OTP from database', async () => { - const { PUT } = await import('./route') - let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ @@ -412,12 +467,10 @@ describe('Chat OTP API Route', () => { describe('DELETE OTP (Redis path)', () => { beforeEach(() => { - storageMethod = 'redis' + mockGetStorageMethod.mockReturnValue('redis') }) it('should delete OTP from Redis after verification', async () => { - const { PUT } = await import('./route') - mockRedisGet.mockResolvedValue(mockOTP) mockDbSelect.mockImplementationOnce(() => ({ @@ -447,13 +500,11 @@ describe('Chat OTP API Route', () => { describe('DELETE OTP (Database path)', () => { beforeEach(() => { - storageMethod = 'database' + mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should delete OTP from database after verification', async () => { - const { PUT } = await import('./route') - let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -490,11 +541,9 @@ describe('Chat OTP API Route', () => { describe('Behavior consistency between Redis and Database', () => { it('should have same behavior for missing OTP in both storage methods', async () => { - storageMethod = 'redis' + mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(null) - const { PUT: PUTRedis } = await import('./route') - mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -508,7 +557,7 @@ describe('Chat OTP API Route', () => { body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) - await PUTRedis(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) + await PUT(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockCreateErrorResponse).toHaveBeenCalledWith( 'No verification code found, request a new one', @@ -519,8 +568,7 @@ describe('Chat OTP API Route', () => { it('should have same OTP expiry time in both storage methods', async () => { const OTP_EXPIRY = 15 * 60 - storageMethod = 'redis' - const { POST: POSTRedis } = await import('./route') + mockGetStorageMethod.mockReturnValue('redis') mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -542,7 +590,7 @@ describe('Chat OTP API Route', () => { body: JSON.stringify({ email: mockEmail }), }) - await POSTRedis(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) + await POST(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisSet).toHaveBeenCalledWith( expect.any(String), diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index d3a14c5ac32..31d3a0bfde4 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -51,6 +51,61 @@ const createMockStream = () => { }) } +const { + mockDbSelect, + mockAddCorsHeaders, + mockValidateChatAuth, + mockSetChatAuthCookie, + mockValidateAuthToken, + mockCreateErrorResponse, + mockCreateSuccessResponse, +} = vi.hoisted(() => ({ + mockDbSelect: vi.fn(), + mockAddCorsHeaders: vi.fn().mockImplementation((response: Response) => response), + mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), + mockSetChatAuthCookie: vi.fn(), + mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockCreateErrorResponse: vi + .fn() + .mockImplementation((message: string, status: number, code?: string) => { + return new Response( + JSON.stringify({ + error: code || 'Error', + message, + }), + { status } + ) + }), + mockCreateSuccessResponse: vi.fn().mockImplementation((data: unknown) => { + return new Response(JSON.stringify(data), { status: 200 }) + }), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockDbSelect }, + chat: {}, + workflow: {}, +})) + +vi.mock('@/lib/core/security/deployment', () => ({ + addCorsHeaders: mockAddCorsHeaders, + validateAuthToken: mockValidateAuthToken, + setDeploymentAuthCookie: vi.fn(), + isEmailAllowed: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/app/api/chat/utils', () => ({ + validateChatAuth: mockValidateChatAuth, + setChatAuthCookie: mockSetChatAuthCookie, +})) + +vi.mock('@sim/logger', () => loggerMock) + +vi.mock('@/app/api/workflows/utils', () => ({ + createErrorResponse: mockCreateErrorResponse, + createSuccessResponse: mockCreateSuccessResponse, +})) + vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: vi.fn().mockResolvedValue({ success: true, @@ -100,12 +155,11 @@ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-password' }), })) -describe('Chat Identifier API Route', () => { - const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response) - const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true }) - const mockSetChatAuthCookie = vi.fn() - const mockValidateAuthToken = vi.fn().mockReturnValue(false) +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { GET, POST } from '@/app/api/chat/[identifier]/route' +describe('Chat Identifier API Route', () => { const mockChatResult = [ { id: 'chat-id', @@ -142,66 +196,42 @@ describe('Chat Identifier API Route', () => { ] beforeEach(() => { - vi.resetModules() - - vi.doMock('@/lib/core/security/deployment', () => ({ - addCorsHeaders: mockAddCorsHeaders, - validateAuthToken: mockValidateAuthToken, - setDeploymentAuthCookie: vi.fn(), - isEmailAllowed: vi.fn().mockReturnValue(false), - })) - - vi.doMock('@/app/api/chat/utils', () => ({ - validateChatAuth: mockValidateChatAuth, - setChatAuthCookie: mockSetChatAuthCookie, - })) - - // Mock logger - use loggerMock from @sim/testing - vi.doMock('@sim/logger', () => loggerMock) - - vi.doMock('@sim/db', () => { - const mockSelect = vi.fn().mockImplementation((fields) => { - if (fields && fields.isDeployed !== undefined) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue(mockWorkflowResult), - }), - }), - } - } + vi.clearAllMocks() + + mockAddCorsHeaders.mockImplementation((response: Response) => response) + mockValidateChatAuth.mockResolvedValue({ authorized: true }) + mockValidateAuthToken.mockReturnValue(false) + mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => { + return new Response( + JSON.stringify({ + error: code || 'Error', + message, + }), + { status } + ) + }) + mockCreateSuccessResponse.mockImplementation((data: unknown) => { + return new Response(JSON.stringify(data), { status: 200 }) + }) + + mockDbSelect.mockImplementation((fields: Record) => { + if (fields && fields.isDeployed !== undefined) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue(mockChatResult), + limit: vi.fn().mockReturnValue(mockWorkflowResult), }), }), } - }) - + } return { - db: { - select: mockSelect, - }, - chat: {}, - workflow: {}, + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockChatResult), + }), + }), } }) - - vi.doMock('@/app/api/workflows/utils', () => ({ - createErrorResponse: vi.fn().mockImplementation((message, status, code) => { - return new Response( - JSON.stringify({ - error: code || 'Error', - message, - }), - { status } - ) - }), - createSuccessResponse: vi.fn().mockImplementation((data) => { - return new Response(JSON.stringify(data), { status: 200 }) - }), - })) }) afterEach(() => { @@ -213,8 +243,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'test-chat' }) - const { GET } = await import('@/app/api/chat/[identifier]/route') - const response = await GET(req, { params }) expect(response.status).toBe(200) @@ -228,24 +256,19 @@ describe('Chat Identifier API Route', () => { }) it('should return 404 for non-existent identifier', async () => { - vi.doMock('@sim/db', () => { - const mockLimit = vi.fn().mockReturnValue([]) - const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) - const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) - const mockSelect = vi.fn().mockReturnValue({ from: mockFrom }) - + mockDbSelect.mockImplementation(() => { return { - db: { - select: mockSelect, - }, + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), } }) const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'nonexistent' }) - const { GET } = await import('@/app/api/chat/[identifier]/route') - const response = await GET(req, { params }) expect(response.status).toBe(404) @@ -256,30 +279,25 @@ describe('Chat Identifier API Route', () => { }) it('should return 403 for inactive chat', async () => { - vi.doMock('@sim/db', () => { - const mockLimit = vi.fn().mockReturnValue([ - { - id: 'chat-id', - isActive: false, - authType: 'public', - }, - ]) - const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) - const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) - const mockSelect = vi.fn().mockReturnValue({ from: mockFrom }) - + mockDbSelect.mockImplementation(() => { return { - db: { - select: mockSelect, - }, + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([ + { + id: 'chat-id', + isActive: false, + authType: 'public', + }, + ]), + }), + }), } }) const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'inactive-chat' }) - const { GET } = await import('@/app/api/chat/[identifier]/route') - const response = await GET(req, { params }) expect(response.status).toBe(403) @@ -290,17 +308,14 @@ describe('Chat Identifier API Route', () => { }) it('should return 401 when authentication is required', async () => { - const originalValidateChatAuth = mockValidateChatAuth.getMockImplementation() - mockValidateChatAuth.mockImplementationOnce(async () => ({ + mockValidateChatAuth.mockResolvedValueOnce({ authorized: false, error: 'auth_required_password', - })) + }) const req = createMockNextRequest('GET') const params = Promise.resolve({ identifier: 'password-protected-chat' }) - const { GET } = await import('@/app/api/chat/[identifier]/route') - const response = await GET(req, { params }) expect(response.status).toBe(401) @@ -308,10 +323,6 @@ describe('Chat Identifier API Route', () => { const data = await response.json() expect(data).toHaveProperty('error') expect(data).toHaveProperty('message', 'auth_required_password') - - if (originalValidateChatAuth) { - mockValidateChatAuth.mockImplementation(originalValidateChatAuth) - } }) }) @@ -320,8 +331,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', { password: 'test-password' }) const params = Promise.resolve({ identifier: 'password-protected-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -336,8 +345,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', {}) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(400) @@ -348,17 +355,14 @@ describe('Chat Identifier API Route', () => { }) it('should return 401 for unauthorized access', async () => { - const originalValidateChatAuth = mockValidateChatAuth.getMockImplementation() - mockValidateChatAuth.mockImplementationOnce(async () => ({ + mockValidateChatAuth.mockResolvedValueOnce({ authorized: false, error: 'Authentication required', - })) + }) const req = createMockNextRequest('POST', { input: 'Hello' }) const params = Promise.resolve({ identifier: 'protected-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -366,16 +370,9 @@ describe('Chat Identifier API Route', () => { const data = await response.json() expect(data).toHaveProperty('error') expect(data).toHaveProperty('message', 'Authentication required') - - if (originalValidateChatAuth) { - mockValidateChatAuth.mockImplementation(originalValidateChatAuth) - } }) it('should return 503 when workflow is not available', async () => { - const { preprocessExecution } = await import('@/lib/execution/preprocessing') - const originalImplementation = vi.mocked(preprocessExecution).getMockImplementation() - vi.mocked(preprocessExecution).mockResolvedValueOnce({ success: false, error: { @@ -388,8 +385,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', { input: 'Hello' }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(403) @@ -397,10 +392,6 @@ describe('Chat Identifier API Route', () => { const data = await response.json() expect(data).toHaveProperty('error') expect(data).toHaveProperty('message', 'Workflow is not deployed') - - if (originalImplementation) { - vi.mocked(preprocessExecution).mockImplementation(originalImplementation) - } }) it('should return streaming response for valid chat messages', async () => { @@ -410,9 +401,6 @@ describe('Chat Identifier API Route', () => { }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') - const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -442,8 +430,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', { input: 'Hello world' }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -463,8 +449,6 @@ describe('Chat Identifier API Route', () => { }) it('should handle workflow execution errors gracefully', async () => { - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') - const originalStreamingResponse = vi.mocked(createStreamingResponse).getMockImplementation() vi.mocked(createStreamingResponse).mockImplementationOnce(async () => { throw new Error('Execution failed') }) @@ -472,8 +456,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', { input: 'Trigger error' }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(500) @@ -481,10 +463,6 @@ describe('Chat Identifier API Route', () => { const data = await response.json() expect(data).toHaveProperty('error') expect(data).toHaveProperty('message', 'Execution failed') - - if (originalStreamingResponse) { - vi.mocked(createStreamingResponse).mockImplementation(originalStreamingResponse) - } }) it('should handle invalid JSON in request body', async () => { @@ -496,8 +474,6 @@ describe('Chat Identifier API Route', () => { const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const response = await POST(req, { params }) expect(response.status).toBe(400) @@ -514,9 +490,6 @@ describe('Chat Identifier API Route', () => { }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') - await POST(req, { params }) expect(createStreamingResponse).toHaveBeenCalledWith( @@ -533,9 +506,6 @@ describe('Chat Identifier API Route', () => { const req = createMockNextRequest('POST', { input: 'Hello world' }) const params = Promise.resolve({ identifier: 'test-chat' }) - const { POST } = await import('@/app/api/chat/[identifier]/route') - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') - await POST(req, { params }) expect(createStreamingResponse).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 71e92f95781..cf396007ee7 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -3,35 +3,100 @@ * * @vitest-environment node */ -import { auditMock, loggerMock } from '@sim/testing' +import { auditMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/audit/log', () => auditMock) +const { + mockGetSession, + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockUpdate, + mockSet, + mockDelete, + mockCreateSuccessResponse, + mockCreateErrorResponse, + mockEncryptSecret, + mockCheckChatAccess, + mockDeployWorkflow, + mockLogger, +} = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockLimit: vi.fn(), + mockUpdate: vi.fn(), + mockSet: vi.fn(), + mockDelete: vi.fn(), + mockCreateSuccessResponse: vi.fn(), + mockCreateErrorResponse: vi.fn(), + mockEncryptSecret: vi.fn(), + mockCheckChatAccess: vi.fn(), + mockDeployWorkflow: vi.fn(), + mockLogger: logger, + } +}) +vi.mock('@/lib/audit/log', () => auditMock) vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, isHosted: false, isProd: false, })) +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + update: mockUpdate, + delete: mockDelete, + }, +})) +vi.mock('@sim/db/schema', () => ({ + chat: { id: 'id', identifier: 'identifier', userId: 'userId' }, +})) +vi.mock('@/app/api/workflows/utils', () => ({ + createSuccessResponse: mockCreateSuccessResponse, + createErrorResponse: mockCreateErrorResponse, +})) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: mockEncryptSecret, +})) +vi.mock('@/lib/core/utils/urls', () => ({ + getEmailDomain: vi.fn().mockReturnValue('localhost:3000'), +})) +vi.mock('@/app/api/chat/utils', () => ({ + checkChatAccess: mockCheckChatAccess, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + deployWorkflow: mockDeployWorkflow, +})) +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), +})) -describe('Chat Edit API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockLimit = vi.fn() - const mockUpdate = vi.fn() - const mockSet = vi.fn() - const mockDelete = vi.fn() - - const mockCreateSuccessResponse = vi.fn() - const mockCreateErrorResponse = vi.fn() - const mockEncryptSecret = vi.fn() - const mockCheckChatAccess = vi.fn() - const mockDeployWorkflow = vi.fn() +import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' +describe('Chat Edit API Route', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() mockLimit.mockResolvedValue([]) mockSelect.mockReturnValue({ from: mockFrom }) @@ -41,56 +106,21 @@ describe('Chat Edit API Route', () => { mockSet.mockReturnValue({ where: mockWhere }) mockDelete.mockReturnValue({ where: mockWhere }) - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - delete: mockDelete, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - chat: { id: 'id', identifier: 'identifier', userId: 'userId' }, - })) - - // Mock logger - use loggerMock from @sim/testing - vi.doMock('@sim/logger', () => loggerMock) - - vi.doMock('@/app/api/workflows/utils', () => ({ - createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => { - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }), - createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - }), - })) - - vi.doMock('@/lib/core/security/encryption', () => ({ - encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }), - })) - - vi.doMock('@/lib/core/utils/urls', () => ({ - getEmailDomain: vi.fn().mockReturnValue('localhost:3000'), - })) - - vi.doMock('@/app/api/chat/utils', () => ({ - checkChatAccess: mockCheckChatAccess, - })) + mockCreateSuccessResponse.mockImplementation((data) => { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) mockDeployWorkflow.mockResolvedValue({ success: true, version: 1 }) - vi.doMock('@/lib/workflows/persistence/utils', () => ({ - deployWorkflow: mockDeployWorkflow, - })) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) }) afterEach(() => { @@ -99,12 +129,9 @@ describe('Chat Edit API Route', () => { describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const { GET } = await import('@/app/api/chat/manage/[id]/route') const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(401) @@ -113,16 +140,13 @@ describe('Chat Edit API Route', () => { }) it('should return 404 when chat not found or access denied', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const { GET } = await import('@/app/api/chat/manage/[id]/route') const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(404) @@ -132,11 +156,9 @@ describe('Chat Edit API Route', () => { }) it('should return chat details when user has access', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const mockChat = { id: 'chat-123', @@ -150,7 +172,6 @@ describe('Chat Edit API Route', () => { mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123') - const { GET } = await import('@/app/api/chat/manage/[id]/route') const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) @@ -165,15 +186,12 @@ describe('Chat Edit API Route', () => { describe('PATCH', () => { it('should return 401 when user is not authenticated', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'PATCH', body: JSON.stringify({ title: 'Updated Chat' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(401) @@ -182,11 +200,9 @@ describe('Chat Edit API Route', () => { }) it('should return 404 when chat not found or access denied', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) @@ -194,7 +210,6 @@ describe('Chat Edit API Route', () => { method: 'PATCH', body: JSON.stringify({ title: 'Updated Chat' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(404) @@ -204,11 +219,9 @@ describe('Chat Edit API Route', () => { }) it('should update chat when user has access', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const mockChat = { id: 'chat-123', @@ -228,7 +241,6 @@ describe('Chat Edit API Route', () => { method: 'PATCH', body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) @@ -240,11 +252,9 @@ describe('Chat Edit API Route', () => { }) it('should handle identifier conflicts', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const mockChat = { id: 'chat-123', @@ -263,7 +273,6 @@ describe('Chat Edit API Route', () => { method: 'PATCH', body: JSON.stringify({ identifier: 'new-identifier' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(400) @@ -272,11 +281,9 @@ describe('Chat Edit API Route', () => { }) it('should validate password requirement for password auth', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const mockChat = { id: 'chat-123', @@ -293,7 +300,6 @@ describe('Chat Edit API Route', () => { method: 'PATCH', body: JSON.stringify({ authType: 'password' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(400) @@ -302,11 +308,9 @@ describe('Chat Edit API Route', () => { }) it('should allow access when user has workspace admin permission', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'admin-user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'admin-user-id' }, + }) const mockChat = { id: 'chat-123', @@ -326,7 +330,6 @@ describe('Chat Edit API Route', () => { method: 'PATCH', body: JSON.stringify({ title: 'Admin Updated Chat' }), }) - const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) @@ -336,14 +339,11 @@ describe('Chat Edit API Route', () => { describe('DELETE', () => { it('should return 401 when user is not authenticated', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', }) - const { DELETE } = await import('@/app/api/chat/manage/[id]/route') const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(401) @@ -352,18 +352,15 @@ describe('Chat Edit API Route', () => { }) it('should return 404 when chat not found or access denied', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', }) - const { DELETE } = await import('@/app/api/chat/manage/[id]/route') const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(404) @@ -373,11 +370,9 @@ describe('Chat Edit API Route', () => { }) it('should delete chat when user has access', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) mockCheckChatAccess.mockResolvedValue({ hasAccess: true, @@ -388,7 +383,6 @@ describe('Chat Edit API Route', () => { const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', }) - const { DELETE } = await import('@/app/api/chat/manage/[id]/route') const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) @@ -398,11 +392,9 @@ describe('Chat Edit API Route', () => { }) it('should allow deletion when user has workspace admin permission', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'admin-user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'admin-user-id' }, + }) mockCheckChatAccess.mockResolvedValue({ hasAccess: true, @@ -413,7 +405,6 @@ describe('Chat Edit API Route', () => { const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', }) - const { DELETE } = await import('@/app/api/chat/manage/[id]/route') const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 0dfc2df5e5d..5f3807f41ae 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -3,27 +3,93 @@ * * @vitest-environment node */ -import { auditMock } from '@sim/testing' +import { auditMock, createEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Chat API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockLimit = vi.fn() - const mockInsert = vi.fn() - const mockValues = vi.fn() - const mockReturning = vi.fn() - - const mockCreateSuccessResponse = vi.fn() - const mockCreateErrorResponse = vi.fn() - const mockEncryptSecret = vi.fn() - const mockCheckWorkflowAccessForChatCreation = vi.fn() - const mockDeployWorkflow = vi.fn() +const { + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockInsert, + mockValues, + mockReturning, + mockCreateSuccessResponse, + mockCreateErrorResponse, + mockEncryptSecret, + mockCheckWorkflowAccessForChatCreation, + mockDeployWorkflow, + mockGetSession, + mockUuidV4, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockLimit: vi.fn(), + mockInsert: vi.fn(), + mockValues: vi.fn(), + mockReturning: vi.fn(), + mockCreateSuccessResponse: vi.fn(), + mockCreateErrorResponse: vi.fn(), + mockEncryptSecret: vi.fn(), + mockCheckWorkflowAccessForChatCreation: vi.fn(), + mockDeployWorkflow: vi.fn(), + mockGetSession: vi.fn(), + mockUuidV4: vi.fn(), +})) + +vi.mock('@/lib/audit/log', () => auditMock) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + insert: mockInsert, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + chat: { userId: 'userId', identifier: 'identifier' }, + workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' }, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + createSuccessResponse: mockCreateSuccessResponse, + createErrorResponse: mockCreateErrorResponse, +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: mockEncryptSecret, +})) + +vi.mock('uuid', () => ({ + v4: mockUuidV4, +})) + +vi.mock('@/app/api/chat/utils', () => ({ + checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + deployWorkflow: mockDeployWorkflow, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + }) +) + +import { GET, POST } from '@/app/api/chat/route' +describe('Chat API Route', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) @@ -31,63 +97,29 @@ describe('Chat API Route', () => { mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) - vi.doMock('@/lib/audit/log', () => auditMock) - - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - chat: { userId: 'userId', identifier: 'identifier' }, - workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' }, - })) - - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - })) - - vi.doMock('@/app/api/workflows/utils', () => ({ - createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => { - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }), - createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - }), - })) - - vi.doMock('@/lib/core/security/encryption', () => ({ - encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }), - })) - - vi.doMock('uuid', () => ({ - v4: vi.fn().mockReturnValue('test-uuid'), - })) - - vi.doMock('@/app/api/chat/utils', () => ({ - checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, - })) - - vi.doMock('@/lib/workflows/persistence/utils', () => ({ - deployWorkflow: mockDeployWorkflow.mockResolvedValue({ - success: true, - version: 1, - deployedAt: new Date(), - }), - })) + mockUuidV4.mockReturnValue('test-uuid') + + mockCreateSuccessResponse.mockImplementation((data) => { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) + + mockDeployWorkflow.mockResolvedValue({ + success: true, + version: 1, + deployedAt: new Date(), + }) }) afterEach(() => { @@ -96,12 +128,9 @@ describe('Chat API Route', () => { describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/chat') - const { GET } = await import('@/app/api/chat/route') const response = await GET(req) expect(response.status).toBe(401) @@ -109,17 +138,14 @@ describe('Chat API Route', () => { }) it('should return chat deployments for authenticated user', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const mockDeployments = [{ id: 'deployment-1' }, { id: 'deployment-2' }] mockWhere.mockResolvedValue(mockDeployments) const req = new NextRequest('http://localhost:3000/api/chat') - const { GET } = await import('@/app/api/chat/route') const response = await GET(req) expect(response.status).toBe(200) @@ -128,16 +154,13 @@ describe('Chat API Route', () => { }) it('should handle errors when fetching deployments', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) mockWhere.mockRejectedValue(new Error('Database error')) const req = new NextRequest('http://localhost:3000/api/chat') - const { GET } = await import('@/app/api/chat/route') const response = await GET(req) expect(response.status).toBe(500) @@ -147,15 +170,12 @@ describe('Chat API Route', () => { describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', body: JSON.stringify({}), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(401) @@ -163,11 +183,9 @@ describe('Chat API Route', () => { }) it('should validate request data', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const invalidData = { title: 'Test Chat' } // Missing required fields @@ -175,18 +193,15 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(invalidData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(400) }) it('should reject if identifier already exists', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const validData = { workflowId: 'workflow-123', @@ -204,7 +219,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(400) @@ -212,11 +226,9 @@ describe('Chat API Route', () => { }) it('should reject if workflow not found', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const validData = { workflowId: 'workflow-123', @@ -235,7 +247,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(404) @@ -246,18 +257,8 @@ describe('Chat API Route', () => { }) it('should allow chat deployment when user owns workflow directly', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }), - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock({ - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id', email: 'user@example.com' }, }) const validData = { @@ -281,7 +282,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(200) @@ -289,18 +289,8 @@ describe('Chat API Route', () => { }) it('should allow chat deployment when user has workspace admin permission', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }), - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock({ - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id', email: 'user@example.com' }, }) const validData = { @@ -324,7 +314,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(200) @@ -332,11 +321,9 @@ describe('Chat API Route', () => { }) it('should reject when workflow is in workspace but user lacks admin permission', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const validData = { workflowId: 'workflow-123', @@ -357,7 +344,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(404) @@ -369,11 +355,9 @@ describe('Chat API Route', () => { }) it('should handle workspace permission check errors gracefully', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) const validData = { workflowId: 'workflow-123', @@ -392,7 +376,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(500) @@ -400,11 +383,9 @@ describe('Chat API Route', () => { }) it('should auto-deploy workflow if not already deployed', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id', email: 'user@example.com' }, - }), - })) + mockGetSession.mockResolvedValue({ + user: { id: 'user-id', email: 'user@example.com' }, + }) const validData = { workflowId: 'workflow-123', @@ -427,7 +408,6 @@ describe('Chat API Route', () => { method: 'POST', body: JSON.stringify(validData), }) - const { POST } = await import('@/app/api/chat/route') const response = await POST(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index a6b19ad9c96..acf629072ac 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -1,11 +1,19 @@ -import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing' -import type { NextResponse } from 'next/server' /** * Tests for chat API utils * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, loggerMock, requestUtilsMock } from '@sim/testing' +import type { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDecryptSecret, mockMergeSubblockStateWithValues, mockMergeSubBlockValues } = vi.hoisted( + () => ({ + mockDecryptSecret: vi.fn(), + mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), + mockMergeSubBlockValues: vi.fn().mockReturnValue({}), + }) +) vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/logger', () => loggerMock) @@ -27,12 +35,10 @@ vi.mock('@/serializer', () => ({ })) vi.mock('@/lib/workflows/subblocks', () => ({ - mergeSubblockStateWithValues: vi.fn().mockReturnValue({}), - mergeSubBlockValues: vi.fn().mockReturnValue({}), + mergeSubblockStateWithValues: mockMergeSubblockStateWithValues, + mergeSubBlockValues: mockMergeSubBlockValues, })) -const mockDecryptSecret = vi.fn() - vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) @@ -49,8 +55,13 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: vi.fn(), })) +import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' +import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' + describe('Chat API Utils', () => { beforeEach(() => { + vi.clearAllMocks() vi.stubGlobal('process', { ...process, env: { @@ -60,14 +71,8 @@ describe('Chat API Utils', () => { }) }) - afterEach(() => { - vi.clearAllMocks() - }) - describe('Auth token utils', () => { - it.concurrent('should validate auth tokens', async () => { - const { validateAuthToken } = await import('@/lib/core/security/deployment') - + it.concurrent('should validate auth tokens', () => { const chatId = 'test-chat-id' const type = 'password' @@ -82,9 +87,7 @@ describe('Chat API Utils', () => { expect(isInvalidChat).toBe(false) }) - it.concurrent('should reject expired tokens', async () => { - const { validateAuthToken } = await import('@/lib/core/security/deployment') - + it.concurrent('should reject expired tokens', () => { const chatId = 'test-chat-id' const expiredToken = Buffer.from( `${chatId}:password:${Date.now() - 25 * 60 * 60 * 1000}` @@ -96,9 +99,7 @@ describe('Chat API Utils', () => { }) describe('Cookie handling', () => { - it('should set auth cookie correctly', async () => { - const { setChatAuthCookie } = await import('@/app/api/chat/utils') - + it('should set auth cookie correctly', () => { const mockSet = vi.fn() const mockResponse = { cookies: { @@ -125,9 +126,7 @@ describe('Chat API Utils', () => { }) describe('CORS handling', () => { - it('should add CORS headers for localhost in development', async () => { - const { addCorsHeaders } = await import('@/lib/core/security/deployment') - + it('should add CORS headers for localhost in development', () => { const mockRequest = { headers: { get: vi.fn().mockReturnValue('http://localhost:3000'), @@ -162,28 +161,11 @@ describe('Chat API Utils', () => { }) describe('Chat auth validation', () => { - beforeEach(async () => { - vi.clearAllMocks() + beforeEach(() => { mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' }) - - vi.doMock('@/app/api/chat/utils', async (importOriginal) => { - const original = (await importOriginal()) as any - return { - ...original, - validateAuthToken: vi.fn((token, id) => { - if (token === 'valid-token' && id === 'chat-id') { - return true - } - return false - }), - } - }) }) it('should allow access to public chats', async () => { - const utils = await import('@/app/api/chat/utils') - const { validateChatAuth } = utils - const deployment = { id: 'chat-id', authType: 'public', @@ -201,8 +183,6 @@ describe('Chat API Utils', () => { }) it('should request password auth for GET requests', async () => { - const { validateChatAuth } = await import('@/app/api/chat/utils') - const deployment = { id: 'chat-id', authType: 'password', @@ -222,9 +202,6 @@ describe('Chat API Utils', () => { }) it('should validate password for POST requests', async () => { - const { validateChatAuth } = await import('@/app/api/chat/utils') - const { decryptSecret } = await import('@/lib/core/security/encryption') - const deployment = { id: 'chat-id', authType: 'password', @@ -249,8 +226,6 @@ describe('Chat API Utils', () => { }) it('should reject incorrect password', async () => { - const { validateChatAuth } = await import('@/app/api/chat/utils') - const deployment = { id: 'chat-id', authType: 'password', @@ -275,8 +250,6 @@ describe('Chat API Utils', () => { }) it('should request email auth for email-protected chats', async () => { - const { validateChatAuth } = await import('@/app/api/chat/utils') - const deployment = { id: 'chat-id', authType: 'email', @@ -297,8 +270,6 @@ describe('Chat API Utils', () => { }) it('should check allowed emails for email auth', async () => { - const { validateChatAuth } = await import('@/app/api/chat/utils') - const deployment = { id: 'chat-id', authType: 'email', diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index 7ec617abf14..81f3a64d57d 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -3,45 +3,46 @@ * * @vitest-environment node */ -import { mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockFetch } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFetch: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/copilot/constants', () => ({ + SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', + SIM_AGENT_API_URL: 'https://agent.sim.example.com', +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + COPILOT_API_KEY: 'test-api-key', + }, + getEnv: vi.fn(), + isTruthy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), + isFalsy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, +})) + +import { DELETE, GET } from '@/app/api/copilot/api-keys/route' describe('Copilot API Keys API Route', () => { - const mockFetch = vi.fn() - beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() - - global.fetch = mockFetch - - vi.doMock('@/lib/copilot/constants', () => ({ - SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', - SIM_AGENT_API_URL: 'https://agent.sim.example.com', - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock({ - SIM_AGENT_API_URL: undefined, - COPILOT_API_KEY: 'test-api-key', - }) - }) - }) - - afterEach(() => { vi.clearAllMocks() - vi.restoreAllMocks() + global.fetch = mockFetch }) describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -51,8 +52,7 @@ describe('Copilot API Keys API Route', () => { }) it('should return list of API keys with masked values', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const mockApiKeys = [ { @@ -76,7 +76,6 @@ describe('Copilot API Keys API Route', () => { json: () => Promise.resolve(mockApiKeys), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -91,15 +90,13 @@ describe('Copilot API Keys API Route', () => { }) it('should return empty array when user has no API keys', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -109,15 +106,13 @@ describe('Copilot API Keys API Route', () => { }) it('should forward userId to Sim Agent', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') await GET(request) @@ -135,8 +130,7 @@ describe('Copilot API Keys API Route', () => { }) it('should return error when Sim Agent returns non-ok response', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: false, @@ -144,7 +138,6 @@ describe('Copilot API Keys API Route', () => { json: () => Promise.resolve({ error: 'Service unavailable' }), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -154,15 +147,13 @@ describe('Copilot API Keys API Route', () => { }) it('should return 500 when Sim Agent returns invalid response', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ invalid: 'response' }), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -172,12 +163,10 @@ describe('Copilot API Keys API Route', () => { }) it('should handle network errors gracefully', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockRejectedValueOnce(new Error('Network error')) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -187,8 +176,7 @@ describe('Copilot API Keys API Route', () => { }) it('should handle API keys with empty apiKey string', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const mockApiKeys = [ { @@ -205,7 +193,6 @@ describe('Copilot API Keys API Route', () => { json: () => Promise.resolve(mockApiKeys), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -215,15 +202,13 @@ describe('Copilot API Keys API Route', () => { }) it('should handle JSON parsing errors from Sim Agent', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new Error('Invalid JSON')), }) - const { GET } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await GET(request) @@ -235,10 +220,8 @@ describe('Copilot API Keys API Route', () => { describe('DELETE', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123') const response = await DELETE(request) @@ -248,10 +231,8 @@ describe('Copilot API Keys API Route', () => { }) it('should return 400 when id parameter is missing', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys') const response = await DELETE(request) @@ -261,15 +242,13 @@ describe('Copilot API Keys API Route', () => { }) it('should successfully delete an API key', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123') const response = await DELETE(request) @@ -291,8 +270,7 @@ describe('Copilot API Keys API Route', () => { }) it('should return error when Sim Agent returns non-ok response', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: false, @@ -300,7 +278,6 @@ describe('Copilot API Keys API Route', () => { json: () => Promise.resolve({ error: 'Key not found' }), }) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=non-existent') const response = await DELETE(request) @@ -310,15 +287,13 @@ describe('Copilot API Keys API Route', () => { }) it('should return 500 when Sim Agent returns invalid response', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: false }), }) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123') const response = await DELETE(request) @@ -328,12 +303,10 @@ describe('Copilot API Keys API Route', () => { }) it('should handle network errors gracefully', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockRejectedValueOnce(new Error('Network error')) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123') const response = await DELETE(request) @@ -343,15 +316,13 @@ describe('Copilot API Keys API Route', () => { }) it('should handle JSON parsing errors from Sim Agent on delete', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new Error('Invalid JSON')), }) - const { DELETE } = await import('@/app/api/copilot/api-keys/route') const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123') const response = await DELETE(request) diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index 3b19bc262e0..5dccbc21c00 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -3,55 +3,68 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Chat Delete API Route', () => { - const mockDelete = vi.fn() - const mockWhere = vi.fn() +const { mockDelete, mockWhere, mockGetSession } = vi.hoisted(() => ({ + mockDelete: vi.fn(), + mockWhere: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: { + delete: mockDelete, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'id', + userId: 'userId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), +})) + +import { DELETE } from '@/app/api/copilot/chat/delete/route' + +function createMockRequest(method: string, body: Record): NextRequest { + return new NextRequest('http://localhost:3000/api/copilot/chat/delete', { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} +describe('Copilot Chat Delete API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() + + mockGetSession.mockResolvedValue(null) mockDelete.mockReturnValue({ where: mockWhere }) mockWhere.mockResolvedValue([]) - - vi.doMock('@sim/db', () => ({ - db: { - delete: mockDelete, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - copilotChats: { - id: 'id', - userId: 'userId', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('DELETE', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('DELETE', { chatId: 'chat-123', }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(401) @@ -60,8 +73,7 @@ describe('Copilot Chat Delete API Route', () => { }) it('should successfully delete a chat', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockWhere.mockResolvedValueOnce([{ id: 'chat-123' }]) @@ -69,7 +81,6 @@ describe('Copilot Chat Delete API Route', () => { chatId: 'chat-123', }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(200) @@ -81,12 +92,10 @@ describe('Copilot Chat Delete API Route', () => { }) it('should return 500 for invalid request body - missing chatId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('DELETE', {}) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(500) @@ -95,14 +104,12 @@ describe('Copilot Chat Delete API Route', () => { }) it('should return 500 for invalid request body - chatId is not a string', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('DELETE', { chatId: 12345, }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(500) @@ -111,8 +118,7 @@ describe('Copilot Chat Delete API Route', () => { }) it('should handle database errors gracefully', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockWhere.mockRejectedValueOnce(new Error('Database connection failed')) @@ -120,7 +126,6 @@ describe('Copilot Chat Delete API Route', () => { chatId: 'chat-123', }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(500) @@ -129,8 +134,7 @@ describe('Copilot Chat Delete API Route', () => { }) it('should handle JSON parsing errors in request body', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = new NextRequest('http://localhost:3000/api/copilot/chat/delete', { method: 'DELETE', @@ -140,7 +144,6 @@ describe('Copilot Chat Delete API Route', () => { }, }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(500) @@ -149,8 +152,7 @@ describe('Copilot Chat Delete API Route', () => { }) it('should delete chat even if it does not exist (idempotent)', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockWhere.mockResolvedValueOnce([]) @@ -158,7 +160,6 @@ describe('Copilot Chat Delete API Route', () => { chatId: 'non-existent-chat', }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(200) @@ -167,14 +168,12 @@ describe('Copilot Chat Delete API Route', () => { }) it('should delete chat with empty string chatId (validation should fail)', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('DELETE', { chatId: '', }) - const { DELETE } = await import('@/app/api/copilot/chat/delete/route') const response = await DELETE(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index a1962153070..0376005c283 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -3,61 +3,86 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Chat Update Messages API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockLimit = vi.fn() - const mockUpdate = vi.fn() - const mockSet = vi.fn() +const { + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockUpdate, + mockSet, + mockUpdateWhere, + mockGetSession, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockLimit: vi.fn(), + mockUpdate: vi.fn(), + mockSet: vi.fn(), + mockUpdateWhere: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + update: mockUpdate, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'id', + userId: 'userId', + messages: 'messages', + updatedAt: 'updatedAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), +})) + +import { POST } from '@/app/api/copilot/chat/update-messages/route' + +function createMockRequest(method: string, body: Record): NextRequest { + return new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} +describe('Copilot Chat Update Messages API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() + + mockGetSession.mockResolvedValue(null) mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([]) // Default: no chat found + mockLimit.mockResolvedValue([]) mockUpdate.mockReturnValue({ set: mockSet }) - mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) // Different where for update - - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - copilotChats: { - id: 'id', - userId: 'userId', - messages: 'messages', - updatedAt: 'updatedAt', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) + mockUpdateWhere.mockResolvedValue(undefined) + mockSet.mockReturnValue({ where: mockUpdateWhere }) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -71,7 +96,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(401) @@ -80,8 +104,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 400 for invalid request body - missing chatId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { messages: [ @@ -92,10 +115,8 @@ describe('Copilot Chat Update Messages API Route', () => { timestamp: '2024-01-01T00:00:00.000Z', }, ], - // Missing chatId }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -104,15 +125,12 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 400 for invalid request body - missing messages', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { chatId: 'chat-123', - // Missing messages }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -121,20 +139,17 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 400 for invalid message structure - missing required fields', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { chatId: 'chat-123', messages: [ { id: 'msg-1', - // Missing role, content, timestamp }, ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -143,8 +158,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 400 for invalid message role', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -158,7 +172,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -167,10 +180,8 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 404 when chat is not found', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat not found mockLimit.mockResolvedValueOnce([]) const req = createMockRequest('POST', { @@ -185,7 +196,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(404) @@ -194,10 +204,8 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should return 404 when chat belongs to different user', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat not found (due to user mismatch) mockLimit.mockResolvedValueOnce([]) const req = createMockRequest('POST', { @@ -212,7 +220,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(404) @@ -221,8 +228,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should successfully update chat messages', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-123', @@ -251,7 +257,6 @@ describe('Copilot Chat Update Messages API Route', () => { messages, }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(200) @@ -270,8 +275,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should successfully update chat messages with optional fields', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-456', @@ -313,7 +317,6 @@ describe('Copilot Chat Update Messages API Route', () => { messages, }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(200) @@ -330,8 +333,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle empty messages array', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-789', @@ -345,7 +347,6 @@ describe('Copilot Chat Update Messages API Route', () => { messages: [], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(200) @@ -362,8 +363,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle database errors during chat lookup', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockLimit.mockRejectedValueOnce(new Error('Database connection failed')) @@ -379,7 +379,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -388,8 +387,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle database errors during update operation', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-123', @@ -414,7 +412,6 @@ describe('Copilot Chat Update Messages API Route', () => { ], }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -423,8 +420,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle JSON parsing errors in request body', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', { method: 'POST', @@ -434,7 +430,6 @@ describe('Copilot Chat Update Messages API Route', () => { }, }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(500) @@ -443,8 +438,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle large message arrays', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-large', @@ -465,7 +459,6 @@ describe('Copilot Chat Update Messages API Route', () => { messages, }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(200) @@ -482,8 +475,7 @@ describe('Copilot Chat Update Messages API Route', () => { }) it('should handle messages with both user and assistant roles', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const existingChat = { id: 'chat-mixed', @@ -531,7 +523,6 @@ describe('Copilot Chat Update Messages API Route', () => { messages, }) - const { POST } = await import('@/app/api/copilot/chat/update-messages/route') const response = await POST(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 71e74e053b5..aba03e59378 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -3,76 +3,84 @@ * * @vitest-environment node */ -import { mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Chats List API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockOrderBy = vi.fn() +const { + mockSelect, + mockFrom, + mockWhere, + mockOrderBy, + mockAuthenticate, + mockCreateUnauthorizedResponse, + mockCreateInternalServerErrorResponse, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockOrderBy: vi.fn(), + mockAuthenticate: vi.fn(), + mockCreateUnauthorizedResponse: vi.fn(), + mockCreateInternalServerErrorResponse: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'id', + title: 'title', + workflowId: 'workflowId', + userId: 'userId', + updatedAt: 'updatedAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), +})) + +vi.mock('@/lib/copilot/request-helpers', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticate, + createUnauthorizedResponse: mockCreateUnauthorizedResponse, + createInternalServerErrorResponse: mockCreateInternalServerErrorResponse, +})) + +import { GET } from '@/app/api/copilot/chats/route' +describe('Copilot Chats List API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockWhere.mockReturnValue({ orderBy: mockOrderBy }) mockOrderBy.mockResolvedValue([]) - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - copilotChats: { - id: 'id', - title: 'title', - workflowId: 'workflowId', - userId: 'userId', - updatedAt: 'updatedAt', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - desc: vi.fn((field) => ({ field, type: 'desc' })), - })) - - vi.doMock('@/lib/copilot/request-helpers', () => ({ - authenticateCopilotRequestSessionOnly: vi.fn(), - createUnauthorizedResponse: vi - .fn() - .mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })), - createInternalServerErrorResponse: vi - .fn() - .mockImplementation( - (message) => new Response(JSON.stringify({ error: message }), { status: 500 }) - ), - })) + mockCreateUnauthorizedResponse.mockReturnValue( + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + ) + mockCreateInternalServerErrorResponse.mockImplementation( + (message: string) => new Response(JSON.stringify({ error: message }), { status: 500 }) + ) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: null, isAuthenticated: false, }) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -82,17 +90,13 @@ describe('Copilot Chats List API Route', () => { }) it('should return empty chats array when user has no chats', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) mockOrderBy.mockResolvedValueOnce([]) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -105,10 +109,7 @@ describe('Copilot Chats List API Route', () => { }) it('should return list of chats for authenticated user', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -129,7 +130,6 @@ describe('Copilot Chats List API Route', () => { ] mockOrderBy.mockResolvedValueOnce(mockChats) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -143,10 +143,7 @@ describe('Copilot Chats List API Route', () => { }) it('should return chats ordered by updatedAt descending', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -173,7 +170,6 @@ describe('Copilot Chats List API Route', () => { ] mockOrderBy.mockResolvedValueOnce(mockChats) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -184,10 +180,7 @@ describe('Copilot Chats List API Route', () => { }) it('should handle chats with null workflowId', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -202,7 +195,6 @@ describe('Copilot Chats List API Route', () => { ] mockOrderBy.mockResolvedValueOnce(mockChats) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -212,17 +204,13 @@ describe('Copilot Chats List API Route', () => { }) it('should handle database errors gracefully', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed')) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -232,10 +220,7 @@ describe('Copilot Chats List API Route', () => { }) it('should only return chats belonging to authenticated user', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -250,7 +235,6 @@ describe('Copilot Chats List API Route', () => { ] mockOrderBy.mockResolvedValueOnce(mockChats) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') await GET(request as any) @@ -259,15 +243,11 @@ describe('Copilot Chats List API Route', () => { }) it('should return 401 when userId is null despite isAuthenticated being true', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: null, isAuthenticated: true, }) - const { GET } = await import('@/app/api/copilot/chats/route') const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index aa464170acc..5dad327cfd3 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -3,63 +3,105 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { + mockSelect, + mockFrom, + mockWhere, + mockThen, + mockDelete, + mockDeleteWhere, + mockAuthorize, + mockGetSession, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockThen: vi.fn(), + mockDelete: vi.fn(), + mockDeleteWhere: vi.fn(), + mockAuthorize: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: vi.fn(() => 'http://localhost:3000'), + getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'), + getBaseDomain: vi.fn(() => 'localhost:3000'), + getEmailDomain: vi.fn(() => 'localhost:3000'), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorize, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + delete: mockDelete, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflowCheckpoints: { + id: 'id', + userId: 'userId', + workflowId: 'workflowId', + workflowState: 'workflowState', + }, + workflow: { + id: 'id', + userId: 'userId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), +})) + +import { POST } from '@/app/api/copilot/checkpoints/revert/route' + describe('Copilot Checkpoints Revert API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockThen = vi.fn() + /** Queued results for successive `.then()` calls in the db select chain */ + let thenResults: unknown[] beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() - - vi.doMock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'http://localhost:3000'), - getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'), - getBaseDomain: vi.fn(() => 'localhost:3000'), - getEmailDomain: vi.fn(() => 'localhost:3000'), - })) - - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: vi.fn().mockResolvedValue({ - allowed: true, - status: 200, - }), - })) + vi.clearAllMocks() + + thenResults = [] + + mockGetSession.mockResolvedValue(null) + + mockAuthorize.mockResolvedValue({ + allowed: true, + status: 200, + }) mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockWhere.mockReturnValue({ then: mockThen }) - mockThen.mockResolvedValue(null) // Default: no data found - - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - workflowCheckpoints: { - id: 'id', - userId: 'userId', - workflowId: 'workflowId', - workflowState: 'workflowState', - }, - workflow: { - id: 'id', - userId: 'userId', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) + + // Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0]. + // We invoke the callback with our mock rows array so the route gets the expected value. + mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => { + const result = thenResults.shift() + if (result instanceof Error) { + return Promise.reject(result) + } + const rows = result === undefined ? [] : [result] + return Promise.resolve(callback(rows)) + }) + + // Mock delete chain + mockDelete.mockReturnValue({ where: mockDeleteWhere }) + mockDeleteWhere.mockResolvedValue(undefined) global.fetch = vi.fn() @@ -83,16 +125,26 @@ describe('Copilot Checkpoints Revert API Route', () => { vi.restoreAllMocks() }) + /** Helper to set authenticated state */ + function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) { + mockGetSession.mockResolvedValue({ user }) + } + + /** Helper to set unauthenticated state */ + function setUnauthenticated() { + mockGetSession.mockResolvedValue(null) + } + describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + setUnauthenticated() - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(401) @@ -101,14 +153,14 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 500 for invalid request body - missing checkpointId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { - // Missing checkpointId + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -117,14 +169,14 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 500 for empty checkpointId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { - checkpointId: '', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: '' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -133,17 +185,17 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 404 when checkpoint is not found', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() // Mock checkpoint not found - mockThen.mockResolvedValueOnce(undefined) + thenResults.push(undefined) - const req = createMockRequest('POST', { - checkpointId: 'non-existent-checkpoint', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'non-existent-checkpoint' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(404) @@ -152,17 +204,17 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 404 when checkpoint belongs to different user', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() // Mock checkpoint not found (due to user mismatch in query) - mockThen.mockResolvedValueOnce(undefined) + thenResults.push(undefined) - const req = createMockRequest('POST', { - checkpointId: 'other-user-checkpoint', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'other-user-checkpoint' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(404) @@ -171,10 +223,8 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 404 when workflow is not found', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - // Mock checkpoint found but workflow not found const mockCheckpoint = { id: 'checkpoint-123', workflowId: 'a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c6', @@ -182,15 +232,15 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - mockThen - .mockResolvedValueOnce(mockCheckpoint) // Checkpoint found - .mockResolvedValueOnce(undefined) // Workflow not found + thenResults.push(mockCheckpoint) // Checkpoint found + thenResults.push(undefined) // Workflow not found - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(404) @@ -199,10 +249,8 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 401 when workflow belongs to different user', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - // Mock checkpoint found but workflow belongs to different user const mockCheckpoint = { id: 'checkpoint-123', workflowId: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7', @@ -215,21 +263,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'different-user', } - mockThen - .mockResolvedValueOnce(mockCheckpoint) // Checkpoint found - .mockResolvedValueOnce(mockWorkflow) // Workflow found but different user + thenResults.push(mockCheckpoint) // Checkpoint found + thenResults.push(mockWorkflow) // Workflow found but different user - const { authorizeWorkflowByWorkspacePermission } = await import('@/lib/workflows/utils') - vi.mocked(authorizeWorkflowByWorkspacePermission).mockResolvedValueOnce({ + mockAuthorize.mockResolvedValueOnce({ allowed: false, status: 403, }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(401) @@ -238,8 +285,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should successfully revert checkpoint with basic workflow state', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -260,11 +306,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen - .mockResolvedValueOnce(mockCheckpoint) // Checkpoint found - .mockResolvedValueOnce(mockWorkflow) // Workflow found - - // Mock successful state API call + thenResults.push(mockCheckpoint) // Checkpoint found + thenResults.push(mockWorkflow) // Workflow found ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -282,7 +325,6 @@ describe('Copilot Checkpoints Revert API Route', () => { }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) @@ -329,8 +371,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle checkpoint state with valid deployedAt date', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-with-date', @@ -349,18 +390,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }), }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-with-date', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-with-date' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) @@ -370,8 +413,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle checkpoint state with invalid deployedAt date', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-invalid-date', @@ -390,18 +432,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }), }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-invalid-date', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-invalid-date' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) @@ -411,8 +455,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle checkpoint state with null/undefined values', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-null-values', @@ -432,18 +475,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }), }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-null-values', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-null-values' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) @@ -462,8 +507,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should return 500 when state API call fails', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -477,22 +521,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen - .mockResolvedValueOnce(mockCheckpoint) - .mockResolvedValueOnce(mockWorkflow) - - // Mock failed state API call + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: false, text: () => Promise.resolve('State validation failed'), }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -501,17 +543,17 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle database errors during checkpoint lookup', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() // Mock database error - mockThen.mockRejectedValueOnce(new Error('Database connection failed')) + thenResults.push(new Error('Database connection failed')) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -520,8 +562,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle database errors during workflow lookup', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -530,15 +571,15 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - mockThen - .mockResolvedValueOnce(mockCheckpoint) // Checkpoint found - .mockRejectedValueOnce(new Error('Database error during workflow lookup')) // Workflow lookup fails + thenResults.push(mockCheckpoint) // Checkpoint found + thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -547,8 +588,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle fetch network errors', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -562,19 +602,17 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen - .mockResolvedValueOnce(mockCheckpoint) - .mockResolvedValueOnce(mockWorkflow) - - // Mock fetch network error + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockRejectedValue(new Error('Network error')) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-123', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -583,10 +621,8 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle JSON parsing errors in request body', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - // Create a request with invalid JSON const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', body: '{invalid-json', @@ -595,7 +631,6 @@ describe('Copilot Checkpoints Revert API Route', () => { }, }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(500) @@ -604,8 +639,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should forward cookies to state API call', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -619,7 +653,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -637,7 +672,6 @@ describe('Copilot Checkpoints Revert API Route', () => { }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') await POST(req) expect(global.fetch).toHaveBeenCalledWith( @@ -654,8 +688,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle missing cookies gracefully', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-123', @@ -669,7 +702,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -687,7 +721,6 @@ describe('Copilot Checkpoints Revert API Route', () => { }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) @@ -705,8 +738,7 @@ describe('Copilot Checkpoints Revert API Route', () => { }) it('should handle complex checkpoint state with all fields', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const mockCheckpoint = { id: 'checkpoint-complex', @@ -742,18 +774,20 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - mockThen.mockResolvedValueOnce(mockCheckpoint).mockResolvedValueOnce(mockWorkflow) + thenResults.push(mockCheckpoint) + thenResults.push(mockWorkflow) ;(global.fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }), }) - const req = createMockRequest('POST', { - checkpointId: 'checkpoint-complex', + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-complex' }), }) - const { POST } = await import('@/app/api/copilot/checkpoints/revert/route') const response = await POST(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index 5a15e37b132..fcf6080c0c7 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -3,22 +3,45 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Checkpoints API Route', () => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockLimit = vi.fn() - const mockOrderBy = vi.fn() - const mockInsert = vi.fn() - const mockValues = vi.fn() - const mockReturning = vi.fn() - - const mockCopilotChats = { id: 'id', userId: 'userId' } - const mockWorkflowCheckpoints = { +const { + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockOrderBy, + mockInsert, + mockValues, + mockReturning, + mockGetSession, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockLimit: vi.fn(), + mockOrderBy: vi.fn(), + mockInsert: vi.fn(), + mockValues: vi.fn(), + mockReturning: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + insert: mockInsert, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { id: 'id', userId: 'userId' }, + workflowCheckpoints: { id: 'id', userId: 'userId', workflowId: 'workflowId', @@ -26,12 +49,30 @@ describe('Copilot Checkpoints API Route', () => { messageId: 'messageId', createdAt: 'createdAt', updatedAt: 'updatedAt', - } + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), +})) + +import { GET, POST } from '@/app/api/copilot/checkpoints/route' + +function createMockRequest(method: string, body: Record): NextRequest { + return new NextRequest('http://localhost:3000/api/copilot/checkpoints', { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} +describe('Copilot Checkpoints API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() + + mockGetSession.mockResolvedValue(null) mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) @@ -43,35 +84,15 @@ describe('Copilot Checkpoints API Route', () => { mockLimit.mockResolvedValue([]) mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) - - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - copilotChats: mockCopilotChats, - workflowCheckpoints: mockWorkflowCheckpoints, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - desc: vi.fn((field) => ({ field, type: 'desc' })), - })) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('POST', { workflowId: 'workflow-123', @@ -79,7 +100,6 @@ describe('Copilot Checkpoints API Route', () => { workflowState: '{"blocks": []}', }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(401) @@ -88,16 +108,12 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return 500 for invalid request body', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = createMockRequest('POST', { - // Missing required fields workflowId: 'workflow-123', - // Missing chatId and workflowState }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(500) @@ -106,10 +122,8 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return 400 when chat not found or unauthorized', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat not found mockLimit.mockResolvedValue([]) const req = createMockRequest('POST', { @@ -118,7 +132,6 @@ describe('Copilot Checkpoints API Route', () => { workflowState: '{"blocks": []}', }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(400) @@ -127,10 +140,8 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return 400 for invalid workflow state JSON', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat exists const chat = { id: 'chat-123', userId: 'user-123', @@ -140,10 +151,9 @@ describe('Copilot Checkpoints API Route', () => { const req = createMockRequest('POST', { workflowId: 'workflow-123', chatId: 'chat-123', - workflowState: 'invalid-json', // Invalid JSON + workflowState: 'invalid-json', }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(400) @@ -152,17 +162,14 @@ describe('Copilot Checkpoints API Route', () => { }) it('should successfully create a checkpoint', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat exists const chat = { id: 'chat-123', userId: 'user-123', } mockLimit.mockResolvedValue([chat]) - // Mock successful checkpoint creation const checkpoint = { id: 'checkpoint-123', userId: 'user-123', @@ -182,7 +189,6 @@ describe('Copilot Checkpoints API Route', () => { workflowState: JSON.stringify(workflowState), }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(200) @@ -200,29 +206,25 @@ describe('Copilot Checkpoints API Route', () => { }, }) - // Verify database operations expect(mockInsert).toHaveBeenCalled() expect(mockValues).toHaveBeenCalledWith({ userId: 'user-123', workflowId: 'workflow-123', chatId: 'chat-123', messageId: 'message-123', - workflowState: workflowState, // Should be parsed JSON object + workflowState: workflowState, }) }) it('should create checkpoint without messageId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat exists const chat = { id: 'chat-123', userId: 'user-123', } mockLimit.mockResolvedValue([chat]) - // Mock successful checkpoint creation const checkpoint = { id: 'checkpoint-123', userId: 'user-123', @@ -238,11 +240,9 @@ describe('Copilot Checkpoints API Route', () => { const req = createMockRequest('POST', { workflowId: 'workflow-123', chatId: 'chat-123', - // No messageId provided workflowState: JSON.stringify(workflowState), }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(200) @@ -252,17 +252,14 @@ describe('Copilot Checkpoints API Route', () => { }) it('should handle database errors during checkpoint creation', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock chat exists const chat = { id: 'chat-123', userId: 'user-123', } mockLimit.mockResolvedValue([chat]) - // Mock database error mockReturning.mockRejectedValue(new Error('Database insert failed')) const req = createMockRequest('POST', { @@ -271,7 +268,6 @@ describe('Copilot Checkpoints API Route', () => { workflowState: '{"blocks": []}', }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(500) @@ -280,10 +276,8 @@ describe('Copilot Checkpoints API Route', () => { }) it('should handle database errors during chat lookup', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock database error during chat lookup mockLimit.mockRejectedValue(new Error('Database query failed')) const req = createMockRequest('POST', { @@ -292,7 +286,6 @@ describe('Copilot Checkpoints API Route', () => { workflowState: '{"blocks": []}', }) - const { POST } = await import('@/app/api/copilot/checkpoints/route') const response = await POST(req) expect(response.status).toBe(500) @@ -303,12 +296,10 @@ describe('Copilot Checkpoints API Route', () => { describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - const { GET } = await import('@/app/api/copilot/checkpoints/route') const response = await GET(req) expect(response.status).toBe(401) @@ -317,12 +308,10 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return 400 when chatId is missing', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints') - const { GET } = await import('@/app/api/copilot/checkpoints/route') const response = await GET(req) expect(response.status).toBe(400) @@ -331,8 +320,7 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return checkpoints for authenticated user and chat', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) const mockCheckpoints = [ { @@ -359,7 +347,6 @@ describe('Copilot Checkpoints API Route', () => { const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - const { GET } = await import('@/app/api/copilot/checkpoints/route') const response = await GET(req) expect(response.status).toBe(200) @@ -388,22 +375,18 @@ describe('Copilot Checkpoints API Route', () => { ], }) - // Verify database query was made correctly expect(mockSelect).toHaveBeenCalled() expect(mockWhere).toHaveBeenCalled() expect(mockOrderBy).toHaveBeenCalled() }) it('should handle database errors when fetching checkpoints', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - // Mock database error mockOrderBy.mockRejectedValue(new Error('Database query failed')) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - const { GET } = await import('@/app/api/copilot/checkpoints/route') const response = await GET(req) expect(response.status).toBe(500) @@ -412,14 +395,12 @@ describe('Copilot Checkpoints API Route', () => { }) it('should return empty array when no checkpoints found', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockOrderBy.mockResolvedValue([]) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - const { GET } = await import('@/app/api/copilot/checkpoints/route') const response = await GET(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 78c46982ede..20f6ecf5a14 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -3,19 +3,29 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Confirm API Route', () => { - const mockRedisExists = vi.fn() - const mockRedisSet = vi.fn() - const mockGetRedisClient = vi.fn() +const { mockGetSession, mockRedisExists, mockRedisSet, mockGetRedisClient } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRedisExists: vi.fn(), + mockRedisSet: vi.fn(), + mockGetRedisClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: mockGetRedisClient, +})) + +import { POST } from '@/app/api/copilot/confirm/route' +describe('Copilot Confirm API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() const mockRedisClient = { exists: mockRedisExists, @@ -26,15 +36,11 @@ describe('Copilot Confirm API Route', () => { mockRedisExists.mockResolvedValue(1) mockRedisSet.mockResolvedValue('OK') - vi.doMock('@/lib/core/config/redis', () => ({ - getRedisClient: mockGetRedisClient, - })) - vi.spyOn(global, 'setTimeout').mockImplementation((callback, _delay) => { if (typeof callback === 'function') { setImmediate(callback) } - return setTimeout(() => {}, 0) as any + return setTimeout(() => {}, 0) as unknown as NodeJS.Timeout }) let mockTime = 1640995200000 @@ -45,21 +51,36 @@ describe('Copilot Confirm API Route', () => { }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) + function createMockPostRequest(body: Record): NextRequest { + return new NextRequest('http://localhost:3000/api/copilot/confirm', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + } + + function setAuthenticated() { + mockGetSession.mockResolvedValue({ + user: { id: 'test-user-id', email: 'test@example.com', name: 'Test User' }, + }) + } + + function setUnauthenticated() { + mockGetSession.mockResolvedValue(null) + } + describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const authMocks = mockAuth() - authMocks.setUnauthenticated() + setUnauthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(401) @@ -68,14 +89,12 @@ describe('Copilot Confirm API Route', () => { }) it('should return 400 for invalid request body - missing toolCallId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -84,15 +103,12 @@ describe('Copilot Confirm API Route', () => { }) it('should return 400 for invalid request body - missing status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', - // Missing status }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -101,15 +117,13 @@ describe('Copilot Confirm API Route', () => { }) it('should return 400 for invalid status value', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'invalid-status', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -118,16 +132,14 @@ describe('Copilot Confirm API Route', () => { }) it('should successfully confirm tool call with success status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'success', message: 'Tool executed successfully', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) @@ -143,16 +155,14 @@ describe('Copilot Confirm API Route', () => { }) it('should successfully confirm tool call with error status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-456', status: 'error', message: 'Tool execution failed', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) @@ -168,15 +178,13 @@ describe('Copilot Confirm API Route', () => { }) it('should successfully confirm tool call with accepted status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-789', status: 'accepted', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) @@ -192,15 +200,13 @@ describe('Copilot Confirm API Route', () => { }) it('should successfully confirm tool call with rejected status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-101', status: 'rejected', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) @@ -214,16 +220,14 @@ describe('Copilot Confirm API Route', () => { }) it('should successfully confirm tool call with background status', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-bg', status: 'background', message: 'Moved to background execution', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) @@ -237,17 +241,15 @@ describe('Copilot Confirm API Route', () => { }) it('should return 400 when Redis client is not available', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() mockGetRedisClient.mockReturnValue(null) - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -256,36 +258,32 @@ describe('Copilot Confirm API Route', () => { }) it('should return 400 when Redis set fails', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() mockRedisSet.mockRejectedValueOnce(new Error('Redis set failed')) - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'non-existent-tool', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) const responseData = await response.json() expect(responseData.error).toBe('Failed to update tool call status or tool call not found') - }, 10000) // 10 second timeout for this specific test + }, 10000) it('should handle Redis errors gracefully', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() mockRedisSet.mockRejectedValueOnce(new Error('Redis connection failed')) - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -294,18 +292,16 @@ describe('Copilot Confirm API Route', () => { }) it('should handle Redis set operation failure', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() mockRedisExists.mockResolvedValue(1) mockRedisSet.mockRejectedValue(new Error('Redis set failed')) - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: 'tool-call-123', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -314,8 +310,7 @@ describe('Copilot Confirm API Route', () => { }) it('should handle JSON parsing errors in request body', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const req = new NextRequest('http://localhost:3000/api/copilot/confirm', { method: 'POST', @@ -325,7 +320,6 @@ describe('Copilot Confirm API Route', () => { }, }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(500) @@ -334,15 +328,13 @@ describe('Copilot Confirm API Route', () => { }) it('should validate empty toolCallId', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: '', status: 'success', }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(400) @@ -351,18 +343,16 @@ describe('Copilot Confirm API Route', () => { }) it('should handle all valid status types', async () => { - const authMocks = mockAuth() - authMocks.setAuthenticated() + setAuthenticated() const validStatuses = ['success', 'error', 'accepted', 'rejected', 'background'] for (const status of validStatuses) { - const req = createMockRequest('POST', { + const req = createMockPostRequest({ toolCallId: `tool-call-${status}`, status, }) - const { POST } = await import('@/app/api/copilot/confirm/route') const response = await POST(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index 5752d7a5af1..de2a4d87576 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -3,21 +3,79 @@ * * @vitest-environment node */ -import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Feedback API Route', () => { - const mockInsert = vi.fn() - const mockValues = vi.fn() - const mockReturning = vi.fn() - const mockSelect = vi.fn() - const mockFrom = vi.fn() +const { + mockInsert, + mockValues, + mockReturning, + mockSelect, + mockFrom, + mockAuthenticate, + mockCreateUnauthorizedResponse, + mockCreateBadRequestResponse, + mockCreateInternalServerErrorResponse, + mockCreateRequestTracker, +} = vi.hoisted(() => ({ + mockInsert: vi.fn(), + mockValues: vi.fn(), + mockReturning: vi.fn(), + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockAuthenticate: vi.fn(), + mockCreateUnauthorizedResponse: vi.fn(), + mockCreateBadRequestResponse: vi.fn(), + mockCreateInternalServerErrorResponse: vi.fn(), + mockCreateRequestTracker: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + insert: mockInsert, + select: mockSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotFeedback: { + feedbackId: 'feedbackId', + userId: 'userId', + chatId: 'chatId', + userQuery: 'userQuery', + agentResponse: 'agentResponse', + isPositive: 'isPositive', + feedback: 'feedback', + workflowYaml: 'workflowYaml', + createdAt: 'createdAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), +})) + +vi.mock('@/lib/copilot/request-helpers', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticate, + createUnauthorizedResponse: mockCreateUnauthorizedResponse, + createBadRequestResponse: mockCreateBadRequestResponse, + createInternalServerErrorResponse: mockCreateInternalServerErrorResponse, + createRequestTracker: mockCreateRequestTracker, +})) + +import { GET, POST } from '@/app/api/copilot/feedback/route' + +function createMockRequest(method: string, body: Record): NextRequest { + return new NextRequest('http://localhost:3000/api/copilot/feedback', { + method, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} +describe('Copilot Feedback API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() + vi.clearAllMocks() mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) @@ -25,64 +83,28 @@ describe('Copilot Feedback API Route', () => { mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockResolvedValue([]) - vi.doMock('@sim/db', () => ({ - db: { - insert: mockInsert, - select: mockSelect, - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - copilotFeedback: { - feedbackId: 'feedbackId', - userId: 'userId', - chatId: 'chatId', - userQuery: 'userQuery', - agentResponse: 'agentResponse', - isPositive: 'isPositive', - feedback: 'feedback', - workflowYaml: 'workflowYaml', - createdAt: 'createdAt', - }, - })) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - })) - - vi.doMock('@/lib/copilot/request-helpers', () => ({ - authenticateCopilotRequestSessionOnly: vi.fn(), - createUnauthorizedResponse: vi - .fn() - .mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })), - createBadRequestResponse: vi - .fn() - .mockImplementation( - (message) => new Response(JSON.stringify({ error: message }), { status: 400 }) - ), - createInternalServerErrorResponse: vi - .fn() - .mockImplementation( - (message) => new Response(JSON.stringify({ error: message }), { status: 500 }) - ), - createRequestTracker: vi.fn().mockReturnValue({ - requestId: 'test-request-id', - getDuration: vi.fn().mockReturnValue(100), - }), - })) + mockCreateRequestTracker.mockReturnValue({ + requestId: 'test-request-id', + getDuration: vi.fn().mockReturnValue(100), + }) + mockCreateUnauthorizedResponse.mockReturnValue( + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + ) + mockCreateBadRequestResponse.mockImplementation( + (message: string) => new Response(JSON.stringify({ error: message }), { status: 400 }) + ) + mockCreateInternalServerErrorResponse.mockImplementation( + (message: string) => new Response(JSON.stringify({ error: message }), { status: 500 }) + ) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: null, isAuthenticated: false, }) @@ -94,7 +116,6 @@ describe('Copilot Feedback API Route', () => { isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(401) @@ -103,10 +124,7 @@ describe('Copilot Feedback API Route', () => { }) it('should successfully submit positive feedback', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -131,7 +149,6 @@ describe('Copilot Feedback API Route', () => { isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(200) @@ -142,10 +159,7 @@ describe('Copilot Feedback API Route', () => { }) it('should successfully submit negative feedback with text', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -171,7 +185,6 @@ describe('Copilot Feedback API Route', () => { feedback: 'The response was not helpful', }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(200) @@ -181,10 +194,7 @@ describe('Copilot Feedback API Route', () => { }) it('should successfully submit feedback with workflow YAML', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -221,7 +231,6 @@ edges: workflowYaml: workflowYaml, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(200) @@ -236,10 +245,7 @@ edges: }) it('should return 400 for invalid chatId format', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -251,7 +257,6 @@ edges: isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(400) @@ -260,10 +265,7 @@ edges: }) it('should return 400 for empty userQuery', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -275,7 +277,6 @@ edges: isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(400) @@ -284,10 +285,7 @@ edges: }) it('should return 400 for empty agentResponse', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -299,7 +297,6 @@ edges: isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(400) @@ -308,10 +305,7 @@ edges: }) it('should return 400 for missing isPositiveFeedback', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -322,7 +316,6 @@ edges: agentResponse: 'You can create a workflow by...', }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(400) @@ -331,10 +324,7 @@ edges: }) it('should handle database errors gracefully', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -348,7 +338,6 @@ edges: isPositiveFeedback: true, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(500) @@ -357,10 +346,7 @@ edges: }) it('should handle JSON parsing errors in request body', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -373,7 +359,6 @@ edges: }, }) - const { POST } = await import('@/app/api/copilot/feedback/route') const response = await POST(req) expect(response.status).toBe(500) @@ -382,15 +367,11 @@ edges: describe('GET', () => { it('should return 401 when user is not authenticated', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: null, isAuthenticated: false, }) - const { GET } = await import('@/app/api/copilot/feedback/route') const request = new Request('http://localhost:3000/api/copilot/feedback') const response = await GET(request as any) @@ -400,17 +381,13 @@ edges: }) it('should return empty feedback array when no feedback exists', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) mockFrom.mockResolvedValueOnce([]) - const { GET } = await import('@/app/api/copilot/feedback/route') const request = new Request('http://localhost:3000/api/copilot/feedback') const response = await GET(request as any) @@ -421,10 +398,7 @@ edges: }) it('should return all feedback records', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -455,7 +429,6 @@ edges: ] mockFrom.mockResolvedValueOnce(mockFeedback) - const { GET } = await import('@/app/api/copilot/feedback/route') const request = new Request('http://localhost:3000/api/copilot/feedback') const response = await GET(request as any) @@ -468,17 +441,13 @@ edges: }) it('should handle database errors gracefully', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) mockFrom.mockRejectedValueOnce(new Error('Database connection failed')) - const { GET } = await import('@/app/api/copilot/feedback/route') const request = new Request('http://localhost:3000/api/copilot/feedback') const response = await GET(request as any) @@ -488,17 +457,13 @@ edges: }) it('should return metadata with response', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticate.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) mockFrom.mockResolvedValueOnce([]) - const { GET } = await import('@/app/api/copilot/feedback/route') const request = new Request('http://localhost:3000/api/copilot/feedback') const response = await GET(request as any) diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index 1732a686fe3..176a97eb371 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -3,66 +3,84 @@ * * @vitest-environment node */ -import { createMockRequest, mockCryptoUuid, setupCommonApiMocks } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -describe('Copilot Stats API Route', () => { - const mockFetch = vi.fn() +const { + mockAuthenticateCopilotRequestSessionOnly, + mockCreateUnauthorizedResponse, + mockCreateBadRequestResponse, + mockCreateInternalServerErrorResponse, + mockCreateRequestTracker, + mockFetch, +} = vi.hoisted(() => ({ + mockAuthenticateCopilotRequestSessionOnly: vi.fn(), + mockCreateUnauthorizedResponse: vi.fn(), + mockCreateBadRequestResponse: vi.fn(), + mockCreateInternalServerErrorResponse: vi.fn(), + mockCreateRequestTracker: vi.fn(), + mockFetch: vi.fn(), +})) + +vi.mock('@/lib/copilot/request-helpers', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticateCopilotRequestSessionOnly, + createUnauthorizedResponse: mockCreateUnauthorizedResponse, + createBadRequestResponse: mockCreateBadRequestResponse, + createInternalServerErrorResponse: mockCreateInternalServerErrorResponse, + createRequestTracker: mockCreateRequestTracker, +})) + +vi.mock('@/lib/copilot/constants', () => ({ + SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', + SIM_AGENT_API_URL: 'https://agent.sim.example.com', +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + COPILOT_API_KEY: 'test-api-key', + }, + getEnv: vi.fn((key: string) => { + const vals: Record = { + COPILOT_API_KEY: 'test-api-key', + } + return vals[key] + }), + isTruthy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), + isFalsy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, +})) + +import { POST } from '@/app/api/copilot/stats/route' +describe('Copilot Stats API Route', () => { beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() - + vi.clearAllMocks() global.fetch = mockFetch - vi.doMock('@/lib/copilot/request-helpers', () => ({ - authenticateCopilotRequestSessionOnly: vi.fn(), - createUnauthorizedResponse: vi - .fn() - .mockReturnValue(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })), - createBadRequestResponse: vi - .fn() - .mockImplementation( - (message) => new Response(JSON.stringify({ error: message }), { status: 400 }) - ), - createInternalServerErrorResponse: vi - .fn() - .mockImplementation( - (message) => new Response(JSON.stringify({ error: message }), { status: 500 }) - ), - createRequestTracker: vi.fn().mockReturnValue({ - requestId: 'test-request-id', - getDuration: vi.fn().mockReturnValue(100), - }), - })) - - vi.doMock('@/lib/copilot/constants', () => ({ - SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', - SIM_AGENT_API_URL: 'https://agent.sim.example.com', - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock({ - SIM_AGENT_API_URL: undefined, - COPILOT_API_KEY: 'test-api-key', - }) + mockCreateUnauthorizedResponse.mockReturnValue( + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + ) + mockCreateBadRequestResponse.mockImplementation( + (message: string) => new Response(JSON.stringify({ error: message }), { status: 400 }) + ) + mockCreateInternalServerErrorResponse.mockImplementation( + (message: string) => new Response(JSON.stringify({ error: message }), { status: 500 }) + ) + mockCreateRequestTracker.mockReturnValue({ + requestId: 'test-request-id', + getDuration: vi.fn().mockReturnValue(100), }) }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) describe('POST', () => { it('should return 401 when user is not authenticated', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: null, isAuthenticated: false, }) @@ -73,7 +91,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(401) @@ -82,10 +99,7 @@ describe('Copilot Stats API Route', () => { }) it('should successfully forward stats to Sim Agent', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -101,7 +115,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: true, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(200) @@ -126,10 +139,7 @@ describe('Copilot Stats API Route', () => { }) it('should return 400 for invalid request body - missing messageId', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -139,7 +149,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -148,10 +157,7 @@ describe('Copilot Stats API Route', () => { }) it('should return 400 for invalid request body - missing diffCreated', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -161,7 +167,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -170,10 +175,7 @@ describe('Copilot Stats API Route', () => { }) it('should return 400 for invalid request body - missing diffAccepted', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -183,7 +185,6 @@ describe('Copilot Stats API Route', () => { diffCreated: true, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -192,10 +193,7 @@ describe('Copilot Stats API Route', () => { }) it('should return 400 when upstream Sim Agent returns error', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -211,7 +209,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -220,10 +217,7 @@ describe('Copilot Stats API Route', () => { }) it('should handle upstream error with message field', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -239,7 +233,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -248,10 +241,7 @@ describe('Copilot Stats API Route', () => { }) it('should handle upstream error with no JSON response', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -267,7 +257,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -276,10 +265,7 @@ describe('Copilot Stats API Route', () => { }) it('should handle network errors gracefully', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -292,7 +278,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(500) @@ -301,10 +286,7 @@ describe('Copilot Stats API Route', () => { }) it('should handle JSON parsing errors in request body', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -317,7 +299,6 @@ describe('Copilot Stats API Route', () => { }, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(400) @@ -326,10 +307,7 @@ describe('Copilot Stats API Route', () => { }) it('should forward stats with diffCreated=false and diffAccepted=false', async () => { - const { authenticateCopilotRequestSessionOnly } = await import( - '@/lib/copilot/request-helpers' - ) - vi.mocked(authenticateCopilotRequestSessionOnly).mockResolvedValueOnce({ + mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: 'user-123', isAuthenticated: true, }) @@ -345,7 +323,6 @@ describe('Copilot Stats API Route', () => { diffAccepted: false, }) - const { POST } = await import('@/app/api/copilot/stats/route') const response = await POST(req) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index 26fa2d9f091..e63955015dc 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -1,110 +1,170 @@ -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - mockHybridAuth, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -/** Setup file API mocks for file delete tests */ -function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth() - if (authenticated) { - authMocks.setAuthenticated() - } else { - authMocks.setUnauthenticated() +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockCheckHybridAuth = vi.fn() + const mockCheckSessionOrInternalAuth = vi.fn() + const mockCheckInternalAuth = vi.fn() + const mockVerifyFileAccess = vi.fn() + const mockVerifyWorkspaceFileAccess = vi.fn() + const mockDeleteFile = vi.fn() + const mockHasCloudStorage = vi.fn() + const mockGetStorageProvider = vi.fn() + const mockIsUsingCloudStorage = vi.fn() + const mockUploadFile = vi.fn() + const mockDownloadFile = vi.fn() + + return { + mockGetSession, + mockCheckHybridAuth, + mockCheckSessionOrInternalAuth, + mockCheckInternalAuth, + mockVerifyFileAccess, + mockVerifyWorkspaceFileAccess, + mockDeleteFile, + mockHasCloudStorage, + mockGetStorageProvider, + mockIsUsingCloudStorage, + mockUploadFile, + mockDownloadFile, } +}) - const { mockCheckSessionOrInternalAuth } = mockHybridAuth() - mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - })) - - const uploadFileMock = vi.fn().mockResolvedValue({ - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }) - const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test content')) - const deleteFileMock = vi.fn().mockResolvedValue(undefined) - const hasCloudStorageMock = vi.fn().mockReturnValue(cloudEnabled) - - vi.doMock('@/lib/uploads', () => ({ - getStorageProvider: vi.fn().mockReturnValue(storageProvider), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - StorageService: { - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - }, - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - })) - - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: uploadFileMock, - downloadFile: downloadFileMock, - deleteFile: deleteFileMock, - hasCloudStorage: hasCloudStorageMock, - })) - - vi.doMock('fs/promises', () => ({ - unlink: vi.fn().mockResolvedValue(undefined), - access: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue({ isFile: () => true }), - })) - - return { auth: authMocks } -} +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@sim/db/schema', () => ({ + workflowFolder: { + id: 'id', + userId: 'userId', + parentId: 'parentId', + updatedAt: 'updatedAt', + workspaceId: 'workspaceId', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + }, + workflow: { id: 'id', folderId: 'folderId', userId: 'userId', updatedAt: 'updatedAt' }, + account: { userId: 'userId', providerId: 'providerId' }, + user: { email: 'email', id: 'id' }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), + gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), + lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), + gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), + lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), + ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), + asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), + desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), + isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), + isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), + inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), + notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), + like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), + ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), + count: vi.fn((field: unknown) => ({ field, type: 'count' })), + sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), + avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), + min: vi.fn((field: unknown) => ({ field, type: 'min' })), + max: vi.fn((field: unknown) => ({ field, type: 'max' })), + sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), +})) + +vi.mock('uuid', () => ({ + v4: vi.fn().mockReturnValue('test-uuid'), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.mockGetSession, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: mocks.mockCheckHybridAuth, + checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth, + checkInternalAuth: mocks.mockCheckInternalAuth, +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mocks.mockVerifyFileAccess, + verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess, +})) + +vi.mock('@/lib/uploads', () => ({ + getStorageProvider: mocks.mockGetStorageProvider, + isUsingCloudStorage: mocks.mockIsUsingCloudStorage, + StorageService: { + uploadFile: mocks.mockUploadFile, + downloadFile: mocks.mockDownloadFile, + deleteFile: mocks.mockDeleteFile, + hasCloudStorage: mocks.mockHasCloudStorage, + }, + uploadFile: mocks.mockUploadFile, + downloadFile: mocks.mockDownloadFile, + deleteFile: mocks.mockDeleteFile, + hasCloudStorage: mocks.mockHasCloudStorage, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: mocks.mockUploadFile, + downloadFile: mocks.mockDownloadFile, + deleteFile: mocks.mockDeleteFile, + hasCloudStorage: mocks.mockHasCloudStorage, +})) + +vi.mock('@/lib/uploads/setup.server', () => ({})) + +vi.mock('fs/promises', () => ({ + unlink: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ isFile: () => true }), +})) + +import { createMockRequest } from '@sim/testing' +import { OPTIONS, POST } from '@/app/api/files/delete/route' describe('File Delete API Route', () => { beforeEach(() => { - vi.resetModules() - vi.doMock('@/lib/uploads/setup.server', () => ({})) - }) - - afterEach(() => { vi.clearAllMocks() + + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), + }) + + mocks.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } }) + mocks.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'test-user-id', + error: undefined, + }) + mocks.mockVerifyFileAccess.mockResolvedValue(true) + mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) + mocks.mockDeleteFile.mockResolvedValue(undefined) + mocks.mockHasCloudStorage.mockReturnValue(true) + mocks.mockGetStorageProvider.mockReturnValue('s3') + mocks.mockIsUsingCloudStorage.mockReturnValue(true) }) it('should handle local file deletion successfully', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) + mocks.mockHasCloudStorage.mockReturnValue(false) + mocks.mockGetStorageProvider.mockReturnValue('local') + mocks.mockIsUsingCloudStorage.mockReturnValue(false) const req = createMockRequest('POST', { filePath: '/api/files/serve/workspace/test-workspace-id/test-file.txt', }) - const { POST } = await import('@/app/api/files/delete/route') - const response = await POST(req) const data = await response.json() @@ -115,17 +175,14 @@ describe('File Delete API Route', () => { }) it('should handle file not found gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) + mocks.mockHasCloudStorage.mockReturnValue(false) + mocks.mockGetStorageProvider.mockReturnValue('local') + mocks.mockIsUsingCloudStorage.mockReturnValue(false) const req = createMockRequest('POST', { filePath: '/api/files/serve/workspace/test-workspace-id/nonexistent.txt', }) - const { POST } = await import('@/app/api/files/delete/route') - const response = await POST(req) const data = await response.json() @@ -135,17 +192,10 @@ describe('File Delete API Route', () => { }) it('should handle S3 file deletion successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - const req = createMockRequest('POST', { filePath: '/api/files/serve/workspace/test-workspace-id/1234567890-test-file.txt', }) - const { POST } = await import('@/app/api/files/delete/route') - const response = await POST(req) const data = await response.json() @@ -153,25 +203,19 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('message', 'File deleted successfully') - const storageService = await import('@/lib/uploads/core/storage-service') - expect(storageService.deleteFile).toHaveBeenCalledWith({ + expect(mocks.mockDeleteFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-test-file.txt', context: 'workspace', }) }) it('should handle Azure Blob file deletion successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) + mocks.mockGetStorageProvider.mockReturnValue('blob') const req = createMockRequest('POST', { filePath: '/api/files/serve/workspace/test-workspace-id/1234567890-test-document.pdf', }) - const { POST } = await import('@/app/api/files/delete/route') - const response = await POST(req) const data = await response.json() @@ -179,20 +223,15 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('message', 'File deleted successfully') - const storageService = await import('@/lib/uploads/core/storage-service') - expect(storageService.deleteFile).toHaveBeenCalledWith({ + expect(mocks.mockDeleteFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-test-document.pdf', context: 'workspace', }) }) it('should handle missing file path', async () => { - setupFileApiMocks() - const req = createMockRequest('POST', {}) - const { POST } = await import('@/app/api/files/delete/route') - const response = await POST(req) const data = await response.json() @@ -202,8 +241,6 @@ describe('File Delete API Route', () => { }) it('should handle CORS preflight requests', async () => { - const { OPTIONS } = await import('@/app/api/files/delete/route') - const response = await OPTIONS() expect(response.status).toBe(204) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index eb69942d38a..51aeecf3ee9 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -1,20 +1,152 @@ -import path from 'path' /** * Tests for file parse API route * * @vitest-environment node */ -import { - createMockRequest, - mockAuth, - mockCryptoUuid, - mockHybridAuth, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { + mockVerifyFileAccess, + mockVerifyWorkspaceFileAccess, + mockGetStorageProvider, + mockIsUsingCloudStorage, + mockIsSupportedFileType, + mockParseFile, + mockParseBuffer, + mockDownloadFile, + mockHasCloudStorage, + mockFsAccess, + mockFsStat, + mockFsReadFile, + mockFsWriteFile, + mockJoin, + mockGetSession, + mockCheckInternalAuth, + mockCheckHybridAuth, + mockCheckSessionOrInternalAuth, + actualPath, +} = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const actualPath = require('path') as typeof import('path') + return { + mockVerifyFileAccess: vi.fn().mockResolvedValue(true), + mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + mockGetStorageProvider: vi.fn().mockReturnValue('s3'), + mockIsUsingCloudStorage: vi.fn().mockReturnValue(true), + mockIsSupportedFileType: vi.fn().mockReturnValue(true), + mockParseFile: vi.fn().mockResolvedValue({ + content: 'parsed content', + metadata: { pageCount: 1 }, + }), + mockParseBuffer: vi.fn().mockResolvedValue({ + content: 'parsed buffer content', + metadata: { pageCount: 1 }, + }), + mockDownloadFile: vi.fn(), + mockHasCloudStorage: vi.fn().mockReturnValue(true), + mockFsAccess: vi.fn().mockResolvedValue(undefined), + mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true })), + mockFsReadFile: vi.fn().mockResolvedValue(Buffer.from('test file content')), + mockFsWriteFile: vi.fn().mockResolvedValue(undefined), + mockJoin: vi.fn((...args: string[]): string => { + if (args[0] === '/test/uploads') { + return `/test/uploads/${args[args.length - 1]}` + } + return actualPath.join(...args) + }), + mockGetSession: vi.fn(), + mockCheckInternalAuth: vi.fn(), + mockCheckHybridAuth: vi.fn(), + mockCheckSessionOrInternalAuth: vi.fn(), + actualPath, + } +}) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mockVerifyFileAccess, + verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess, +})) + +vi.mock('@/lib/uploads', () => ({ + getStorageProvider: mockGetStorageProvider, + isUsingCloudStorage: mockIsUsingCloudStorage, +})) + +vi.mock('@/lib/file-parsers', () => ({ + isSupportedFileType: mockIsSupportedFileType, + parseFile: mockParseFile, + parseBuffer: mockParseBuffer, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + hasCloudStorage: mockHasCloudStorage, +})) + +vi.mock('path', () => ({ + default: actualPath, + ...actualPath, + join: mockJoin, + basename: actualPath.basename, + extname: actualPath.extname, +})) + +vi.mock('@/lib/uploads/setup.server', () => ({})) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + UPLOAD_DIR_SERVER: '/test/uploads', +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, + auth: vi.fn(), + signIn: vi.fn(), + signUp: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkInternalAuth: mockCheckInternalAuth, + checkHybridAuth: mockCheckHybridAuth, + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: vi.fn(), + validateUrlWithDNS: vi.fn(), +})) + +vi.mock('@/lib/core/utils/logging', () => ({ + sanitizeUrlForLog: vi.fn((url: string) => url), +})) + +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + getFileMetadataByKey: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue({ canView: true }), +})) + +vi.mock('fs/promises', () => ({ + default: { + access: mockFsAccess, + stat: mockFsStat, + readFile: mockFsReadFile, + writeFile: mockFsWriteFile, + }, + access: mockFsAccess, + stat: mockFsStat, + readFile: mockFsReadFile, + writeFile: mockFsWriteFile, +})) + +import { POST } from '@/app/api/files/parse/route' + function setupFileApiMocks( options: { authenticated?: boolean @@ -24,75 +156,53 @@ function setupFileApiMocks( ) { const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth() if (authenticated) { - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ + user: { id: 'test-user-id', email: 'test@example.com' }, + }) } else { - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) } - const { mockCheckInternalAuth } = mockHybridAuth() mockCheckInternalAuth.mockResolvedValue({ success: authenticated, userId: authenticated ? 'test-user-id' : undefined, error: authenticated ? undefined : 'Unauthorized', }) - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - })) + mockCheckHybridAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) - vi.doMock('@/lib/uploads', () => ({ - getStorageProvider: vi.fn().mockReturnValue(storageProvider), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - })) + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: authenticated, + userId: authenticated ? 'test-user-id' : undefined, + error: authenticated ? undefined : 'Unauthorized', + }) - return { auth: authMocks } + mockGetStorageProvider.mockReturnValue(storageProvider) + mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) } -const mockJoin = vi.fn((...args: string[]): string => { - if (args[0] === '/test/uploads') { - return `/test/uploads/${args[args.length - 1]}` - } - return path.join(...args) -}) - describe('File Parse API Route', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() setupFileApiMocks({ authenticated: true, }) - vi.doMock('@/lib/file-parsers', () => ({ - isSupportedFileType: vi.fn().mockReturnValue(true), - parseFile: vi.fn().mockResolvedValue({ - content: 'parsed content', - metadata: { pageCount: 1 }, - }), - parseBuffer: vi.fn().mockResolvedValue({ - content: 'parsed buffer content', - metadata: { pageCount: 1 }, - }), - })) - - vi.doMock('path', () => { - return { - default: path, - ...path, - join: mockJoin, - basename: path.basename, - extname: path.extname, - } + mockIsSupportedFileType.mockReturnValue(true) + mockParseFile.mockResolvedValue({ + content: 'parsed content', + metadata: { pageCount: 1 }, + }) + mockParseBuffer.mockResolvedValue({ + content: 'parsed buffer content', + metadata: { pageCount: 1 }, }) - - vi.doMock('@/lib/uploads/setup.server', () => ({})) }) afterEach(() => { @@ -101,7 +211,6 @@ describe('File Parse API Route', () => { it('should handle missing file path', async () => { const req = createMockRequest('POST', {}) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -121,7 +230,6 @@ describe('File Parse API Route', () => { filePath: '/api/files/serve/test-file.txt', }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -147,7 +255,6 @@ describe('File Parse API Route', () => { filePath: '/api/files/serve/s3/test-file.pdf', }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -171,7 +278,6 @@ describe('File Parse API Route', () => { filePath: ['/api/files/serve/file1.txt', '/api/files/serve/file2.txt'], }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -194,7 +300,6 @@ describe('File Parse API Route', () => { '/api/files/serve/s3/6vzIweweXAS1pJ1mMSrr9Flh6paJpHAx/79dac297-5ebb-410b-b135-cc594dfcb361/c36afbb0-af50-42b0-9b23-5dae2d9384e8/Confirmation.pdf?context=execution', }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -219,7 +324,6 @@ describe('File Parse API Route', () => { '/api/files/serve/s3/fa8e96e6-7482-4e3c-a0e8-ea083b28af55-be56ca4f-83c2-4559-a6a4-e25eb4ab8ee2_1761691045516-1ie5q86-Confirmation.pdf?context=workspace', }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -239,12 +343,8 @@ describe('File Parse API Route', () => { authenticated: true, }) - const downloadFileMock = vi.fn().mockRejectedValue(new Error('Access denied')) - - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: downloadFileMock, - hasCloudStorage: vi.fn().mockReturnValue(true), - })) + mockDownloadFile.mockRejectedValue(new Error('Access denied')) + mockHasCloudStorage.mockReturnValue(true) const req = new NextRequest('http://localhost:3000/api/files/parse', { method: 'POST', @@ -253,7 +353,6 @@ describe('File Parse API Route', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -268,18 +367,12 @@ describe('File Parse API Route', () => { authenticated: true, }) - vi.doMock('fs/promises', () => ({ - access: vi.fn().mockRejectedValue(new Error('ENOENT: no such file')), - stat: vi.fn().mockImplementation(() => ({ isFile: () => true })), - readFile: vi.fn().mockResolvedValue(Buffer.from('test file content')), - writeFile: vi.fn().mockResolvedValue(undefined), - })) + mockFsAccess.mockRejectedValue(new Error('ENOENT: no such file')) const req = createMockRequest('POST', { filePath: 'nonexistent.txt', }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(req) const data = await response.json() @@ -291,7 +384,7 @@ describe('File Parse API Route', () => { describe('Files Parse API - Path Traversal Security', () => { beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() setupFileApiMocks({ authenticated: true, }) @@ -315,7 +408,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -341,7 +433,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -367,7 +458,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -391,7 +481,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -418,7 +507,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -444,7 +532,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -462,7 +549,6 @@ describe('Files Parse API - Path Traversal Security', () => { }), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() @@ -476,7 +562,6 @@ describe('Files Parse API - Path Traversal Security', () => { body: JSON.stringify({}), }) - const { POST } = await import('@/app/api/files/parse/route') const response = await POST(request) const result = await response.json() diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 4089343a9cd..83c2229abcb 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -1,19 +1,100 @@ -import { - mockAuth, - mockCryptoUuid, - mockHybridAuth, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - /** * Tests for file presigned API route * * @vitest-environment node */ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockVerifyFileAccess, + mockVerifyWorkspaceFileAccess, + mockUseBlobStorage, + mockUseS3Storage, + mockGetStorageConfig, + mockIsUsingCloudStorage, + mockGetStorageProvider, + mockHasCloudStorage, + mockGeneratePresignedUploadUrl, + mockGeneratePresignedDownloadUrl, + mockValidateFileType, + mockGenerateCopilotUploadUrl, + mockIsImageFileType, + mockGetStorageProviderUploads, + mockIsUsingCloudStorageUploads, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyFileAccess: vi.fn().mockResolvedValue(true), + mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), + mockUseBlobStorage: { value: false }, + mockUseS3Storage: { value: true }, + mockGetStorageConfig: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + mockGetStorageProvider: vi.fn(), + mockHasCloudStorage: vi.fn(), + mockGeneratePresignedUploadUrl: vi.fn(), + mockGeneratePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'), + mockValidateFileType: vi.fn().mockReturnValue(null), + mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({ + url: 'https://example.com/presigned-url', + key: 'copilot/test-key.txt', + }), + mockIsImageFileType: vi.fn().mockReturnValue(true), + mockGetStorageProviderUploads: vi.fn(), + mockIsUsingCloudStorageUploads: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mockVerifyFileAccess, + verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess, +})) + +vi.mock('@/lib/uploads/config', () => ({ + get USE_BLOB_STORAGE() { + return mockUseBlobStorage.value + }, + get USE_S3_STORAGE() { + return mockUseS3Storage.value + }, + UPLOAD_DIR: '/uploads', + getStorageConfig: mockGetStorageConfig, + isUsingCloudStorage: mockIsUsingCloudStorage, + getStorageProvider: mockGetStorageProvider, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + hasCloudStorage: mockHasCloudStorage, + generatePresignedUploadUrl: mockGeneratePresignedUploadUrl, + generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, +})) + +vi.mock('@/lib/uploads/utils/validation', () => ({ + validateFileType: mockValidateFileType, +})) + +vi.mock('@/lib/uploads', () => ({ + CopilotFiles: { + generateCopilotUploadUrl: mockGenerateCopilotUploadUrl, + isImageFileType: mockIsImageFileType, + }, + getStorageProvider: mockGetStorageProviderUploads, + isUsingCloudStorage: mockIsUsingCloudStorageUploads, +})) + +import { OPTIONS, POST } from '@/app/api/files/presigned/route' + +const defaultMockUser = { + id: 'test-user-id', + name: 'Test User', + email: 'test@example.com', +} + function setupFileApiMocks( options: { authenticated?: boolean @@ -23,100 +104,61 @@ function setupFileApiMocks( ) { const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth() if (authenticated) { - authMocks.setAuthenticated() + mockGetSession.mockResolvedValue({ user: defaultMockUser }) } else { - authMocks.setUnauthenticated() + mockGetSession.mockResolvedValue(null) } - const { mockCheckHybridAuth } = mockHybridAuth() - mockCheckHybridAuth.mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - })) - const useBlobStorage = storageProvider === 'blob' && cloudEnabled const useS3Storage = storageProvider === 's3' && cloudEnabled - vi.doMock('@/lib/uploads/config', () => ({ - USE_BLOB_STORAGE: useBlobStorage, - USE_S3_STORAGE: useS3Storage, - UPLOAD_DIR: '/uploads', - getStorageConfig: vi.fn().mockReturnValue( - useBlobStorage - ? { - accountName: 'testaccount', - accountKey: 'testkey', - connectionString: 'testconnection', - containerName: 'testcontainer', - } - : { - bucket: 'test-bucket', - region: 'us-east-1', - } - ), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - getStorageProvider: vi - .fn() - .mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ), - })) - - const mockGeneratePresignedUploadUrl = vi.fn().mockImplementation(async (opts) => { - const timestamp = Date.now() - const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') - const key = `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}` - return { - url: 'https://example.com/presigned-url', - key, - } - }) + mockUseBlobStorage.value = useBlobStorage + mockUseS3Storage.value = useS3Storage - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - generatePresignedUploadUrl: mockGeneratePresignedUploadUrl, - generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'), - })) + mockGetStorageConfig.mockReturnValue( + useBlobStorage + ? { + accountName: 'testaccount', + accountKey: 'testkey', + connectionString: 'testconnection', + containerName: 'testcontainer', + } + : { + bucket: 'test-bucket', + region: 'us-east-1', + } + ) + mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) + mockGetStorageProvider.mockReturnValue( + storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' + ) + + mockHasCloudStorage.mockReturnValue(cloudEnabled) + mockGeneratePresignedUploadUrl.mockImplementation( + async (opts: { fileName: string; context: string }) => { + const timestamp = Date.now() + const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') + const key = `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}` + return { + url: 'https://example.com/presigned-url', + key, + } + } + ) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://example.com/presigned-url') - vi.doMock('@/lib/uploads/utils/validation', () => ({ - validateFileType: vi.fn().mockReturnValue(null), - })) + mockValidateFileType.mockReturnValue(null) - vi.doMock('@/lib/uploads', () => ({ - CopilotFiles: { - generateCopilotUploadUrl: vi.fn().mockResolvedValue({ - url: 'https://example.com/presigned-url', - key: 'copilot/test-key.txt', - }), - isImageFileType: vi.fn().mockReturnValue(true), - }, - getStorageProvider: vi - .fn() - .mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - })) - - return { auth: authMocks } + mockGetStorageProviderUploads.mockReturnValue( + storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' + ) + mockIsUsingCloudStorageUploads.mockReturnValue(cloudEnabled) } describe('/api/files/presigned', () => { beforeEach(() => { vi.clearAllMocks() - vi.resetModules() vi.useFakeTimers() vi.setSystemTime(new Date('2024-01-01T00:00:00Z')) @@ -136,8 +178,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', body: JSON.stringify({ @@ -166,8 +206,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned', { method: 'POST', body: JSON.stringify({ @@ -190,8 +228,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned', { method: 'POST', body: JSON.stringify({ @@ -214,8 +250,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned', { method: 'POST', body: JSON.stringify({ @@ -239,8 +273,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit) const request = new NextRequest('http://localhost:3000/api/files/presigned', { method: 'POST', @@ -265,8 +297,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', body: JSON.stringify({ @@ -297,8 +327,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest( 'http://localhost:3000/api/files/presigned?type=knowledge-base', { @@ -325,8 +353,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', body: JSON.stringify({ @@ -352,8 +378,6 @@ describe('/api/files/presigned', () => { storageProvider: 'blob', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', body: JSON.stringify({ @@ -384,8 +408,6 @@ describe('/api/files/presigned', () => { storageProvider: 'blob', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', body: JSON.stringify({ @@ -411,14 +433,9 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: vi.fn().mockReturnValue(true), - generatePresignedUploadUrl: vi - .fn() - .mockRejectedValue(new Error('Unknown storage provider: unknown')), - })) - - const { POST } = await import('@/app/api/files/presigned/route') + mockGeneratePresignedUploadUrl.mockRejectedValue( + new Error('Unknown storage provider: unknown') + ) const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', @@ -443,12 +460,7 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: vi.fn().mockReturnValue(true), - generatePresignedUploadUrl: vi.fn().mockRejectedValue(new Error('S3 service unavailable')), - })) - - const { POST } = await import('@/app/api/files/presigned/route') + mockGeneratePresignedUploadUrl.mockRejectedValue(new Error('S3 service unavailable')) const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', @@ -473,14 +485,7 @@ describe('/api/files/presigned', () => { storageProvider: 'blob', }) - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: vi.fn().mockReturnValue(true), - generatePresignedUploadUrl: vi - .fn() - .mockRejectedValue(new Error('Azure service unavailable')), - })) - - const { POST } = await import('@/app/api/files/presigned/route') + mockGeneratePresignedUploadUrl.mockRejectedValue(new Error('Azure service unavailable')) const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { method: 'POST', @@ -505,8 +510,6 @@ describe('/api/files/presigned', () => { storageProvider: 's3', }) - const { POST } = await import('@/app/api/files/presigned/route') - const request = new NextRequest('http://localhost:3000/api/files/presigned', { method: 'POST', body: 'invalid json', @@ -523,8 +526,6 @@ describe('/api/files/presigned', () => { describe('OPTIONS', () => { it('should handle CORS preflight requests', async () => { - const { OPTIONS } = await import('@/app/api/files/presigned/route') - const response = await OPTIONS() expect(response.status).toBe(200) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index d2b3b58a352..390348c4c01 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -3,91 +3,105 @@ * * @vitest-environment node */ -import { - defaultMockUser, - mockAuth, - mockCryptoUuid, - mockHybridAuth, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -function setupApiTestMocks( - options: { - authenticated?: boolean - user?: { id: string; email: string } - withFileSystem?: boolean - withUploadUtils?: boolean - } = {} -) { - const { authenticated = true, user = defaultMockUser, withFileSystem = false } = options - - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() - - const authMocks = mockAuth(user) - if (authenticated) { - authMocks.setAuthenticated(user) - } else { - authMocks.setUnauthenticated() +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckSessionOrInternalAuth, + mockVerifyFileAccess, + mockReadFile, + mockIsUsingCloudStorage, + mockDownloadFile, + mockDownloadCopilotFile, + mockInferContextFromKey, + mockGetContentType, + mockFindLocalFile, + mockCreateFileResponse, + mockCreateErrorResponse, + FileNotFoundError, +} = vi.hoisted(() => { + class FileNotFoundErrorClass extends Error { + constructor(message: string) { + super(message) + this.name = 'FileNotFoundError' + } } - - if (withFileSystem) { - vi.doMock('fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue(Buffer.from('test content')), - access: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }), - })) + return { + mockCheckSessionOrInternalAuth: vi.fn(), + mockVerifyFileAccess: vi.fn(), + mockReadFile: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + mockDownloadFile: vi.fn(), + mockDownloadCopilotFile: vi.fn(), + mockInferContextFromKey: vi.fn(), + mockGetContentType: vi.fn(), + mockFindLocalFile: vi.fn(), + mockCreateFileResponse: vi.fn(), + mockCreateErrorResponse: vi.fn(), + FileNotFoundError: FileNotFoundErrorClass, } +}) - return { auth: authMocks } -} +vi.mock('fs/promises', () => ({ + readFile: mockReadFile, + access: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mockVerifyFileAccess, +})) + +vi.mock('@/lib/uploads', () => ({ + CopilotFiles: { + downloadCopilotFile: mockDownloadCopilotFile, + }, + isUsingCloudStorage: mockIsUsingCloudStorage, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + hasCloudStorage: vi.fn().mockReturnValue(true), +})) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + inferContextFromKey: mockInferContextFromKey, +})) + +vi.mock('@/lib/uploads/setup.server', () => ({})) + +vi.mock('@/app/api/files/utils', () => ({ + FileNotFoundError, + createFileResponse: mockCreateFileResponse, + createErrorResponse: mockCreateErrorResponse, + getContentType: mockGetContentType, + extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + extractFilename: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + findLocalFile: mockFindLocalFile, +})) + +import { GET } from '@/app/api/files/serve/[...path]/route' describe('File Serve API Route', () => { beforeEach(() => { - vi.resetModules() - - setupApiTestMocks({ - withFileSystem: true, - withUploadUtils: true, - }) + vi.clearAllMocks() - const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() - serveAuthMock.mockResolvedValue({ + mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'test-user-id', }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - })) - - vi.doMock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - })) - - vi.doMock('@/lib/uploads', () => ({ - CopilotFiles: { - downloadCopilotFile: vi.fn(), - }, - isUsingCloudStorage: vi.fn().mockReturnValue(false), - })) - - vi.doMock('@/lib/uploads/utils/file-utils', () => ({ - inferContextFromKey: vi.fn().mockReturnValue('workspace'), - })) - - vi.doMock('@/app/api/files/utils', () => ({ - FileNotFoundError: class FileNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'FileNotFoundError' - } - }, - createFileResponse: vi.fn().mockImplementation((file) => { + mockVerifyFileAccess.mockResolvedValue(true) + mockReadFile.mockResolvedValue(Buffer.from('test content')) + mockIsUsingCloudStorage.mockReturnValue(false) + mockInferContextFromKey.mockReturnValue('workspace') + mockGetContentType.mockReturnValue('text/plain') + mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt') + mockCreateFileResponse.mockImplementation( + (file: { buffer: Buffer; contentType: string; filename: string }) => { return new Response(file.buffer, { status: 200, headers: { @@ -95,24 +109,14 @@ describe('File Serve API Route', () => { 'Content-Disposition': `inline; filename="${file.filename}"`, }, }) - }), - createErrorResponse: vi.fn().mockImplementation((error) => { - return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, - headers: { 'Content-Type': 'application/json' }, - }) - }), - getContentType: vi.fn().mockReturnValue('text/plain'), - extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), - findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'), - })) - - vi.doMock('@/lib/uploads/setup.server', () => ({})) - }) - - afterEach(() => { - vi.clearAllMocks() + } + ) + mockCreateErrorResponse.mockImplementation((error: Error) => { + return new Response(JSON.stringify({ error: error.name, message: error.message }), { + status: error.name === 'FileNotFoundError' ? 404 : 500, + headers: { 'Content-Type': 'application/json' }, + }) + }) }) it('should serve local file successfully', async () => { @@ -120,7 +124,6 @@ describe('File Serve API Route', () => { 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/test-file.txt' ) const params = { path: ['workspace', 'test-workspace-id', 'test-file.txt'] } - const { GET } = await import('@/app/api/files/serve/[...path]/route') const response = await GET(req, { params: Promise.resolve(params) }) @@ -131,198 +134,53 @@ describe('File Serve API Route', () => { expect(disposition).toContain('filename=') expect(disposition).toContain('test-file.txt') - const fs = await import('fs/promises') - expect(fs.readFile).toHaveBeenCalled() + expect(mockReadFile).toHaveBeenCalled() }) it('should handle nested paths correctly', async () => { - vi.doMock('@/app/api/files/utils', () => ({ - FileNotFoundError: class FileNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'FileNotFoundError' - } - }, - createFileResponse: vi.fn().mockImplementation((file) => { - return new Response(file.buffer, { - status: 200, - headers: { - 'Content-Type': file.contentType, - 'Content-Disposition': `inline; filename="${file.filename}"`, - }, - }) - }), - createErrorResponse: vi.fn().mockImplementation((error) => { - return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, - headers: { 'Content-Type': 'application/json' }, - }) - }), - getContentType: vi.fn().mockReturnValue('text/plain'), - extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), - findLocalFile: vi.fn().mockReturnValue('/test/uploads/nested/path/file.txt'), - })) - - const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() - serveAuthMock.mockResolvedValue({ - success: true, - userId: 'test-user-id', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - })) - - vi.doMock('@/lib/uploads', () => ({ - CopilotFiles: { - downloadCopilotFile: vi.fn(), - }, - isUsingCloudStorage: vi.fn().mockReturnValue(false), - })) - - vi.doMock('@/lib/uploads/utils/file-utils', () => ({ - inferContextFromKey: vi.fn().mockReturnValue('workspace'), - })) + mockFindLocalFile.mockReturnValue('/test/uploads/nested/path/file.txt') const req = new NextRequest( 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nested-path-file.txt' ) const params = { path: ['workspace', 'test-workspace-id', 'nested-path-file.txt'] } - const { GET } = await import('@/app/api/files/serve/[...path]/route') const response = await GET(req, { params: Promise.resolve(params) }) expect(response.status).toBe(200) - const fs = await import('fs/promises') - expect(fs.readFile).toHaveBeenCalledWith('/test/uploads/nested/path/file.txt') + expect(mockReadFile).toHaveBeenCalledWith('/test/uploads/nested/path/file.txt') }) it('should serve cloud file by downloading and proxying', async () => { - const downloadFileMock = vi.fn().mockResolvedValue(Buffer.from('test cloud file content')) - - vi.doMock('@/lib/uploads', () => ({ - StorageService: { - downloadFile: downloadFileMock, - generatePresignedDownloadUrl: vi - .fn() - .mockResolvedValue('https://example-s3.com/presigned-url'), - hasCloudStorage: vi.fn().mockReturnValue(true), - }, - isUsingCloudStorage: vi.fn().mockReturnValue(true), - })) - - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: downloadFileMock, - hasCloudStorage: vi.fn().mockReturnValue(true), - })) - - vi.doMock('@/lib/uploads/setup', () => ({ - UPLOAD_DIR: '/test/uploads', - USE_S3_STORAGE: true, - USE_BLOB_STORAGE: false, - })) - - const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() - serveAuthMock.mockResolvedValue({ - success: true, - userId: 'test-user-id', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - })) - - vi.doMock('@/app/api/files/utils', () => ({ - FileNotFoundError: class FileNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'FileNotFoundError' - } - }, - createFileResponse: vi.fn().mockImplementation((file) => { - return new Response(file.buffer, { - status: 200, - headers: { - 'Content-Type': file.contentType, - 'Content-Disposition': `inline; filename="${file.filename}"`, - }, - }) - }), - createErrorResponse: vi.fn().mockImplementation((error) => { - return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, - headers: { 'Content-Type': 'application/json' }, - }) - }), - getContentType: vi.fn().mockReturnValue('image/png'), - extractStorageKey: vi.fn().mockImplementation((path) => path.split('/').pop()), - extractFilename: vi.fn().mockImplementation((path) => path.split('/').pop()), - findLocalFile: vi.fn().mockReturnValue('/test/uploads/test-file.txt'), - })) + mockIsUsingCloudStorage.mockReturnValue(true) + mockDownloadFile.mockResolvedValue(Buffer.from('test cloud file content')) + mockGetContentType.mockReturnValue('image/png') const req = new NextRequest( 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/1234567890-image.png' ) const params = { path: ['workspace', 'test-workspace-id', '1234567890-image.png'] } - const { GET } = await import('@/app/api/files/serve/[...path]/route') const response = await GET(req, { params: Promise.resolve(params) }) expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('image/png') - expect(downloadFileMock).toHaveBeenCalledWith({ + expect(mockDownloadFile).toHaveBeenCalledWith({ key: 'workspace/test-workspace-id/1234567890-image.png', context: 'workspace', }) }) it('should return 404 when file not found', async () => { - vi.doMock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), - })) - - vi.doMock('fs/promises', () => ({ - readFile: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')), - })) - - const { mockCheckSessionOrInternalAuth: serveAuthMock } = mockHybridAuth() - serveAuthMock.mockResolvedValue({ - success: true, - userId: 'test-user-id', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(false), // File not found = no access - })) - - vi.doMock('@/app/api/files/utils', () => ({ - FileNotFoundError: class FileNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'FileNotFoundError' - } - }, - createFileResponse: vi.fn(), - createErrorResponse: vi.fn().mockImplementation((error) => { - return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, - headers: { 'Content-Type': 'application/json' }, - }) - }), - getContentType: vi.fn().mockReturnValue('text/plain'), - extractStorageKey: vi.fn(), - extractFilename: vi.fn(), - findLocalFile: vi.fn().mockReturnValue(null), - })) + mockVerifyFileAccess.mockResolvedValue(false) + mockFindLocalFile.mockReturnValue(null) const req = new NextRequest( 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nonexistent.txt' ) const params = { path: ['workspace', 'test-workspace-id', 'nonexistent.txt'] } - const { GET } = await import('@/app/api/files/serve/[...path]/route') const response = await GET(req, { params: Promise.resolve(params) }) @@ -346,42 +204,24 @@ describe('File Serve API Route', () => { for (const test of contentTypeTests) { it(`should serve ${test.ext} file with correct content type`, async () => { - const { mockCheckSessionOrInternalAuth: ctAuthMock } = mockHybridAuth() - ctAuthMock.mockResolvedValue({ - success: true, - userId: 'test-user-id', - }) - - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - })) - - vi.doMock('@/app/api/files/utils', () => ({ - FileNotFoundError: class FileNotFoundError extends Error { - constructor(message: string) { - super(message) - this.name = 'FileNotFoundError' - } - }, - getContentType: () => test.contentType, - findLocalFile: () => `/test/uploads/file.${test.ext}`, - createFileResponse: (obj: { buffer: Buffer; contentType: string; filename: string }) => - new Response(obj.buffer as any, { + mockGetContentType.mockReturnValue(test.contentType) + mockFindLocalFile.mockReturnValue(`/test/uploads/file.${test.ext}`) + mockCreateFileResponse.mockImplementation( + (obj: { buffer: Buffer; contentType: string; filename: string }) => + new Response(obj.buffer, { status: 200, headers: { 'Content-Type': obj.contentType, 'Content-Disposition': `inline; filename="${obj.filename}"`, 'Cache-Control': 'public, max-age=31536000', }, - }), - createErrorResponse: () => new Response(null, { status: 404 }), - })) + }) + ) const req = new NextRequest( `http://localhost:3000/api/files/serve/workspace/test-workspace-id/file.${test.ext}` ) const params = { path: ['workspace', 'test-workspace-id', `file.${test.ext}`] } - const { GET } = await import('@/app/api/files/serve/[...path]/route') const response = await GET(req, { params: Promise.resolve(params) }) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index be6f4f9bf54..a2361fd506a 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -3,16 +3,144 @@ * * @vitest-environment node */ -import { - mockAuth, - mockCryptoUuid, - mockHybridAuth, - mockUuid, - setupCommonApiMocks, -} from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockCheckHybridAuth = vi.fn() + const mockCheckSessionOrInternalAuth = vi.fn() + const mockCheckInternalAuth = vi.fn() + const mockVerifyFileAccess = vi.fn() + const mockVerifyWorkspaceFileAccess = vi.fn() + const mockVerifyKBFileAccess = vi.fn() + const mockVerifyCopilotFileAccess = vi.fn() + const mockGetUserEntityPermissions = vi.fn() + const mockUploadWorkspaceFile = vi.fn() + const mockGetStorageProvider = vi.fn() + const mockIsUsingCloudStorage = vi.fn() + const mockUploadFile = vi.fn() + const mockHasCloudStorage = vi.fn() + const mockStorageUploadFile = vi.fn() + + return { + mockGetSession, + mockCheckHybridAuth, + mockCheckSessionOrInternalAuth, + mockCheckInternalAuth, + mockVerifyFileAccess, + mockVerifyWorkspaceFileAccess, + mockVerifyKBFileAccess, + mockVerifyCopilotFileAccess, + mockGetUserEntityPermissions, + mockUploadWorkspaceFile, + mockGetStorageProvider, + mockIsUsingCloudStorage, + mockUploadFile, + mockHasCloudStorage, + mockStorageUploadFile, + } +}) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@sim/db/schema', () => ({ + workflowFolder: { + id: 'id', + userId: 'userId', + parentId: 'parentId', + updatedAt: 'updatedAt', + workspaceId: 'workspaceId', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + }, + workflow: { id: 'id', folderId: 'folderId', userId: 'userId', updatedAt: 'updatedAt' }, + account: { userId: 'userId', providerId: 'providerId' }, + user: { email: 'email', id: 'id' }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), + gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), + lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), + gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), + lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), + ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), + asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), + desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), + isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), + isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), + inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), + notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), + like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), + ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), + count: vi.fn((field: unknown) => ({ field, type: 'count' })), + sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), + avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), + min: vi.fn((field: unknown) => ({ field, type: 'min' })), + max: vi.fn((field: unknown) => ({ field, type: 'max' })), + sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), +})) + +vi.mock('uuid', () => ({ + v4: vi.fn().mockReturnValue('test-uuid'), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.mockGetSession, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: mocks.mockCheckHybridAuth, + checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth, + checkInternalAuth: mocks.mockCheckInternalAuth, +})) + +vi.mock('@/app/api/files/authorization', () => ({ + verifyFileAccess: mocks.mockVerifyFileAccess, + verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess, + verifyKBFileAccess: mocks.mockVerifyKBFileAccess, + verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + uploadWorkspaceFile: mocks.mockUploadWorkspaceFile, +})) + +vi.mock('@/lib/uploads', () => ({ + getStorageProvider: mocks.mockGetStorageProvider, + isUsingCloudStorage: mocks.mockIsUsingCloudStorage, + uploadFile: mocks.mockUploadFile, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: mocks.mockStorageUploadFile, + hasCloudStorage: mocks.mockHasCloudStorage, +})) + +vi.mock('@/lib/uploads/setup.server', () => ({ + UPLOAD_DIR_SERVER: '/tmp/test-uploads', +})) + +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { OPTIONS, POST } from '@/app/api/files/upload/route' + +/** + * Configure mocks for authenticated file upload tests + */ function setupFileApiMocks( options: { authenticated?: boolean @@ -22,49 +150,43 @@ function setupFileApiMocks( ) { const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - setupCommonApiMocks() - mockUuid() - mockCryptoUuid() + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), + }) - const authMocks = mockAuth() if (authenticated) { - authMocks.setAuthenticated() + mocks.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } }) } else { - authMocks.setUnauthenticated() + mocks.mockGetSession.mockResolvedValue(null) } - const { mockCheckHybridAuth } = mockHybridAuth() - mockCheckHybridAuth.mockResolvedValue({ + mocks.mockCheckHybridAuth.mockResolvedValue({ success: authenticated, userId: authenticated ? 'test-user-id' : undefined, error: authenticated ? undefined : 'Unauthorized', }) - vi.doMock('@/app/api/files/authorization', () => ({ - verifyFileAccess: vi.fn().mockResolvedValue(true), - verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - verifyKBFileAccess: vi.fn().mockResolvedValue(true), - verifyCopilotFileAccess: vi.fn().mockResolvedValue(true), - })) - - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), - })) - - vi.doMock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: vi.fn().mockResolvedValue({ - id: 'test-file-id', - name: 'test.txt', - url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'workspace/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }), - })) - - const uploadFileMock = vi.fn().mockResolvedValue({ + mocks.mockVerifyFileAccess.mockResolvedValue(true) + mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) + mocks.mockVerifyKBFileAccess.mockResolvedValue(true) + mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true) + + mocks.mockGetUserEntityPermissions.mockResolvedValue('admin') + + mocks.mockUploadWorkspaceFile.mockResolvedValue({ + id: 'test-file-id', + name: 'test.txt', + url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', + size: 100, + type: 'text/plain', + key: 'workspace/test-workspace-id/1234567890-test.txt', + uploadedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }) + + mocks.mockGetStorageProvider.mockReturnValue(storageProvider) + mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) + mocks.mockUploadFile.mockResolvedValue({ path: '/api/files/serve/test-key.txt', key: 'test-key.txt', name: 'test.txt', @@ -72,13 +194,11 @@ function setupFileApiMocks( type: 'text/plain', }) - vi.doMock('@/lib/uploads', () => ({ - getStorageProvider: vi.fn().mockReturnValue(storageProvider), - isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled), - uploadFile: uploadFileMock, - })) - - return { auth: authMocks } + mocks.mockHasCloudStorage.mockReturnValue(cloudEnabled) + mocks.mockStorageUploadFile.mockResolvedValue({ + key: 'test-key', + path: '/test/path', + }) } describe('File Upload API Route', () => { @@ -101,10 +221,7 @@ describe('File Upload API Route', () => { } beforeEach(() => { - vi.resetModules() - vi.doMock('@/lib/uploads/setup.server', () => ({ - UPLOAD_DIR_SERVER: '/tmp/test-uploads', - })) + vi.clearAllMocks() }) afterEach(() => { @@ -125,8 +242,6 @@ describe('File Upload API Route', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req) const data = await response.json() @@ -138,7 +253,6 @@ describe('File Upload API Route', () => { expect(data).toHaveProperty('type', 'text/plain') expect(data).toHaveProperty('key') - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') expect(uploadWorkspaceFile).toHaveBeenCalled() }) @@ -156,8 +270,6 @@ describe('File Upload API Route', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req) const data = await response.json() @@ -169,7 +281,6 @@ describe('File Upload API Route', () => { expect(data).toHaveProperty('type', 'text/plain') expect(data).toHaveProperty('key') - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') expect(uploadWorkspaceFile).toHaveBeenCalled() }) @@ -188,8 +299,6 @@ describe('File Upload API Route', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req) const data = await response.json() @@ -208,8 +317,6 @@ describe('File Upload API Route', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req) const data = await response.json() @@ -219,16 +326,12 @@ describe('File Upload API Route', () => { }) it('should handle S3 upload errors', async () => { - vi.resetModules() - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3', }) - vi.doMock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: vi.fn().mockRejectedValue(new Error('Storage limit exceeded')), - })) + mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded')) const mockFile = createMockFile() const formData = createMockFormData([mockFile]) @@ -238,21 +341,15 @@ describe('File Upload API Route', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req) const data = await response.json() expect(response.status).toBe(413) expect(data).toHaveProperty('error') expect(typeof data.error).toBe('string') - - vi.resetModules() }) it('should handle CORS preflight requests', async () => { - const { OPTIONS } = await import('@/app/api/files/upload/route') - const response = await OPTIONS() expect(response.status).toBe(204) @@ -263,35 +360,18 @@ describe('File Upload API Route', () => { describe('File Upload Security Tests', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'test-user-id' }, - }), - })) - - vi.doMock('@/lib/uploads', () => ({ - isUsingCloudStorage: vi.fn().mockReturnValue(false), - StorageService: { - uploadFile: vi.fn().mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }), - hasCloudStorage: vi.fn().mockReturnValue(false), - }, - })) - - vi.doMock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: vi.fn().mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }), - hasCloudStorage: vi.fn().mockReturnValue(false), - })) - - vi.doMock('@/lib/uploads/setup.server', () => ({})) + mocks.mockGetSession.mockResolvedValue({ + user: { id: 'test-user-id' }, + }) + + mocks.mockHasCloudStorage.mockReturnValue(false) + mocks.mockStorageUploadFile.mockResolvedValue({ + key: 'test-key', + path: '/test/path', + }) + mocks.mockIsUsingCloudStorage.mockReturnValue(false) }) afterEach(() => { @@ -300,7 +380,6 @@ describe('File Upload Security Tests', () => { describe('File Extension Validation', () => { beforeEach(() => { - vi.resetModules() setupFileApiMocks({ cloudEnabled: false, storageProvider: 'local', @@ -335,8 +414,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(200) } @@ -355,8 +433,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -376,8 +453,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -397,8 +473,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -418,8 +493,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -438,8 +512,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -464,8 +537,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(400) const data = await response.json() @@ -475,9 +547,7 @@ describe('File Upload Security Tests', () => { describe('Authentication Requirements', () => { it('should reject uploads without authentication', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) + mocks.mockGetSession.mockResolvedValue(null) const formData = new FormData() const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) @@ -488,8 +558,7 @@ describe('File Upload Security Tests', () => { body: formData, }) - const { POST } = await import('@/app/api/files/upload/route') - const response = await POST(req as any) + const response = await POST(req as unknown as NextRequest) expect(response.status).toBe(401) const data = await response.json() diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 77a5ab26922..ecdcc2c4b5a 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -3,17 +3,44 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - type MockUser, - mockAuth, - mockConsoleLogger, - setupCommonApiMocks, -} from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { auditMock, createMockRequest, type MockUser } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockLogger: logger, + mockDbRef: { current: null as any }, + } +}) vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@sim/db', () => ({ + get db() { + return mockDbRef.current + }, +})) + +import { DELETE, PUT } from '@/app/api/folders/[id]/route' /** Type for captured folder values in tests */ interface CapturedFolderValues { @@ -32,130 +59,103 @@ interface FolderDbMockOptions { circularCheckResults?: any[] } -describe('Individual Folder API Route', () => { - let mockLogger: ReturnType - - const TEST_USER: MockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - } +const TEST_USER: MockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', +} - const mockFolder = { - id: 'folder-1', - name: 'Test Folder', - userId: TEST_USER.id, - workspaceId: 'workspace-123', - parentId: null, - color: '#6B7280', - sortOrder: 1, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-01T00:00:00Z'), - } +const mockFolder = { + id: 'folder-1', + name: 'Test Folder', + userId: TEST_USER.id, + workspaceId: 'workspace-123', + parentId: null, + color: '#6B7280', + sortOrder: 1, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), +} - let mockAuthenticatedUser: (user?: MockUser) => void - let mockUnauthenticated: () => void - const mockGetUserEntityPermissions = vi.fn() - - function createFolderDbMock(options: FolderDbMockOptions = {}) { - const { - folderLookupResult = mockFolder, - updateResult = [{ ...mockFolder, name: 'Updated Folder' }], - throwError = false, - circularCheckResults = [], - } = options - - let callCount = 0 - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - if (throwError) { - throw new Error('Database error') - } - - callCount++ - // First call: folder lookup - if (callCount === 1) { - // The route code does .then((rows) => rows[0]) - // So we need to return an array for folderLookupResult - const result = folderLookupResult === undefined ? [] : [folderLookupResult] - return Promise.resolve(callback(result)) - } - // Subsequent calls: circular reference checks - if (callCount > 1 && circularCheckResults.length > 0) { - const index = callCount - 2 - const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] - return Promise.resolve(callback(result)) - } - return Promise.resolve(callback([])) - }), - })), +function createFolderDbMock(options: FolderDbMockOptions = {}) { + const { + folderLookupResult = mockFolder, + updateResult = [{ ...mockFolder, name: 'Updated Folder' }], + throwError = false, + circularCheckResults = [], + } = options + + let callCount = 0 + + const mockSelect = vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + then: vi.fn().mockImplementation((callback) => { + if (throwError) { + throw new Error('Database error') + } + + callCount++ + if (callCount === 1) { + const result = folderLookupResult === undefined ? [] : [folderLookupResult] + return Promise.resolve(callback(result)) + } + if (callCount > 1 && circularCheckResults.length > 0) { + const index = callCount - 2 + const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] + return Promise.resolve(callback(result)) + } + return Promise.resolve(callback([])) + }), })), - })) + })), + })) - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue(updateResult), - })), + const mockUpdate = vi.fn().mockImplementation(() => ({ + set: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + returning: vi.fn().mockReturnValue(updateResult), })), - })) + })), + })) - const mockDelete = vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => Promise.resolve()), - })) + const mockDelete = vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => Promise.resolve()), + })) - return { - db: { - select: mockSelect, - update: mockUpdate, - delete: mockDelete, - }, - mocks: { - select: mockSelect, - update: mockUpdate, - delete: mockDelete, - }, - } + return { + select: mockSelect, + update: mockUpdate, + delete: mockDelete, } +} + +function mockAuthenticatedUser(user?: MockUser) { + mockGetSession.mockResolvedValue({ user: user || TEST_USER }) +} + +function mockUnauthenticated() { + mockGetSession.mockResolvedValue(null) +} +describe('Individual Folder API Route', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() - setupCommonApiMocks() - mockLogger = mockConsoleLogger() - const auth = mockAuth(TEST_USER) - mockAuthenticatedUser = auth.mockAuthenticatedUser - mockUnauthenticated = auth.mockUnauthenticated mockGetUserEntityPermissions.mockResolvedValue('admin') - - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, - })) - }) - - afterEach(() => { - vi.clearAllMocks() + mockDbRef.current = createFolderDbMock() }) describe('PUT /api/folders/[id]', () => { it('should update folder successfully', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('PUT', { name: 'Updated Folder Name', color: '#FF0000', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(200) @@ -170,17 +170,12 @@ describe('Individual Folder API Route', () => { it('should update parent folder successfully', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('PUT', { name: 'Updated Folder', parentId: 'parent-folder-1', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(200) @@ -189,16 +184,11 @@ describe('Individual Folder API Route', () => { it('should return 401 for unauthenticated requests', async () => { mockUnauthenticated() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('PUT', { name: 'Updated Folder', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(401) @@ -209,18 +199,13 @@ describe('Individual Folder API Route', () => { it('should return 403 when user has only read permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions - - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) + mockGetUserEntityPermissions.mockResolvedValue('read') const req = createMockRequest('PUT', { name: 'Updated Folder', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(403) @@ -231,18 +216,13 @@ describe('Individual Folder API Route', () => { it('should allow folder update for write permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions - - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) + mockGetUserEntityPermissions.mockResolvedValue('write') const req = createMockRequest('PUT', { name: 'Updated Folder', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(200) @@ -253,18 +233,13 @@ describe('Individual Folder API Route', () => { it('should allow folder update for admin permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions - - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) + mockGetUserEntityPermissions.mockResolvedValue('admin') const req = createMockRequest('PUT', { name: 'Updated Folder', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(200) @@ -276,17 +251,12 @@ describe('Individual Folder API Route', () => { it('should return 400 when trying to set folder as its own parent', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('PUT', { name: 'Updated Folder', - parentId: 'folder-1', // Same as the folder ID + parentId: 'folder-1', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(400) @@ -299,28 +269,39 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() let capturedUpdates: CapturedFolderValues | null = null - const dbMock = createFolderDbMock({ - updateResult: [{ ...mockFolder, name: 'Folder With Spaces' }], - }) - // Override the set implementation to capture updates - const originalSet = dbMock.mocks.update().set - dbMock.mocks.update.mockReturnValue({ + const mockSelect = vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + then: vi.fn().mockImplementation((callback) => { + return Promise.resolve(callback([mockFolder])) + }), + })), + })), + })) + + const mockUpdate = vi.fn().mockImplementation(() => ({ set: vi.fn().mockImplementation((updates) => { capturedUpdates = updates - return originalSet(updates) + return { + where: vi.fn().mockImplementation(() => ({ + returning: vi.fn().mockReturnValue([{ ...mockFolder, name: 'Folder With Spaces' }]), + })), + } }), - }) + })) - vi.doMock('@sim/db', () => dbMock) + mockDbRef.current = { + select: mockSelect, + update: mockUpdate, + delete: vi.fn(), + } const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - await PUT(req, { params }) expect(capturedUpdates).not.toBeNull() @@ -330,18 +311,15 @@ describe('Individual Folder API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock({ + mockDbRef.current = createFolderDbMock({ throwError: true, }) - vi.doMock('@sim/db', () => dbMock) const req = createMockRequest('PUT', { name: 'Updated Folder', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) expect(response.status).toBe(500) @@ -358,29 +336,19 @@ describe('Individual Folder API Route', () => { it('should handle empty folder name', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('PUT', { - name: '', // Empty name + name: '', }) const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) - // Should still work as the API doesn't validate empty names expect(response.status).toBe(200) }) it('should handle invalid JSON payload', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - - // Create a request with invalid JSON const req = new Request('http://localhost:3000/api/folders/folder-1', { method: 'PUT', headers: { @@ -391,11 +359,9 @@ describe('Individual Folder API Route', () => { const params = Promise.resolve({ id: 'folder-1' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) - expect(response.status).toBe(500) // Should handle JSON parse error gracefully + expect(response.status).toBe(500) }) }) @@ -403,31 +369,21 @@ describe('Individual Folder API Route', () => { it('should prevent circular references when updating parent', async () => { mockAuthenticatedUser() - // Mock the circular reference scenario - // folder-3 trying to set folder-1 as parent, - // but folder-1 -> folder-2 -> folder-3 (would create cycle) - const circularCheckResults = [ - { parentId: 'folder-2' }, // folder-1 has parent folder-2 - { parentId: 'folder-3' }, // folder-2 has parent folder-3 (creates cycle!) - ] + const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }] - const dbMock = createFolderDbMock({ + mockDbRef.current = createFolderDbMock({ folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' }, circularCheckResults, }) - vi.doMock('@sim/db', () => dbMock) const req = createMockRequest('PUT', { name: 'Updated Folder 3', - parentId: 'folder-1', // This would create a circular reference + parentId: 'folder-1', }) const params = Promise.resolve({ id: 'folder-3' }) - const { PUT } = await import('@/app/api/folders/[id]/route') - const response = await PUT(req, { params }) - // Should return 400 due to circular reference expect(response.status).toBe(400) const data = await response.json() @@ -439,18 +395,13 @@ describe('Individual Folder API Route', () => { it('should delete folder and all contents successfully', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock({ + mockDbRef.current = createFolderDbMock({ folderLookupResult: mockFolder, }) - // Mock the recursive deletion function - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(200) @@ -463,14 +414,9 @@ describe('Individual Folder API Route', () => { it('should return 401 for unauthenticated delete requests', async () => { mockUnauthenticated() - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) - const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(401) @@ -481,16 +427,11 @@ describe('Individual Folder API Route', () => { it('should return 403 when user has only read permissions for delete', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions - - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) + mockGetUserEntityPermissions.mockResolvedValue('read') const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(403) @@ -501,16 +442,11 @@ describe('Individual Folder API Route', () => { it('should return 403 when user has only write permissions for delete', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions (not enough for delete) - - const dbMock = createFolderDbMock() - vi.doMock('@sim/db', () => dbMock) + mockGetUserEntityPermissions.mockResolvedValue('write') const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(403) @@ -521,18 +457,15 @@ describe('Individual Folder API Route', () => { it('should allow folder deletion for admin permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions + mockGetUserEntityPermissions.mockResolvedValue('admin') - const dbMock = createFolderDbMock({ + mockDbRef.current = createFolderDbMock({ folderLookupResult: mockFolder, }) - vi.doMock('@sim/db', () => dbMock) const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(200) @@ -544,16 +477,13 @@ describe('Individual Folder API Route', () => { it('should handle database errors during deletion', async () => { mockAuthenticatedUser() - const dbMock = createFolderDbMock({ + mockDbRef.current = createFolderDbMock({ throwError: true, }) - vi.doMock('@sim/db', () => dbMock) const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) - const { DELETE } = await import('@/app/api/folders/[id]/route') - const response = await DELETE(req, { params }) expect(response.status).toBe(500) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 92f71796e82..5fa3a709018 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -3,21 +3,46 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockAuth, - mockConsoleLogger, - setupCommonApiMocks, -} from '@sim/testing' +import { auditMock, createMockRequest } from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockLogger } = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockLogger: logger, + } +}) vi.mock('@/lib/audit/log', () => auditMock) vi.mock('drizzle-orm', () => ({ ...drizzleOrmMock, min: vi.fn((field) => ({ type: 'min', field })), })) +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { db } from '@sim/db' +import { GET, POST } from '@/app/api/folders/route' + +const mockDb = db as any interface CapturedFolderValues { name?: string @@ -60,8 +85,13 @@ function createMockTransaction(mockData: { } } +const defaultMockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', +} + describe('Folders API Route', () => { - let mockLogger: ReturnType const mockFolders = [ { id: 'folder-1', @@ -89,34 +119,32 @@ describe('Folders API Route', () => { }, ] - let mockAuthenticatedUser: () => void - let mockUnauthenticated: () => void const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' - const mockSelect = vi.fn() + const mockSelect = mockDb.select const mockFrom = vi.fn() const mockWhere = vi.fn() const mockOrderBy = vi.fn() - const mockInsert = vi.fn() + const mockInsert = mockDb.insert const mockValues = vi.fn() const mockReturning = vi.fn() - const mockTransaction = vi.fn() - const mockGetUserEntityPermissions = vi.fn() + const mockTransaction = mockDb.transaction + + function mockAuthenticatedUser() { + mockGetSession.mockResolvedValue({ user: defaultMockUser }) + } + + function mockUnauthenticated() { + mockGetSession.mockResolvedValue(null) + } beforeEach(() => { - vi.resetModules() vi.clearAllMocks() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue(mockUUID), }) - setupCommonApiMocks() - mockLogger = mockConsoleLogger() - const auth = mockAuth() - mockAuthenticatedUser = auth.mockAuthenticatedUser - mockUnauthenticated = auth.mockUnauthenticated - mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockWhere.mockReturnValue({ orderBy: mockOrderBy }) @@ -127,22 +155,6 @@ describe('Folders API Route', () => { mockReturning.mockReturnValue([mockFolders[0]]) mockGetUserEntityPermissions.mockResolvedValue('admin') - - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - transaction: mockTransaction, - }, - })) - - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, - })) - }) - - afterEach(() => { - vi.clearAllMocks() }) describe('GET /api/folders', () => { @@ -154,7 +166,6 @@ describe('Folders API Route', () => { value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) expect(response.status).toBe(200) @@ -177,7 +188,6 @@ describe('Folders API Route', () => { value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) expect(response.status).toBe(401) @@ -194,7 +204,6 @@ describe('Folders API Route', () => { value: 'http://localhost:3000/api/folders', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) expect(response.status).toBe(400) @@ -205,14 +214,13 @@ describe('Folders API Route', () => { it('should return 403 when user has no workspace permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue(null) // No permissions + mockGetUserEntityPermissions.mockResolvedValue(null) const mockRequest = createMockRequest('GET') Object.defineProperty(mockRequest, 'url', { value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) expect(response.status).toBe(403) @@ -223,17 +231,16 @@ describe('Folders API Route', () => { it('should return 403 when user has only read permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions + mockGetUserEntityPermissions.mockResolvedValue('read') const mockRequest = createMockRequest('GET') Object.defineProperty(mockRequest, 'url', { value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) - expect(response.status).toBe(200) // Should work for read permissions + expect(response.status).toBe(200) const data = await response.json() expect(data).toHaveProperty('folders') @@ -251,7 +258,6 @@ describe('Folders API Route', () => { value: 'http://localhost:3000/api/folders?workspaceId=workspace-123', }) - const { GET } = await import('@/app/api/folders/route') const response = await GET(mockRequest) expect(response.status).toBe(500) @@ -281,7 +287,6 @@ describe('Folders API Route', () => { color: '#6B7280', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) const responseBody = await response.json() @@ -313,7 +318,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(200) @@ -342,7 +346,6 @@ describe('Folders API Route', () => { parentId: 'folder-1', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(200) @@ -361,7 +364,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(401) @@ -372,14 +374,13 @@ describe('Folders API Route', () => { it('should return 403 when user has only read permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions + mockGetUserEntityPermissions.mockResolvedValue('read') const req = createMockRequest('POST', { name: 'Test Folder', workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(403) @@ -390,7 +391,7 @@ describe('Folders API Route', () => { it('should allow folder creation for write permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions + mockGetUserEntityPermissions.mockResolvedValue('write') mockTransaction.mockImplementationOnce( createMockTransaction({ @@ -404,7 +405,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(200) @@ -415,7 +415,7 @@ describe('Folders API Route', () => { it('should allow folder creation for admin permissions', async () => { mockAuthenticatedUser() - mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions + mockGetUserEntityPermissions.mockResolvedValue('admin') mockTransaction.mockImplementationOnce( createMockTransaction({ @@ -429,7 +429,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(200) @@ -440,10 +439,10 @@ describe('Folders API Route', () => { it('should return 400 when required fields are missing', async () => { const testCases = [ - { name: '', workspaceId: 'workspace-123' }, // Missing name - { name: 'Test Folder', workspaceId: '' }, // Missing workspaceId - { workspaceId: 'workspace-123' }, // Missing name entirely - { name: 'Test Folder' }, // Missing workspaceId entirely + { name: '', workspaceId: 'workspace-123' }, + { name: 'Test Folder', workspaceId: '' }, + { workspaceId: 'workspace-123' }, + { name: 'Test Folder' }, ] for (const body of testCases) { @@ -451,7 +450,6 @@ describe('Folders API Route', () => { const req = createMockRequest('POST', body) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(400) @@ -464,7 +462,6 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - // Make transaction throw an error mockTransaction.mockImplementationOnce(() => { throw new Error('Database transaction failed') }) @@ -474,7 +471,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') const response = await POST(req) expect(response.status).toBe(500) @@ -506,7 +502,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') await POST(req) expect(capturedValues).not.toBeNull() @@ -533,7 +528,6 @@ describe('Folders API Route', () => { workspaceId: 'workspace-123', }) - const { POST } = await import('@/app/api/folders/route') await POST(req) expect(capturedValues).not.toBeNull() diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts index ad4d518fa2c..f40773efdd0 100644 --- a/apps/sim/app/api/form/utils.test.ts +++ b/apps/sim/app/api/form/utils.test.ts @@ -1,17 +1,19 @@ -import { databaseMock, loggerMock } from '@sim/testing' -import type { NextResponse } from 'next/server' /** * Tests for form API utils * * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMock, loggerMock } from '@sim/testing' +import type { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDecryptSecret } = vi.hoisted(() => ({ + mockDecryptSecret: vi.fn(), +})) vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/logger', () => loggerMock) -const mockDecryptSecret = vi.fn() - vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) @@ -26,15 +28,22 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: vi.fn(), })) +import crypto from 'crypto' +import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' +import { + DEFAULT_FORM_CUSTOMIZATIONS, + setFormAuthCookie, + validateFormAuth, +} from '@/app/api/form/utils' + describe('Form API Utils', () => { - afterEach(() => { + beforeEach(() => { vi.clearAllMocks() }) describe('Auth token utils', () => { - it.concurrent('should validate auth tokens', async () => { - const { validateAuthToken } = await import('@/lib/core/security/deployment') - + it.concurrent('should validate auth tokens', () => { const formId = 'test-form-id' const type = 'password' @@ -49,9 +58,7 @@ describe('Form API Utils', () => { expect(isInvalidForm).toBe(false) }) - it.concurrent('should reject expired tokens', async () => { - const { validateAuthToken } = await import('@/lib/core/security/deployment') - + it.concurrent('should reject expired tokens', () => { const formId = 'test-form-id' const expiredToken = Buffer.from( `${formId}:password:${Date.now() - 25 * 60 * 60 * 1000}` @@ -61,10 +68,7 @@ describe('Form API Utils', () => { expect(isValid).toBe(false) }) - it.concurrent('should validate tokens with password hash', async () => { - const { validateAuthToken } = await import('@/lib/core/security/deployment') - const crypto = await import('crypto') - + it.concurrent('should validate tokens with password hash', () => { const formId = 'test-form-id' const encryptedPassword = 'encrypted-password-value' const pwHash = crypto @@ -84,9 +88,7 @@ describe('Form API Utils', () => { }) describe('Cookie handling', () => { - it('should set auth cookie correctly', async () => { - const { setFormAuthCookie } = await import('@/app/api/form/utils') - + it('should set auth cookie correctly', () => { const mockSet = vi.fn() const mockResponse = { cookies: { @@ -112,9 +114,7 @@ describe('Form API Utils', () => { }) describe('CORS handling', () => { - it.concurrent('should add CORS headers for any origin', async () => { - const { addCorsHeaders } = await import('@/lib/core/security/deployment') - + it.concurrent('should add CORS headers for any origin', () => { const mockRequest = { headers: { get: vi.fn().mockReturnValue('http://localhost:3000'), @@ -147,9 +147,7 @@ describe('Form API Utils', () => { ) }) - it.concurrent('should not set CORS headers when no origin', async () => { - const { addCorsHeaders } = await import('@/lib/core/security/deployment') - + it.concurrent('should not set CORS headers when no origin', () => { const mockRequest = { headers: { get: vi.fn().mockReturnValue(''), @@ -169,14 +167,12 @@ describe('Form API Utils', () => { }) describe('Form auth validation', () => { - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks() mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' }) }) it('should allow access to public forms', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'public', @@ -194,8 +190,6 @@ describe('Form API Utils', () => { }) it('should request password auth for GET requests', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'password', @@ -215,9 +209,6 @@ describe('Form API Utils', () => { }) it('should validate password for POST requests', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const { decryptSecret } = await import('@/lib/core/security/encryption') - const deployment = { id: 'form-id', authType: 'password', @@ -242,8 +233,6 @@ describe('Form API Utils', () => { }) it('should reject incorrect password', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'password', @@ -268,8 +257,6 @@ describe('Form API Utils', () => { }) it('should request email auth for email-protected forms', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'email', @@ -290,8 +277,6 @@ describe('Form API Utils', () => { }) it('should check allowed emails for email auth', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'email', @@ -326,8 +311,6 @@ describe('Form API Utils', () => { }) it('should require password when formData is present without password', async () => { - const { validateFormAuth } = await import('@/app/api/form/utils') - const deployment = { id: 'form-id', authType: 'password', @@ -354,9 +337,7 @@ describe('Form API Utils', () => { }) describe('Default customizations', () => { - it.concurrent('should have correct default values', async () => { - const { DEFAULT_FORM_CUSTOMIZATIONS } = await import('@/app/api/form/utils') - + it.concurrent('should have correct default values', () => { expect(DEFAULT_FORM_CUSTOMIZATIONS).toEqual({ welcomeMessage: '', thankYouTitle: 'Thank you!', diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index e73e30e3504..70a56b06b19 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -3,12 +3,42 @@ * * @vitest-environment node */ -import { createMockRequest, loggerMock } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckInternalAuth, mockExecuteInE2B, mockExecuteInIsolatedVM } = vi.hoisted(() => ({ + mockCheckInternalAuth: vi.fn(), + mockExecuteInE2B: vi.fn(), + mockExecuteInIsolatedVM: vi.fn(), +})) vi.mock('@/lib/execution/isolated-vm', () => ({ - executeInIsolatedVM: vi.fn().mockImplementation(async (req) => { + executeInIsolatedVM: mockExecuteInIsolatedVM, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkInternalAuth: mockCheckInternalAuth, +})) + +vi.mock('@/lib/execution/e2b', () => ({ + executeInE2B: mockExecuteInE2B, +})) + +import { validateProxyUrl } from '@/lib/core/security/input-validation' +import { POST } from '@/app/api/function/execute/route' + +/** + * Creates a fake isolated-vm execution result by evaluating code + * in a sandboxed context, mimicking the real executeInIsolatedVM behavior. + */ +function createIsolatedVmImplementation() { + return async (req: { + code: string + params: Record + envVars: Record + contextVariables: Record + }) => { const { code, params, envVars, contextVariables } = req const stdoutChunks: string[] = [] @@ -79,48 +109,31 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ }, } } - }), -})) - -vi.mock('@sim/logger', () => loggerMock) - -vi.mock('@/lib/auth/hybrid', () => ({ - checkInternalAuth: vi.fn().mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'internal_jwt', - }), -})) - -vi.mock('@/lib/execution/e2b', () => ({ - executeInE2B: vi.fn(), -})) - -import { validateProxyUrl } from '@/lib/core/security/input-validation' -import { executeInE2B } from '@/lib/execution/e2b' -import { POST } from './route' - -const mockedExecuteInE2B = vi.mocked(executeInE2B) + } +} describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() - mockedExecuteInE2B.mockResolvedValue({ + mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + + mockExecuteInIsolatedVM.mockImplementation(createIsolatedVmImplementation()) + + mockExecuteInE2B.mockResolvedValue({ result: 'e2b success', stdout: 'e2b output', sandboxId: 'test-sandbox-id', }) }) - afterEach(() => { - vi.clearAllMocks() - }) - describe('Security Tests', () => { it('should reject unauthorized requests', async () => { - const { checkInternalAuth } = await import('@/lib/auth/hybrid') - vi.mocked(checkInternalAuth).mockResolvedValueOnce({ + mockCheckInternalAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized', }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index b3be9e79667..d3612f1bc4d 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -3,17 +3,100 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockAuth, - mockConsoleLogger, - mockDrizzleOrm, - mockKnowledgeSchemas, -} from '@sim/testing' +import { auditMock, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -mockKnowledgeSchemas() +const { mockGetSession, mockDbChain } = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + transaction: vi.fn(), + } + return { mockGetSession, mockDbChain } +}) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@sim/db/schema', () => ({ + knowledgeBase: { + id: 'kb_id', + userId: 'user_id', + name: 'kb_name', + description: 'description', + tokenCount: 'token_count', + embeddingModel: 'embedding_model', + embeddingDimension: 'embedding_dimension', + chunkingConfig: 'chunking_config', + workspaceId: 'workspace_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + deletedAt: 'deleted_at', + }, + document: { + id: 'doc_id', + knowledgeBaseId: 'kb_id', + filename: 'filename', + fileUrl: 'file_url', + fileSize: 'file_size', + mimeType: 'mime_type', + chunkCount: 'chunk_count', + tokenCount: 'token_count', + characterCount: 'character_count', + processingStatus: 'processing_status', + processingStartedAt: 'processing_started_at', + processingCompletedAt: 'processing_completed_at', + processingError: 'processing_error', + enabled: 'enabled', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + uploadedAt: 'uploaded_at', + deletedAt: 'deleted_at', + }, + embedding: { + id: 'embedding_id', + documentId: 'doc_id', + knowledgeBaseId: 'kb_id', + chunkIndex: 'chunk_index', + content: 'content', + embedding: 'embedding', + tokenCount: 'token_count', + characterCount: 'character_count', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + createdAt: 'created_at', + }, + permissions: { + id: 'permission_id', + userId: 'user_id', + entityType: 'entity_type', + entityId: 'entity_id', + permissionType: 'permission_type', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +})) vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseAccess: vi.fn(), @@ -33,25 +116,18 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ processDocumentAsync: vi.fn(), })) -mockDrizzleOrm() -mockConsoleLogger() - vi.mock('@/lib/audit/log', () => auditMock) -describe('Document By ID API Route', () => { - const mockAuth$ = mockAuth() - - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } +import { + deleteDocument, + markDocumentAsFailedTimeout, + retryDocumentProcessing, + updateDocument, +} from '@/lib/knowledge/documents/service' +import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/documents/[documentId]/route' +import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' +describe('Document By ID API Route', () => { const mockDocument = { id: 'doc-123', knowledgeBaseId: 'kb-123', @@ -100,13 +176,9 @@ describe('Document By ID API Route', () => { }) } - beforeEach(async () => { + beforeEach(() => { resetMocks() - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) - vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), }) @@ -120,9 +192,7 @@ describe('Document By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) it('should retrieve document successfully for authenticated user', async () => { - const { checkDocumentAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -130,7 +200,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -142,10 +211,9 @@ describe('Document By ID API Route', () => { }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -154,9 +222,7 @@ describe('Document By ID API Route', () => { }) it('should return not found for non-existent document', async () => { - const { checkDocumentAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentAccess).mockResolvedValue({ hasAccess: false, notFound: true, @@ -164,7 +230,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -173,16 +238,13 @@ describe('Document By ID API Route', () => { }) it('should return unauthorized for document without access', async () => { - const { checkDocumentAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentAccess).mockResolvedValue({ hasAccess: false, reason: 'Access denied', }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -191,13 +253,10 @@ describe('Document By ID API Route', () => { }) it('should handle database errors', async () => { - const { checkDocumentAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentAccess).mockRejectedValue(new Error('Database error')) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -216,10 +275,7 @@ describe('Document By ID API Route', () => { } it('should update document successfully', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { updateDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -234,7 +290,6 @@ describe('Document By ID API Route', () => { vi.mocked(updateDocument).mockResolvedValue(updatedDocument) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -250,9 +305,7 @@ describe('Document By ID API Route', () => { }) it('should validate update data', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -266,7 +319,6 @@ describe('Document By ID API Route', () => { } const req = createMockRequest('PUT', invalidData) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -280,16 +332,13 @@ describe('Document By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) it('should mark document as failed due to timeout successfully', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { markDocumentAsFailedTimeout } = await import('@/lib/knowledge/documents/service') - const processingDocument = { ...mockDocument, processingStatus: 'processing', processingStartedAt: new Date(Date.now() - 200000), // 200 seconds ago } - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: processingDocument, @@ -302,7 +351,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -319,9 +367,7 @@ describe('Document By ID API Route', () => { }) it('should reject marking failed for non-processing document', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: { ...mockDocument, processingStatus: 'completed' }, @@ -329,7 +375,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -338,16 +383,13 @@ describe('Document By ID API Route', () => { }) it('should reject marking failed for recently started processing', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { markDocumentAsFailedTimeout } = await import('@/lib/knowledge/documents/service') - const recentProcessingDocument = { ...mockDocument, processingStatus: 'processing', processingStartedAt: new Date(Date.now() - 60000), // 60 seconds ago } - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: recentProcessingDocument, @@ -359,7 +401,6 @@ describe('Document By ID API Route', () => { ) const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -372,16 +413,13 @@ describe('Document By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) it('should retry processing successfully', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { retryDocumentProcessing } = await import('@/lib/knowledge/documents/service') - const failedDocument = { ...mockDocument, processingStatus: 'failed', processingError: 'Previous processing failed', } - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: failedDocument, @@ -395,7 +433,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('PUT', { retryProcessing: true }) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -417,9 +454,7 @@ describe('Document By ID API Route', () => { }) it('should reject retry for non-failed document', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: { ...mockDocument, processingStatus: 'completed' }, @@ -427,7 +462,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('PUT', { retryProcessing: true }) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -441,10 +475,9 @@ describe('Document By ID API Route', () => { const validUpdateData = { filename: 'updated-document.pdf' } it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -453,9 +486,7 @@ describe('Document By ID API Route', () => { }) it('should return not found for non-existent document', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: false, notFound: true, @@ -463,7 +494,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -472,10 +502,7 @@ describe('Document By ID API Route', () => { }) it('should handle database errors during update', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { updateDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -485,7 +512,6 @@ describe('Document By ID API Route', () => { vi.mocked(updateDocument).mockRejectedValue(new Error('Database error')) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -498,10 +524,7 @@ describe('Document By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) it('should delete document successfully', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { deleteDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -514,7 +537,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -525,10 +547,9 @@ describe('Document By ID API Route', () => { }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -537,9 +558,7 @@ describe('Document By ID API Route', () => { }) it('should return not found for non-existent document', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: false, notFound: true, @@ -547,7 +566,6 @@ describe('Document By ID API Route', () => { }) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -556,16 +574,13 @@ describe('Document By ID API Route', () => { }) it('should return unauthorized for document without access', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: false, reason: 'Access denied', }) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -574,10 +589,7 @@ describe('Document By ID API Route', () => { }) it('should handle database errors during deletion', async () => { - const { checkDocumentWriteAccess } = await import('@/app/api/knowledge/utils') - const { deleteDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ hasAccess: true, document: mockDocument, @@ -586,7 +598,6 @@ describe('Document By ID API Route', () => { vi.mocked(deleteDocument).mockRejectedValue(new Error('Database error')) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index e0877478625..70eacdf46e8 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -3,17 +3,103 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockAuth, - mockConsoleLogger, - mockDrizzleOrm, - mockKnowledgeSchemas, -} from '@sim/testing' +import { auditMock, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -mockKnowledgeSchemas() +const { mockGetSession, mockDbChain } = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + transaction: vi.fn(), + } + return { mockGetSession, mockDbChain } +}) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@sim/db/schema', () => ({ + knowledgeBase: { + id: 'kb_id', + userId: 'user_id', + name: 'kb_name', + description: 'description', + tokenCount: 'token_count', + embeddingModel: 'embedding_model', + embeddingDimension: 'embedding_dimension', + chunkingConfig: 'chunking_config', + workspaceId: 'workspace_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + deletedAt: 'deleted_at', + }, + document: { + id: 'doc_id', + knowledgeBaseId: 'kb_id', + filename: 'filename', + fileUrl: 'file_url', + fileSize: 'file_size', + mimeType: 'mime_type', + chunkCount: 'chunk_count', + tokenCount: 'token_count', + characterCount: 'character_count', + processingStatus: 'processing_status', + processingStartedAt: 'processing_started_at', + processingCompletedAt: 'processing_completed_at', + processingError: 'processing_error', + enabled: 'enabled', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + uploadedAt: 'uploaded_at', + deletedAt: 'deleted_at', + }, + embedding: { + id: 'embedding_id', + documentId: 'doc_id', + knowledgeBaseId: 'kb_id', + chunkIndex: 'chunk_index', + content: 'content', + embedding: 'embedding', + tokenCount: 'token_count', + characterCount: 'character_count', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + createdAt: 'created_at', + }, + permissions: { + id: 'permission_id', + userId: 'user_id', + entityType: 'entity_type', + entityId: 'entity_id', + permissionType: 'permission_type', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +})) vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseAccess: vi.fn(), @@ -38,28 +124,19 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ retryDocumentProcessing: vi.fn(), })) -mockDrizzleOrm() -mockConsoleLogger() - vi.mock('@/lib/audit/log', () => auditMock) -describe('Knowledge Base Documents API Route', () => { - const mockAuth$ = mockAuth() - - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - offset: vi.fn().mockReturnThis(), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } +import { + createDocumentRecords, + createSingleDocument, + getDocuments, + getProcessingConfig, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import { GET, POST } from '@/app/api/knowledge/[id]/documents/route' +import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' +describe('Knowledge Base Documents API Route', () => { const mockDocument = { id: 'doc-123', knowledgeBaseId: 'kb-123', @@ -108,13 +185,9 @@ describe('Knowledge Base Documents API Route', () => { }) } - beforeEach(async () => { + beforeEach(() => { resetMocks() - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) - vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), }) @@ -128,10 +201,7 @@ describe('Knowledge Base Documents API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) it('should retrieve documents successfully for authenticated user', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - const { getDocuments } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -148,7 +218,6 @@ describe('Knowledge Base Documents API Route', () => { }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -170,10 +239,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return documents with default filter', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - const { getDocuments } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -190,7 +256,6 @@ describe('Knowledge Base Documents API Route', () => { }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) expect(response.status).toBe(200) @@ -207,10 +272,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should filter documents by enabled status when requested', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - const { getDocuments } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -229,7 +291,6 @@ describe('Knowledge Base Documents API Route', () => { const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled' const req = new Request(url, { method: 'GET' }) as any - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) expect(response.status).toBe(200) @@ -246,10 +307,9 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -258,16 +318,13 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return not found for non-existent knowledge base', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false, notFound: true, }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -276,13 +333,10 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return unauthorized for knowledge base without access', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -291,10 +345,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should handle database errors', async () => { - const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils') - const { getDocuments } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -302,7 +353,6 @@ describe('Knowledge Base Documents API Route', () => { vi.mocked(getDocuments).mockRejectedValue(new Error('Database error')) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/documents/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -321,10 +371,7 @@ describe('Knowledge Base Documents API Route', () => { } it('should create single document successfully', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - const { createSingleDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -353,7 +400,6 @@ describe('Knowledge Base Documents API Route', () => { vi.mocked(createSingleDocument).mockResolvedValue(createdDocument) const req = createMockRequest('POST', validDocumentData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -369,9 +415,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should validate single document data', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -385,7 +429,6 @@ describe('Knowledge Base Documents API Route', () => { } const req = createMockRequest('POST', invalidData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -423,11 +466,7 @@ describe('Knowledge Base Documents API Route', () => { } it('should create bulk documents successfully', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - const { createDocumentRecords, processDocumentsWithQueue, getProcessingConfig } = - await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -460,7 +499,6 @@ describe('Knowledge Base Documents API Route', () => { }) const req = createMockRequest('POST', validBulkData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -478,9 +516,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should validate bulk document data', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -506,7 +542,6 @@ describe('Knowledge Base Documents API Route', () => { } const req = createMockRequest('POST', invalidBulkData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -516,11 +551,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should handle processing errors gracefully', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - const { createDocumentRecords, processDocumentsWithQueue, getProcessingConfig } = - await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -546,7 +577,6 @@ describe('Knowledge Base Documents API Route', () => { }) const req = createMockRequest('POST', validBulkData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -565,10 +595,9 @@ describe('Knowledge Base Documents API Route', () => { } it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('POST', validDocumentData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -577,16 +606,13 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return not found for non-existent knowledge base', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: false, notFound: true, }) const req = createMockRequest('POST', validDocumentData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -595,13 +621,10 @@ describe('Knowledge Base Documents API Route', () => { }) it('should return unauthorized for knowledge base without access', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: false }) const req = createMockRequest('POST', validDocumentData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() @@ -610,10 +633,7 @@ describe('Knowledge Base Documents API Route', () => { }) it('should handle database errors during creation', async () => { - const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils') - const { createSingleDocument } = await import('@/lib/knowledge/documents/service') - - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, @@ -621,7 +641,6 @@ describe('Knowledge Base Documents API Route', () => { vi.mocked(createSingleDocument).mockRejectedValue(new Error('Database error')) const req = createMockRequest('POST', validDocumentData) - const { POST } = await import('@/app/api/knowledge/[id]/documents/route') const response = await POST(req, { params: mockParams }) const data = await response.json() diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 3764c87d5f9..b9a527431aa 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -3,19 +3,98 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockAuth, - mockConsoleLogger, - mockDrizzleOrm, - mockKnowledgeSchemas, -} from '@sim/testing' +import { auditMock, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -mockKnowledgeSchemas() -mockDrizzleOrm() -mockConsoleLogger() +const { mockGetSession, mockDbChain } = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + } + return { mockGetSession, mockDbChain } +}) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@sim/db/schema', () => ({ + knowledgeBase: { + id: 'kb_id', + userId: 'user_id', + name: 'kb_name', + description: 'description', + tokenCount: 'token_count', + embeddingModel: 'embedding_model', + embeddingDimension: 'embedding_dimension', + chunkingConfig: 'chunking_config', + workspaceId: 'workspace_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + deletedAt: 'deleted_at', + }, + document: { + id: 'doc_id', + knowledgeBaseId: 'kb_id', + filename: 'filename', + fileUrl: 'file_url', + fileSize: 'file_size', + mimeType: 'mime_type', + chunkCount: 'chunk_count', + tokenCount: 'token_count', + characterCount: 'character_count', + processingStatus: 'processing_status', + processingStartedAt: 'processing_started_at', + processingCompletedAt: 'processing_completed_at', + processingError: 'processing_error', + enabled: 'enabled', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + uploadedAt: 'uploaded_at', + deletedAt: 'deleted_at', + }, + embedding: { + id: 'embedding_id', + documentId: 'doc_id', + knowledgeBaseId: 'kb_id', + chunkIndex: 'chunk_index', + content: 'content', + embedding: 'embedding', + tokenCount: 'token_count', + characterCount: 'character_count', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + createdAt: 'created_at', + }, + permissions: { + id: 'permission_id', + userId: 'user_id', + entityType: 'entity_type', + entityId: 'entity_id', + permissionType: 'permission_type', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +})) vi.mock('@/lib/audit/log', () => auditMock) @@ -30,24 +109,15 @@ vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseWriteAccess: vi.fn(), })) -describe('Knowledge Base By ID API Route', () => { - const mockAuth$ = mockAuth() - - let mockGetKnowledgeBaseById: any - let mockUpdateKnowledgeBase: any - let mockDeleteKnowledgeBase: any - let mockCheckKnowledgeBaseAccess: any - let mockCheckKnowledgeBaseWriteAccess: any - - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } +import { + deleteKnowledgeBase, + getKnowledgeBaseById, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/route' +import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' +describe('Knowledge Base By ID API Route', () => { const mockKnowledgeBase = { id: 'kb-123', userId: 'user-123', @@ -72,25 +142,12 @@ describe('Knowledge Base By ID API Route', () => { }) } - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks() - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) - vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), }) - - const knowledgeService = await import('@/lib/knowledge/service') - const knowledgeUtils = await import('@/app/api/knowledge/utils') - - mockGetKnowledgeBaseById = knowledgeService.getKnowledgeBaseById as any - mockUpdateKnowledgeBase = knowledgeService.updateKnowledgeBase as any - mockDeleteKnowledgeBase = knowledgeService.deleteKnowledgeBase as any - mockCheckKnowledgeBaseAccess = knowledgeUtils.checkKnowledgeBaseAccess as any - mockCheckKnowledgeBaseWriteAccess = knowledgeUtils.checkKnowledgeBaseWriteAccess as any }) afterEach(() => { @@ -101,17 +158,16 @@ describe('Knowledge Base By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) it('should retrieve knowledge base successfully for authenticated user', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockGetKnowledgeBaseById.mockResolvedValueOnce(mockKnowledgeBase) + vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(mockKnowledgeBase) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -119,15 +175,14 @@ describe('Knowledge Base By ID API Route', () => { expect(data.success).toBe(true) expect(data.data.id).toBe('kb-123') expect(data.data.name).toBe('Test Knowledge Base') - expect(mockCheckKnowledgeBaseAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(mockGetKnowledgeBaseById).toHaveBeenCalledWith('kb-123') + expect(checkKnowledgeBaseAccess).toHaveBeenCalledWith('kb-123', 'user-123') + expect(getKnowledgeBaseById).toHaveBeenCalledWith('kb-123') }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -136,15 +191,14 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return not found for non-existent knowledge base', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ hasAccess: false, notFound: true, }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -153,15 +207,14 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return unauthorized for knowledge base owned by different user', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ hasAccess: false, notFound: false, }) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -170,17 +223,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return not found when service returns null', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockGetKnowledgeBaseById.mockResolvedValueOnce(null) + vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -189,12 +241,11 @@ describe('Knowledge Base By ID API Route', () => { }) it('should handle database errors', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseAccess.mockRejectedValueOnce(new Error('Database error')) + vi.mocked(checkKnowledgeBaseAccess).mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/[id]/route') const response = await GET(req, { params: mockParams }) const data = await response.json() @@ -211,28 +262,27 @@ describe('Knowledge Base By ID API Route', () => { } it('should update knowledge base successfully', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) const updatedKnowledgeBase = { ...mockKnowledgeBase, ...validUpdateData } - mockUpdateKnowledgeBase.mockResolvedValueOnce(updatedKnowledgeBase) + vi.mocked(updateKnowledgeBase).mockResolvedValueOnce(updatedKnowledgeBase) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) expect(data.data.name).toBe('Updated Knowledge Base') - expect(mockCheckKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') + expect(updateKnowledgeBase).toHaveBeenCalledWith( 'kb-123', { name: validUpdateData.name, @@ -245,10 +295,9 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -257,17 +306,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return not found for non-existent knowledge base', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: false, notFound: true, }) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -276,11 +324,11 @@ describe('Knowledge Base By ID API Route', () => { }) it('should validate update data', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) @@ -290,7 +338,6 @@ describe('Knowledge Base By ID API Route', () => { } const req = createMockRequest('PUT', invalidData) - const { PUT } = await import('@/app/api/knowledge/[id]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -300,18 +347,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should handle database errors during update', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - // Mock successful write access check - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockUpdateKnowledgeBase.mockRejectedValueOnce(new Error('Database error')) + vi.mocked(updateKnowledgeBase).mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('PUT', validUpdateData) - const { PUT } = await import('@/app/api/knowledge/[id]/route') const response = await PUT(req, { params: mockParams }) const data = await response.json() @@ -324,34 +369,32 @@ describe('Knowledge Base By ID API Route', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) it('should delete knowledge base successfully', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockDeleteKnowledgeBase.mockResolvedValueOnce(undefined) + vi.mocked(deleteKnowledgeBase).mockResolvedValueOnce(undefined) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) expect(data.data.message).toBe('Knowledge base deleted successfully') - expect(mockCheckKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-123', expect.any(String)) + expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') + expect(deleteKnowledgeBase).toHaveBeenCalledWith('kb-123', expect.any(String)) }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -360,17 +403,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return not found for non-existent knowledge base', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: false, notFound: true, }) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -379,17 +421,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should return unauthorized for knowledge base owned by different user', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) resetMocks() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: false, notFound: false, }) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() @@ -398,17 +439,16 @@ describe('Knowledge Base By ID API Route', () => { }) it('should handle database errors during delete', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) - mockCheckKnowledgeBaseWriteAccess.mockResolvedValueOnce({ + vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ hasAccess: true, knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockDeleteKnowledgeBase.mockRejectedValueOnce(new Error('Database error')) + vi.mocked(deleteKnowledgeBase).mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('DELETE') - const { DELETE } = await import('@/app/api/knowledge/[id]/route') const response = await DELETE(req, { params: mockParams }) const data = await response.json() diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 3484ac52072..02697edad38 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -3,29 +3,11 @@ * * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockAuth, - mockConsoleLogger, - mockDrizzleOrm, - mockKnowledgeSchemas, -} from '@sim/testing' +import { auditMock, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -mockKnowledgeSchemas() -mockDrizzleOrm() -mockConsoleLogger() - -vi.mock('@/lib/audit/log', () => auditMock) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), -})) - -describe('Knowledge Base API Route', () => { - const mockAuth$ = mockAuth() - +const { mockGetSession, mockDbChain } = vi.hoisted(() => { + const mockGetSession = vi.fn() const mockDbChain = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), @@ -36,13 +18,97 @@ describe('Knowledge Base API Route', () => { insert: vi.fn().mockReturnThis(), values: vi.fn().mockResolvedValue(undefined), } + return { mockGetSession, mockDbChain } +}) - beforeEach(async () => { - vi.clearAllMocks() +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@sim/db/schema', () => ({ + knowledgeBase: { + id: 'kb_id', + userId: 'user_id', + name: 'kb_name', + description: 'description', + tokenCount: 'token_count', + embeddingModel: 'embedding_model', + embeddingDimension: 'embedding_dimension', + chunkingConfig: 'chunking_config', + workspaceId: 'workspace_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + deletedAt: 'deleted_at', + }, + document: { + id: 'doc_id', + knowledgeBaseId: 'kb_id', + filename: 'filename', + fileUrl: 'file_url', + fileSize: 'file_size', + mimeType: 'mime_type', + chunkCount: 'chunk_count', + tokenCount: 'token_count', + characterCount: 'character_count', + processingStatus: 'processing_status', + processingStartedAt: 'processing_started_at', + processingCompletedAt: 'processing_completed_at', + processingError: 'processing_error', + enabled: 'enabled', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + uploadedAt: 'uploaded_at', + deletedAt: 'deleted_at', + }, + embedding: { + id: 'embedding_id', + documentId: 'doc_id', + knowledgeBaseId: 'kb_id', + chunkIndex: 'chunk_index', + content: 'content', + embedding: 'embedding', + tokenCount: 'token_count', + characterCount: 'character_count', + tag1: 'tag1', + tag2: 'tag2', + tag3: 'tag3', + tag4: 'tag4', + tag5: 'tag5', + tag6: 'tag6', + tag7: 'tag7', + createdAt: 'created_at', + }, + permissions: { + id: 'permission_id', + userId: 'user_id', + entityType: 'entity_type', + entityId: 'entity_id', + permissionType: 'permission_type', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +})) + +vi.mock('@/lib/audit/log', () => auditMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), +})) + +import { GET, POST } from '@/app/api/knowledge/route' + +describe('Knowledge Base API Route', () => { + beforeEach(() => { + vi.clearAllMocks() Object.values(mockDbChain).forEach((fn) => { if (typeof fn === 'function') { @@ -64,10 +130,9 @@ describe('Knowledge Base API Route', () => { describe('GET /api/knowledge', () => { it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/route') const response = await GET(req) const data = await response.json() @@ -76,11 +141,10 @@ describe('Knowledge Base API Route', () => { }) it('should handle database errors', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockDbChain.orderBy.mockRejectedValue(new Error('Database error')) const req = createMockRequest('GET') - const { GET } = await import('@/app/api/knowledge/route') const response = await GET(req) const data = await response.json() @@ -102,10 +166,9 @@ describe('Knowledge Base API Route', () => { } it('should create knowledge base successfully', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const req = createMockRequest('POST', validKnowledgeBaseData) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -117,10 +180,9 @@ describe('Knowledge Base API Route', () => { }) it('should return unauthorized for unauthenticated user', async () => { - mockAuth$.mockUnauthenticated() + mockGetSession.mockResolvedValue(null) const req = createMockRequest('POST', validKnowledgeBaseData) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -129,10 +191,9 @@ describe('Knowledge Base API Route', () => { }) it('should validate required fields', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const req = createMockRequest('POST', { description: 'Missing name' }) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -142,10 +203,9 @@ describe('Knowledge Base API Route', () => { }) it('should require workspaceId', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const req = createMockRequest('POST', { name: 'Test KB' }) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -155,7 +215,7 @@ describe('Knowledge Base API Route', () => { }) it('should validate chunking config constraints', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const invalidData = { name: 'Test KB', @@ -168,7 +228,6 @@ describe('Knowledge Base API Route', () => { } const req = createMockRequest('POST', invalidData) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -177,11 +236,10 @@ describe('Knowledge Base API Route', () => { }) it('should use default values for optional fields', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' } const req = createMockRequest('POST', minimalData) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() @@ -196,11 +254,10 @@ describe('Knowledge Base API Route', () => { }) it('should handle database errors during creation', async () => { - mockAuth$.mockAuthenticatedUser() + mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' } }) mockDbChain.values.mockRejectedValue(new Error('Database error')) const req = createMockRequest('POST', validKnowledgeBaseData) - const { POST } = await import('@/app/api/knowledge/route') const response = await POST(req) const data = await response.json() diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index bf7ae1f72e1..d257bb71655 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -8,12 +8,47 @@ import { createEnvMock, createMockRequest, - mockConsoleLogger, mockKnowledgeSchemas, requestUtilsMock, } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { + mockDbChain, + mockCheckSessionOrInternalAuth, + mockAuthorizeWorkflowByWorkspacePermission, + mockCheckKnowledgeBaseAccess, + mockGetDocumentTagDefinitions, + mockHandleTagOnlySearch, + mockHandleVectorOnlySearch, + mockHandleTagAndVectorSearch, + mockGetQueryStrategy, + mockGenerateSearchEmbedding, + mockGetDocumentNamesByIds, +} = vi.hoisted(() => ({ + mockDbChain: { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + having: vi.fn().mockReturnThis(), + }, + mockCheckSessionOrInternalAuth: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockCheckKnowledgeBaseAccess: vi.fn(), + mockGetDocumentTagDefinitions: vi.fn(), + mockHandleTagOnlySearch: vi.fn(), + mockHandleVectorOnlySearch: vi.fn(), + mockHandleTagAndVectorSearch: vi.fn(), + mockGetQueryStrategy: vi.fn(), + mockGenerateSearchEmbedding: vi.fn(), + mockGetDocumentNamesByIds: vi.fn(), +})) + vi.mock('drizzle-orm', () => ({ and: vi.fn().mockImplementation((...args) => ({ and: args })), eq: vi.fn().mockImplementation((a, b) => ({ eq: [a, b] })), @@ -28,6 +63,18 @@ vi.mock('drizzle-orm', () => ({ mockKnowledgeSchemas() +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) + vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' })) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) @@ -53,22 +100,14 @@ vi.mock('@/providers/utils', () => ({ }), })) -const mockCheckKnowledgeBaseAccess = vi.fn() vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess, })) -const mockGetDocumentTagDefinitions = vi.fn() vi.mock('@/lib/knowledge/tags/service', () => ({ getDocumentTagDefinitions: mockGetDocumentTagDefinitions, })) -const mockHandleTagOnlySearch = vi.fn() -const mockHandleVectorOnlySearch = vi.fn() -const mockHandleTagAndVectorSearch = vi.fn() -const mockGetQueryStrategy = vi.fn() -const mockGenerateSearchEmbedding = vi.fn() -const mockGetDocumentNamesByIds = vi.fn() vi.mock('./utils', () => ({ handleTagOnlySearch: mockHandleTagOnlySearch, handleVectorOnlySearch: mockHandleVectorOnlySearch, @@ -86,25 +125,13 @@ vi.mock('./utils', () => ({ }, })) -mockConsoleLogger() +import { estimateTokenCount } from '@/lib/tokenization/estimators' +import { POST } from '@/app/api/knowledge/search/route' +import { calculateCost } from '@/providers/utils' describe('Knowledge Search API Route', () => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - having: vi.fn().mockReturnThis(), - } - const mockGetUserId = vi.fn() const mockFetch = vi.fn() - const mockCheckSessionOrInternalAuth = vi.fn() - const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5] const mockSearchResults = [ @@ -126,21 +153,9 @@ describe('Knowledge Search API Route', () => { }, ] - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks() - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) - - vi.doMock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, - })) - - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, - })) - Object.values(mockDbChain).forEach((fn) => { if (typeof fn === 'function') { fn.mockClear().mockReturnThis() @@ -225,7 +240,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -273,7 +287,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', multiKbData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -319,7 +332,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', workflowData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -339,7 +351,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -360,7 +371,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', workflowData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -377,7 +387,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -393,13 +402,11 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') - // Mock access check: first KB has access, second doesn't mockCheckKnowledgeBaseAccess .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: mockKnowledgeBases[0] }) .mockResolvedValueOnce({ hasAccess: false, notFound: true }) const req = createMockRequest('POST', multiKbData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -415,7 +422,6 @@ describe('Knowledge Search API Route', () => { } const req = createMockRequest('POST', invalidData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -432,7 +438,6 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') - // Mock knowledge base access check to return success mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -454,7 +459,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', dataWithoutTopK) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -466,13 +470,11 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) - // Mock generateSearchEmbedding to throw an error mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('OpenAI API error: 401 Unauthorized - Invalid API key') ) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -484,11 +486,9 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) - // Mock generateSearchEmbedding to throw missing API key error mockGenerateSearchEmbedding.mockRejectedValueOnce(new Error('OPENAI_API_KEY not configured')) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -500,11 +500,9 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) - // Mock the search handler to throw a database error mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -516,13 +514,11 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) - // Mock generateSearchEmbedding to throw invalid response format error mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('Invalid response format from OpenAI embeddings API') ) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -534,7 +530,6 @@ describe('Knowledge Search API Route', () => { it.concurrent('should include cost information in successful search response', async () => { mockGetUserId.mockResolvedValue('user-123') - // Mock knowledge base access check to return success mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -556,14 +551,12 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) expect(data.success).toBe(true) - // Verify cost information is included expect(data.data.cost).toBeDefined() expect(data.data.cost.input).toBe(0.00001042) expect(data.data.cost.output).toBe(0) @@ -582,12 +575,8 @@ describe('Knowledge Search API Route', () => { }) it('should call cost calculation functions with correct parameters', async () => { - const { estimateTokenCount } = await import('@/lib/tokenization/estimators') - const { calculateCost } = await import('@/providers/utils') - mockGetUserId.mockResolvedValue('user-123') - // Mock knowledge base access check to return success mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -609,21 +598,14 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', validSearchData) - const { POST } = await import('@/app/api/knowledge/search/route') await POST(req) - // Verify token estimation was called with correct parameters expect(estimateTokenCount).toHaveBeenCalledWith('test search query', 'openai') - // Verify cost calculation was called with correct parameters expect(calculateCost).toHaveBeenCalledWith('text-embedding-3-small', 521, 0, false) }) it('should handle cost calculation with different query lengths', async () => { - const { estimateTokenCount } = await import('@/lib/tokenization/estimators') - const { calculateCost } = await import('@/providers/utils') - - // Mock different token count for longer query vi.mocked(estimateTokenCount).mockReturnValue({ count: 1042, confidence: 'high', @@ -649,7 +631,6 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') - // Mock knowledge base access check to return success mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -671,7 +652,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', longQueryData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -730,17 +710,13 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions for validation mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - // Mock tag definitions queries for display mapping mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) - // Mock the tag-only search handler mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) const req = createMockRequest('POST', tagOnlyData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -779,13 +755,10 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions for validation mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - // Mock tag definitions queries for display mapping mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) - // Mock the tag + vector search handler mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ @@ -797,7 +770,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', combinedData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -825,7 +797,6 @@ describe('Knowledge Search API Route', () => { } const req = createMockRequest('POST', emptyData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -850,7 +821,6 @@ describe('Knowledge Search API Route', () => { } const req = createMockRequest('POST', emptyFiltersData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -859,17 +829,13 @@ describe('Knowledge Search API Route', () => { }) it('should handle empty tag values gracefully', async () => { - // This simulates what happens when the frontend sends empty tag values - // The tool transformation should filter out empty values, resulting in no filters const emptyTagValueData = { knowledgeBaseIds: 'kb-123', query: '', topK: 10, - // This would result in no filters after tool transformation } const req = createMockRequest('POST', emptyTagValueData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -886,8 +852,6 @@ describe('Knowledge Search API Route', () => { }) it('should handle null values from frontend gracefully', async () => { - // This simulates the exact scenario the user reported - // Null values should be transformed to undefined and then trigger validation const nullValuesData = { knowledgeBaseIds: 'kb-123', topK: null, @@ -896,7 +860,6 @@ describe('Knowledge Search API Route', () => { } const req = createMockRequest('POST', nullValuesData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -941,7 +904,6 @@ describe('Knowledge Search API Route', () => { }) const req = createMockRequest('POST', queryOnlyData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -979,17 +941,13 @@ describe('Knowledge Search API Route', () => { knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' }, }) - // Mock tag definitions for validation mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - // Mock the tag-only search handler mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) - // Mock tag definitions queries for display mapping mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) const req = createMockRequest('POST', multiKbTagData) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -1057,7 +1015,6 @@ describe('Knowledge Search API Route', () => { topK: 10, }) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -1081,7 +1038,6 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions for validation mockGetDocumentTagDefinitions.mockResolvedValue([ { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) @@ -1130,7 +1086,6 @@ describe('Knowledge Search API Route', () => { topK: 10, }) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() @@ -1155,7 +1110,6 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions for validation mockGetDocumentTagDefinitions.mockResolvedValue([ { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) @@ -1206,7 +1160,6 @@ describe('Knowledge Search API Route', () => { topK: 10, }) - const { POST } = await import('@/app/api/knowledge/search/route') const response = await POST(req) const data = await response.json() diff --git a/apps/sim/app/api/mcp/events/route.test.ts b/apps/sim/app/api/mcp/events/route.test.ts index f3db4d57548..2d5fd7bdedd 100644 --- a/apps/sim/app/api/mcp/events/route.test.ts +++ b/apps/sim/app/api/mcp/events/route.test.ts @@ -3,26 +3,37 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -mockConsoleLogger() -const auth = mockAuth() +const { mockGetSession, mockGetUserEntityPermissions } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) -const mockGetUserEntityPermissions = vi.fn() -vi.doMock('@/lib/workspaces/permissions/utils', () => ({ +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.doMock('@/lib/mcp/connection-manager', () => ({ +vi.mock('@/lib/mcp/connection-manager', () => ({ mcpConnectionManager: null, })) -vi.doMock('@/lib/mcp/pubsub', () => ({ +vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: null, })) -const { GET } = await import('./route') +import { GET } from './route' + +const defaultMockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', +} describe('MCP Events SSE Endpoint', () => { beforeEach(() => { @@ -30,7 +41,7 @@ describe('MCP Events SSE Endpoint', () => { }) it('returns 401 when session is missing', async () => { - auth.setUnauthenticated() + mockGetSession.mockResolvedValue(null) const request = createMockRequest( 'GET', @@ -47,7 +58,7 @@ describe('MCP Events SSE Endpoint', () => { }) it('returns 400 when workspaceId is missing', async () => { - auth.setAuthenticated() + mockGetSession.mockResolvedValue({ user: defaultMockUser }) const request = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/mcp/events') @@ -59,7 +70,7 @@ describe('MCP Events SSE Endpoint', () => { }) it('returns 403 when user lacks workspace access', async () => { - auth.setAuthenticated() + mockGetSession.mockResolvedValue({ user: defaultMockUser }) mockGetUserEntityPermissions.mockResolvedValue(null) const request = createMockRequest( @@ -78,7 +89,7 @@ describe('MCP Events SSE Endpoint', () => { }) it('returns SSE stream when authorized', async () => { - auth.setAuthenticated() + mockGetSession.mockResolvedValue({ user: defaultMockUser }) mockGetUserEntityPermissions.mockResolvedValue({ read: true }) const request = createMockRequest( diff --git a/apps/sim/app/api/mcp/events/route.ts b/apps/sim/app/api/mcp/events/route.ts index 6df91db5c01..7def26b345e 100644 --- a/apps/sim/app/api/mcp/events/route.ts +++ b/apps/sim/app/api/mcp/events/route.ts @@ -14,6 +14,7 @@ import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' import { mcpPubSub } from '@/lib/mcp/pubsub' +import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('McpEventsSSE') @@ -41,10 +42,24 @@ export async function GET(request: NextRequest) { const encoder = new TextEncoder() const unsubscribers: Array<() => void> = [] + let cleaned = false + + const cleanup = () => { + if (cleaned) return + cleaned = true + for (const unsub of unsubscribers) { + unsub() + } + decrementSSEConnections('mcp-events') + logger.info(`SSE connection closed for workspace ${workspaceId}`) + } const stream = new ReadableStream({ start(controller) { + incrementSSEConnections('mcp-events') + const send = (eventName: string, data: Record) => { + if (cleaned) return try { controller.enqueue( encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`) @@ -82,6 +97,10 @@ export async function GET(request: NextRequest) { // Heartbeat to keep the connection alive const heartbeat = setInterval(() => { + if (cleaned) { + clearInterval(heartbeat) + return + } try { controller.enqueue(encoder.encode(': heartbeat\n\n')) } catch { @@ -91,20 +110,24 @@ export async function GET(request: NextRequest) { unsubscribers.push(() => clearInterval(heartbeat)) // Cleanup when client disconnects - request.signal.addEventListener('abort', () => { - for (const unsub of unsubscribers) { - unsub() - } - try { - controller.close() - } catch { - // Already closed - } - logger.info(`SSE connection closed for workspace ${workspaceId}`) - }) + request.signal.addEventListener( + 'abort', + () => { + cleanup() + try { + controller.close() + } catch { + // Already closed + } + }, + { once: true } + ) logger.info(`SSE connection opened for workspace ${workspaceId}`) }, + cancel() { + cleanup() + }, }) return new Response(stream, { headers: SSE_HEADERS }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index fc6b5182edb..97f887e9559 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -3,86 +3,99 @@ * * @vitest-environment node */ -import { mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -let mockCheckHybridAuth: ReturnType -const mockGetUserEntityPermissions = vi.fn() -const mockGenerateInternalToken = vi.fn() -const mockDbSelect = vi.fn() -const mockDbFrom = vi.fn() -const mockDbWhere = vi.fn() -const mockDbLimit = vi.fn() -const fetchMock = vi.fn() +const { + mockCheckHybridAuth, + mockGetUserEntityPermissions, + mockGenerateInternalToken, + mockDbSelect, + mockDbFrom, + mockDbWhere, + mockDbLimit, + fetchMock, +} = vi.hoisted(() => ({ + mockCheckHybridAuth: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGenerateInternalToken: vi.fn(), + mockDbSelect: vi.fn(), + mockDbFrom: vi.fn(), + mockDbWhere: vi.fn(), + mockDbLimit: vi.fn(), + fetchMock: vi.fn(), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflowMcpServer: { + id: 'id', + name: 'name', + workspaceId: 'workspaceId', + isPublic: 'isPublic', + createdBy: 'createdBy', + }, + workflowMcpTool: { + serverId: 'serverId', + toolName: 'toolName', + toolDescription: 'toolDescription', + parameterSchema: 'parameterSchema', + workflowId: 'workflowId', + }, + workflow: { + id: 'id', + isDeployed: 'isDeployed', + }, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: mockCheckHybridAuth, + checkSessionOrInternalAuth: vi.fn(), + checkInternalAuth: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/auth/internal', () => ({ + generateInternalToken: mockGenerateInternalToken, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'http://localhost:3000', + getInternalApiBaseUrl: () => 'http://localhost:3000', +})) + +vi.mock('@/lib/core/execution-limits', () => ({ + getMaxExecutionTimeout: () => 10_000, +})) + +import { GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() mockDbSelect.mockReturnValue({ from: mockDbFrom }) mockDbFrom.mockReturnValue({ where: mockDbWhere }) mockDbWhere.mockReturnValue({ limit: mockDbLimit }) - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - })), - })) - vi.doMock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - })) - vi.doMock('@sim/db', () => ({ - db: { - select: mockDbSelect, - }, - })) - vi.doMock('@sim/db/schema', () => ({ - workflowMcpServer: { - id: 'id', - name: 'name', - workspaceId: 'workspaceId', - isPublic: 'isPublic', - createdBy: 'createdBy', - }, - workflowMcpTool: { - serverId: 'serverId', - toolName: 'toolName', - toolDescription: 'toolDescription', - parameterSchema: 'parameterSchema', - workflowId: 'workflowId', - }, - workflow: { - id: 'id', - isDeployed: 'isDeployed', - }, - })) - ;({ mockCheckHybridAuth } = mockHybridAuth()) - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, - })) - vi.doMock('@/lib/auth/internal', () => ({ - generateInternalToken: mockGenerateInternalToken, - })) - vi.doMock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'http://localhost:3000', - getInternalApiBaseUrl: () => 'http://localhost:3000', - })) - vi.doMock('@/lib/core/execution-limits', () => ({ - getMaxExecutionTimeout: () => 10_000, - })) - vi.stubGlobal('fetch', fetchMock) }) afterEach(() => { vi.unstubAllGlobals() - vi.clearAllMocks() }) it('returns 401 for private server when auth fails', async () => { @@ -97,7 +110,6 @@ describe('MCP Serve Route', () => { ]) mockCheckHybridAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized' }) - const { POST } = await import('./route') const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), @@ -119,7 +131,6 @@ describe('MCP Serve Route', () => { ]) mockCheckHybridAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized' }) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1') const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) }) @@ -154,7 +165,6 @@ describe('MCP Serve Route', () => { }) ) - const { POST } = await import('./route') const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', headers: { 'X-API-Key': 'pk_test_123' }, @@ -204,7 +214,6 @@ describe('MCP Serve Route', () => { }) ) - const { POST } = await import('./route') const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', body: JSON.stringify({ diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 0d44e1ccd57..66dc9fd6f3c 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -4,7 +4,137 @@ * @vitest-environment node */ import type { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockVerifyCronAuth, + mockExecuteScheduleJob, + mockFeatureFlags, + mockDbReturning, + mockDbUpdate, + mockEnqueue, + mockStartJob, + mockCompleteJob, + mockMarkJobFailed, +} = vi.hoisted(() => { + const mockDbReturning = vi.fn().mockReturnValue([]) + const mockDbWhere = vi.fn().mockReturnValue({ returning: mockDbReturning }) + const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere }) + const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet }) + const mockEnqueue = vi.fn().mockResolvedValue('job-id-1') + const mockStartJob = vi.fn().mockResolvedValue(undefined) + const mockCompleteJob = vi.fn().mockResolvedValue(undefined) + const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined) + + return { + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), + mockFeatureFlags: { + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, + }, + mockDbReturning, + mockDbUpdate, + mockEnqueue, + mockStartJob, + mockCompleteJob, + mockMarkJobFailed, + } +}) + +vi.mock('@/lib/auth/internal', () => ({ + verifyCronAuth: mockVerifyCronAuth, +})) + +vi.mock('@/background/schedule-execution', () => ({ + executeScheduleJob: mockExecuteScheduleJob, +})) + +vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ + enqueue: mockEnqueue, + startJob: mockStartJob, + completeJob: mockCompleteJob, + markJobFailed: mockMarkJobFailed, + }), + shouldExecuteInline: vi.fn().mockReturnValue(false), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })), + lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })), + not: vi.fn((condition: unknown) => ({ type: 'not', condition })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), + or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), + sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })), +})) + +vi.mock('@sim/db', () => ({ + db: { + update: mockDbUpdate, + }, + workflowSchedule: { + id: 'id', + workflowId: 'workflowId', + blockId: 'blockId', + cronExpression: 'cronExpression', + lastRanAt: 'lastRanAt', + failedCount: 'failedCount', + status: 'status', + nextRunAt: 'nextRunAt', + lastQueuedAt: 'lastQueuedAt', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, + workflow: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', + }, +})) + +import { GET } from '@/app/api/schedules/execute/route' + +const SINGLE_SCHEDULE = [ + { + id: 'schedule-1', + workflowId: 'workflow-1', + blockId: null, + cronExpression: null, + lastRanAt: null, + failedCount: 0, + nextRunAt: new Date('2025-01-01T00:00:00.000Z'), + lastQueuedAt: undefined, + }, +] + +const MULTIPLE_SCHEDULES = [ + ...SINGLE_SCHEDULE, + { + id: 'schedule-2', + workflowId: 'workflow-2', + blockId: null, + cronExpression: null, + lastRanAt: null, + failedCount: 0, + nextRunAt: new Date('2025-01-01T01:00:00.000Z'), + lastQueuedAt: undefined, + }, +] function createMockRequest(): NextRequest { const mockHeaders = new Map([ @@ -23,92 +153,16 @@ function createMockRequest(): NextRequest { describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { vi.clearAllMocks() - vi.resetModules() - }) - - afterEach(() => { - vi.clearAllMocks() - vi.resetModules() + mockFeatureFlags.isTriggerDevEnabled = false + mockFeatureFlags.isHosted = false + mockFeatureFlags.isProd = false + mockFeatureFlags.isDev = true + mockDbReturning.mockReturnValue([]) }) it('should execute scheduled workflows with Trigger.dev disabled', async () => { - const mockExecuteScheduleJob = vi.fn().mockResolvedValue(undefined) - - vi.doMock('@/lib/auth/internal', () => ({ - verifyCronAuth: vi.fn().mockReturnValue(null), - })) + mockDbReturning.mockReturnValue(SINGLE_SCHEDULE) - vi.doMock('@/background/schedule-execution', () => ({ - executeScheduleJob: mockExecuteScheduleJob, - })) - - vi.doMock('@/lib/core/config/feature-flags', () => ({ - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ type: 'and', conditions })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - lte: vi.fn((field, value) => ({ field, value, type: 'lte' })), - lt: vi.fn((field, value) => ({ field, value, type: 'lt' })), - not: vi.fn((condition) => ({ type: 'not', condition })), - isNull: vi.fn((field) => ({ type: 'isNull', field })), - or: vi.fn((...conditions) => ({ type: 'or', conditions })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), - })) - - vi.doMock('@sim/db', () => { - const returningSchedules = [ - { - id: 'schedule-1', - workflowId: 'workflow-1', - blockId: null, - cronExpression: null, - lastRanAt: null, - failedCount: 0, - nextRunAt: new Date('2025-01-01T00:00:00.000Z'), - lastQueuedAt: undefined, - }, - ] - - const mockReturning = vi.fn().mockReturnValue(returningSchedules) - const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning }) - const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - - return { - db: { - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - blockId: 'blockId', - cronExpression: 'cronExpression', - lastRanAt: 'lastRanAt', - failedCount: 'failedCount', - status: 'status', - nextRunAt: 'nextRunAt', - lastQueuedAt: 'lastQueuedAt', - deploymentVersionId: 'deploymentVersionId', - }, - workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - isActive: 'isActive', - }, - workflow: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - }, - } - }) - - const { GET } = await import('@/app/api/schedules/execute/route') const response = await GET(createMockRequest()) expect(response).toBeDefined() @@ -119,85 +173,9 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should queue schedules to Trigger.dev when enabled', async () => { - const mockTrigger = vi.fn().mockResolvedValue({ id: 'task-id-123' }) - - vi.doMock('@/lib/auth/internal', () => ({ - verifyCronAuth: vi.fn().mockReturnValue(null), - })) - - vi.doMock('@trigger.dev/sdk', () => ({ - tasks: { - trigger: mockTrigger, - }, - })) - - vi.doMock('@/lib/core/config/feature-flags', () => ({ - isTriggerDevEnabled: true, - isHosted: false, - isProd: false, - isDev: true, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ type: 'and', conditions })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - lte: vi.fn((field, value) => ({ field, value, type: 'lte' })), - lt: vi.fn((field, value) => ({ field, value, type: 'lt' })), - not: vi.fn((condition) => ({ type: 'not', condition })), - isNull: vi.fn((field) => ({ type: 'isNull', field })), - or: vi.fn((...conditions) => ({ type: 'or', conditions })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), - })) - - vi.doMock('@sim/db', () => { - const returningSchedules = [ - { - id: 'schedule-1', - workflowId: 'workflow-1', - blockId: null, - cronExpression: null, - lastRanAt: null, - failedCount: 0, - nextRunAt: new Date('2025-01-01T00:00:00.000Z'), - lastQueuedAt: undefined, - }, - ] - - const mockReturning = vi.fn().mockReturnValue(returningSchedules) - const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning }) - const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - - return { - db: { - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - blockId: 'blockId', - cronExpression: 'cronExpression', - lastRanAt: 'lastRanAt', - failedCount: 'failedCount', - status: 'status', - nextRunAt: 'nextRunAt', - lastQueuedAt: 'lastQueuedAt', - deploymentVersionId: 'deploymentVersionId', - }, - workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - isActive: 'isActive', - }, - workflow: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - }, - } - }) + mockFeatureFlags.isTriggerDevEnabled = true + mockDbReturning.mockReturnValue(SINGLE_SCHEDULE) - const { GET } = await import('@/app/api/schedules/execute/route') const response = await GET(createMockRequest()) expect(response).toBeDefined() @@ -207,68 +185,8 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should handle case with no due schedules', async () => { - vi.doMock('@/lib/auth/internal', () => ({ - verifyCronAuth: vi.fn().mockReturnValue(null), - })) - - vi.doMock('@/background/schedule-execution', () => ({ - executeScheduleJob: vi.fn().mockResolvedValue(undefined), - })) - - vi.doMock('@/lib/core/config/feature-flags', () => ({ - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - })) + mockDbReturning.mockReturnValue([]) - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ type: 'and', conditions })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - lte: vi.fn((field, value) => ({ field, value, type: 'lte' })), - lt: vi.fn((field, value) => ({ field, value, type: 'lt' })), - not: vi.fn((condition) => ({ type: 'not', condition })), - isNull: vi.fn((field) => ({ type: 'isNull', field })), - or: vi.fn((...conditions) => ({ type: 'or', conditions })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), - })) - - vi.doMock('@sim/db', () => { - const mockReturning = vi.fn().mockReturnValue([]) - const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning }) - const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - - return { - db: { - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - blockId: 'blockId', - cronExpression: 'cronExpression', - lastRanAt: 'lastRanAt', - failedCount: 'failedCount', - status: 'status', - nextRunAt: 'nextRunAt', - lastQueuedAt: 'lastQueuedAt', - deploymentVersionId: 'deploymentVersionId', - }, - workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - isActive: 'isActive', - }, - workflow: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - }, - } - }) - - const { GET } = await import('@/app/api/schedules/execute/route') const response = await GET(createMockRequest()) expect(response.status).toBe(200) @@ -278,91 +196,8 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute multiple schedules in parallel', async () => { - vi.doMock('@/lib/auth/internal', () => ({ - verifyCronAuth: vi.fn().mockReturnValue(null), - })) - - vi.doMock('@/background/schedule-execution', () => ({ - executeScheduleJob: vi.fn().mockResolvedValue(undefined), - })) - - vi.doMock('@/lib/core/config/feature-flags', () => ({ - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - })) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn((...conditions) => ({ type: 'and', conditions })), - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), - lte: vi.fn((field, value) => ({ field, value, type: 'lte' })), - lt: vi.fn((field, value) => ({ field, value, type: 'lt' })), - not: vi.fn((condition) => ({ type: 'not', condition })), - isNull: vi.fn((field) => ({ type: 'isNull', field })), - or: vi.fn((...conditions) => ({ type: 'or', conditions })), - sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), - })) - - vi.doMock('@sim/db', () => { - const returningSchedules = [ - { - id: 'schedule-1', - workflowId: 'workflow-1', - blockId: null, - cronExpression: null, - lastRanAt: null, - failedCount: 0, - nextRunAt: new Date('2025-01-01T00:00:00.000Z'), - lastQueuedAt: undefined, - }, - { - id: 'schedule-2', - workflowId: 'workflow-2', - blockId: null, - cronExpression: null, - lastRanAt: null, - failedCount: 0, - nextRunAt: new Date('2025-01-01T01:00:00.000Z'), - lastQueuedAt: undefined, - }, - ] - - const mockReturning = vi.fn().mockReturnValue(returningSchedules) - const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning }) - const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) - const mockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - - return { - db: { - update: mockUpdate, - }, - workflowSchedule: { - id: 'id', - workflowId: 'workflowId', - blockId: 'blockId', - cronExpression: 'cronExpression', - lastRanAt: 'lastRanAt', - failedCount: 'failedCount', - status: 'status', - nextRunAt: 'nextRunAt', - lastQueuedAt: 'lastQueuedAt', - deploymentVersionId: 'deploymentVersionId', - }, - workflowDeploymentVersion: { - id: 'id', - workflowId: 'workflowId', - isActive: 'isActive', - }, - workflow: { - id: 'id', - userId: 'userId', - workspaceId: 'workspaceId', - }, - } - }) + mockDbReturning.mockReturnValue(MULTIPLE_SCHEDULES) - const { GET } = await import('@/app/api/schedules/execute/route') const response = await GET(createMockRequest()) expect(response.status).toBe(200) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 15e26ba5062..7e6e7e6da23 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -3,86 +3,243 @@ * * @vitest-environment node */ -import { createMockRequest, loggerMock, mockHybridAuth } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockSelect, + mockFrom, + mockWhere, + mockOrderBy, + mockInsert, + mockValues, + mockUpdate, + mockSet, + mockDelete, + mockLimit, + mockCheckSessionOrInternalAuth, + mockGetSession, + mockGetUserEntityPermissions, + mockUpsertCustomTools, + mockAuthorizeWorkflowByWorkspacePermission, + mockLogger, +} = vi.hoisted(() => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), + mockOrderBy: vi.fn(), + mockInsert: vi.fn(), + mockValues: vi.fn(), + mockUpdate: vi.fn(), + mockSet: vi.fn(), + mockDelete: vi.fn(), + mockLimit: vi.fn(), + mockCheckSessionOrInternalAuth: vi.fn(), + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockUpsertCustomTools: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockLogger: logger, + } +}) -describe('Custom Tools API Routes', () => { - const sampleTools = [ - { - id: 'tool-1', - workspaceId: 'workspace-123', - userId: 'user-123', - title: 'Weather Tool', - schema: { - type: 'function', - function: { - name: 'getWeather', - description: 'Get weather information for a location', - parameters: { - type: 'object', - properties: { - location: { - type: 'string', - description: 'The city and state, e.g. San Francisco, CA', - }, +const sampleTools = [ + { + id: 'tool-1', + workspaceId: 'workspace-123', + userId: 'user-123', + title: 'Weather Tool', + schema: { + type: 'function', + function: { + name: 'getWeather', + description: 'Get weather information for a location', + parameters: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA', }, - required: ['location'], }, + required: ['location'], }, }, - code: 'return { temperature: 72, conditions: "sunny" };', - createdAt: '2023-01-01T00:00:00.000Z', - updatedAt: '2023-01-02T00:00:00.000Z', }, - { - id: 'tool-2', - workspaceId: 'workspace-123', - userId: 'user-123', - title: 'Calculator Tool', - schema: { - type: 'function', - function: { - name: 'calculator', - description: 'Perform basic calculations', - parameters: { - type: 'object', - properties: { - operation: { - type: 'string', - description: 'The operation to perform (add, subtract, multiply, divide)', - }, - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' }, + code: 'return { temperature: 72, conditions: "sunny" };', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-02T00:00:00.000Z', + }, + { + id: 'tool-2', + workspaceId: 'workspace-123', + userId: 'user-123', + title: 'Calculator Tool', + schema: { + type: 'function', + function: { + name: 'calculator', + description: 'Perform basic calculations', + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'The operation to perform (add, subtract, multiply, divide)', }, - required: ['operation', 'a', 'b'], + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' }, }, + required: ['operation', 'a', 'b'], }, }, - code: 'const { operation, a, b } = params; if (operation === "add") return a + b;', - createdAt: '2023-02-01T00:00:00.000Z', - updatedAt: '2023-02-02T00:00:00.000Z', }, - ] - - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhere = vi.fn() - const mockOrderBy = vi.fn() - const mockInsert = vi.fn() - const mockValues = vi.fn() - const mockUpdate = vi.fn() - const mockSet = vi.fn() - const mockDelete = vi.fn() - const mockLimit = vi.fn() + code: 'const { operation, a, b } = params; if (operation === "add") return a + b;', + createdAt: '2023-02-01T00:00:00.000Z', + updatedAt: '2023-02-02T00:00:00.000Z', + }, +] + +vi.mock('@sim/db', () => ({ + db: { + select: (...args: unknown[]) => mockSelect(...args), + insert: (...args: unknown[]) => mockInsert(...args), + update: (...args: unknown[]) => mockUpdate(...args), + delete: (...args: unknown[]) => mockDelete(...args), + transaction: vi + .fn() + .mockImplementation(async (callback: (tx: Record) => unknown) => { + const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom }) + const txMockInsert = vi.fn().mockReturnValue({ values: mockValues }) + const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet }) + const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere }) + + const txMockOrderBy = vi.fn().mockImplementation(() => { + const queryBuilder = { + limit: mockLimit, + then: (resolve: (value: typeof sampleTools) => void) => { + resolve(sampleTools) + return queryBuilder + }, + catch: (_reject: (error: Error) => void) => queryBuilder, + } + return queryBuilder + }) + + const txMockWhere = vi.fn().mockImplementation(() => { + const queryBuilder = { + orderBy: txMockOrderBy, + limit: mockLimit, + then: (resolve: (value: typeof sampleTools) => void) => { + resolve(sampleTools) + return queryBuilder + }, + catch: (_reject: (error: Error) => void) => queryBuilder, + } + return queryBuilder + }) + + const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere }) + txMockSelect.mockReturnValue({ from: txMockFrom }) + + return await callback({ + select: txMockSelect, + insert: txMockInsert, + update: txMockUpdate, + delete: txMockDelete, + }) + }), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + customTools: { + id: 'id', + workspaceId: 'workspaceId', + userId: 'userId', + title: 'title', + }, + workflow: { + id: 'id', + workspaceId: 'workspaceId', + userId: 'userId', + }, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ + field, + value, + operator: 'eq', + })), + and: vi.fn().mockImplementation((...conditions: unknown[]) => ({ + operator: 'and', + conditions, + })), + or: vi.fn().mockImplementation((...conditions: unknown[]) => ({ + operator: 'or', + conditions, + })), + isNull: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'isNull' })), + ne: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ + field, + value, + operator: 'ne', + })), + desc: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'desc' })), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + upsertCustomTools: (...args: unknown[]) => mockUpsertCustomTools(...args), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: (...args: unknown[]) => + mockAuthorizeWorkflowByWorkspacePermission(...args), +})) + +import { DELETE, GET, POST } from '@/app/api/tools/custom/route' + +describe('Custom Tools API Routes', () => { const mockSession = { user: { id: 'user-123' } } beforeEach(() => { - vi.resetModules() + vi.clearAllMocks() mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockImplementation((condition) => { + mockWhere.mockImplementation(() => { const queryBuilder = { orderBy: mockOrderBy, limit: mockLimit, @@ -90,7 +247,7 @@ describe('Custom Tools API Routes', () => { resolve(sampleTools) return queryBuilder }, - catch: (reject: (error: Error) => void) => queryBuilder, + catch: (_reject: (error: Error) => void) => queryBuilder, } return queryBuilder }) @@ -101,7 +258,7 @@ describe('Custom Tools API Routes', () => { resolve(sampleTools) return queryBuilder }, - catch: (reject: (error: Error) => void) => queryBuilder, + catch: (_reject: (error: Error) => void) => queryBuilder, } return queryBuilder }) @@ -112,119 +269,19 @@ describe('Custom Tools API Routes', () => { mockSet.mockReturnValue({ where: mockWhere }) mockDelete.mockReturnValue({ where: mockWhere }) - vi.doMock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - update: mockUpdate, - delete: mockDelete, - transaction: vi.fn().mockImplementation(async (callback) => { - const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom }) - const txMockInsert = vi.fn().mockReturnValue({ values: mockValues }) - const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere }) - - const txMockOrderBy = vi.fn().mockImplementation(() => { - const queryBuilder = { - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockWhere = vi.fn().mockImplementation((condition) => { - const queryBuilder = { - orderBy: txMockOrderBy, - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere }) - txMockSelect.mockReturnValue({ from: txMockFrom }) - - return await callback({ - select: txMockSelect, - insert: txMockInsert, - update: txMockUpdate, - delete: txMockDelete, - }) - }), - }, - })) - - vi.doMock('@sim/db/schema', () => ({ - customTools: { - id: 'id', - workspaceId: 'workspaceId', - userId: 'userId', - title: 'title', - }, - workflow: { - id: 'id', - workspaceId: 'workspaceId', - userId: 'userId', - }, - })) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(mockSession), - })) - - const { mockCheckSessionOrInternalAuth: hybridAuthMock } = mockHybridAuth() - hybridAuthMock.mockResolvedValue({ + mockGetSession.mockResolvedValue(mockSession) + mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-123', authType: 'session', }) - - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), - })) - - vi.doMock('@sim/logger', () => loggerMock) - - vi.doMock('drizzle-orm', async () => { - const actual = await vi.importActual('drizzle-orm') - return { - ...(actual as object), - eq: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'eq' })), - and: vi.fn().mockImplementation((...conditions) => ({ operator: 'and', conditions })), - or: vi.fn().mockImplementation((...conditions) => ({ operator: 'or', conditions })), - isNull: vi.fn().mockImplementation((field) => ({ field, operator: 'isNull' })), - ne: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'ne' })), - desc: vi.fn().mockImplementation((field) => ({ field, operator: 'desc' })), - } + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockUpsertCustomTools.mockResolvedValue(sampleTools) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { workspaceId: 'workspace-123' }, }) - - vi.doMock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn().mockReturnValue('test-request-id'), - })) - - vi.doMock('@/lib/workflows/custom-tools/operations', () => ({ - upsertCustomTools: vi.fn().mockResolvedValue(sampleTools), - })) - - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: vi.fn().mockResolvedValue({ - allowed: true, - status: 200, - workflow: { workspaceId: 'workspace-123' }, - }), - })) - }) - - afterEach(() => { - vi.clearAllMocks() }) /** @@ -240,8 +297,6 @@ describe('Custom Tools API Routes', () => { orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)), }) - const { GET } = await import('@/app/api/tools/custom/route') - const response = await GET(req) const data = await response.json() @@ -260,14 +315,11 @@ describe('Custom Tools API Routes', () => { 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() - unauthMock.mockResolvedValue({ + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized', }) - const { GET } = await import('@/app/api/tools/custom/route') - const response = await GET(req) const data = await response.json() @@ -278,8 +330,6 @@ describe('Custom Tools API Routes', () => { it('should handle workflowId parameter', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123') - const { GET } = await import('@/app/api/tools/custom/route') - const response = await GET(req) const data = await response.json() @@ -295,16 +345,13 @@ describe('Custom Tools API Routes', () => { */ describe('POST /api/tools/custom', () => { it('should reject unauthorized requests', async () => { - const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() - unauthMock.mockResolvedValue({ + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized', }) const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' }) - const { POST } = await import('@/app/api/tools/custom/route') - const response = await POST(req) const data = await response.json() @@ -319,8 +366,6 @@ describe('Custom Tools API Routes', () => { const req = createMockRequest('POST', { tools: [invalidTool], workspaceId: 'workspace-123' }) - const { POST } = await import('@/app/api/tools/custom/route') - const response = await POST(req) const data = await response.json() @@ -341,8 +386,6 @@ describe('Custom Tools API Routes', () => { 'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123' ) - const { DELETE } = await import('@/app/api/tools/custom/route') - const response = await DELETE(req) const data = await response.json() @@ -356,8 +399,6 @@ describe('Custom Tools API Routes', () => { it('should reject requests missing tool ID', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom') - const { DELETE } = await import('@/app/api/tools/custom/route') - const response = await DELETE(req) const data = await response.json() @@ -371,8 +412,6 @@ describe('Custom Tools API Routes', () => { const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent') - const { DELETE } = await import('@/app/api/tools/custom/route') - const response = await DELETE(req) const data = await response.json() @@ -381,8 +420,7 @@ describe('Custom Tools API Routes', () => { }) it('should prevent unauthorized deletion of user-scoped tool', async () => { - const { mockCheckSessionOrInternalAuth: diffUserMock } = mockHybridAuth() - diffUserMock.mockResolvedValue({ + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: true, userId: 'user-456', authType: 'session', @@ -394,8 +432,6 @@ describe('Custom Tools API Routes', () => { const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') - const { DELETE } = await import('@/app/api/tools/custom/route') - const response = await DELETE(req) const data = await response.json() @@ -404,16 +440,13 @@ describe('Custom Tools API Routes', () => { }) it('should reject unauthorized requests', async () => { - const { mockCheckSessionOrInternalAuth: unauthMock } = mockHybridAuth() - unauthMock.mockResolvedValue({ + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized', }) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') - const { DELETE } = await import('@/app/api/tools/custom/route') - const response = await DELETE(req) const data = await response.json() diff --git a/apps/sim/app/api/tools/mail/send/route.ts b/apps/sim/app/api/tools/mail/send/route.ts index dbd37d50f00..b75f5c06ebc 100644 --- a/apps/sim/app/api/tools/mail/send/route.ts +++ b/apps/sim/app/api/tools/mail/send/route.ts @@ -10,12 +10,26 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MailSendAPI') const MailSendSchema = z.object({ - fromAddress: z.string().email('Invalid from email address').min(1, 'From address is required'), - to: z.string().email('Invalid email address').min(1, 'To email is required'), + fromAddress: z.string().min(1, 'From address is required'), + to: z.string().min(1, 'To email is required'), subject: z.string().min(1, 'Subject is required'), body: z.string().min(1, 'Email body is required'), contentType: z.enum(['text', 'html']).optional().nullable(), resendApiKey: z.string().min(1, 'Resend API key is required'), + cc: z + .union([z.string().min(1), z.array(z.string().min(1))]) + .optional() + .nullable(), + bcc: z + .union([z.string().min(1), z.array(z.string().min(1))]) + .optional() + .nullable(), + replyTo: z + .union([z.string().min(1), z.array(z.string().min(1))]) + .optional() + .nullable(), + scheduledAt: z.string().datetime().optional().nullable(), + tags: z.string().optional().nullable(), }) export async function POST(request: NextRequest) { @@ -52,23 +66,52 @@ export async function POST(request: NextRequest) { const resend = new Resend(validatedData.resendApiKey) const contentType = validatedData.contentType || 'text' - const emailData = - contentType === 'html' - ? { - from: validatedData.fromAddress, - to: validatedData.to, - subject: validatedData.subject, - html: validatedData.body, - text: validatedData.body.replace(/<[^>]*>/g, ''), // Strip HTML for text version - } - : { - from: validatedData.fromAddress, - to: validatedData.to, - subject: validatedData.subject, - text: validatedData.body, - } - - const { data, error } = await resend.emails.send(emailData) + const emailData: Record = { + from: validatedData.fromAddress, + to: validatedData.to, + subject: validatedData.subject, + } + + if (contentType === 'html') { + emailData.html = validatedData.body + emailData.text = validatedData.body.replace(/<[^>]*>/g, '') + } else { + emailData.text = validatedData.body + } + + if (validatedData.cc) { + emailData.cc = validatedData.cc + } + + if (validatedData.bcc) { + emailData.bcc = validatedData.bcc + } + + if (validatedData.replyTo) { + emailData.replyTo = validatedData.replyTo + } + + if (validatedData.scheduledAt) { + emailData.scheduledAt = validatedData.scheduledAt + } + + if (validatedData.tags) { + const tagPairs = validatedData.tags.split(',').map((pair) => { + const trimmed = pair.trim() + const colonIndex = trimmed.indexOf(':') + if (colonIndex === -1) return null + const name = trimmed.substring(0, colonIndex).trim() + const value = trimmed.substring(colonIndex + 1).trim() + return { name, value: value || '' } + }) + emailData.tags = tagPairs.filter( + (tag): tag is { name: string; value: string } => tag !== null && !!tag.name + ) + } + + const { data, error } = await resend.emails.send( + emailData as unknown as Parameters[0] + ) if (error) { logger.error(`[${requestId}] Email sending failed:`, error) diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index f868364ae66..abebcc18948 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -10,6 +10,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' +import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { enrichTableSchema } from '@/lib/table/llm/wand' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' @@ -330,10 +331,14 @@ export async function POST(req: NextRequest) { const encoder = new TextEncoder() const decoder = new TextDecoder() + let wandStreamClosed = false const readable = new ReadableStream({ async start(controller) { + incrementSSEConnections('wand') const reader = response.body?.getReader() if (!reader) { + wandStreamClosed = true + decrementSSEConnections('wand') controller.close() return } @@ -478,6 +483,16 @@ export async function POST(req: NextRequest) { controller.close() } finally { reader.releaseLock() + if (!wandStreamClosed) { + wandStreamClosed = true + decrementSSEConnections('wand') + } + } + }, + cancel() { + if (!wandStreamClosed) { + wandStreamClosed = true + decrementSSEConnections('wand') } }, }) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 97cabebf616..640b2d01808 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -100,6 +100,7 @@ const { fetchAndProcessAirtablePayloadsMock, processWebhookMock, executeMock, + getWorkspaceBilledAccountUserIdMock, } = vi.hoisted(() => ({ generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'), validateSlackSignatureMock: vi.fn().mockResolvedValue(true), @@ -119,6 +120,11 @@ const { endTime: new Date().toISOString(), }, }), + getWorkspaceBilledAccountUserIdMock: vi + .fn() + .mockImplementation(async (workspaceId: string | null | undefined) => + workspaceId ? 'test-user-id' : null + ), })) vi.mock('@trigger.dev/sdk', () => ({ @@ -192,17 +198,10 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({ })), })) -vi.mock('@/lib/workspaces/utils', async () => { - const actual = await vi.importActual('@/lib/workspaces/utils') - return { - ...(actual as Record), - getWorkspaceBilledAccountUserId: vi - .fn() - .mockImplementation(async (workspaceId: string | null | undefined) => - workspaceId ? 'test-user-id' : null - ), - } -}) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: vi.fn().mockResolvedValue(null), + getWorkspaceBilledAccountUserId: getWorkspaceBilledAccountUserIdMock, +})) vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: vi.fn().mockImplementation(() => ({ @@ -502,12 +501,6 @@ describe('Webhook Trigger API Route', () => { workspaceId: 'test-workspace-id', }) - vi.doMock('@trigger.dev/sdk', () => ({ - tasks: { - trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), - }, - })) - const testCases = [ 'Bearer case-test-token', 'bearer case-test-token', @@ -548,12 +541,6 @@ describe('Webhook Trigger API Route', () => { workspaceId: 'test-workspace-id', }) - vi.doMock('@trigger.dev/sdk', () => ({ - tasks: { - trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), - }, - })) - const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key'] for (const headerName of testCases) { diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 1d3df876cb8..3456e372e8f 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -3,65 +3,74 @@ * * @vitest-environment node */ -import { loggerMock, mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -let mockCheckSessionOrInternalAuth: ReturnType -const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() -const mockDbSelect = vi.fn() -const mockDbFrom = vi.fn() -const mockDbWhere = vi.fn() -const mockDbLimit = vi.fn() +const { + mockCheckSessionOrInternalAuth, + mockAuthorizeWorkflowByWorkspacePermission, + mockDbSelect, + mockDbFrom, + mockDbWhere, + mockDbLimit, +} = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockDbSelect: vi.fn(), + mockDbFrom: vi.fn(), + mockDbWhere: vi.fn(), + mockDbLimit: vi.fn(), +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + chat: { + id: 'id', + identifier: 'identifier', + title: 'title', + description: 'description', + customizations: 'customizations', + authType: 'authType', + allowedEmails: 'allowedEmails', + outputConfigs: 'outputConfigs', + password: 'password', + isActive: 'isActive', + workflowId: 'workflowId', + }, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) + +import { GET } from '@/app/api/workflows/[id]/chat/status/route' describe('Workflow Chat Status Route', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() mockDbSelect.mockReturnValue({ from: mockDbFrom }) mockDbFrom.mockReturnValue({ where: mockDbWhere }) mockDbWhere.mockReturnValue({ limit: mockDbLimit }) mockDbLimit.mockResolvedValue([]) - - vi.doMock('@sim/logger', () => loggerMock) - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn(), - })) - vi.doMock('@sim/db', () => ({ - db: { - select: mockDbSelect, - }, - })) - vi.doMock('@sim/db/schema', () => ({ - chat: { - id: 'id', - identifier: 'identifier', - title: 'title', - description: 'description', - customizations: 'customizations', - authType: 'authType', - allowedEmails: 'allowedEmails', - outputConfigs: 'outputConfigs', - password: 'password', - isActive: 'isActive', - workflowId: 'workflowId', - }, - })) - ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, - })) - }) - - afterEach(() => { - vi.clearAllMocks() }) it('returns 401 when unauthenticated', async () => { mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false }) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) @@ -82,7 +91,6 @@ describe('Workflow Chat Status Route', () => { workspacePermission: null, }) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) @@ -116,7 +124,6 @@ describe('Workflow Chat Status Route', () => { }, ]) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index b393ae492ab..b2cb3c1f8c0 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -22,6 +22,7 @@ import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/ev import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, @@ -763,6 +764,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const encoder = new TextEncoder() const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) let isStreamClosed = false + let sseDecremented = false const eventWriter = createExecutionEventWriter(executionId) setExecutionMeta(executionId, { @@ -773,6 +775,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const stream = new ReadableStream({ async start(controller) { + incrementSSEConnections('workflow-execute') let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null const sendEvent = (event: ExecutionEvent) => { @@ -1147,6 +1150,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (executionId) { await cleanupExecutionBase64Cache(executionId) } + if (!sseDecremented) { + sseDecremented = true + decrementSSEConnections('workflow-execute') + } if (!isStreamClosed) { try { controller.enqueue(encoder.encode('data: [DONE]\n\n')) @@ -1158,6 +1165,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: cancel() { isStreamClosed = true logger.info(`[${requestId}] Client disconnected from SSE stream`) + if (!sseDecremented) { + sseDecremented = true + decrementSSEConnections('workflow-execute') + } }, }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 1f77ff391d6..88e3c874470 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -7,6 +7,7 @@ import { getExecutionMeta, readExecutionEvents, } from '@/lib/execution/event-buffer' +import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { formatSSEEvent } from '@/lib/workflows/executor/execution-events' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -73,8 +74,10 @@ export async function GET( let closed = false + let sseDecremented = false const stream = new ReadableStream({ async start(controller) { + incrementSSEConnections('execution-stream-reconnect') let lastEventId = fromEventId const pollDeadline = Date.now() + MAX_POLL_DURATION_MS @@ -142,11 +145,20 @@ export async function GET( controller.close() } catch {} } + } finally { + if (!sseDecremented) { + sseDecremented = true + decrementSSEConnections('execution-stream-reconnect') + } } }, cancel() { closed = true logger.info('Client disconnected from reconnection stream', { executionId }) + if (!sseDecremented) { + sseDecremented = true + decrementSSEConnections('execution-stream-reconnect') + } }, }) diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts index 4ab4b2a5dcc..8099d2d844e 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts @@ -3,60 +3,69 @@ * * @vitest-environment node */ -import { loggerMock, mockHybridAuth } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -let mockCheckSessionOrInternalAuth: ReturnType -const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() -const mockDbSelect = vi.fn() -const mockDbFrom = vi.fn() -const mockDbWhere = vi.fn() -const mockDbLimit = vi.fn() +const { + mockCheckSessionOrInternalAuth, + mockAuthorizeWorkflowByWorkspacePermission, + mockDbSelect, + mockDbFrom, + mockDbWhere, + mockDbLimit, +} = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockDbSelect: vi.fn(), + mockDbFrom: vi.fn(), + mockDbWhere: vi.fn(), + mockDbLimit: vi.fn(), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + form: { + id: 'id', + identifier: 'identifier', + title: 'title', + workflowId: 'workflowId', + isActive: 'isActive', + }, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) + +import { GET } from '@/app/api/workflows/[id]/form/status/route' describe('Workflow Form Status Route', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() mockDbSelect.mockReturnValue({ from: mockDbFrom }) mockDbFrom.mockReturnValue({ where: mockDbWhere }) mockDbWhere.mockReturnValue({ limit: mockDbLimit }) mockDbLimit.mockResolvedValue([]) - - vi.doMock('@sim/logger', () => loggerMock) - vi.doMock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - })) - vi.doMock('@sim/db', () => ({ - db: { - select: mockDbSelect, - }, - })) - vi.doMock('@sim/db/schema', () => ({ - form: { - id: 'id', - identifier: 'identifier', - title: 'title', - workflowId: 'workflowId', - isActive: 'isActive', - }, - })) - ;({ mockCheckSessionOrInternalAuth } = mockHybridAuth()) - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, - })) - }) - - afterEach(() => { - vi.clearAllMocks() }) it('returns 401 when unauthenticated', async () => { mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false }) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) @@ -77,7 +86,6 @@ describe('Workflow Form Status Route', () => { workspacePermission: null, }) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) @@ -105,7 +113,6 @@ describe('Workflow Form Status Route', () => { }, ]) - const { GET } = await import('./route') const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status') const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index d3aa03d6399..68c863502a3 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -4,49 +4,48 @@ * * @vitest-environment node */ -import { - auditMock, - databaseMock, - defaultMockUser, - mockAuth, - mockCryptoUuid, - setupCommonApiMocks, -} from '@sim/testing' +import { auditMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -describe('Workflow Variables API Route', () => { - let authMocks: ReturnType - const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() +const { mockCheckSessionOrInternalAuth, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted( + () => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + }) +) - beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid('mock-request-id-12345678') - authMocks = mockAuth(defaultMockUser) - mockAuthorizeWorkflowByWorkspacePermission.mockReset() +vi.mock('@/lib/audit/log', () => auditMock) - vi.doMock('@sim/db', () => databaseMock) +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) - vi.doMock('@/lib/audit/log', () => auditMock) +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) - vi.doMock('@/lib/workflows/utils', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, - })) - }) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('mock-request-id-12345678'), +})) - afterEach(() => { +import { GET, POST } from '@/app/api/workflows/[id]/variables/route' + +describe('Workflow Variables API Route', () => { + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/workflows/[id]/variables', () => { it('should return 401 when user is not authenticated', async () => { - authMocks.setUnauthenticated() + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'Authentication required', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(401) @@ -55,7 +54,11 @@ describe('Workflow Variables API Route', () => { }) it('should return 404 when workflow does not exist', async () => { - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 404, @@ -67,7 +70,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables') const params = Promise.resolve({ id: 'nonexistent' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(404) @@ -85,7 +87,11 @@ describe('Workflow Variables API Route', () => { }, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, @@ -96,7 +102,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(200) @@ -114,7 +119,11 @@ describe('Workflow Variables API Route', () => { }, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, @@ -125,7 +134,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(200) @@ -141,7 +149,11 @@ describe('Workflow Variables API Route', () => { variables: {}, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, @@ -153,7 +165,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(403) @@ -161,7 +172,7 @@ describe('Workflow Variables API Route', () => { expect(data.error).toBe('Unauthorized: Access denied to read this workflow') }) - it.concurrent('should include proper cache headers', async () => { + it('should include proper cache headers', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -171,7 +182,11 @@ describe('Workflow Variables API Route', () => { }, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, @@ -182,7 +197,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(200) @@ -200,7 +214,11 @@ describe('Workflow Variables API Route', () => { variables: {}, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, @@ -224,7 +242,6 @@ describe('Workflow Variables API Route', () => { }) const params = Promise.resolve({ id: 'workflow-123' }) - const { POST } = await import('./route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -240,7 +257,11 @@ describe('Workflow Variables API Route', () => { variables: {}, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, status: 403, @@ -265,7 +286,6 @@ describe('Workflow Variables API Route', () => { }) const params = Promise.resolve({ id: 'workflow-123' }) - const { POST } = await import('./route') const response = await POST(req, { params }) expect(response.status).toBe(403) @@ -273,7 +293,7 @@ describe('Workflow Variables API Route', () => { expect(data.error).toBe('Unauthorized: Access denied to write this workflow') }) - it.concurrent('should validate request data schema', async () => { + it('should validate request data schema', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -281,7 +301,11 @@ describe('Workflow Variables API Route', () => { variables: {}, } - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: true, status: 200, @@ -297,7 +321,6 @@ describe('Workflow Variables API Route', () => { }) const params = Promise.resolve({ id: 'workflow-123' }) - const { POST } = await import('./route') const response = await POST(req, { params }) expect(response.status).toBe(400) @@ -307,8 +330,12 @@ describe('Workflow Variables API Route', () => { }) describe('Error handling', () => { - it.concurrent('should handle database errors gracefully', async () => { - authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + it('should handle database errors gracefully', async () => { + mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce( new Error('Database connection failed') ) @@ -316,7 +343,6 @@ describe('Workflow Variables API Route', () => { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') const params = Promise.resolve({ id: 'workflow-123' }) - const { GET } = await import('./route') const response = await GET(req, { params }) expect(response.status).toBe(500) diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 9920a7b71c4..e1b83bdb0eb 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -1,41 +1,99 @@ /** * @vitest-environment node */ -import { - auditMock, - createMockRequest, - mockConsoleLogger, - mockHybridAuth, - setupCommonApiMocks, -} from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mockGetUserEntityPermissions = vi.fn() -const mockDbSelect = vi.fn() -const mockDbInsert = vi.fn() -const mockWorkflowCreated = vi.fn() +const { + mockCheckSessionOrInternalAuth, + mockGetUserEntityPermissions, + mockWorkflowCreated, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockWorkflowCreated: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})) vi.mock('drizzle-orm', () => ({ ...drizzleOrmMock, min: vi.fn((field) => ({ type: 'min', field })), })) -vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('@sim/db', () => ({ + db: { + select: (...args: unknown[]) => mockDbSelect(...args), + insert: (...args: unknown[]) => mockDbInsert(...args), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflowFolder: { + id: 'id', + userId: 'userId', + parentId: 'parentId', + updatedAt: 'updatedAt', + workspaceId: 'workspaceId', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + }, + workflow: { + id: 'id', + folderId: 'folderId', + userId: 'userId', + updatedAt: 'updatedAt', + workspaceId: 'workspaceId', + sortOrder: 'sortOrder', + createdAt: 'createdAt', + }, + permissions: { + entityId: 'entityId', + userId: 'userId', + entityType: 'entityType', + }, +})) + +vi.mock('@/lib/audit/log', () => ({ + recordAudit: vi.fn(), + AuditAction: { WORKFLOW_CREATED: 'workflow.created' }, + AuditResourceType: { WORKFLOW: 'workflow' }, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkHybridAuth: vi.fn(), + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, + checkInternalAuth: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), + workspaceExists: vi.fn(), +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: vi.fn(), +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { + workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args), + }, +})) + +import { POST } from '@/app/api/workflows/route' describe('Workflows API Route - POST ordering', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() - setupCommonApiMocks() - mockConsoleLogger() - vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('workflow-new-id'), }) - const { mockCheckSessionOrInternalAuth } = mockHybridAuth() mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-123', @@ -43,28 +101,6 @@ describe('Workflows API Route - POST ordering', () => { userEmail: 'test@example.com', }) mockGetUserEntityPermissions.mockResolvedValue('write') - - vi.doMock('@sim/db', () => ({ - db: { - select: (...args: unknown[]) => mockDbSelect(...args), - insert: (...args: unknown[]) => mockDbInsert(...args), - }, - })) - - vi.doMock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), - workspaceExists: vi.fn(), - })) - - vi.doMock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: vi.fn(), - })) - - vi.doMock('@/lib/core/telemetry', () => ({ - PlatformEvents: { - workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args), - }, - })) }) it('uses top insertion against mixed siblings (folders + workflows)', async () => { @@ -95,7 +131,6 @@ describe('Workflows API Route - POST ordering', () => { folderId: null, }) - const { POST } = await import('@/app/api/workflows/route') const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) @@ -129,7 +164,6 @@ describe('Workflows API Route - POST ordering', () => { folderId: null, }) - const { POST } = await import('@/app/api/workflows/route') const response = await POST(req) const data = await response.json() expect(response.status).toBe(200) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index a636200d5a8..0919385d0f9 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -1,125 +1,187 @@ -import { auditMock, createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing' +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { + mockGetSession, + mockInsertValues, + mockDbResults, + mockResendSend, + mockDbChain, + mockRender, + mockWorkspaceInvitationEmail, + mockGetEmailDomain, + mockValidateInvitationsAllowed, + mockRandomUUID, +} = vi.hoisted(() => { + const mockGetSession = vi.fn() + const mockInsertValues = vi.fn().mockResolvedValue(undefined) + const mockResendSend = vi.fn().mockResolvedValue({ id: 'email-id' }) + const mockRender = vi.fn().mockResolvedValue('email content') + const mockWorkspaceInvitationEmail = vi.fn() + const mockGetEmailDomain = vi.fn().mockReturnValue('sim.ai') + const mockValidateInvitationsAllowed = vi.fn().mockResolvedValue(undefined) + const mockRandomUUID = vi.fn().mockReturnValue('mock-uuid-1234') + + const mockDbResults: { value: any[] } = { value: [] } + + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((callback: any) => { + const result = mockDbResults.value.shift() || [] + return callback ? callback(result) : Promise.resolve(result) + }), + insert: vi.fn().mockReturnThis(), + values: mockInsertValues, + } + + return { + mockGetSession, + mockInsertValues, + mockDbResults, + mockResendSend, + mockDbChain, + mockRender, + mockWorkspaceInvitationEmail, + mockGetEmailDomain, + mockValidateInvitationsAllowed, + mockRandomUUID, + } +}) + +vi.mock('crypto', () => ({ + randomUUID: mockRandomUUID, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/db', () => ({ + db: mockDbChain, +})) + +vi.mock('@sim/db/schema', () => ({ + user: { id: 'user_id', email: 'user_email', name: 'user_name', image: 'user_image' }, + workspace: { id: 'workspace_id', name: 'workspace_name', ownerId: 'owner_id' }, + permissions: { + userId: 'user_id', + entityId: 'entity_id', + entityType: 'entity_type', + permissionType: 'permission_type', + }, + workspaceInvitation: { + id: 'invitation_id', + workspaceId: 'workspace_id', + email: 'invitation_email', + status: 'invitation_status', + token: 'invitation_token', + inviterId: 'inviter_id', + role: 'invitation_role', + permissions: 'invitation_permissions', + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + permissionTypeEnum: { enumValues: ['admin', 'write', 'read'] as const }, +})) + +vi.mock('resend', () => ({ + Resend: vi.fn().mockImplementation(() => ({ + emails: { send: mockResendSend }, + })), +})) + +vi.mock('@react-email/render', () => ({ + render: mockRender, +})) + +vi.mock('@/components/emails/workspace-invitation', () => ({ + WorkspaceInvitationEmail: mockWorkspaceInvitationEmail, +})) + +vi.mock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock() +}) + +vi.mock('@/lib/core/utils/urls', () => ({ + getEmailDomain: mockGetEmailDomain, +})) + +vi.mock('@/lib/audit/log', async () => { + const { auditMock } = await import('@sim/testing') + return auditMock +}) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn().mockImplementation((...args: any[]) => ({ type: 'and', conditions: args })), + eq: vi.fn().mockImplementation((field: any, value: any) => ({ type: 'eq', field, value })), + inArray: vi + .fn() + .mockImplementation((field: any, values: any) => ({ type: 'inArray', field, values })), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateInvitationsAllowed: mockValidateInvitationsAllowed, + InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { + constructor() { + super('Invitations are not allowed based on your permission group settings') + this.name = 'InvitationsNotAllowedError' + } + }, +})) + +import { GET, POST } from '@/app/api/workspaces/invitations/route' + describe('Workspace Invitations API Route', () => { const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' } const mockUser = { id: 'user-1', email: 'test@example.com' } const mockInvitation = { id: 'invitation-1', status: 'pending' } - let mockDbResults: any[] = [] - let mockGetSession: any - let mockResendSend: any - let mockInsertValues: any - beforeEach(() => { - vi.resetModules() - vi.resetAllMocks() - - mockDbResults = [] - mockConsoleLogger() - mockAuth(mockUser) - - vi.doMock('crypto', () => ({ - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'), - })) - - mockGetSession = vi.fn() - vi.doMock('@/lib/auth', () => ({ - getSession: mockGetSession, - })) - - mockInsertValues = vi.fn().mockResolvedValue(undefined) - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - then: vi.fn().mockImplementation((callback: any) => { - const result = mockDbResults.shift() || [] - return callback ? callback(result) : Promise.resolve(result) - }), - insert: vi.fn().mockReturnThis(), - values: mockInsertValues, - } - - vi.doMock('@sim/db', () => ({ - db: mockDbChain, - })) - - vi.doMock('@sim/db/schema', () => ({ - user: { id: 'user_id', email: 'user_email', name: 'user_name', image: 'user_image' }, - workspace: { id: 'workspace_id', name: 'workspace_name', ownerId: 'owner_id' }, - permissions: { - userId: 'user_id', - entityId: 'entity_id', - entityType: 'entity_type', - permissionType: 'permission_type', - }, - workspaceInvitation: { - id: 'invitation_id', - workspaceId: 'workspace_id', - email: 'invitation_email', - status: 'invitation_status', - token: 'invitation_token', - inviterId: 'inviter_id', - role: 'invitation_role', - permissions: 'invitation_permissions', - expiresAt: 'expires_at', - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - permissionTypeEnum: { enumValues: ['admin', 'write', 'read'] as const }, - })) - - mockResendSend = vi.fn().mockResolvedValue({ id: 'email-id' }) - vi.doMock('resend', () => ({ - Resend: vi.fn().mockImplementation(() => ({ - emails: { send: mockResendSend }, - })), - })) - - vi.doMock('@react-email/render', () => ({ - render: vi.fn().mockResolvedValue('email content'), - })) - - vi.doMock('@/components/emails/workspace-invitation', () => ({ - WorkspaceInvitationEmail: vi.fn(), - })) - - vi.doMock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock() + vi.clearAllMocks() + mockDbResults.value = [] + + // Reset mockDbChain methods that need fresh returnThis behavior + mockDbChain.select.mockReturnThis() + mockDbChain.from.mockReturnThis() + mockDbChain.where.mockReturnThis() + mockDbChain.innerJoin.mockReturnThis() + mockDbChain.limit.mockReturnThis() + mockDbChain.insert.mockReturnThis() + mockDbChain.then.mockImplementation((callback: any) => { + const result = mockDbResults.value.shift() || [] + return callback ? callback(result) : Promise.resolve(result) }) - - vi.doMock('@/lib/core/utils/urls', () => ({ - getEmailDomain: vi.fn().mockReturnValue('sim.ai'), - })) - - vi.doMock('@/lib/audit/log', () => auditMock) - - vi.doMock('drizzle-orm', () => ({ - and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })), - eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })), - inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), - })) - - vi.doMock('@/ee/access-control/utils/permission-check', () => ({ - validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), - InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { - constructor() { - super('Invitations are not allowed based on your permission group settings') - this.name = 'InvitationsNotAllowedError' - } - }, - })) + mockDbChain.values = mockInsertValues + mockInsertValues.mockResolvedValue(undefined) + mockResendSend.mockResolvedValue({ id: 'email-id' }) + mockRandomUUID.mockReturnValue('mock-uuid-1234') + mockRender.mockResolvedValue('email content') + mockGetEmailDomain.mockReturnValue('sim.ai') + mockValidateInvitationsAllowed.mockResolvedValue(undefined) }) describe('GET /api/workspaces/invitations', () => { it('should return 401 when user is not authenticated', async () => { mockGetSession.mockResolvedValue(null) - const { GET } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('GET') const response = await GET(req) const data = await response.json() @@ -130,9 +192,8 @@ describe('Workspace Invitations API Route', () => { it('should return empty invitations when user has no workspaces', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [[], []] // No workspaces, no invitations + mockDbResults.value = [[], []] // No workspaces, no invitations - const { GET } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('GET') const response = await GET(req) const data = await response.json() @@ -148,9 +209,8 @@ describe('Workspace Invitations API Route', () => { { id: 'invitation-1', workspaceId: 'workspace-1', email: 'test@example.com' }, { id: 'invitation-2', workspaceId: 'workspace-2', email: 'test2@example.com' }, ] - mockDbResults = [mockWorkspaces, mockInvitations] + mockDbResults.value = [mockWorkspaces, mockInvitations] - const { GET } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('GET') const response = await GET(req) const data = await response.json() @@ -164,7 +224,6 @@ describe('Workspace Invitations API Route', () => { it('should return 401 when user is not authenticated', async () => { mockGetSession.mockResolvedValue(null) - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -179,7 +238,6 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when workspaceId is missing', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { email: 'test@example.com' }) const response = await POST(req) const data = await response.json() @@ -191,7 +249,6 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when email is missing', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1' }) const response = await POST(req) const data = await response.json() @@ -203,7 +260,6 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when permission type is invalid', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -220,9 +276,8 @@ describe('Workspace Invitations API Route', () => { it('should return 403 when user does not have admin permissions', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [[]] // No admin permissions found + mockDbResults.value = [[]] // No admin permissions found - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -236,12 +291,11 @@ describe('Workspace Invitations API Route', () => { it('should return 404 when workspace is not found', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [ + mockDbResults.value = [ [{ permissionType: 'admin' }], // User has admin permissions [], // Workspace not found ] - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -255,14 +309,13 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when user already has workspace access', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [ + mockDbResults.value = [ [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [mockUser], // User exists [{ permissionType: 'read' }], // User already has access ] - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -279,14 +332,13 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when invitation already exists', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [ + mockDbResults.value = [ [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [], // User doesn't exist [mockInvitation], // Invitation exists ] - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', @@ -305,14 +357,13 @@ describe('Workspace Invitations API Route', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123', name: 'Test User', email: 'sender@example.com' }, }) - mockDbResults = [ + mockDbResults.value = [ [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [], // User doesn't exist [], // No existing invitation ] - const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { workspaceId: 'workspace-1', email: 'test@example.com', diff --git a/apps/sim/app/llms-full.txt/route.ts b/apps/sim/app/llms-full.txt/route.ts index c7efe0d248f..47eaedfc6e8 100644 --- a/apps/sim/app/llms-full.txt/route.ts +++ b/apps/sim/app/llms-full.txt/route.ts @@ -5,7 +5,7 @@ export async function GET() { const llmsFullContent = `# Sim - AI Agent Workflow Builder -> Sim is an open-source AI agent workflow builder used by 60,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. SOC2 and HIPAA compliant. +> Sim is an open-source AI agent workflow builder used by 70,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. SOC2 and HIPAA compliant. ## Overview diff --git a/apps/sim/app/llms.txt/route.ts b/apps/sim/app/llms.txt/route.ts index 64a1f90bcdb..f2f6974b86a 100644 --- a/apps/sim/app/llms.txt/route.ts +++ b/apps/sim/app/llms.txt/route.ts @@ -5,7 +5,7 @@ export async function GET() { const llmsContent = `# Sim -> Sim is an open-source AI agent workflow builder. 60,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. SOC2 and HIPAA compliant. +> Sim is an open-source AI agent workflow builder. 70,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. SOC2 and HIPAA compliant. Sim provides a visual drag-and-drop interface for building and deploying AI agent workflows. Connect to 100+ integrations and ship production-ready AI automations. diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts index 9ea6507437e..bfd0784215e 100644 --- a/apps/sim/app/manifest.ts +++ b/apps/sim/app/manifest.ts @@ -8,7 +8,7 @@ export default function manifest(): MetadataRoute.Manifest { name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name, short_name: brand.name, description: - 'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.', + 'Open-source AI agent workflow builder. 70,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.', start_url: '/', scope: '/', display: 'standalone', diff --git a/apps/sim/app/page.tsx b/apps/sim/app/page.tsx index 4bd1e643a4d..2c533dea8ac 100644 --- a/apps/sim/app/page.tsx +++ b/apps/sim/app/page.tsx @@ -8,7 +8,7 @@ export const metadata: Metadata = { metadataBase: new URL(baseUrl), title: 'Sim - AI Agent Workflow Builder | Open Source Platform', description: - 'Open-source AI agent workflow builder used by 60,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.', + 'Open-source AI agent workflow builder used by 70,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.', keywords: 'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI', authors: [{ name: 'Sim' }], @@ -22,7 +22,7 @@ export const metadata: Metadata = { openGraph: { title: 'Sim - AI Agent Workflow Builder | Open Source', description: - 'Open-source platform used by 60,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.', + 'Open-source platform used by 70,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.', type: 'website', url: baseUrl, siteName: 'Sim', @@ -43,7 +43,7 @@ export const metadata: Metadata = { creator: '@simdotai', title: 'Sim - AI Agent Workflow Builder | Open Source', description: - 'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.', + 'Open-source platform for agentic workflows. 70,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.', images: { url: '/logo/426-240/primary/small.png', alt: 'Sim - AI Agent Workflow Builder', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 3776f322f01..cfe1d604f62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -108,7 +108,6 @@ export function ChatDeploy({ onVersionActivated, }: ChatDeployProps) { const [imageUrl, setImageUrl] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false) const showDeleteConfirmation = @@ -122,6 +121,7 @@ export function ChatDeploy({ const [formData, setFormData] = useState(initialFormData) const [errors, setErrors] = useState({}) const formRef = useRef(null) + const [formInitCounter, setFormInitCounter] = useState(0) const createChatMutation = useCreateChat() const updateChatMutation = useUpdateChat() @@ -222,13 +222,20 @@ export function ChatDeploy({ setChatSubmitting(true) + const isNewChat = !existingChat?.id + + // Open window before async operation to avoid popup blockers + const newTab = isNewChat ? window.open('', '_blank') : null + try { if (!validateForm(!!existingChat)) { + newTab?.close() setChatSubmitting(false) return } if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) { + newTab?.close() setError('identifier', 'Please wait for identifier validation to complete') setChatSubmitting(false) return @@ -257,13 +264,18 @@ export function ChatDeploy({ onDeployed?.() onVersionActivated?.() - if (chatUrl) { - window.open(chatUrl, '_blank', 'noopener,noreferrer') + if (newTab && chatUrl) { + newTab.opener = null + newTab.location.href = chatUrl + } else if (newTab) { + newTab.close() } - setHasInitializedForm(false) await onRefetchChat() + setHasInitializedForm(false) + setFormInitCounter((c) => c + 1) } catch (error: any) { + newTab?.close() if (error.message?.includes('identifier')) { setError('identifier', error.message) } else { @@ -278,8 +290,6 @@ export function ChatDeploy({ if (!existingChat || !existingChat.id) return try { - setIsDeleting(true) - await deleteChatMutation.mutateAsync({ chatId: existingChat.id, workflowId, @@ -287,6 +297,7 @@ export function ChatDeploy({ setImageUrl(null) setHasInitializedForm(false) + setFormInitCounter((c) => c + 1) await onRefetchChat() onDeploymentComplete?.() @@ -294,7 +305,6 @@ export function ChatDeploy({ logger.error('Failed to delete chat:', error) setError('general', error.message || 'An unexpected error occurred while deleting') } finally { - setIsDeleting(false) setShowDeleteConfirmation(false) } } @@ -363,7 +373,7 @@ export function ChatDeploy({ setShowDeleteConfirmation(false)} - disabled={isDeleting} + disabled={deleteChatMutation.isPending} > Cancel -
+
-
+
| null>(null) const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false) const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false) @@ -232,6 +233,12 @@ export function DeployModal({ setActiveTab('general') setDeployError(null) setDeployWarnings([]) + setChatSuccess(false) + } + return () => { + if (chatSuccessTimeoutRef.current) { + clearTimeout(chatSuccessTimeoutRef.current) + } } }, [open, workflowId]) @@ -377,15 +384,16 @@ export function DeployModal({ const handleChatDeployed = useCallback(async () => { if (!workflowId) return - queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }) queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }) - queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) }) await refetchDeployedState() useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) + if (chatSuccessTimeoutRef.current) { + clearTimeout(chatSuccessTimeoutRef.current) + } setChatSuccess(true) - setTimeout(() => setChatSuccess(false), 2000) + chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000) }, [workflowId, queryClient, refetchDeployedState]) const handleRefetchChat = useCallback(async () => { @@ -394,14 +402,7 @@ export function DeployModal({ const handleChatFormSubmit = useCallback(() => { const form = document.getElementById('chat-deploy-form') as HTMLFormElement - if (form) { - const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement - if (updateTrigger) { - updateTrigger.click() - } else { - form.requestSubmit() - } - } + form?.requestSubmit() }, []) const handleChatDelete = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index c8146a28018..fb340a877bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -40,6 +40,7 @@ const SCOPE_DESCRIPTIONS: Record = { 'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files', 'https://www.googleapis.com/auth/drive': 'Access all Google Drive files', 'https://www.googleapis.com/auth/calendar': 'View and manage calendar', + 'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts', 'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks', 'https://www.googleapis.com/auth/userinfo.email': 'View email address', 'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info', @@ -102,8 +103,19 @@ const SCOPE_DESCRIPTIONS: Record = { 'read:user': 'Read public user information', 'user:email': 'Access email address', 'tweet.read': 'Read tweets and timeline', - 'tweet.write': 'Post tweets', - 'users.read': 'Read profile information', + 'tweet.write': 'Post and delete tweets', + 'tweet.moderate.write': 'Hide and unhide replies to tweets', + 'users.read': 'Read user profiles and account information', + 'follows.read': 'View followers and following lists', + 'follows.write': 'Follow and unfollow users', + 'bookmark.read': 'View bookmarked tweets', + 'bookmark.write': 'Add and remove bookmarks', + 'like.read': 'View liked tweets and liking users', + 'like.write': 'Like and unlike tweets', + 'block.read': 'View blocked users', + 'block.write': 'Block and unblock users', + 'mute.read': 'View muted users', + 'mute.write': 'Mute and unmute users', 'offline.access': 'Access account when not using the application', 'data.records:read': 'Read records', 'data.records:write': 'Write to records', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector.tsx deleted file mode 100644 index f1e47ab7104..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client' - -import { useCallback, useMemo } from 'react' -import { Tooltip } from '@/components/emcn' -import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' -import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' - -interface DocumentSelectorProps { - blockId: string - subBlock: SubBlockConfig - disabled?: boolean - onDocumentSelect?: (documentId: string) => void - isPreview?: boolean - previewValue?: string | null - previewContextValues?: Record -} - -export function DocumentSelector({ - blockId, - subBlock, - disabled = false, - onDocumentSelect, - isPreview = false, - previewValue, - previewContextValues, -}: DocumentSelectorProps) { - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') - const knowledgeBaseIdValue = previewContextValues - ? resolvePreviewContextValue(previewContextValues.knowledgeBaseId) - : knowledgeBaseIdFromStore - const normalizedKnowledgeBaseId = - typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 - ? knowledgeBaseIdValue - : null - - const selectorContext = useMemo( - () => ({ - knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined, - }), - [normalizedKnowledgeBaseId] - ) - - const handleDocumentChange = useCallback( - (documentId: string) => { - if (isPreview) return - onDocumentSelect?.(documentId) - }, - [isPreview, onDocumentSelect] - ) - - const missingKnowledgeBase = !normalizedKnowledgeBaseId - const isDisabled = finalDisabled || missingKnowledgeBase - const placeholder = subBlock.placeholder || 'Select document' - - return ( - - -
- -
-
- {missingKnowledgeBase && ( - -

Select a knowledge base first.

-
- )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx index 7a6fa78fefd..405599aa9a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx @@ -15,9 +15,9 @@ import { cn } from '@/lib/core/utils/cn' import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' @@ -77,10 +77,12 @@ export function DocumentTagEntry({ disabled, }) - const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') - const knowledgeBaseIdValue = previewContextValues - ? resolvePreviewContextValue(previewContextValues.knowledgeBaseId) - : knowledgeBaseIdFromStore + const { dependencyValues } = useDependsOnGate(blockId, subBlock, { + disabled, + isPreview, + previewContextValues, + }) + const knowledgeBaseIdValue = dependencyValues.knowledgeBaseSelector const knowledgeBaseId = typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 ? knowledgeBaseIdValue @@ -367,7 +369,7 @@ export function DocumentTagEntry({ })) return ( -
+
-} - -export function FileSelectorInput({ - blockId, - subBlock, - disabled, - isPreview = false, - previewValue, - previewContextValues, -}: FileSelectorInputProps) { - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - const { activeWorkflowId } = useWorkflowRegistry() - const params = useParams() - const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' - - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) - const canonicalModeOverrides = blockState?.data?.canonicalModes - - const blockValues = useSubBlockStore((state) => { - if (!activeWorkflowId) return {} - const workflowValues = state.workflowValues[activeWorkflowId] || {} - return (workflowValues as Record>)[blockId] || {} - }) - - const [domainValueFromStore] = useSubBlockValue(blockId, 'domain') - - const connectedCredential = previewContextValues - ? resolvePreviewContextValue(previewContextValues.credential) - : blockValues.credential - const domainValue = previewContextValues - ? resolvePreviewContextValue(previewContextValues.domain) - : domainValueFromStore - - const teamIdValue = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.teamId) - : resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const siteIdValue = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.siteId) - : resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const collectionIdValue = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.collectionId) - : resolveDependencyValue( - 'collectionId', - blockValues, - canonicalIndex, - canonicalModeOverrides - ), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const projectIdValue = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.projectId) - : resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const planIdValue = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.planId) - : resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const normalizedCredentialId = - typeof connectedCredential === 'string' - ? connectedCredential - : typeof connectedCredential === 'object' && connectedCredential !== null - ? ((connectedCredential as Record).id ?? '') - : '' - - const serviceId = subBlock.serviceId || '' - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - - const selectorResolution = useMemo(() => { - return resolveSelectorForSubBlock(subBlock, { - workflowId: workflowIdFromUrl, - credentialId: normalizedCredentialId, - domain: (domainValue as string) || undefined, - projectId: (projectIdValue as string) || undefined, - planId: (planIdValue as string) || undefined, - teamId: (teamIdValue as string) || undefined, - siteId: (siteIdValue as string) || undefined, - collectionId: (collectionIdValue as string) || undefined, - }) - }, [ - subBlock, - workflowIdFromUrl, - normalizedCredentialId, - domainValue, - projectIdValue, - planIdValue, - teamIdValue, - siteIdValue, - collectionIdValue, - ]) - - const missingCredential = !normalizedCredentialId - const missingDomain = - selectorResolution?.key && - (selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') && - !selectorResolution.context.domain - const missingProject = - selectorResolution?.key === 'jira.issues' && - isDependency(subBlock.dependsOn, 'projectId') && - !selectorResolution.context.projectId - const missingPlan = - selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId - const missingSite = - selectorResolution?.key === 'webflow.collections' && !selectorResolution.context.siteId - const missingCollection = - selectorResolution?.key === 'webflow.items' && !selectorResolution.context.collectionId - - const disabledReason = - finalDisabled || - missingCredential || - missingDomain || - missingProject || - missingPlan || - missingSite || - missingCollection || - !selectorResolution?.key - - if (!selectorResolution?.key) { - return ( - - -
- File selector not supported for service: {serviceId || 'unknown'} -
-
- -

This file selector is not implemented for {serviceId || 'unknown'}

-
-
- ) - } - - return ( - { - if (!isPreview) { - collaborativeSetSubblockValue(blockId, subBlock.id, value) - } - }} - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx index 8be48e02d8d..a1894292b25 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx @@ -165,7 +165,7 @@ export function FilterRuleRow({ ) const renderContent = () => ( -
+
{index > 0 && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx deleted file mode 100644 index 25fec739bc3..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useState } from 'react' -import { getProviderIdFromServiceId } from '@/lib/oauth' -import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' -import type { SubBlockConfig } from '@/blocks/types' -import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface FolderSelectorInputProps { - blockId: string - subBlock: SubBlockConfig - disabled?: boolean - isPreview?: boolean - previewValue?: any | null - previewContextValues?: Record -} - -export function FolderSelectorInput({ - blockId, - subBlock, - disabled = false, - isPreview = false, - previewValue, - previewContextValues, -}: FolderSelectorInputProps) { - const [storeValue] = useSubBlockValue(blockId, subBlock.id) - const [credentialFromStore] = useSubBlockValue(blockId, 'credential') - const connectedCredential = previewContextValues - ? resolvePreviewContextValue(previewContextValues.credential) - : credentialFromStore - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - const { activeWorkflowId } = useWorkflowRegistry() - const [selectedFolderId, setSelectedFolderId] = useState('') - - // Derive provider from serviceId using OAuth config (same pattern as credential-selector) - const serviceId = subBlock.serviceId || '' - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const providerKey = serviceId.toLowerCase() - - const isCopyDestinationSelector = - subBlock.canonicalParamId === 'copyDestinationId' || - subBlock.id === 'copyDestinationFolder' || - subBlock.id === 'manualCopyDestinationFolder' - - // Central dependsOn gating - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - - // Get the current value from the store or prop value if in preview mode - useEffect(() => { - if (finalDisabled) return - if (isPreview && previewValue !== undefined) { - setSelectedFolderId(previewValue) - return - } - const current = storeValue as string | undefined - if (current) { - setSelectedFolderId(current) - return - } - const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector - if (shouldDefaultInbox) { - setSelectedFolderId('INBOX') - if (!isPreview) { - collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX') - } - } - }, [ - blockId, - subBlock.id, - storeValue, - collaborativeSetSubblockValue, - isPreview, - previewValue, - finalDisabled, - providerKey, - isCopyDestinationSelector, - ]) - - const credentialId = (connectedCredential as string) || '' - const missingCredential = credentialId.length === 0 - const selectorResolution = useMemo( - () => - resolveSelectorForSubBlock(subBlock, { - credentialId: credentialId || undefined, - workflowId: activeWorkflowId || undefined, - }), - [subBlock, credentialId, activeWorkflowId] - ) - - const handleChange = useCallback( - (value: string) => { - setSelectedFolderId(value) - if (!isPreview) { - collaborativeSetSubblockValue(blockId, subBlock.id, value) - } - }, - [blockId, subBlock.id, collaborativeSetSubblockValue, isPreview] - ) - - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 33ccd2acd44..af6314a94f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -3,14 +3,11 @@ export { Code } from './code/code' export { ComboBox } from './combobox/combobox' export { ConditionInput } from './condition-input/condition-input' export { CredentialSelector } from './credential-selector/credential-selector' -export { DocumentSelector } from './document-selector/document-selector' export { DocumentTagEntry } from './document-tag-entry/document-tag-entry' export { Dropdown } from './dropdown/dropdown' export { EvalInput } from './eval-input/eval-input' -export { FileSelectorInput } from './file-selector/file-selector-input' export { FileUpload } from './file-upload/file-upload' export { FilterBuilder } from './filter-builder/filter-builder' -export { FolderSelectorInput } from './folder-selector/components/folder-selector-input' export { GroupedCheckboxList } from './grouped-checkbox-list/grouped-checkbox-list' export { InputMapping } from './input-mapping/input-mapping' export { KnowledgeBaseSelector } from './knowledge-base-selector/knowledge-base-selector' @@ -20,13 +17,11 @@ export { McpDynamicArgs } from './mcp-dynamic-args/mcp-dynamic-args' export { McpServerSelector } from './mcp-server-modal/mcp-server-selector' export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector' export { MessagesInput } from './messages-input/messages-input' -export { ProjectSelectorInput } from './project-selector/project-selector-input' export { ResponseFormat } from './response/response-format' export { ScheduleInfo } from './schedule-info/schedule-info' -export { SheetSelectorInput } from './sheet-selector/sheet-selector-input' +export { SelectorInput, type SelectorOverrides } from './selector-input/selector-input' export { ShortInput } from './short-input/short-input' export { SkillInput } from './skill-input/skill-input' -export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' export { SortBuilder } from './sort-builder/sort-builder' export { InputFormat } from './starter/input-format' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx index ac78d291378..73563ba9bf9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx @@ -239,7 +239,7 @@ function InputMappingField({
{!collapsed && ( -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index 3ea3baa167c..2d36e69de5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -16,8 +16,8 @@ import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/c import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' @@ -69,10 +69,12 @@ export function KnowledgeTagFilters({ const valueInputRefs = useRef>({}) const overlayRefs = useRef>({}) - const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') - const knowledgeBaseIdValue = previewContextValues - ? resolvePreviewContextValue(previewContextValues.knowledgeBaseId) - : knowledgeBaseIdFromStore + const { dependencyValues } = useDependsOnGate(blockId, subBlock, { + disabled, + isPreview, + previewContextValues, + }) + const knowledgeBaseIdValue = dependencyValues.knowledgeBaseSelector const knowledgeBaseId = typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 ? knowledgeBaseIdValue @@ -358,7 +360,7 @@ export function KnowledgeTagFilters({ const isBetween = filter.operator === 'between' return ( -
+
@@ -351,15 +350,14 @@ export function McpDynamicArgs({
{showLabel && ( - +
+ +
)} {renderParameterInput(paramName, paramSchema as any)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx deleted file mode 100644 index 3df3acd4643..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' -import { useParams } from 'next/navigation' -import { Tooltip } from '@/components/emcn' -import { getProviderIdFromServiceId } from '@/lib/oauth' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' -import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' -import { getBlock } from '@/blocks/registry' -import type { SubBlockConfig } from '@/blocks/types' -import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -interface ProjectSelectorInputProps { - blockId: string - subBlock: SubBlockConfig - disabled?: boolean - onProjectSelect?: (projectId: string) => void - isPreview?: boolean - previewValue?: any | null - previewContextValues?: Record -} - -export function ProjectSelectorInput({ - blockId, - subBlock, - disabled = false, - onProjectSelect, - isPreview = false, - previewValue, - previewContextValues, -}: ProjectSelectorInputProps) { - const params = useParams() - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null - const [selectedProjectId, setSelectedProjectId] = useState('') - const [storeValue] = useSubBlockValue(blockId, subBlock.id) - const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain') - - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) - const canonicalModeOverrides = blockState?.data?.canonicalModes - - const blockValues = useSubBlockStore((state) => { - if (!activeWorkflowId) return {} - const workflowValues = state.workflowValues[activeWorkflowId] || {} - return (workflowValues as Record>)[blockId] || {} - }) - - const connectedCredential = previewContextValues - ? resolvePreviewContextValue(previewContextValues.credential) - : blockValues.credential - const jiraDomain = previewContextValues - ? resolvePreviewContextValue(previewContextValues.domain) - : jiraDomainFromStore - - const linearTeamId = useMemo( - () => - previewContextValues - ? resolvePreviewContextValue(previewContextValues.teamId) - : resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), - [previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const serviceId = subBlock.serviceId || '' - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - - const domain = (jiraDomain as string) || '' - - useEffect(() => { - if (isPreview && previewValue !== undefined) { - setSelectedProjectId(previewValue) - } else if (typeof storeValue === 'string') { - setSelectedProjectId(storeValue) - } else { - setSelectedProjectId('') - } - }, [isPreview, previewValue, storeValue]) - - const selectorResolution = useMemo(() => { - return resolveSelectorForSubBlock(subBlock, { - workflowId: workflowIdFromUrl || undefined, - credentialId: (connectedCredential as string) || undefined, - domain, - teamId: (linearTeamId as string) || undefined, - }) - }, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId]) - - const missingCredential = !selectorResolution?.context.credentialId - - const handleChange = (value: string) => { - setSelectedProjectId(value) - onProjectSelect?.(value) - } - - return ( - - -
- {selectorResolution?.key ? ( - - ) : ( -
- Project selector not supported for service: {serviceId} -
- )} -
-
- {missingCredential && ( - -

Please select an account first

-
- )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx new file mode 100644 index 00000000000..de6c1df71d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { Tooltip } from '@/components/emcn' +import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' +import { useSelectorSetup } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import type { SubBlockConfig } from '@/blocks/types' +import type { SelectorContext } from '@/hooks/selectors/types' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' + +export interface SelectorOverrides { + transformContext?: (context: SelectorContext, deps: Record) => SelectorContext + getDefaultValue?: (subBlock: SubBlockConfig) => string | null +} + +interface SelectorInputProps { + blockId: string + subBlock: SubBlockConfig + disabled?: boolean + isPreview?: boolean + previewValue?: any + previewContextValues?: Record + overrides?: SelectorOverrides +} + +export function SelectorInput({ + blockId, + subBlock, + disabled = false, + isPreview = false, + previewValue, + previewContextValues, + overrides, +}: SelectorInputProps) { + const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() + const [storeValue] = useSubBlockValue(blockId, subBlock.id) + const defaultAppliedRef = useRef(false) + + const { + selectorKey, + selectorContext: autoContext, + allowSearch, + disabled: selectorDisabled, + dependencyValues, + } = useSelectorSetup(blockId, subBlock, { disabled, isPreview, previewContextValues }) + + const selectorContext = overrides?.transformContext + ? overrides.transformContext(autoContext, dependencyValues) + : autoContext + + useEffect(() => { + if (defaultAppliedRef.current || isPreview || selectorDisabled) return + if (storeValue) return + + const defaultValue = overrides?.getDefaultValue?.(subBlock) + if (defaultValue) { + defaultAppliedRef.current = true + collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue) + } + }, [ + blockId, + subBlock, + storeValue, + isPreview, + selectorDisabled, + overrides, + collaborativeSetSubblockValue, + ]) + + const serviceId = subBlock.serviceId || 'unknown' + + if (!selectorKey) { + return ( + + +
+ Selector not supported for service: {serviceId} +
+
+ +

This selector is not implemented for {serviceId}

+
+
+ ) + } + + return ( + { + if (!isPreview) { + collaborativeSetSubblockValue(blockId, subBlock.id, value) + } + }} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx deleted file mode 100644 index ee33b320a61..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { useParams } from 'next/navigation' -import { Tooltip } from '@/components/emcn' -import { getProviderIdFromServiceId } from '@/lib/oauth' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' -import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' -import { getBlock } from '@/blocks/registry' -import type { SubBlockConfig } from '@/blocks/types' -import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -interface SheetSelectorInputProps { - blockId: string - subBlock: SubBlockConfig - disabled: boolean - isPreview?: boolean - previewValue?: any | null - previewContextValues?: Record -} - -export function SheetSelectorInput({ - blockId, - subBlock, - disabled, - isPreview = false, - previewValue, - previewContextValues, -}: SheetSelectorInputProps) { - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - const { activeWorkflowId } = useWorkflowRegistry() - const params = useParams() - const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' - - const { finalDisabled } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) - const canonicalModeOverrides = blockState?.data?.canonicalModes - - const blockValues = useSubBlockStore((state) => { - if (!activeWorkflowId) return {} - const workflowValues = state.workflowValues[activeWorkflowId] || {} - return (workflowValues as Record>)[blockId] || {} - }) - - const connectedCredentialFromStore = blockValues.credential - - const spreadsheetIdFromStore = useMemo( - () => - resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides), - [blockValues, canonicalIndex, canonicalModeOverrides] - ) - - const connectedCredential = previewContextValues - ? resolvePreviewContextValue(previewContextValues.credential) - : connectedCredentialFromStore - const spreadsheetId = previewContextValues - ? (resolvePreviewContextValue(previewContextValues.spreadsheetId) ?? - resolvePreviewContextValue(previewContextValues.manualSpreadsheetId)) - : spreadsheetIdFromStore - - const normalizedCredentialId = - typeof connectedCredential === 'string' - ? connectedCredential - : typeof connectedCredential === 'object' && connectedCredential !== null - ? ((connectedCredential as Record).id ?? '') - : '' - - const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : '' - - const serviceId = subBlock.serviceId || '' - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - - const selectorResolution = useMemo(() => { - return resolveSelectorForSubBlock(subBlock, { - workflowId: workflowIdFromUrl, - credentialId: normalizedCredentialId, - spreadsheetId: normalizedSpreadsheetId, - }) - }, [subBlock, workflowIdFromUrl, normalizedCredentialId, normalizedSpreadsheetId]) - - const missingCredential = !normalizedCredentialId - const missingSpreadsheet = !normalizedSpreadsheetId - - const disabledReason = - finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key - - if (!selectorResolution?.key) { - return ( - - -
- Sheet selector not supported for service: {serviceId || 'unknown'} -
-
- -

This sheet selector is not implemented for {serviceId || 'unknown'}

-
-
- ) - } - - return ( - { - if (!isPreview) { - collaborativeSetSubblockValue(blockId, subBlock.id, value) - } - }} - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx deleted file mode 100644 index e3e4e214859..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' -import { useParams } from 'next/navigation' -import { Tooltip } from '@/components/emcn' -import { getProviderIdFromServiceId } from '@/lib/oauth' -import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' -import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' -import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' - -type SlackSelectorType = 'channel-selector' | 'user-selector' - -const SELECTOR_CONFIG: Record< - SlackSelectorType, - { selectorKey: SelectorKey; placeholder: string; label: string } -> = { - 'channel-selector': { - selectorKey: 'slack.channels', - placeholder: 'Select Slack channel', - label: 'Channel', - }, - 'user-selector': { - selectorKey: 'slack.users', - placeholder: 'Select Slack user', - label: 'User', - }, -} - -interface SlackSelectorInputProps { - blockId: string - subBlock: SubBlockConfig - disabled?: boolean - onSelect?: (value: string) => void - isPreview?: boolean - previewValue?: any | null - previewContextValues?: Record -} - -export function SlackSelectorInput({ - blockId, - subBlock, - disabled = false, - onSelect, - isPreview = false, - previewValue, - previewContextValues, -}: SlackSelectorInputProps) { - const selectorType = subBlock.type as SlackSelectorType - const config = SELECTOR_CONFIG[selectorType] - - const params = useParams() - const workflowIdFromUrl = (params?.workflowId as string) || '' - const [storeValue] = useSubBlockValue(blockId, subBlock.id) - const [authMethod] = useSubBlockValue(blockId, 'authMethod') - const [botToken] = useSubBlockValue(blockId, 'botToken') - const [connectedCredential] = useSubBlockValue(blockId, 'credential') - - const effectiveAuthMethod = previewContextValues - ? resolvePreviewContextValue(previewContextValues.authMethod) - : authMethod - const effectiveBotToken = previewContextValues - ? resolvePreviewContextValue(previewContextValues.botToken) - : botToken - const effectiveCredential = previewContextValues - ? resolvePreviewContextValue(previewContextValues.credential) - : connectedCredential - const [_selectedValue, setSelectedValue] = useState(null) - - const serviceId = subBlock.serviceId || '' - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const isSlack = serviceId === 'slack' - - const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, { - disabled, - isPreview, - previewContextValues, - }) - - const credential: string = - (effectiveAuthMethod as string) === 'bot_token' - ? (effectiveBotToken as string) || '' - : (effectiveCredential as string) || '' - - useEffect(() => { - const val = isPreview && previewValue !== undefined ? previewValue : storeValue - if (typeof val === 'string') { - setSelectedValue(val) - } - }, [isPreview, previewValue, storeValue]) - - const requiresCredential = dependsOn.includes('credential') - const missingCredential = !credential || credential.trim().length === 0 - const shouldForceDisable = requiresCredential && missingCredential - - const context: SelectorContext = useMemo( - () => ({ - credentialId: credential, - workflowId: workflowIdFromUrl, - }), - [credential, workflowIdFromUrl] - ) - - if (!isSlack) { - return ( - - -
- {config.label} selector not supported for service: {serviceId || 'unknown'} -
-
- -

- This {config.label.toLowerCase()} selector is not yet implemented for{' '} - {serviceId || 'unknown'} -

-
-
- ) - } - - return ( - - -
- { - setSelectedValue(value) - if (!isPreview) { - onSelect?.(value) - } - }} - /> -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx index 72716ac9467..77d238e88e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx @@ -70,7 +70,7 @@ export function SortRuleRow({ ) const renderContent = () => ( -
+
+
{renderNameInput(field)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 9df538fb0dd..cc3225b9c20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1,7 +1,7 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Loader2, WrenchIcon, XIcon } from 'lucide-react' +import { ArrowLeft, ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -481,6 +481,8 @@ export const ToolInput = memo(function ToolInput({ const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) + const [mcpRemovePopoverIndex, setMcpRemovePopoverIndex] = useState(null) + const [mcpServerDrilldown, setMcpServerDrilldown] = useState(null) const canonicalModeOverrides = useWorkflowStore( useCallback( @@ -543,6 +545,17 @@ export const ToolInput = memo(function ToolInput({ const serverId = tool.params?.serverId as string const toolName = tool.params?.toolName as string + const serverStates = mcpServers.map((s) => ({ + id: s.id, + url: s.url, + connectionStatus: s.connectionStatus, + lastError: s.lastError ?? undefined, + })) + const discoveredTools = mcpTools.map((t) => ({ + serverId: t.serverId, + name: t.name, + inputSchema: t.inputSchema, + })) // Try to get fresh schema from DB (enables real-time updates after MCP refresh) const storedTool = @@ -561,17 +574,8 @@ export const ToolInput = memo(function ToolInput({ toolName, schema, }, - mcpServers.map((s) => ({ - id: s.id, - url: s.url, - connectionStatus: s.connectionStatus, - lastError: s.lastError ?? undefined, - })), - mcpTools.map((t) => ({ - serverId: t.serverId, - name: t.name, - inputSchema: t.inputSchema, - })) + serverStates, + discoveredTools ) }, [mcpTools, mcpServers, storedMcpTools, workflowId] @@ -702,6 +706,30 @@ export const ToolInput = memo(function ToolInput({ return selectedTools.some((tool) => tool.toolId === toolId) } + /** + * Groups MCP tools by their parent server. + */ + const mcpToolsByServer = useMemo(() => { + const grouped = new Map() + for (const tool of availableMcpTools) { + if (!grouped.has(tool.serverId)) { + grouped.set(tool.serverId, []) + } + grouped.get(tool.serverId)!.push(tool) + } + return grouped + }, [availableMcpTools]) + + /** + * Resets the MCP server drilldown when the combobox closes. + */ + const handleComboboxOpenChange = useCallback((isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setMcpServerDrilldown(null) + } + }, []) + const handleSelectTool = useCallback( (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return @@ -838,6 +866,16 @@ export const ToolInput = memo(function ToolInput({ [isPreview, disabled, selectedTools, setStoreValue] ) + const handleRemoveAllFromServer = useCallback( + (serverId: string | undefined) => { + if (isPreview || disabled || !serverId) return + setStoreValue( + selectedTools.filter((t) => !(t.type === 'mcp' && t.params?.serverId === serverId)) + ) + }, + [isPreview, disabled, selectedTools, setStoreValue] + ) + const handleDeleteTool = useCallback( (toolId: string) => { const updatedTools = selectedTools.filter((tool) => { @@ -1012,6 +1050,7 @@ export const ToolInput = memo(function ToolInput({ ]) if (closePopover) { + setMcpServerDrilldown(null) setOpen(false) } }, @@ -1225,6 +1264,109 @@ export const ToolInput = memo(function ToolInput({ const toolGroups = useMemo((): ComboboxOptionGroup[] => { const groups: ComboboxOptionGroup[] = [] + // MCP Server drill-down: when navigated into a server, show only its tools + if (mcpServerDrilldown && !permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + const tools = mcpToolsByServer.get(mcpServerDrilldown) + if (tools && tools.length > 0) { + const server = mcpServers.find((s) => s.id === mcpServerDrilldown) + const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' + const toolCount = tools.length + const selectedToolIdsForServer = new Set( + selectedTools + .filter((t) => t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) + .map((t) => t.toolId) + ) + const allAlreadySelected = tools.every((t) => selectedToolIdsForServer.has(t.id)) + const serverToolItems: ComboboxOption[] = [] + + // Back navigation + serverToolItems.push({ + label: 'Back', + value: `mcp-server-back`, + iconElement: , + onSelect: () => { + setMcpServerDrilldown(null) + }, + keepOpen: true, + }) + + // "Use all tools" option — adds each tool individually + serverToolItems.push({ + label: `Use all ${toolCount} tools`, + value: `mcp-server-all-${mcpServerDrilldown}`, + iconElement: createToolIcon('#6366F1', ServerIcon), + onSelect: () => { + if (allAlreadySelected) return + // Remove existing individual tools from this server to avoid duplicates + const filteredTools = selectedTools.filter( + (t) => !(t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) + ) + // Add all tools individually + const newTools: StoredTool[] = tools.map((mcpTool) => ({ + type: 'mcp' as const, + title: mcpTool.name, + toolId: mcpTool.id, + params: { + serverId: mcpTool.serverId, + ...(server?.url && { serverUrl: server.url }), + toolName: mcpTool.name, + serverName: mcpTool.serverName, + }, + isExpanded: false, + usageControl: 'auto' as const, + schema: { + ...mcpTool.inputSchema, + description: mcpTool.description, + }, + })) + setStoreValue([...filteredTools.map((t) => ({ ...t, isExpanded: false })), ...newTools]) + setMcpServerDrilldown(null) + setOpen(false) + }, + disabled: isPreview || disabled || allAlreadySelected, + }) + + // Individual tools + for (const mcpTool of tools) { + const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) + serverToolItems.push({ + label: mcpTool.name, + value: `mcp-${mcpTool.id}`, + iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), + onSelect: () => { + if (alreadySelected) return + const newTool: StoredTool = { + type: 'mcp', + title: mcpTool.name, + toolId: mcpTool.id, + params: { + serverId: mcpTool.serverId, + ...(server?.url && { serverUrl: server.url }), + toolName: mcpTool.name, + serverName: mcpTool.serverName, + }, + isExpanded: true, + usageControl: 'auto', + schema: { + ...mcpTool.inputSchema, + description: mcpTool.description, + }, + } + handleMcpToolSelect(newTool, true) + }, + disabled: isPreview || disabled || alreadySelected, + }) + } + + groups.push({ + section: serverName, + items: serverToolItems, + }) + } + return groups + } + + // Root view: show all tool categories const actionItems: ComboboxOption[] = [] if (!permissionConfig.disableCustomTools) { actionItems.push({ @@ -1283,40 +1425,30 @@ export const ToolInput = memo(function ToolInput({ }) } - if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { + // MCP Servers — root folder view + if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + const serverItems: ComboboxOption[] = [] + + for (const [serverId, tools] of mcpToolsByServer) { + const server = mcpServers.find((s) => s.id === serverId) + const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' + const toolCount = tools.length + + serverItems.push({ + label: `${serverName} (${toolCount} tools)`, + value: `mcp-server-folder-${serverId}`, + iconElement: createToolIcon('#6366F1', ServerIcon), + suffixElement: , + onSelect: () => { + setMcpServerDrilldown(serverId) + }, + keepOpen: true, + }) + } + groups.push({ - section: 'MCP Tools', - items: availableMcpTools.map((mcpTool) => { - const server = mcpServers.find((s) => s.id === mcpTool.serverId) - const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) - return { - label: mcpTool.name, - value: `mcp-${mcpTool.id}`, - iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, - params: { - serverId: mcpTool.serverId, - ...(server?.url && { serverUrl: server.url }), - toolName: mcpTool.name, - serverName: mcpTool.serverName, - }, - isExpanded: true, - usageControl: 'auto', - schema: { - ...mcpTool.inputSchema, - description: mcpTool.description, - }, - } - handleMcpToolSelect(newTool, true) - }, - disabled: isPreview || disabled || alreadySelected, - } - }), + section: 'MCP Servers', + items: serverItems, }) } @@ -1393,9 +1525,11 @@ export const ToolInput = memo(function ToolInput({ return groups }, [ + mcpServerDrilldown, customTools, availableMcpTools, mcpServers, + mcpToolsByServer, toolBlocks, isPreview, disabled, @@ -1420,7 +1554,8 @@ export const ToolInput = memo(function ToolInput({ searchPlaceholder='Search tools...' maxHeight={240} emptyMessage='No tools found' - onOpenChange={setOpen} + onOpenChange={handleComboboxOpenChange} + onArrowLeft={mcpServerDrilldown ? () => setMcpServerDrilldown(null) : undefined} /> {selectedTools.length > 0 && @@ -1683,21 +1818,77 @@ export const ToolInput = memo(function ToolInput({ )} - + {isMcpTool && + selectedTools.filter( + (t) => t.type === 'mcp' && t.params?.serverId === tool.params?.serverId + ).length > 1 ? ( + { + if (!isOpen) setMcpRemovePopoverIndex(null) + }} + > + + + + e.stopPropagation()} + className='gap-[2px]' + border + > + { + handleRemoveTool(toolIndex) + setMcpRemovePopoverIndex(null) + }} + > + Remove + + { + handleRemoveAllFromServer(tool.params?.serverId) + setMcpRemovePopoverIndex(null) + }} + > + Remove all from {tool.params?.serverName || 'server'} + + + + ) : ( + + )}
{!isCustomTool && isExpandedForDisplay && (
+ {/* Operation dropdown for tools with multiple operations */} {(() => { const hasOperations = hasMultipleOperations(tool.type) const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts index 138b6a56213..c46bb97c186 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts @@ -2,6 +2,11 @@ * Represents a tool selected and configured in the workflow * * @remarks + * Valid types include: + * - Standard block types (e.g., 'api', 'search', 'function') + * - 'custom-tool': User-defined tools with custom code + * - 'mcp': Individual MCP tool from a connected server + * * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. * Everything else (title, schema, code) is loaded dynamically from the database. * Legacy custom tools with inline schema/code are still supported for backwards compatibility. @@ -13,7 +18,7 @@ export interface StoredTool { title?: string /** Direct tool ID for execution (optional for new custom tool format) */ toolId?: string - /** Parameter values configured by the user (optional for new custom tool format) */ + /** Parameter values configured by the user */ params?: Record /** Whether the tool details are expanded in UI */ isExpanded?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index c3ab7a8cde3..3ca20f572f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -377,7 +377,7 @@ export function VariablesInput({
{!collapsed && ( -
+
} +) { + const params = useParams() + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const workflowId = (params?.workflowId as string) || activeWorkflowId || '' + + const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate( + blockId, + subBlock, + opts + ) + + const selectorContext = useMemo(() => { + const context: SelectorContext = { + workflowId, + mimeType: subBlock.mimeType, + } + + for (const [depKey, value] of Object.entries(dependencyValues)) { + if (value === null || value === undefined) continue + const strValue = String(value) + if (!strValue) continue + + const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey + + if (canonicalParamId === 'oauthCredential') { + context.credentialId = strValue + } else if (canonicalParamId in CONTEXT_FIELD_SET) { + ;(context as Record)[canonicalParamId] = strValue + } + } + + return context + }, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType]) + + return { + selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null, + selectorContext, + allowSearch: subBlock.selectorAllowSearch ?? true, + disabled: finalDisabled || !subBlock.selectorKey, + dependencyValues, + } +} + +const CONTEXT_FIELD_SET: Record = { + credentialId: true, + domain: true, + teamId: true, + projectId: true, + knowledgeBaseId: true, + planId: true, + siteId: true, + collectionId: true, + spreadsheetId: true, + fileId: true, +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 07253f6d7b9..3887b46e55f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -18,14 +18,11 @@ import { ComboBox, ConditionInput, CredentialSelector, - DocumentSelector, DocumentTagEntry, Dropdown, EvalInput, - FileSelectorInput, FileUpload, FilterBuilder, - FolderSelectorInput, GroupedCheckboxList, InputFormat, InputMapping, @@ -36,13 +33,12 @@ import { McpServerSelector, McpToolSelector, MessagesInput, - ProjectSelectorInput, ResponseFormat, ScheduleInfo, - SheetSelectorInput, + SelectorInput, + type SelectorOverrides, ShortInput, SkillInput, - SlackSelectorInput, SliderInput, SortBuilder, Switch, @@ -58,6 +54,23 @@ import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import type { SubBlockConfig } from '@/blocks/types' import { useWebhookManagement } from '@/hooks/use-webhook-management' +const SLACK_OVERRIDES: SelectorOverrides = { + transformContext: (context, deps) => { + const authMethod = deps.authMethod as string + const credentialId = + authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '') + return { ...context, credentialId } + }, +} + +const FOLDER_OVERRIDES: SelectorOverrides = { + getDefaultValue: (subBlock) => { + const isGmail = subBlock.serviceId === 'gmail' + const isCopyDest = subBlock.canonicalParamId === 'copyDestinationId' + return isGmail && !isCopyDest ? 'INBOX' : null + }, +} + /** * Interface for wand control handlers exposed by sub-block inputs */ @@ -901,32 +914,10 @@ function SubBlockComponent({ ) case 'file-selector': - return ( - - ) - case 'sheet-selector': - return ( - - ) - case 'project-selector': return ( - ) @@ -985,12 +977,12 @@ function SubBlockComponent({ case 'document-selector': return ( - ) @@ -1068,13 +1060,14 @@ function SubBlockComponent({ case 'channel-selector': case 'user-selector': return ( - ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index e10bedb79bb..47e6e420709 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -423,6 +423,12 @@ export const Panel = memo(function Panel() { Auto layout + { + setVariablesOpen(!isVariablesOpen)}> + + Variables + + } {userPermissions.canAdmin && !isSnapshotView && ( {allBlocksLocked ? ( @@ -433,12 +439,6 @@ export const Panel = memo(function Panel() { {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} )} - { - setVariablesOpen(!isVariablesOpen)}> - - Variables - - } {/* Debug diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index 8e5ebc1b9ac..08b09689604 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -284,7 +284,7 @@ export function Variables() { const isCollapsed = collapsedById[variable.id] ?? false return (
toggleCollapsed(variable.id)} onKeyDown={(e) => handleHeaderKeyDown(e, variable.id)} role='button' @@ -297,7 +297,7 @@ export function Variables() { {variable.name || `Variable ${index + 1}`} {variable.name && ( - + {variable.type} )} @@ -460,7 +460,7 @@ export function Variables() { {!(collapsedById[variable.id] ?? false) && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx index dab88c358ba..f2f0a6a1710 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx @@ -1138,31 +1138,6 @@ export function CredentialsManager({ onOpenChange }: CredentialsManagerProps) { {createType === 'oauth' ? (
-
- - setCreateDisplayName(event.target.value)} - placeholder='Secret name' - autoComplete='off' - data-lpignore='true' - className='mt-[6px]' - /> -
-
- -