diff --git a/examples/cookbook/n8n-integration/.env.example b/examples/cookbook/n8n-integration/.env.example new file mode 100644 index 0000000..47f030b --- /dev/null +++ b/examples/cookbook/n8n-integration/.env.example @@ -0,0 +1,8 @@ +# Moss Project Credentials +# Replace these values with your actual Moss project ID and project key +MOSS_PROJECT_ID=your-project-id-here +MOSS_PROJECT_KEY=your-project-key-here + +# Optional: N8N specific configurations (if needed) +# N8N_HOST=http://localhost:5678 +# N8N_API_KEY=your-n8n-api-key \ No newline at end of file diff --git a/examples/cookbook/n8n-integration/README.md b/examples/cookbook/n8n-integration/README.md new file mode 100644 index 0000000..cf21ada --- /dev/null +++ b/examples/cookbook/n8n-integration/README.md @@ -0,0 +1,127 @@ +# Moss N8N Integration Cookbook + +This cookbook provides a TypeScript helper and example workflow for integrating Moss with n8n, the open-source workflow automation platform. + +## Overview + +The integration exposes Moss's core operations so any n8n workflow can incorporate real-time semantic search without writing SDK code from scratch: + +- **Moss: Create Index** - Bootstrap a new index from an upstream data source +- **Moss: Add Documents** - Upsert one or more documents (with optional metadata) into an index +- **Moss: Delete Documents** - Remove documents by ID +- **Moss: Query** - Run a semantic/hybrid search query against an existing index + +## Files + +1. `moss-n8n-helper.ts` - A TypeScript wrapper around the Moss SDK optimized for n8n usage +2. `n8n-moss-workflow.json` - An example n8n workflow demonstrating the four core operations +3. `.env.example` - Example environment variables for Moss project credentials +4. `test_moss_n8n_helper.ts` - Unit tests for the helper + + +## Usage + +### Option 1: Using the TypeScript Helper (Recommended) + +For the best developer experience, use the provided TypeScript helper in your n8n Function nodes: + +1. Copy `moss-n8n-helper.ts` to your n8n custom nodes directory or bundle it with your workflow +2. Import and use it in Function nodes: + +```typescript +// Import the helper (adjust path as needed) +import { MossN8NHelper } from './moss-n8n-helper'; + +// Initialize with your Moss credentials +const helper = new MossN8NHelper('your-project-id', 'your-project-key'); + +// Create an index +const createResult = await helper.createIndex('my-index', [ + { id: '1', text: 'Hello world', metadata: { source: 'example' } } +]); + +// Check creation status (optional) +const createStatus = await helper.getJobStatus(createResult.jobId); + +// Add docs +const addResult = await helper.addDocs('my-index', [ + { id: '2', text: 'Another document', metadata: { source: 'example' } } +]); + +// Load index for fast local queries (recommended before querying) +await helper.loadIndex('my-index'); + +// Query +const results = await helper.query('my-index', 'hello', { topK: 5 }); + +// Delete docs +const deleteResult = await helper.deleteDocs('my-index', ['1']); + +// Clean up +helper.close(); +``` + +### Option 2: Direct HTTP Requests + +If you prefer not to use the helper, you can call Moss's public REST endpoints directly using n8n's HTTP Request nodes. Refer to the [Moss API documentation](https://docs.moss.dev) for endpoint details. + +## Environment Setup + +1. Copy `.env.example` to `.env` +2. Replace the placeholder values with your actual Moss project ID and project key +3. Never commit your actual `.env` file to version control + +## Example Workflow + +The included `n8n-moss-workflow.json` demonstrates a complete workflow: + +1. **Start** - Manual trigger to begin the workflow +2. **Moss Config** - Function node to set up Moss credentials +3. **Create Moss Index** - Function node showing how to create an index +4. **Add Documents** - Function node showing how to add documents +5. **Query Moss Index** - Function node showing how to query the index +6. **Delete Documents** - Function node showing how to delete documents + +Note: The example workflow uses Function nodes to illustrate the logic. In a real implementation, you would replace the placeholder code with actual calls to the Moss N8N Helper. + +## Common Use Cases + +### Real-time Knowledge Base Sync +Pair n8n triggers (Google Drive, GitHub, Postgres, etc.) with the Moss "Add Documents" node to automatically keep a semantic index in sync with any upstream source. + +### RAG Pipelines Without Code +Feed data from various sources into a Moss index entirely within n8n workflows, then query it from AI agents or LLMs—no custom SDK code required. + +### AI Agent Workflows +Use the Moss query operation within AI agent chains to retrieve relevant context before generating responses. + +## Requirements + +- Node.js >= 16 +- n8n >= 0.200.0 +- Moss account with project ID and project key + +## Setup + +1. Obtain your Moss project ID and project key from [Moss Dashboard](https://app.moss.dev) +2. Install the Moss JavaScript SDK in your n8n environment (if using the helper approach): + ```bash + npm install @moss-dev/moss + ``` +3. Set up your environment variables using the `.env.example` file +4. Import the helper into your n8n Function nodes as shown above + +## Development + +To modify the helper: +1. Edit `moss-n8n-helper.ts` +2. Run tests with: `npm test` (if you have a test setup) +3. Test with your n8n instance + +## Demo + +See `DEMO_SCRIPT.md` for a script to create a 1-minute demonstration video showing the integration in action. + +## License + +BSD-2-Clause - See [LICENSE](../LICENSE) for details. \ No newline at end of file diff --git a/examples/cookbook/n8n-integration/moss-n8n-helper.ts b/examples/cookbook/n8n-integration/moss-n8n-helper.ts new file mode 100644 index 0000000..6b6a60d --- /dev/null +++ b/examples/cookbook/n8n-integration/moss-n8n-helper.ts @@ -0,0 +1,262 @@ +import { MossClient } from '@moss-dev/moss'; + + +// Moss N8N Helper - Wrapper around Moss SDK for use in n8n workflows +// This helper provides easy-to-use functions for the four core Moss operations: +// - createIndex: Bootstrap a new index from documents +// - addDocs: Upsert documents into an index +// - deleteDocs: Remove documents by ID +// - query: Run semantic/hybrid search queries +type MossDocumentInput = { + id: string; + text: string; + metadata?: Record; + embedding?: number[]; +}; + +/** + * MossN8NHelper - Wrapper for Moss SDK optimized for n8n usage + */ +export class MossN8NHelper { + private client: MossClient; + + /** + * Initialize the Moss client + * @param projectId - Your Moss project identifier + * @param projectKey - Your Moss project authentication key + */ + constructor(projectId: string, projectKey: string) { + this.client = new MossClient(projectId, projectKey); + } + + /** + * Create a new index with documents + * @param indexName - Name of the index to create + * @param docs - Array of documents (each with id and text, optionally with metadata and embedding) + * @param options - Optional configuration (modelId, onProgress callback) + * @returns Promise resolving to the creation result with job info + */ + + + async createIndex( + indexName: string, + docs: MossDocumentInput[], + options?: { + modelId?: string; + onProgress?: (jobProgress: { status: string; progress: number; currentPhase?: string }) => void; + } + ): Promise<{ + jobId: string; + indexName: string; + docCount: number; + }> { + // Convert to Moss DocumentInfo format + const mossDocs = docs.map(doc => ({ + id: doc.id, + text: doc.text, + ...(doc.metadata && { metadata: doc.metadata }), + ...((doc.embedding?.length ?? 0) > 0 && { + embedding: doc.embedding + }) + })); + + // Call the SDK method which returns MutationResult + const result = await this.client.createIndex(indexName, mossDocs, options); + + // If there's a progress callback, we need to poll for status updates + // But for simplicity in this helper, we'll just return the basic result + // Users can call getJobStatus separately if they need progress tracking + return { + jobId: result.jobId, + indexName: result.indexName, + docCount: result.docCount + }; + } + + /** + * Add or update documents in an index + * @param indexName - Name of the target index + * @param docs - Array of documents to add/update + * @param options - Optional configuration (upsert, onProgress callback) + * @returns Promise resolving to the operation result with job info + */ + async addDocs( + indexName: string, + docs: Array<{ + id: string; + text: string; + metadata?: Record; + embedding?: number[]; + }>, + options?: { + upsert?: boolean; + onProgress?: (jobProgress: { status: string; progress: number; currentPhase?: string }) => void; + } + ): Promise<{ + jobId: string; + indexName: string; + docCount: number; + }> { + // Convert to Moss DocumentInfo format + const mossDocs = docs.map((doc: { + id: string; + text: string; + metadata?: Record; + embedding?: number[]; + }) => ({ + id: doc.id, + text: doc.text, + ...(doc.metadata && { metadata: doc.metadata }), + ...((doc.embedding?.length ?? 0) > 0 && { + embedding: doc.embedding + }) + })); + + // Call the SDK method which returns MutationResult + const result = await this.client.addDocs(indexName, mossDocs, options); + + // Return the basic result - users can call getJobStatus for progress + return { + jobId: result.jobId, + indexName: result.indexName, + docCount: result.docCount + }; + } + + /** + * Delete documents from an index by their IDs + * @param indexName - Name of the target index + * @param docIds - Array of document IDs to delete + * @param options - Optional configuration (onProgress callback) + * @returns Promise resolving to the deletion result with job info + */ + async deleteDocs( + indexName: string, + docIds: string[], + options?: { + onProgress?: (jobProgress: { status: string; progress: number; currentPhase?: string }) => void; + } + ): Promise<{ + jobId: string; + indexName: string; + docCount: number; + }> { + // Call the SDK method which returns MutationResult + const result = await this.client.deleteDocs(indexName, docIds, options); + + // Return the basic result - users can call getJobStatus for progress + return { + jobId: result.jobId, + indexName: result.indexName, + docCount: result.docCount + }; + } + + /** + * Query an index for semantic/hybrid search + * @param indexName - Name of the index to query + * @param queryText - The search query text + * @param options - Optional query configuration (topK, etc.) + * @returns Promise resolving to search results + */ + async query( + indexName: string, + queryText: string, + options?: { + topK?: number; + } + ): Promise; + score: number; + }>> { + // Note: Per Moss SDK docs, query() will use local index if loaded via loadIndex(), + // otherwise it falls back to cloud query. For best performance in n8n workflows, + // users should call loadIndex() once before doing multiple queries. + const results = await this.client.query(indexName, queryText, { + topK: options?.topK ?? 10 + }); + + // Convert to n8n-friendly format + return results.docs.map(doc => ({ + id: doc.id, + text: doc.text, + ...(doc.metadata && { metadata: doc.metadata }), + score: doc.score + })); + } + + /** + * Load an index into memory for fast local querying + * @param indexName - Name of the index to load + * @param options - Optional configuration including auto-refresh settings + * @returns Promise that resolves to index info + */ + async loadIndex( + indexName: string, + options?: { + autoRefresh?: boolean; + pollingIntervalInSeconds?: number; + } + ): Promise { + return this.client.loadIndex(indexName, options); + } + + /** + * Get the status of an async job + * @param jobId - The job ID from createIndex, addDocs, or deleteDocs + * @returns Promise resolving to job status with progress details + */ + async getJobStatus(jobId: string): Promise<{ + status: string; + progress: number; + currentPhase?: string; + error?: string; + }> { + const status = await this.client.getJobStatus(jobId); + return { + status: status.status, + progress: status.progress, + currentPhase: status.currentPhase?.toString(), + error: status.error ?? undefined + }; + } + + /** + * Close the client and release resources + * Note: MossClient doesn't expose a public close()/dispose() API, so this is a no-op. + * The helper remains usable after calling close(). + */ + close(): void { + // No-op: do not invalidate `this.client` by nulling it out. + } +} + +// Example usage for n8n: +// const helper = new MossN8NHelper('your-project-id', 'your-project-key'); +// +// // Create index +// const createResult = await helper.createIndex('my-index', [ +// { id: '1', text: 'Hello world', metadata: { source: 'example' } } +// ]); +// +// // Check creation status (optional) +// const createStatus = await helper.getJobStatus(createResult.jobId); +// +// // Add docs +// const addResult = await helper.addDocs('my-index', [ +// { id: '2', text: 'Another document', metadata: { source: 'example' } } +// ]); +// +// // Load index for fast local queries (recommended before querying) +// await helper.loadIndex('my-index'); +// +// // Query +// const results = await helper.query('my-index', 'hello', { topK: 5 }); +// +// // Delete docs +// const deleteResult = await helper.deleteDocs('my-index', ['1']); +// +// // Clean up +// helper.close(); \ No newline at end of file diff --git a/examples/cookbook/n8n-integration/n8n-moss-workflow.json b/examples/cookbook/n8n-integration/n8n-moss-workflow.json new file mode 100644 index 0000000..d95cf1e --- /dev/null +++ b/examples/cookbook/n8n-integration/n8n-moss-workflow.json @@ -0,0 +1,152 @@ +{ + "name": "Moss Document Sync Workflow", + "nodes": [ + { + "parameters": { + "rules": { + "items": [ + { + "value": "manual", + "name": "trigger", + "operation": "equal" + } + ] + } + }, + "name": "Start", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 250, + 300 + ] + }, + { + "parameters": { + "functionCode": "// Initialize Moss N8N Helper\n// Read credentials from n8n environment variables instead of hardcoding them in the workflow\nconst projectId = process.env.MOSS_PROJECT_ID;\nconst projectKey = process.env.MOSS_PROJECT_KEY;\n\nif (!projectId || !projectKey) {\n throw new Error('Missing Moss configuration. Set MOSS_PROJECT_ID and MOSS_PROJECT_KEY as environment variables in n8n.');\n}\n\n// This would normally be done in a Function node\n// For demo purposes, we're just returning the config\nreturn [{\n projectId,\n projectKey\n}];" + }, + "name": "Moss Config", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 450, + 300 + ] + }, + { + "parameters": { + "functionCode": "// Example: Create a new index with sample documents\n// In a real workflow, this data would come from previous nodes\n\nconst { projectId, projectKey } = items[0].json;\n\n// This is where you'd use the Moss N8N Helper\n// Since we can't directly import TypeScript in n8n Function nodes,\n// we'll show the logic that would be implemented\n\nconst sampleDocs = [\n { id: 'doc1', text: 'Machine learning is a subset of AI', metadata: { category: 'tech' } },\n { id: 'doc2', text: 'Deep learning uses neural networks with multiple layers', metadata: { category: 'tech' } },\n { id: 'doc3', text: 'Natural language processing enables computers to understand text', metadata: { category: 'tech' } }\n];\n\n// In practice, you would:\n// 1. Create a helper instance: const helper = new MossN8NHelper(projectId, projectKey);\n// 2. Create index: const createResult = await helper.createIndex('knowledge-base', sampleDocs);\n// 3. Wait for completion using getJobStatus in a loop\n// 4. Return the job ID for tracking\n\nreturn [{\n indexName: 'knowledge-base',\n documentCount: sampleDocs.length,\n status: 'index_creation_started',\n note: 'In actual n8n workflow, use Function items to call Moss N8N Helper methods'\n}];" + }, + "name": "Create Moss Index", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 650, + 200 + ] + }, + { + "parameters": { + "functionCode": "// Example: Add documents to an existing index\n// This would typically follow a trigger from a data source (Notion, DB, etc.)\n\nconst newDocs = [\n { id: 'doc4', text: 'Computer vision enables machines to interpret visual information', metadata: { category: 'tech', source: 'webhook' } },\n { id: 'doc5', text: 'Reinforcement learning trains agents through reward signals', metadata: { category: 'tech', source: 'webhook' } }\n];\n\n// In actual implementation:\n// const helper = new MossN8NHelper($json[\"projectId\"], $json[\"projectKey\"]);\n// const addResult = await helper.addDocs('knowledge-base', newDocs, { upsert: true });\n\nreturn [{\n addedDocCount: newDocs.length,\n indexName: 'knowledge-base',\n status: 'documents_added',\n note: 'In actual n8n workflow, use Function items to call Moss N8N Helper methods'\n}];" + }, + "name": "Add Documents", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 650, + 400 + ] + }, + { + "parameters": { + "functionCode": "// Example: Query the index for relevant information\n// This could be used in an AI agent workflow\n\nconst queryText = \"What is deep learning?\";\n\n// In actual implementation:\n// const helper = new MossN8NHelper($json[\"projectId\"], $json[\"projectKey\"]);\n// await helper.loadIndex('knowledge-base'); // For fast local queries\n// const results = await helper.query('knowledge-base', queryText, { topK: 3 });\n\n// Return formatted results for n8n\nreturn [{\n query: queryText,\n results: [\n {\n id: \"doc2\",\n text: \"Deep learning uses neural networks with multiple layers\",\n metadata: { category: \"tech\" },\n score: 0.95\n },\n {\n id: \"doc3\",\n text: \"Natural language processing enables computers to understand text\",\n metadata: { category: \"tech\" },\n score: 0.78\n }\n ],\n resultCount: 2,\n status: 'query_completed'\n}];" + }, + "name": "Query Moss Index", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 850, + 300 + ] + }, + { + "parameters": { + "functionCode": "// Example: Delete documents from index\n// Useful for removing outdated information\n\nconst docsToDelete = ['doc1', 'doc3'];\n\n// In actual implementation:\n// const helper = new MossN8NHelper($json[\"projectId\"], $json[\"projectKey\"]);\n// const deleteResult = await helper.deleteDocs('knowledge-base', docsToDelete);\n\nreturn [{\n deletedDocIds: docsToDelete,\n indexName: 'knowledge-base',\n status: 'documents_deleted',\n note: 'In actual n8n workflow, use Function items to call Moss N8N Helper methods'\n}];" + }, + "name": "Delete Documents", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [ + 1050, + 300 + ] + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Moss Config", + "type": "main", + "index": 0 + } + ] + ] + }, + "Moss Config": { + "main": [ + [ + { + "node": "Create Moss Index", + "type": "main", + "index": 0 + }, + { + "node": "Add Documents", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Moss Index": { + "main": [ + [ + { + "node": "Query Moss Index", + "type": "main", + "index": 0 + } + ] + ] + }, + "Add Documents": { + "main": [ + [ + { + "node": "Query Moss Index", + "type": "main", + "index": 0 + } + ] + ] + }, + "Query Moss Index": { + "main": [ + [ + { + "node": "Delete Documents", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionTimeout": 5400 + }, + "id": "1" +} \ No newline at end of file diff --git a/examples/cookbook/n8n-integration/test_moss_n8n_helper.ts b/examples/cookbook/n8n-integration/test_moss_n8n_helper.ts new file mode 100644 index 0000000..c8751da --- /dev/null +++ b/examples/cookbook/n8n-integration/test_moss_n8n_helper.ts @@ -0,0 +1,244 @@ +import { MossN8NHelper } from './moss-n8n-helper'; + +// Mock MossClient for testing +const mockCreateIndex = jest.fn(); +const mockAddDocs = jest.fn(); +const mockDeleteDocs = jest.fn(); +const mockQuery = jest.fn(); +const mockLoadIndex = jest.fn(); +const mockGetJobStatus = jest.fn(); +const mockClose = jest.fn(); + +// Mock the MossClient class +jest.mock('@moss-dev/moss', () => { + return { + MossClient: jest.fn().mockImplementation(() => ({ + createIndex: mockCreateIndex, + addDocs: mockAddDocs, + deleteDocs: mockDeleteDocs, + query: mockQuery, + loadIndex: mockLoadIndex, + getJobStatus: mockGetJobStatus, + close: mockClose + })) + }; +}); + +describe('MossN8NHelper', () => { + let helper: MossN8NHelper; + const projectId = 'test-project-id'; + const projectKey = 'test-project-key'; + + beforeEach(() => { + helper = new MossN8NHelper(projectId, projectKey); + jest.clearAllMocks(); + }); + + describe('createIndex', () => { + it('should call MossClient.createIndex with correct parameters', async () => { + const mockResult = { + jobId: 'job-123', + indexName: 'test-index', + docCount: 2 + }; + mockCreateIndex.mockResolvedValue(mockResult); + + const docs = [ + { id: '1', text: 'Hello world', metadata: { source: 'test' } }, + { id: '2', text: 'Another doc', metadata: { source: 'test' } } + ]; + + const result = await helper.createIndex('test-index', docs); + + expect(mockCreateIndex).toHaveBeenCalledWith( + 'test-index', + expect.arrayContaining([ + expect.objectContaining({ id: '1', text: 'Hello world' }), + expect.objectContaining({ id: '2', text: 'Another doc' }) + ]), + undefined + ); + expect(result).toEqual(mockResult); + }); + + it('should handle options correctly', async () => { + const mockResult = { + jobId: 'job-123', + indexName: 'test-index', + docCount: 1 + }; + mockCreateIndex.mockResolvedValue(mockResult); + + const docs = [{ id: '1', text: 'Test doc' }]; + const options = { modelId: 'custom-model' }; + + await helper.createIndex('test-index', docs, options); + + expect(mockCreateIndex).toHaveBeenCalledWith( + 'test-index', + expect.any(Array), + options + ); + }); + }); + + describe('addDocs', () => { + it('should call MossClient.addDocs with correct parameters', async () => { + const mockResult = { + jobId: 'job-456', + indexName: 'test-index', + docCount: 5 + }; + mockAddDocs.mockResolvedValue(mockResult); + + const docs = [ + { id: '3', text: 'New doc', metadata: { source: 'api' } } + ]; + + const result = await helper.addDocs('test-index', docs); + + expect(mockAddDocs).toHaveBeenCalledWith( + 'test-index', + expect.arrayContaining([ + expect.objectContaining({ id: '3', text: 'New doc' }) + ]), + undefined + ); + expect(result).toEqual(mockResult); + }); + + it('should handle upsert option', async () => { + const mockResult = { + jobId: 'job-456', + indexName: 'test-index', + docCount: 3 + }; + mockAddDocs.mockResolvedValue(mockResult); + + const docs = [{ id: '1', text: 'Updated doc' }]; + const options = { upsert: true }; + + await helper.addDocs('test-index', docs, options); + + expect(mockAddDocs).toHaveBeenCalledWith( + 'test-index', + expect.any(Array), + options + ); + }); + }); + + describe('deleteDocs', () => { + it('should call MossClient.deleteDocs with correct parameters', async () => { + const mockResult = { + jobId: 'job-789', + indexName: 'test-index', + docCount: 0 + }; + mockDeleteDocs.mockResolvedValue(mockResult); + + const docIds = ['1', '2']; + const result = await helper.deleteDocs('test-index', docIds); + + expect(mockDeleteDocs).toHaveBeenCalledWith('test-index', ['1', '2'], undefined); + expect(result).toEqual(mockResult); + }); + }); + + describe('query', () => { + it('should call MossClient.query and return formatted results', async () => { + const mockSearchResult = { + docs: [ + { + id: 'doc1', + text: 'Test document', + metadata: { category: 'test' }, + score: 0.95 + } + ], + query: 'test query', + timeTakenInMs: 5 + }; + mockQuery.mockResolvedValue(mockSearchResult); + + const results = await helper.query('test-index', 'test query', { topK: 5 }); + + expect(mockQuery).toHaveBeenCalledWith('test-index', 'test query', { topK: 5 }); + expect(results).toEqual([ + { + id: 'doc1', + text: 'Test document', + metadata: { category: 'test' }, + score: 0.95 + } + ]); + }); + + it('should use default topK when not provided', async () => { + const mockSearchResult = { + docs: [], + query: 'test', + timeTakenInMs: 2 + }; + mockQuery.mockResolvedValue(mockSearchResult); + + await helper.query('test-index', 'test'); + + expect(mockQuery).toHaveBeenCalledWith('test-index', 'test', { topK: 10 }); // default value + }); + }); + + describe('loadIndex', () => { + it('should call MossClient.loadIndex', async () => { + const mockResult = 'test-index'; + mockLoadIndex.mockResolvedValue(mockResult); + + const result = await helper.loadIndex('test-index'); + + expect(mockLoadIndex).toHaveBeenCalledWith('test-index', undefined); + expect(result).toBe(mockResult); + }); + + it('should pass options to MossClient.loadIndex', async () => { + const mockResult = 'test-index'; + mockLoadIndex.mockResolvedValue(mockResult); + const options = { autoRefresh: true, pollingIntervalInSeconds: 300 }; + + await helper.loadIndex('test-index', options); + + expect(mockLoadIndex).toHaveBeenCalledWith('test-index', options); + }); + }); + + describe('getJobStatus', () => { + it('should call MossClient.getJobStatus and return formatted response', async () => { + const mockStatus = { + jobId: 'job-123', + status: 'completed', + progress: 100, + currentPhase: undefined, + error: undefined, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:05:00Z' + }; + mockGetJobStatus.mockResolvedValue(mockStatus); + + const result = await helper.getJobStatus('job-123'); + + expect(mockGetJobStatus).toHaveBeenCalledWith('job-123'); + expect(result).toEqual({ + status: 'completed', + progress: 100, + currentPhase: undefined, + error: undefined + }); + }); + }); + + describe('close', () => { + it('should call MossClient.close', () => { + helper.close(); + expect(mockClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file