Compare commits

...

29 Commits

Author SHA1 Message Date
waleed
eb493d94ed type fixes 2026-02-28 12:03:17 -08:00
Vasyl Abramovych
dd14f9d750 fix(lint): satisfy biome for short.io
Made-with: Cursor
2026-02-28 11:38:34 -08:00
Vasyl Abramovych
fc5e4237ab fix(short-io): address PR review feedback
- Change apiKey visibility from 'hidden' to 'user-only' in all 6 tools
- Simplify block tool selector to string interpolation
- Move QR code generation to server-side API route, return as file
  object (name, mimeType, data, size) matching standard file pattern
- Update block outputs and docs to reflect file type for QR code
2026-02-28 11:38:34 -08:00
Vasyl Abramovych
fcefd01f94 docs(short-io): add Short.io tool documentation
Add documentation page covering all 6 Short.io tools with input/output
parameter tables and usage instructions.
2026-02-28 11:38:34 -08:00
Vasyl Abramovych
2408b5af2d feat(blocks): add Short.io block and icon
Add Short.io block config with 6 operations (create link, list domains,
list links, delete link, get QR code, get analytics). Add ShortIoIcon
and register the block in the blocks registry.
2026-02-28 11:38:34 -08:00
Vasyl Abramovych
7327ec0058 feat(tools): add Short.io tools and registry
Add 6 Short.io tool implementations (create link, list domains, list
links, delete link, get QR code, get analytics) with shared types and
barrel export. Register all tools in the tools registry.
2026-02-28 11:38:34 -08:00
Waleed
96096e0ad1 improvement(resend): add error handling, authMode, and naming consistency (#3382) 2026-02-28 11:19:42 -08:00
Waleed
647a3eb05b improvement(luma): expand host response fields and harden event ID inputs (#3383) 2026-02-28 11:19:24 -08:00
Waleed
0195a4cd18 improvement(ashby): validate ashby integration and update skill files (#3381) 2026-02-28 11:16:40 -08:00
Waleed
b42f80e8ab fix(sse): fix memory leaks in SSE stream cleanup and add memory telemetry (#3378)
* fix(sse): fix memory leaks in SSE stream cleanup and add memory telemetry

* improvement(monitoring): add SSE metering to wand, execution-stream, and a2a-message endpoints

* fix(workflow-execute): remove abort from cancel() to preserve run-on-leave behavior

* improvement(monitoring): use stable process.getActiveResourcesInfo() API

* refactor(a2a): hoist resubscribe cleanup to eliminate duplication between start() and cancel()

* style(a2a): format import line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(wand): set guard flag on early-return decrement for consistency

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:37:07 -08:00
Waleed
38ac86c4fd improvement(mcp): add all MCP server tools individually instead of as single server entry (#3376)
* improvement(mcp): add all MCP server tools individually instead of as single server entry

* fix(mcp): prevent remove popover from opening inadvertently
2026-02-27 14:02:11 -08:00
Waleed
4cfe8be75a feat(google-contacts): add google contacts integration (#3340)
* feat(google-contacts): add google contacts integration

* fix(google-contacts): throw error when no update fields provided

* lint

* update icon

* improvement(google-contacts): add advanced mode, error handling, and input trimming

- Set mode: 'advanced' on optional fields (emailType, phoneType, notes, pageSize, pageToken, sortOrder)
- Add createLogger and response.ok error handling to all 6 tools
- Add .trim() on resourceName in get, update, delete URL builders
2026-02-27 10:55:51 -08:00
Vikhyath Mondreti
49db3ca50b improvement(selectors): consolidate selector input logic (#3375) 2026-02-27 10:18:25 -08:00
Vikhyath Mondreti
e3ff595a84 improvement(selectors): make selectorKeys declarative (#3374)
* fix(webflow): resolution for selectors

* remove unecessary fallback'

* fix teams selector resolution

* make selector keys declarative

* selectors fixes
2026-02-27 07:56:35 -08:00
Waleed
b3424e2047 improvement(ci): add sticky disk caches and bump runner for faster builds (#3373) 2026-02-27 00:12:36 -08:00
Waleed
71ecf6c82e improvement(x): align OAuth scopes, add scope descriptions, and set optional fields to advanced mode (#3372)
* improvement(x): align OAuth scopes, add scope descriptions, and set optional fields to advanced mode

* improvement(skills): add typed JSON outputs guidance to add-tools, add-block, and add-integration skills

* improvement(skills): add final validation steps to add-tools, add-block, and add-integration skills

* fix(skills): correct misleading JSON array comment in wandConfig example

* feat(skills): add validate-integration skill for auditing tools, blocks, and registry against API docs

* improvement(skills): expand validate-integration with full block-tool alignment, OAuth scopes, pagination, and error handling checks
2026-02-26 23:30:24 -08:00
Waleed
e9e5ba2c5b improvement(docs): audit and standardize tool description sections, update developer count to 70k (#3371) 2026-02-26 23:02:58 -08:00
Waleed
9233d4ebc9 feat(x): add 28 new X API v2 tool integrations and expand OAuth scopes (#3365)
* feat(x): add 28 new X API v2 tool integrations and expand OAuth scopes

* fix(x): add missing nextToken param to search tweets and fix XCreateTweetParams type

* fix(x): correct API spec issues in retweeted_by, quote_tweets, personalized_trends, and usage tools

* fix(x): add missing newestId and oldestId to error meta in get_liked_tweets and get_quote_tweets

* fix(x): add missing newestId/oldestId to get_liked_tweets success branch and includes to XTweetListResponse

* fix(x): add error handling to create_tweet and delete_tweet transformResponse

* fix(x): add error handling and logger to all X tools

* fix(x): revert block requiredScopes to match current operations

* feat(x): update block to support all 28 new X API v2 tools

* fix(x): add missing text output and fix hiddenResult output key mismatch

* docs(x): regenerate docs for all 28 new X API v2 tools
2026-02-26 22:40:57 -08:00
Waleed
78901ef517 improvement(blocks): update luma styling and linkup field modes (#3370)
* improvement(blocks): update luma styling and linkup field modes

* improvement(fireflies): move optional fields to advanced mode

* improvement(blocks): move optional fields to advanced mode for 10 integrations

* improvement(blocks): move optional fields to advanced mode for 6 more integrations
2026-02-26 22:27:58 -08:00
Waleed
47fef540cc feat(resend): expand integration with contacts, domains, and enhanced email ops (#3366) 2026-02-26 22:12:48 -08:00
Waleed
f193e9ebbc feat(loops): add Loops email platform integration (#3359)
* feat(loops): add Loops email platform integration

Add complete Loops integration with 10 tools covering all API endpoints:
- Contact management: create, update, find, delete
- Email: send transactional emails with attachments
- Events: trigger automated email sequences
- Lists: list mailing lists and transactional email templates
- Properties: create and list contact properties

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ran litn

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:09:02 -08:00
Waleed
c0f22d7722 improvement(oauth): reordered oauth modal (#3368) 2026-02-26 19:43:59 -08:00
Waleed
bf0e25c9d0 feat(ashby): add ashby integration for candidate, job, and application management (#3362)
* feat(ashby): add ashby integration for candidate, job, and application management

* fix(ashby): auto-fix lint formatting in docs files
2026-02-26 19:10:06 -08:00
Waleed
d4f8ac8107 feat(greenhouse): add greenhouse integration for managing candidates, jobs, and applications (#3363) 2026-02-26 19:09:03 -08:00
Waleed
63fa938dd7 feat(gamma): add gamma integration for AI-powered content generation (#3358)
* feat(gamma): add gamma integration for AI-powered content generation

* fix(gamma): address PR review comments

- Make credits/error conditionally included in check_status response to avoid always-truthy objects
- Replace full wordmark SVG with square "G" letterform for proper rendering in icon slots

* fix(gamma): remove imageSource from generate_from_template endpoint

The from-template API only accepts imageOptions.model and imageOptions.style,
not imageOptions.source (image source is inherited from the template).

* fix(gamma): use typed output in check_status transformResponse

* regen docs
2026-02-26 19:08:46 -08:00
Waleed
50b882a3ad feat(luma): add Luma integration for event and guest management (#3364)
* feat(luma): add Luma integration for event and guest management

Add complete Luma (lu.ma) integration with 6 tools: get event, create event,
update event, list calendar events, get guests, and add guests. Includes block
configuration with wandConfig for timestamps/timezones/durations, advanced mode
for optional fields, and generated documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(luma): address PR review feedback

- Remove hosts field from list_events transformResponse (not in LumaEventEntry type)
- Fix truncated add_guests description by removing quotes that broke docs generator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(luma): fix update_event field name and add_guests response parsing

- Use 'id' instead of 'event_id' in update_event request body per API spec
- Fix add_guests to parse entries[].guest response structure instead of flat guests array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:08:20 -08:00
Waleed
c8a0b62a9c feat(databricks): add Databricks integration with 8 tools (#3361)
* feat(databricks): add Databricks integration with 8 tools

Add complete Databricks integration supporting SQL execution, job management,
run monitoring, and cluster listing via Personal Access Token authentication.

Tools: execute_sql, list_jobs, run_job, get_run, list_runs, cancel_run,
get_run_output, list_clusters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(databricks): throw on invalid JSON params, fix boolean coercion, add expandTasks field

- Throw errors on invalid JSON in jobParameters/notebookParams instead of silently defaulting to {}
- Always set boolean params explicitly to prevent string 'false' being truthy
- Add missing expandTasks dropdown UI field for list_jobs operation

* fix(databricks): align tool inputs/outputs with official API spec

- execute_sql: fix wait_timeout default description (50s, not 10s)
- get_run: add queueDuration field, update lifecycle/result state enums
- get_run_output: fix notebook output size (5 MB not 1 MB), add logsTruncated field
- list_runs: add userCancelledOrTimedout to state, fix limit range (1-24), update state enums
- list_jobs: fix name filter description to "exact case-insensitive"
- list_clusters: add PIPELINE_MAINTENANCE to ClusterSource enum

* fix(databricks): regenerate docs to reflect API spec fixes

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:05:47 -08:00
Waleed
4ccb57371b improvement(tests): speed up unit tests by eliminating vi.resetModules anti-pattern (#3357)
* improvement(tests): speed up unit tests by eliminating vi.resetModules anti-pattern

- convert 51 test files from vi.resetModules/vi.doMock/dynamic import to vi.hoisted/vi.mock/static import
- add global @sim/db mock to vitest.setup.ts
- switch 4 test files from jsdom to node environment
- remove all vi.importActual calls that loaded heavy modules (200+ block files)
- remove slow mockConsoleLogger/mockAuth/setupCommonApiMocks helpers
- reduce real setTimeout delays in engine tests
- mock heavy transitive deps in diff-engine test

test execution time: 34s -> 9s (3.9x faster)
environment time: 2.5s -> 0.6s (4x faster)

* docs(testing): update testing best practices with performance rules

- document vi.hoisted + vi.mock + static import as the standard pattern
- explicitly ban vi.resetModules, vi.doMock, vi.importActual, mockAuth, setupCommonApiMocks
- document global mocks from vitest.setup.ts
- add mock pattern reference for auth, hybrid auth, and database chains
- add performance rules section covering heavy deps, jsdom vs node, real timers

* fix(tests): fix 4 failing test files with missing mocks

- socket/middleware/permissions: add vi.mock for @/lib/auth to prevent transitive getBaseUrl() call
- workflow-handler: add vi.mock for @/executor/utils/http matching executor mock pattern
- evaluator-handler: add db.query.account mock structure before vi.spyOn
- router-handler: same db.query.account fix as evaluator

* fix(tests): replace banned Function type with explicit callback signature
2026-02-26 15:46:49 -08:00
Waleed
c6e147e56a feat(agent): add MCP server discovery mode for agent tool input (#3353)
* feat(agent): add MCP server discovery mode for agent tool input

* fix(tool-input): use type variant for MCP server tool count badge

* fix(mcp-dynamic-args): align label styling with standard subblock labels

* standardized inp format UI

* feat(tool-input): replace MCP server inline expand with drill-down navigation

* feat(tool-input): add chevron affordance and keyboard nav for MCP server drill-down

* fix(tool-input): handle mcp-server type in refresh, validation, badges, and usage control

* refactor(tool-validation): extract getMcpServerIssue, remove fake tool hack

* lint

* reorder dropdown

* perf(agent): parallelize MCP server tool creation with Promise.all

* fix(combobox): preserve cursor movement in search input, reset query on drilldown

* fix(combobox): route ArrowRight through handleSelect, remove redundant type guards

* fix(agent): rename mcpServers to mcpServerSelections to avoid shadowing DB import, route ArrowRight through handleSelect

* docs: update google integration docs

* fix(tool-input): reset drilldown state on tool selection to prevent stale view

* perf(agent): parallelize MCP server discovery across multiple servers
2026-02-26 15:17:23 -08:00
335 changed files with 30198 additions and 6821 deletions

View File

@@ -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

View File

@@ -102,6 +102,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
- 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

View File

@@ -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)

View File

@@ -0,0 +1,283 @@
---
description: Validate an existing Sim integration (tools, block, registry) against the service's API docs
argument-hint: <service-name> [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.output>`
### 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

View File

@@ -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'
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/logger', () => loggerMock)
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
import { myFunction } from '@/lib/feature'
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
describe('myFunction', () => {
beforeEach(() => vi.clearAllMocks())
it.concurrent('isolated tests run in parallel', () => { ... })
import { GET, POST } from '@/app/api/my-route/route'
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)
})
})
```
## Performance Rules (Critical)
### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()`
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
```typescript
const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({
mockCheckSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
// In tests:
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true, userId: 'user-1', authType: 'session',
})
```
### Database chain mocking
```typescript
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 mocks.
Always prefer over local test data.
| Category | Utilities |
|----------|-----------|
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` |
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` |
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
| **Requests** | `createMockRequest()`, `createEnvMock()` |
## Rules
## Rules Summary
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
## Hoisted Mocks
For mutable mock references:
```typescript
const mockFn = vi.hoisted(() => vi.fn())
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
mockFn.mockResolvedValue({ data: 'test' })
```
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()`

View File

@@ -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'
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/logger', () => loggerMock)
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
import { myFunction } from '@/lib/feature'
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
describe('myFunction', () => {
beforeEach(() => vi.clearAllMocks())
it.concurrent('isolated tests run in parallel', () => { ... })
import { GET, POST } from '@/app/api/my-route/route'
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)
})
})
```
## Performance Rules (Critical)
### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()`
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
```typescript
const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({
mockCheckSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
// In tests:
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true, userId: 'user-1', authType: 'session',
})
```
### Database chain mocking
```typescript
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 mocks.
Always prefer over local test data.
| Category | Utilities |
|----------|-----------|
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` |
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` |
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
| **Requests** | `createMockRequest()`, `createEnvMock()` |
## Rules
## Rules Summary
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
## Hoisted Mocks
For mutable mock references:
```typescript
const mockFn = vi.hoisted(() => vi.fn())
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
mockFn.mockResolvedValue({ data: 'test' })
```
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()`

View File

@@ -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

View File

@@ -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

View File

@@ -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'
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/logger', () => loggerMock)
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
import { myFunction } from '@/lib/feature'
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
describe('feature', () => {
beforeEach(() => vi.clearAllMocks())
it.concurrent('runs in parallel', () => { ... })
import { GET } from '@/app/api/my-route/route'
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

View File

@@ -13,7 +13,7 @@ export function TOCFooter() {
<div className='text-balance font-semibold text-base leading-tight'>
Start building today
</div>
<div className='text-muted-foreground'>Trusted by over 60,000 builders.</div>
<div className='text-muted-foreground'>Trusted by over 70,000 builders.</div>
<div className='text-muted-foreground'>
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
</div>

View File

@@ -76,7 +76,6 @@ export function ApiIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -526,6 +525,17 @@ export function SlackMonoIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GammaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='-14 0 192 192' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
d='M47.2,14.4c-14.4,8.2-26,19.6-34.4,33.6C4.3,62.1,0,77.7,0,94.3s4.3,32.2,12.7,46.3c8.5,14.1,20,25.4,34.4,33.6,14.4,8.2,30.4,12.4,47.7,12.4h69.8v-112.5h-81v39.1h38.2v31.8h-25.6c-9.1,0-17.6-2.3-25.2-6.9-7.6-4.6-13.8-10.8-18.3-18.4-4.5-7.7-6.7-16.2-6.7-25.3s2.3-17.7,6.7-25.3c4.5-7.7,10.6-13.9,18.3-18.4,7.6-4.6,16.1-6.9,25.2-6.9h68.5V2h-69.8c-17.3,0-33.3,4.2-47.7,12.4h0Z'
/>
</svg>
)
}
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} width='26' height='26' viewBox='0 0 26 26' xmlns='http://www.w3.org/2000/svg'>
@@ -1254,6 +1264,20 @@ export function GoogleSlidesIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleContactsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<path fill='#86a9ff' d='M199 244c-89 0-161 71-161 160v67c0 16 13 29 29 29h77l77-256z' />
<path fill='#578cff' d='M462 349c0-58-48-105-106-105h-77v256h77c58 0 106-47 106-106' />
<path
fill='#0057cc'
d='M115 349c0-58 48-105 106-105h58c58 0 106 47 106 105v45c0 59-48 106-106 106H144c-16 0-29-13-29-29z'
/>
<circle cx='250' cy='99.4' r='99.4' fill='#0057cc' />
</svg>
)
}
export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -2962,6 +2986,19 @@ export function QdrantIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AshbyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 254 260' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M76.07 250.537v9.16H.343v-9.16c19.618 0 27.465-4.381 34.527-23.498l73.764-209.09h34.92l81.219 209.09c7.847 19.515 11.77 23.498 28.642 23.498v9.16H134.363v-9.16c28.242 0 30.625-2.582 22.14-23.498l-21.58-57.35H69.399l-19.226 56.155c-5.614 18.997-4.387 24.693 25.896 24.693zm24.326-171.653l-26.681 78.459h56.5l-29.819-78.459z'
fill='currentColor'
/>
</svg>
)
}
export function ArxivIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} id='logomark' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 17.732 24.269'>
@@ -3956,6 +3993,28 @@ export function IntercomIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function LoopsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 256 256' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
d='M192.352 88.042c0-7.012-5.685-12.697-12.697-12.697s-12.697 5.685-12.697 12.697c0 .634.052 1.255.142 1.866a25.248 25.248 0 0 0-4.9-.49c-14.006 0-25.36 11.354-25.36 25.36 0 1.63.16 3.222.456 4.765a37.8 37.8 0 0 0-9.296-1.173c-20.95 0-37.935 16.985-37.935 37.935S107.05 194.24 128 194.24s37.935-16.985 37.935-37.935a37.7 37.7 0 0 0-3.78-16.555 25.2 25.2 0 0 0 12.487-3.336 25.2 25.2 0 0 0 4.558 3.336v.02c14.006 0 25.36-11.354 25.36-25.36 0-12.48-9.018-22.855-20.888-24.996a12.6 12.6 0 0 0 8.68-11.972m-77.05 68.263c0-7.012 5.685-12.697 12.697-12.697s12.697 5.685 12.697 12.697c0 7.013-5.685 12.697-12.697 12.697s-12.697-5.685-12.697-12.697'
/>
</svg>
)
}
export function LumaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} fill='none' viewBox='0 0 133 134' xmlns='http://www.w3.org/2000/svg'>
<path
d='M133 67C96.282 67 66.5 36.994 66.5 0c0 36.994-29.782 67-66.5 67 36.718 0 66.5 30.006 66.5 67 0-36.994 29.782-67 66.5-67'
fill='#000000'
/>
</svg>
)
}
export function MailchimpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4477,6 +4536,17 @@ export function SSHIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DatabricksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 241 266' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M228.085 109.654L120.615 171.674L5.53493 105.41L0 108.475V156.582L120.615 225.911L228.085 164.128V189.596L120.615 251.615L5.53493 185.351L0 188.417V196.67L120.615 266L241 196.67V148.564L235.465 145.498L120.615 211.527L12.9148 149.743V124.275L120.615 186.059L241 116.729V69.3298L235.004 65.7925L120.615 131.585L18.4498 73.1028L120.615 14.3848L204.562 62.7269L211.942 58.4823V52.5869L120.615 0L0 69.3298V76.8759L120.615 146.206L228.085 84.1862V109.654Z'
fill='#F9F7F4'
/>
</svg>
)
}
export function DatadogIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
@@ -4825,6 +4895,17 @@ export function CirclebackIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GreenhouseIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 51.4 107.7' xmlns='http://www.w3.org/2000/svg'>
<path
d='M44.9,32c0,5.2-2.2,9.8-5.8,13.4c-4,4-9.8,5-9.8,8.4c0,4.6,7.4,3.2,14.5,10.3c4.7,4.7,7.6,10.9,7.6,18.1c0,14.2-11.4,25.5-25.7,25.5S0,96.4,0,82.2C0,75,2.9,68.8,7.6,64.1c7.1-7.1,14.5-5.7,14.5-10.3c0-3.4-5.8-4.4-9.8-8.4c-3.6-3.6-5.8-8.2-5.8-13.6C6.5,21.4,15,13,25.4,13c2,0,3.8,0.3,5.3,0.3c2.7,0,4.1-1.2,4.1-3.1c0-1.1-0.5-2.5-0.5-4c0-3.4,2.9-6.2,6.4-6.2S47,2.9,47,6.4c0,3.7-2.9,5.4-5.1,6.2c-1.8,0.6-3.2,1.4-3.2,3.2C38.7,19.2,44.9,22.5,44.9,32z M42.9,82.2c0-9.9-7.3-17.9-17.2-17.9s-17.2,8-17.2,17.9c0,9.8,7.3,17.9,17.2,17.9S42.9,92,42.9,82.2z M37,31.8c0-6.3-5.1-11.5-11.3-11.5s-11.3,5.2-11.3,11.5s5.1,11.5,11.3,11.5S37,38.1,37,31.8z'
fill='currentColor'
/>
</svg>
)
}
export function GreptileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
@@ -5930,3 +6011,20 @@ export function HexIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function ShortIoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 64 65' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect width='64' height='65' fill='#FFFFFF' />
<path
d='M41.1 45.7c0 2-.8 3.5-2.5 4.6-1.6 1-3.8 1.6-6.5 1.6-3.4 0-6-.8-8-2.3-2-1.6-3-3.6-3.2-6.1l-16.3-.4c0 4.1 1.2 7.8 3.6 11.1A24 24 0 0 0 18 62c2.2 1 4.5 1.7 7 2.2l.4.1H0V.2h24.9A25.4 25.4 0 0 0 9.3 9.5C7.1 12.5 6 15.9 6 19.7c0 4.2.9 7.6 2.6 10.1 1.7 2.5 4 4.4 6.8 5.7 2.8 1.3 6.3 2.3 10.6 3.2 4.4.9 7.5 1.6 9.5 2.2 1.9.5 3.3 1.1 4.3 1.9.8.6 1.3 1.6 1.3 2.9Z'
fill='#0BB07D'
/>
<path d='M25.3 64.2h-.6l.1-.1.5.1Z' fill='#33333D' />
<path
d='M64 64.2H38.1a28 28 0 0 0 7.1-2.2 23 23 0 0 0 9.4-7.6c2.2-3.2 3.4-6.8 3.4-10.8a17 17 0 0 0-2.6-9.8c-1.7-2.4-4-4.3-6.9-5.5a54.4 54.4 0 0 0-10.8-3.1c-4.3-.8-7.3-1.5-9.2-2.1a12 12 0 0 1-4.2-1.8c-.9-.7-1.3-1.7-1.3-3 0-1.9.7-3.3 2.2-4.3 1.5-1 3.4-1.5 5.8-1.5 2.7 0 4.9.7 6.5 2.1a7.8 7.8 0 0 1 2.7 5.4h16.4c0-3.8-1.1-7.3-3.3-10.5a23 23 0 0 0-9.1-7.4c-2.1-1-4.4-1.7-6.8-2.1H64v64.2Z'
fill='#383738'
/>
</svg>
)
}

View File

@@ -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,
@@ -119,6 +126,7 @@ import {
ServiceNowIcon,
SftpIcon,
ShopifyIcon,
ShortIoIcon,
SimilarwebIcon,
SlackIcon,
SmtpIcon,
@@ -164,6 +172,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
ashby: AshbyIcon,
attio: AttioIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
@@ -174,6 +183,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
cloudflare: CloudflareIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
databricks: DatabricksIcon,
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
@@ -188,6 +198,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,
gamma: GammaIcon,
github_v2: GithubIcon,
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
@@ -195,6 +206,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
google_contacts: GoogleContactsIcon,
google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
@@ -208,6 +220,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,
greenhouse: GreenhouseIcon,
greptile: GreptileIcon,
hex: HexIcon,
hubspot: HubspotIcon,
@@ -227,6 +240,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
luma: LumaIcon,
mailchimp: MailchimpIcon,
mailgun: MailgunIcon,
mem0: Mem0Icon,
@@ -269,6 +284,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
sftp: SftpIcon,
sharepoint: MicrosoftSharepointIcon,
shopify: ShopifyIcon,
short_io: ShortIoIcon,
similarweb: SimilarwebIcon,
slack: SlackIcon,
smtp: SmtpIcon,

View File

@@ -0,0 +1,473 @@
---
title: Ashby
description: Manage candidates, jobs, and applications in Ashby
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="ashby"
color="#5D4ED6"
/>
{/* 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: &lt;b&gt;, &lt;i&gt;, &lt;u&gt;, &lt;a&gt;, &lt;ul&gt;, &lt;ol&gt;, &lt;li&gt;, &lt;code&gt;, &lt;pre&gt; |
| `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 |

View File

@@ -0,0 +1,267 @@
---
title: Databricks
description: Run SQL queries and manage jobs on Databricks
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="databricks"
color="#FF3621"
/>
{/* 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\) |

View File

@@ -0,0 +1,165 @@
---
title: Gamma
description: Generate presentations, documents, and webpages with AI
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="gamma"
color="#002253"
/>
{/* 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 |

View File

@@ -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 worlds 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 worlds 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 Gmails 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 */}

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -0,0 +1,144 @@
---
title: Google Contacts
description: Manage Google Contacts
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_contacts"
color="#E0E0E0"
/>
## 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 |

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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.

View File

@@ -0,0 +1,575 @@
---
title: Greenhouse
description: Manage candidates, jobs, and applications in Greenhouse
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="greenhouse"
color="#469776"
/>
{/* 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`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_get_candidate`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_jobs`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_get_job`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_applications`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_get_application`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_users`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_get_user`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_departments`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_offices`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |
### `greenhouse_list_job_stages`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates |
| `jobs` | json | List of jobs |
| `applications` | json | List of applications |
| `users` | json | List of users |
| `departments` | json | List of departments |
| `offices` | json | List of offices |
| `stages` | json | List of job stages |
| `count` | number | Number of results returned |
| `id` | number | Resource ID |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `name` | string | Resource name |
| `status` | string | Status |
| `email_addresses` | json | Email addresses |
| `phone_numbers` | json | Phone numbers |
| `tags` | json | Tags |
| `application_ids` | json | Associated application IDs |
| `recruiter` | json | Assigned recruiter |
| `coordinator` | json | Assigned coordinator |
| `current_stage` | json | Current interview stage |
| `source` | json | Application source |
| `hiring_team` | json | Hiring team members |
| `openings` | json | Job openings |
| `custom_fields` | json | Custom field values |
| `attachments` | json | File attachments |
| `educations` | json | Education history |
| `employments` | json | Employment history |
| `answers` | json | Application question answers |
| `prospect` | boolean | Whether this is a prospect |
| `confidential` | boolean | Whether the job is confidential |
| `is_private` | boolean | Whether the candidate is private |
| `can_email` | boolean | Whether the candidate can be emailed |
| `disabled` | boolean | Whether the user is disabled |
| `site_admin` | boolean | Whether the user is a site admin |
| `primary_email_address` | string | Primary email address |
| `created_at` | string | Creation timestamp \(ISO 8601\) |
| `updated_at` | string | Last updated timestamp \(ISO 8601\) |

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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](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.
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.
With the Jira integration in Sim, you can:
Key features of Jira include:
- **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
- 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.
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 */}

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -0,0 +1,273 @@
---
title: Loops
description: Manage contacts and send emails with Loops
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="loops"
color="#FAFAF9"
/>
{/* 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\) |

View File

@@ -0,0 +1,284 @@
---
title: Luma
description: Manage events and guests on Luma
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="luma"
color="#FFFFFF"
/>
{/* 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 |

View File

@@ -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",
@@ -115,6 +122,7 @@
"sftp",
"sharepoint",
"shopify",
"short_io",
"similarweb",
"slack",
"smtp",

View File

@@ -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 */}

View File

@@ -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 |

View File

@@ -0,0 +1,173 @@
---
title: Short.io
description: Create and manage short links, domains, and analytics.
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="short_io"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Short.io](https://short.io/) is a white-label URL shortener that lets you create branded short links on your own domain, track clicks, and manage links at scale. Short.io is designed for businesses that want professional short URLs, QR codes, and link analytics without relying on generic shorteners.
With Short.io in Sim, you can:
- **Create short links**: Generate branded short URLs from long URLs using your custom domain, with optional custom paths
- **List domains**: Retrieve all Short.io domains on your account to get domain IDs for listing links
- **List links**: List short links for a domain with pagination and optional date sort order
- **Delete links**: Remove a short link by its ID (e.g. lnk_abc123_abcdef)
- **Generate QR codes**: Create QR codes for any Short.io link with optional size, color, background color, and format (PNG or SVG); returns a base64 data URL
- **Get link statistics**: Fetch click analytics for a link including total clicks, human clicks, referrer/country/browser/OS/city breakdowns, UTM dimensions, time-series data, and date interval
These capabilities allow your Sim agents to automate link shortening, QR code generation, and analytics reporting directly in your workflows — from campaign tracking to link management and performance dashboards.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Short.io to generate branded short links, list domains and links, delete links, generate QR codes, and view link statistics. Requires your Short.io Secret API Key.
## Tools
### `short_io_create_link`
Create a short link using your Short.io custom domain.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
| `domain` | string | Yes | Your registered Short.io custom domain |
| `originalURL` | string | Yes | The long URL to shorten |
| `path` | string | No | Optional custom path for the short link |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `shortURL` | string | The generated short link URL |
| `idString` | string | The unique Short.io link ID string |
| `originalURL` | string | The original long URL |
| `path` | string | The path/slug of the short link |
| `createdAt` | string | ISO 8601 creation timestamp |
### `short_io_list_domains`
List Short.io domains. Returns domain IDs and details for use in List Links.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `domains` | array | List of domain objects \(id, hostname, etc.\) |
| `count` | number | Number of domains |
### `short_io_list_links`
List short links for a domain. Requires domain_id (from List Domains or dashboard). Max 150 per request.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
| `domainId` | number | Yes | Domain ID \(from List Domains\) |
| `limit` | number | No | Max links to return \(1150\) |
| `pageToken` | string | No | Pagination token from previous response |
| `dateSortOrder` | string | No | Sort by date: asc or desc |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `links` | array | List of link objects \(idString, shortURL, originalURL, path, etc.\) |
| `count` | number | Number of links returned |
| `nextPageToken` | string | Token for next page |
### `short_io_delete_link`
Delete a short link by ID (e.g. lnk_abc123_abcdef). Rate limit 20/s.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
| `linkId` | string | Yes | Link ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the link was deleted |
| `idString` | string | Deleted link ID |
### `short_io_get_qr_code`
Generate a QR code for a Short.io link (POST /links/qr/{linkIdString}).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
| `linkId` | string | Yes | Link ID \(e.g. lnk_abc123_abcdef\) |
| `color` | string | No | QR color hex \(e.g. 000000\) |
| `backgroundColor` | string | No | Background color hex \(e.g. FFFFFF\) |
| `size` | number | No | QR size 199 |
| `type` | string | No | Output format: png or svg |
| `useDomainSettings` | boolean | No | Use domain settings \(default true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Generated QR code image file |
### `short_io_get_analytics`
Fetch click statistics for a Short.io link (Statistics API: totalClicks, humanClicks, referer, country, etc.).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Short.io Secret API Key |
| `linkId` | string | Yes | No description |
| `period` | string | Yes | Period: today, yesterday, last7, last30, total, week, month, lastmonth |
| `tz` | string | No | Timezone \(default UTC\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalClicks` | number | Total clicks |
| `humanClicks` | number | Human clicks |
| `totalClicksChange` | string | Change vs previous period |
| `humanClicksChange` | string | Human clicks change |
| `referer` | array | Referrer breakdown \(referer, score\) |
| `country` | array | Country breakdown \(countryName, country, score\) |
| `browser` | array | Browser breakdown \(browser, score\) |
| `os` | array | OS breakdown \(os, score\) |
| `city` | array | City breakdown \(city, name, countryCode, score\) |
| `device` | array | Device breakdown |
| `social` | array | Social source breakdown \(social, score\) |
| `utmMedium` | array | UTM medium breakdown |
| `utmSource` | array | UTM source breakdown |
| `utmCampaign` | array | UTM campaign breakdown |
| `clickStatistics` | object | Time-series click data \(datasets with x/y points per interval\) |
| `interval` | object | Date range \(startDate, endDate, prevStartDate, prevEndDate, tz\) |

View File

@@ -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

View File

@@ -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.
<iframe
width="100%"
@@ -27,7 +37,7 @@ Learn how to create a webhook trigger in Sim that seamlessly initiates workflows
allowFullScreen
></iframe>
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.
<iframe
width="100%"
@@ -38,15 +48,6 @@ Learn how to use the Telegram Tool in Sim to seamlessly automate message deliver
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
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 */}

View File

@@ -11,11 +11,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/>
{/* MANUAL-CONTENT-START:intro */}
[WordPress](https://wordpress.org/) is the worlds 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 worlds 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.
Sims 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 */}

View File

@@ -29,74 +29,65 @@ 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 | Tweet ID |
| ↳ `text` | string | Tweet content text |
| ↳ `createdAt` | string | Tweet creation timestamp |
| ↳ `authorId` | string | ID of the tweet author |
| ↳ `conversationId` | string | Conversation thread ID |
| ↳ `attachments` | object | Media or poll attachments |
| ↳ `mediaKeys` | array | Media attachment keys |
| ↳ `pollId` | string | Poll ID if poll attached |
| `id` | string | The ID of the created tweet |
| `text` | string | The text of the created tweet |
### `x_read`
### `x_delete_tweet`
Read tweet details, including replies and conversation context
Delete a tweet authored by the authenticated user
#### 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 ID of the tweet to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tweet` | object | The main tweet data |
| ↳ `id` | string | Tweet ID |
| ↳ `text` | string | Tweet content text |
| ↳ `createdAt` | string | Tweet creation timestamp |
| ↳ `authorId` | string | ID of the tweet author |
| `context` | object | Conversation context including parent and root tweets |
| `deleted` | boolean | Whether the tweet was successfully deleted |
### `x_search`
### `x_search_tweets`
Search for tweets using keywords, hashtags, or advanced queries
Search for recent tweets using keywords, hashtags, or advanced query operators
#### 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\) |
| `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
@@ -104,38 +95,748 @@ Search for tweets using keywords, hashtags, or advanced queries
| --------- | ---- | ----------- |
| `tweets` | array | Array of tweets matching the search query |
| ↳ `id` | string | Tweet ID |
| ↳ `text` | string | Tweet content |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `text` | string | Tweet text content |
| ↳ `createdAt` | string | Tweet creation timestamp |
| ↳ `authorId` | string | Author user ID |
| `includes` | object | Additional data including user profiles and media |
| ↳ `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 | 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_user`
### `x_get_tweets_by_ids`
Get user profile information
Look up multiple tweets by their IDs (up to 100)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `username` | string | Yes | Username to look up without @ symbol \(e.g., elonmusk, openai\) |
| `ids` | string | Yes | Comma-separated tweet IDs \(up to 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `user` | object | X user profile information |
| `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 | 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 |
| --------- | ---- | ----------- |
| `tweets` | array | Array of quote 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 |
| `meta` | object | Pagination metadata |
| ↳ `resultCount` | number | Number of results returned |
| ↳ `nextToken` | string | Token for next page |
### `x_hide_reply`
Hide or unhide a reply to a tweet authored by the authenticated user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `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 |
| --------- | ---- | ----------- |
| `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 |
| `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/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_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
#### 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
| 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 |
| --------- | ---- | -------- | ----------- |
| `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 |
| --------- | ---- | ----------- |
| `users` | array | Array of blocked 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_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 |

View File

@@ -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.',
},
},
{

View File

@@ -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<typeof setTimeout> | 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()
},
})

View File

@@ -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()

View File

@@ -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'
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(),
}
return {
mockForgetPassword: vi.fn(),
mockLogger: logger,
}
})
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 createAuthMethod = (config: { success?: boolean; error?: string }) => {
return vi.fn().mockImplementation(() => {
if (config.success) {
return Promise.resolve()
}
return Promise.reject(new Error(config.error))
})
}
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
resetPassword: createAuthMethod(defaultOperations.resetPassword),
},
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()
})
})

View File

@@ -3,52 +3,81 @@
*
* @vitest-environment node
*/
import { 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'
describe('OAuth Connections API Route', () => {
const mockGetSession = vi.fn()
const mockDb = {
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,
}))
vi.mock('@sim/db', () => ({
db: mockDb,
account: { userId: 'userId', providerId: 'providerId' },
user: { email: 'email', id: 'id' },
eq: mockEq,
}))
vi.mock('drizzle-orm', () => ({
eq: mockEq,
}))
vi.mock('jwt-decode', () => ({
jwtDecode: mockJwtDecode,
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
import { GET } from '@/app/api/auth/oauth/connections/route'
describe('OAuth Connections API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
vi.doMock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
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.doMock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
}))
vi.doMock('jwt-decode', () => ({
jwtDecode: vi.fn(),
}))
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
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()

View File

@@ -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'
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(),
}
return {
mockCheckSessionOrInternalAuth: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockLogger: logger,
}
})
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', () => {
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(),
}
const mockLogger = createMockLogger()
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
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' },
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'github',
})
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()

View File

@@ -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'
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'
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)
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({}),
}
})
afterEach(() => {
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.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()

View File

@@ -3,48 +3,63 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest, mockHybridAuth } 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 {
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,
}
})
vi.mock('@/app/api/auth/oauth/utils', () => ({
getUserId: mockGetUserId,
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn(),
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
checkInternalAuth: vi.fn(),
}))
import { GET, POST } from '@/app/api/auth/oauth/token/route'
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<typeof vi.fn>
const mockLogger = createMockLogger()
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
const mockRequestId = mockUUID.slice(0, 8)
beforeEach(() => {
vi.resetModules()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue(mockUUID),
})
vi.doMock('@/app/api/auth/oauth/utils', () => ({
getUserId: mockGetUserId,
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
}))
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
;({ mockCheckSessionOrInternalAuth } = mockHybridAuth())
})
afterEach(() => {
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()

View File

@@ -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()
})
})

View File

@@ -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()
let storageMethod: 'redis' | 'database' = 'redis'
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<string, unknown>) => 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,
}))
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('<html>OTP Email</html>')
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()
})
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(),
}),
},
}))
mockGenerateRequestId.mockReturnValue('req-123')
vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: mockGenerateRequestId,
}))
mockZodParse.mockImplementation((data: unknown) => data)
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),

View File

@@ -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.clearAllMocks()
vi.doMock('@/lib/core/security/deployment', () => ({
addCorsHeaders: mockAddCorsHeaders,
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: vi.fn(),
isEmailAllowed: vi.fn().mockReturnValue(false),
}))
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 })
})
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),
}),
}),
}
}
mockDbSelect.mockImplementation((fields: Record<string, unknown>) => {
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(

View File

@@ -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' })),
}))
import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route'
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()
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)

View File

@@ -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'
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', () => {
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()
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)
mockUuidV4.mockReturnValue('test-uuid')
vi.doMock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
vi.doMock('@sim/db/schema', () => ({
chat: { userId: 'userId', identifier: 'identifier' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
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(),
}),
}))
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)

View File

@@ -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',

View File

@@ -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)

View File

@@ -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<string, unknown>): 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)

View File

@@ -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<string, unknown>): 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)

View File

@@ -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)

View File

@@ -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.clearAllMocks()
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'),
}))
thenResults = []
vi.doMock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: vi.fn().mockResolvedValue({
allowed: true,
status: 200,
}),
}))
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,
},
}))
// 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))
})
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' })),
}))
// 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)

View File

@@ -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 {
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(),
}))
const mockCopilotChats = { id: 'id', userId: 'userId' }
const mockWorkflowCheckpoints = {
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<string, unknown>): 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)

View File

@@ -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<string, unknown>): 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)

View File

@@ -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<string, unknown>): 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)

View File

@@ -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'
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<string, string | undefined> = {
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', () => {
const mockFetch = vi.fn()
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)

View File

@@ -1,110 +1,170 @@
import {
createMockRequest,
mockAuth,
mockCryptoUuid,
mockHybridAuth,
mockUuid,
setupCommonApiMocks,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* @vitest-environment node
*/
import { 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
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()
setupCommonApiMocks()
mockUuid()
mockCryptoUuid()
const authMocks = mockAuth()
if (authenticated) {
authMocks.setAuthenticated()
} else {
authMocks.setUnauthenticated()
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.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.doMock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn().mockResolvedValue(true),
verifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
}))
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' },
}))
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.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.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.mock('uuid', () => ({
v4: vi.fn().mockReturnValue('test-uuid'),
}))
vi.doMock('@/lib/uploads/core/storage-service', () => ({
uploadFile: uploadFileMock,
downloadFile: downloadFileMock,
deleteFile: deleteFileMock,
hasCloudStorage: hasCloudStorageMock,
}))
vi.mock('@/lib/auth', () => ({
getSession: mocks.mockGetSession,
}))
vi.doMock('fs/promises', () => ({
unlink: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isFile: () => true }),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mocks.mockCheckHybridAuth,
checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth,
checkInternalAuth: mocks.mockCheckInternalAuth,
}))
return { auth: authMocks }
}
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)

View File

@@ -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()

View File

@@ -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'
),
}))
mockUseBlobStorage.value = useBlobStorage
mockUseS3Storage.value = useS3Storage
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,
}
})
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'
)
vi.doMock('@/lib/uploads/core/storage-service', () => ({
hasCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
generatePresignedUploadUrl: mockGeneratePresignedUploadUrl,
generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'),
}))
vi.doMock('@/lib/uploads/utils/validation', () => ({
validateFileType: vi.fn().mockReturnValue(null),
}))
vi.doMock('@/lib/uploads', () => ({
CopilotFiles: {
generateCopilotUploadUrl: vi.fn().mockResolvedValue({
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: '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),
}))
key,
}
}
)
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://example.com/presigned-url')
return { auth: authMocks }
mockValidateFileType.mockReturnValue(null)
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)

View File

@@ -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'
import { 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()
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()
vi.clearAllMocks()
setupApiTestMocks({
withFileSystem: true,
withUploadUtils: true,
})
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) })

View File

@@ -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),
}))
mocks.mockVerifyFileAccess.mockResolvedValue(true)
mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true)
mocks.mockVerifyKBFileAccess.mockResolvedValue(true)
mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true)
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
}))
mocks.mockGetUserEntityPermissions.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(),
}),
}))
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(),
})
const uploadFileMock = vi.fn().mockResolvedValue({
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' },
}),
}))
mocks.mockGetSession.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.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()

View File

@@ -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[]
}
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'),
}
function createFolderDbMock(options: FolderDbMockOptions = {}) {
const {
folderLookupResult = mockFolder,
updateResult = [{ ...mockFolder, name: 'Updated Folder' }],
throwError = false,
circularCheckResults = [],
} = options
let callCount = 0
const mockSelect = vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
then: vi.fn().mockImplementation((callback) => {
if (throwError) {
throw new Error('Database error')
}
callCount++
if (callCount === 1) {
const result = folderLookupResult === undefined ? [] : [folderLookupResult]
return Promise.resolve(callback(result))
}
if (callCount > 1 && circularCheckResults.length > 0) {
const index = callCount - 2
const result = circularCheckResults[index] ? [circularCheckResults[index]] : []
return Promise.resolve(callback(result))
}
return Promise.resolve(callback([]))
}),
})),
})),
}))
const mockUpdate = vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
returning: vi.fn().mockReturnValue(updateResult),
})),
})),
}))
const mockDelete = vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => Promise.resolve()),
}))
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', () => {
let mockLogger: ReturnType<typeof mockConsoleLogger>
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'),
}
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([]))
}),
})),
})),
}))
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()),
}))
return {
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
mocks: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}
}
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)

View File

@@ -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<typeof mockConsoleLogger>
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()

View File

@@ -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!',

View File

@@ -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<string, unknown>
envVars: Record<string, unknown>
contextVariables: Record<string, unknown>
}) => {
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',
})

View File

@@ -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)
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 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(),
}
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()

View File

@@ -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)
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 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(),
}
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()

View File

@@ -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(),
}))
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 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(),
}
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()

View File

@@ -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,14 +18,98 @@ describe('Knowledge Base API Route', () => {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
}
return { mockGetSession, mockDbChain }
})
beforeEach(async () => {
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)
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()
vi.doMock('@sim/db', () => ({
db: mockDbChain,
}))
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear()
@@ -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()

View File

@@ -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()

View File

@@ -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(),
}))
const mockGetUserEntityPermissions = vi.fn()
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
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(

View File

@@ -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<string, unknown>) => {
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 })

View File

@@ -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<typeof vi.fn>
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({

View File

@@ -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)
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
vi.doMock('@/lib/auth/internal', () => ({
verifyCronAuth: vi.fn().mockReturnValue(null),
}))
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' })
mockFeatureFlags.isTriggerDevEnabled = true
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
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',
},
}
})
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),
}))
mockDbReturning.mockReturnValue([])
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 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),
}))
mockDbReturning.mockReturnValue(MULTIPLE_SCHEDULES)
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',
},
}
})
const { GET } = await import('@/app/api/schedules/execute/route')
const response = await GET(createMockRequest())
expect(response.status).toBe(200)

View File

@@ -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,
}
})
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'],
},
},
},
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' },
},
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',
},
]
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<string, unknown>) => 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 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'],
},
},
},
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' },
},
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()
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()

View File

@@ -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 emailData: Record<string, unknown> = {
from: validatedData.fromAddress,
to: validatedData.to,
subject: validatedData.subject,
}
const { data, error } = await resend.emails.send(emailData)
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<typeof resend.emails.send>[0]
)
if (error) {
logger.error(`[${requestId}] Email sending failed:`, error)

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('ShortIoQrAPI')
const ShortIoQrSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
linkId: z.string().min(1, 'Link ID is required'),
color: z.string().optional(),
backgroundColor: z.string().optional(),
size: z.number().min(1).max(99).optional(),
type: z.enum(['png', 'svg']).optional(),
useDomainSettings: z.boolean().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Short.io QR request: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
const body = await request.json()
const validated = ShortIoQrSchema.parse(body)
const qrBody: Record<string, unknown> = {
useDomainSettings: validated.useDomainSettings ?? true,
}
if (validated.color) qrBody.color = validated.color
if (validated.backgroundColor) qrBody.backgroundColor = validated.backgroundColor
if (validated.size) qrBody.size = validated.size
if (validated.type) qrBody.type = validated.type
const response = await fetch(`https://api.short.io/links/qr/${validated.linkId}`, {
method: 'POST',
headers: {
Authorization: validated.apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(qrBody),
})
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText)
logger.error(`[${requestId}] Short.io QR API error: ${errorText}`)
return NextResponse.json(
{ success: false, error: `Short.io API error: ${errorText}` },
{ status: response.status }
)
}
const contentType = response.headers.get('Content-Type') ?? 'image/png'
const fileBuffer = Buffer.from(await response.arrayBuffer())
const mimeType = contentType.split(';')[0]?.trim() || 'image/png'
const ext = validated.type === 'svg' ? 'svg' : 'png'
const fileName = `qr-${validated.linkId}.${ext}`
logger.info(`[${requestId}] QR code generated`, {
linkId: validated.linkId,
size: fileBuffer.length,
mimeType,
})
return NextResponse.json({
success: true,
output: {
file: {
name: fileName,
mimeType,
data: fileBuffer.toString('base64'),
size: fileBuffer.length,
},
},
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: `Validation error: ${error.errors.map((e) => e.message).join(', ')}`,
},
{ status: 400 }
)
}
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Short.io QR error: ${message}`)
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -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')
}
},
})

View File

@@ -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<string, unknown>),
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) {

View File

@@ -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<typeof vi.fn>
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' }) })

View File

@@ -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<Uint8Array>({
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')
}
},
})

View File

@@ -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<Uint8Array>({
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')
}
},
})

View File

@@ -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<typeof vi.fn>
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' }) })

View File

@@ -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'
const { mockCheckSessionOrInternalAuth, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(
() => ({
mockCheckSessionOrInternalAuth: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
})
)
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('mock-request-id-12345678'),
}))
import { GET, POST } from '@/app/api/workflows/[id]/variables/route'
describe('Workflow Variables API Route', () => {
let authMocks: ReturnType<typeof mockAuth>
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
beforeEach(() => {
vi.resetModules()
setupCommonApiMocks()
mockCryptoUuid('mock-request-id-12345678')
authMocks = mockAuth(defaultMockUser)
mockAuthorizeWorkflowByWorkspacePermission.mockReset()
vi.doMock('@sim/db', () => databaseMock)
vi.doMock('@/lib/audit/log', () => auditMock)
vi.doMock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))
})
afterEach(() => {
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)

View File

@@ -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)

View File

@@ -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('<html>email content</html>')
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()
vi.clearAllMocks()
mockDbResults.value = []
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('<html>email content</html>'),
}))
vi.doMock('@/components/emails/workspace-invitation', () => ({
WorkspaceInvitationEmail: vi.fn(),
}))
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock()
// 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('<html>email content</html>')
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',

View File

@@ -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

View File

@@ -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.

View File

@@ -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',

View File

@@ -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',

View File

@@ -268,7 +268,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input

View File

@@ -547,7 +547,7 @@ export function McpDeploy({
</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input

View File

@@ -40,6 +40,7 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -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<string, unknown>
}
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<SelectorContext>(
() => ({
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 (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='knowledge.documents'
selectorContext={selectorContext}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={placeholder}
onOptionChange={handleDocumentChange}
/>
</div>
</Tooltip.Trigger>
{missingKnowledgeBase && (
<Tooltip.Content side='top'>
<p>Select a knowledge base first.</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}

Some files were not shown because too many files have changed in this diff Show More