From 356eba9670f29552a465e2fab54a4b721c3b06d7 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 23 Jul 2025 15:37:06 +0300 Subject: [PATCH] functions - invoke style --- package.json | 3 +- src/modules/functions.ts | 85 +++----- tests/unit/functions.test.js | 390 --------------------------------- tests/unit/functions.test.ts | 411 +++++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 449 insertions(+), 442 deletions(-) delete mode 100644 tests/unit/functions.test.js create mode 100644 tests/unit/functions.test.ts diff --git a/package.json b/package.json index 507dd96..df9cc2c 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,6 @@ "bugs": { "url": "https://github.com/base44/sdk/issues" }, - "homepage": "https://github.com/base44/sdk#readme" + "homepage": "https://github.com/base44/sdk#readme", + "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" } diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 7d975c1..4dfa102 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -8,59 +8,44 @@ import { AxiosInstance } from "axios"; */ export function createFunctionsModule(axios: AxiosInstance, appId: string) { // Using nested Proxy objects to handle dynamic function names - return new Proxy( - {}, - { - get(_, functionName) { - // Skip internal properties - if ( - typeof functionName !== "string" || - functionName === "then" || - functionName.startsWith("_") - ) { - return undefined; - } + return { + async invoke(functionName: string, data: Record) { + // Validate input + if (typeof data === "string") { + throw new Error( + `Function ${functionName} must receive an object with named parameters, received: ${data}` + ); + } - // Return a function that calls the function endpoint - return async (data: Record) => { - // Validate input - if (typeof data === "string") { - throw new Error( - `Function ${functionName} must receive an object with named parameters, received: ${data}` - ); - } - - let formData: FormData | Record; - let contentType: string; + let formData: FormData | Record; + let contentType: string; - // Handle file uploads with FormData - if ( - data instanceof FormData || - (data && Object.values(data).some((value) => value instanceof File)) - ) { - formData = new FormData(); - Object.keys(data).forEach((key) => { - if (data[key] instanceof File) { - formData.append(key, data[key], data[key].name); - } else if (typeof data[key] === "object" && data[key] !== null) { - formData.append(key, JSON.stringify(data[key])); - } else { - formData.append(key, data[key]); - } - }); - contentType = "multipart/form-data"; + // Handle file uploads with FormData + if ( + data instanceof FormData || + (data && Object.values(data).some((value) => value instanceof File)) + ) { + formData = new FormData(); + Object.keys(data).forEach((key) => { + if (data[key] instanceof File) { + formData.append(key, data[key], data[key].name); + } else if (typeof data[key] === "object" && data[key] !== null) { + formData.append(key, JSON.stringify(data[key])); } else { - formData = data; - contentType = "application/json"; + formData.append(key, data[key]); } + }); + contentType = "multipart/form-data"; + } else { + formData = data; + contentType = "application/json"; + } - return axios.post( - `/apps/${appId}/functions/${functionName}`, - formData || data, - { headers: { "Content-Type": contentType } } - ); - }; - }, - } - ); + return axios.post( + `/apps/${appId}/functions/${functionName}`, + formData || data, + { headers: { "Content-Type": contentType } } + ); + }, + }; } diff --git a/tests/unit/functions.test.js b/tests/unit/functions.test.js deleted file mode 100644 index d15f9b3..0000000 --- a/tests/unit/functions.test.js +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import nock from 'nock'; -import { createClient } from '../../src/index.ts'; - -describe('Functions Module', () => { - let base44; - let scope; - const appId = 'test-app-id'; - const serverUrl = 'https://api.base44.com'; - - beforeEach(() => { - // Create a new client for each test - base44 = createClient({ - serverUrl, - appId, - }); - - // Create a nock scope for mocking API calls - scope = nock(serverUrl); - - // Enable request debugging for Nock - nock.disableNetConnect(); - nock.emitter.on('no match', (req) => { - console.log(`Nock: No match for ${req.method} ${req.path}`); - console.log('Headers:', req.getHeaders()); - }); - }); - - afterEach(() => { - // Clean up any pending mocks - nock.cleanAll(); - nock.emitter.removeAllListeners('no match'); - nock.enableNetConnect(); - }); - - test('should call a function with JSON data', async () => { - const functionName = 'sendNotification'; - const functionData = { - userId: '123', - message: 'Hello World', - priority: 'high' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - success: true, - messageId: 'msg-456' - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.success).toBe(true); - expect(result.data.messageId).toBe('msg-456'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle function with empty object parameters', async () => { - const functionName = 'getStatus'; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, {}) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - status: 'healthy', - timestamp: '2024-01-01T00:00:00Z' - }); - - // Call the function - const result = await base44.functions[functionName]({}); - - // Verify the response - expect(result.data.status).toBe('healthy'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle function with complex nested objects', async () => { - const functionName = 'processData'; - const functionData = { - user: { - id: '123', - profile: { - name: 'John Doe', - preferences: { - theme: 'dark', - notifications: true - } - } - }, - settings: { - timeout: 5000, - retries: 3 - } - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - processed: true, - userId: '123' - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle file uploads with FormData', async () => { - const functionName = 'uploadFile'; - const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); - const functionData = { - file: file, - description: 'Test file upload 2', - category: 'documents' - }; - - // Mock the API response - // TODO: Add validation to the request body - scope.post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader('Content-Type', /^multipart\/form-data/) - .reply(() => { - return [200, { - fileId: 'file-789', - filename: 'test.txt', - size: 12 - }]; - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.fileId).toBe('file-789'); - expect(result.data.filename).toBe('test.txt'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle mixed data with files and regular data', async () => { - const functionName = 'processDocument'; - const file = new File(['document content'], 'document.pdf', { type: 'application/pdf' }); - const functionData = { - file: file, - metadata: { - title: 'Important Document', - author: 'Jane Smith', - tags: ['important', 'confidential'] - }, - priority: 'high' - }; - - // Mock the API response - // TODO: Add validation to the request body - scope.post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader('Content-Type', /^multipart\/form-data/) - .reply(200, { - documentId: 'doc-123', - processed: true, - extractedText: 'document content' - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.documentId).toBe('doc-123'); - expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle FormData input directly', async () => { - const functionName = 'submitForm'; - const formData = new FormData(); - formData.append('name', 'John Doe'); - formData.append('email', 'john@example.com'); - formData.append('message', 'Hello there'); - - // Mock the API response - // TODO: Add validation to the request body - scope.post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader('Content-Type', /^multipart\/form-data/) - .reply(200, { - formId: 'form-456', - submitted: true - }); - - // Call the function - const result = await base44.functions[functionName](formData); - - // Verify the response - expect(result.data.formId).toBe('form-456'); - expect(result.data.submitted).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should throw error for string input instead of object', async () => { - const functionName = 'processData'; - - // Call the function with string input (should throw) - await expect(base44.functions[functionName]('invalid string input')) - .rejects - .toThrow(`Function ${functionName} must receive an object with named parameters, received: invalid string input`); - }); - - test('should handle function names with special characters', async () => { - const functionName = 'process-data_v2'; - const functionData = { - input: 'test data' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - processed: true - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.processed).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle API errors gracefully', async () => { - const functionName = 'failingFunction'; - const functionData = { - param: 'value' - }; - - // Mock the API error response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(500, { - error: 'Internal server error', - code: 'INTERNAL_ERROR' - }); - - // Call the function and expect it to throw - await expect(base44.functions[functionName](functionData)) - .rejects - .toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle 404 errors for non-existent functions', async () => { - const functionName = 'nonExistentFunction'; - const functionData = { - param: 'value' - }; - - // Mock the API 404 response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(404, { - error: 'Function not found', - code: 'FUNCTION_NOT_FOUND' - }); - - // Call the function and expect it to throw - await expect(base44.functions[functionName](functionData)) - .rejects - .toThrow(); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle null and undefined values in data', async () => { - const functionName = 'handleNullValues'; - const functionData = { - stringValue: 'test', - nullValue: null, - undefinedValue: undefined, - emptyString: '' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - received: true, - values: functionData - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.received).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should handle array values in data', async () => { - const functionName = 'processArray'; - const functionData = { - numbers: [1, 2, 3, 4, 5], - strings: ['a', 'b', 'c'], - mixed: [1, 'two', { three: 3 }] - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`, functionData) - .matchHeader('Content-Type', 'application/json') - .reply(200, { - processed: true, - count: 3 - }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.processed).toBe(true); - expect(result.data.count).toBe(3); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should create FormData correctly when files are present', async () => { - const functionName = 'uploadFile'; - const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); - const functionData = { - file: file, - description: 'Test file upload', - category: 'documents' - }; - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader('Content-Type', /^multipart\/form-data/) - .reply(200, { success: true }); - - // Call the function - const result = await base44.functions[functionName](functionData); - - // Verify the response - expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - - test('should create FormData correctly when FormData is passed directly', async () => { - const functionName = 'submitForm'; - const formData = new FormData(); - formData.append('name', 'John Doe'); - formData.append('email', 'john@example.com'); - - // Mock the API response - scope.post(`/api/apps/${appId}/functions/${functionName}`) - .matchHeader('Content-Type', /^multipart\/form-data/) - .reply(200, { success: true }); - - // Call the function - const result = await base44.functions[functionName](formData); - - // Verify the response - expect(result.data.success).toBe(true); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); -}); \ No newline at end of file diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts new file mode 100644 index 0000000..11101c4 --- /dev/null +++ b/tests/unit/functions.test.ts @@ -0,0 +1,411 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Functions Module", () => { + let base44: ReturnType; + let scope; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + + beforeEach(() => { + // Create a new client for each test + base44 = createClient({ + serverUrl, + appId, + }); + + // Create a nock scope for mocking API calls + scope = nock(serverUrl); + + // Enable request debugging for Nock + nock.disableNetConnect(); + nock.emitter.on("no match", (req) => { + console.log(`Nock: No match for ${req.method} ${req.path}`); + console.log("Headers:", req.getHeaders()); + }); + }); + + afterEach(() => { + // Clean up any pending mocks + nock.cleanAll(); + nock.emitter.removeAllListeners("no match"); + nock.enableNetConnect(); + }); + + test("should call a function with JSON data", async () => { + const functionName = "sendNotification"; + const functionData = { + userId: "123", + message: "Hello World", + priority: "high", + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(200, { + success: true, + messageId: "msg-456", + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.success).toBe(true); + expect(result.data.messageId).toBe("msg-456"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle function with empty object parameters", async () => { + const functionName = "getStatus"; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, {}) + .matchHeader("Content-Type", "application/json") + .reply(200, { + status: "healthy", + timestamp: "2024-01-01T00:00:00Z", + }); + + // Call the function + const result = await base44.functions.invoke(functionName, {}); + + // Verify the response + expect(result.data.status).toBe("healthy"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle function with complex nested objects", async () => { + const functionName = "processData"; + const functionData = { + user: { + id: "123", + profile: { + name: "John Doe", + preferences: { + theme: "dark", + notifications: true, + }, + }, + }, + settings: { + timeout: 5000, + retries: 3, + }, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(200, { + processed: true, + userId: "123", + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.processed).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle file uploads with FormData", async () => { + const functionName = "uploadFile"; + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + const functionData = { + file: file, + description: "Test file upload 2", + category: "documents", + }; + + // Mock the API response + // TODO: Add validation to the request body + scope + .post(`/api/apps/${appId}/functions/${functionName}`) + .matchHeader("Content-Type", /^multipart\/form-data/) + .reply(() => { + return [ + 200, + { + fileId: "file-789", + filename: "test.txt", + size: 12, + }, + ]; + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.fileId).toBe("file-789"); + expect(result.data.filename).toBe("test.txt"); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle mixed data with files and regular data", async () => { + const functionName = "processDocument"; + const file = new File(["document content"], "document.pdf", { + type: "application/pdf", + }); + const functionData = { + file: file, + metadata: { + title: "Important Document", + author: "Jane Smith", + tags: ["important", "confidential"], + }, + priority: "high", + }; + + // Mock the API response + // TODO: Add validation to the request body + scope + .post(`/api/apps/${appId}/functions/${functionName}`) + .matchHeader("Content-Type", /^multipart\/form-data/) + .reply(200, { + documentId: "doc-123", + processed: true, + extractedText: "document content", + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.documentId).toBe("doc-123"); + expect(result.data.processed).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle FormData input directly", async () => { + const functionName = "submitForm"; + const formData = new FormData(); + formData.append("name", "John Doe"); + formData.append("email", "john@example.com"); + formData.append("message", "Hello there"); + + // Mock the API response + // TODO: Add validation to the request body + scope + .post(`/api/apps/${appId}/functions/${functionName}`) + .matchHeader("Content-Type", /^multipart\/form-data/) + .reply(200, { + formId: "form-456", + submitted: true, + }); + + // Call the function + const result = await base44.functions.invoke(functionName, formData); + + // Verify the response + expect(result.data.formId).toBe("form-456"); + expect(result.data.submitted).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should throw error for string input instead of object", async () => { + const functionName = "processData"; + + // Call the function with string input (should throw) + await expect( + // @ts-expect-error + base44.functions.invoke(functionName, "invalid string input") + ).rejects.toThrow( + `Function ${functionName} must receive an object with named parameters, received: invalid string input` + ); + }); + + test("should handle function names with special characters", async () => { + const functionName = "process-data_v2"; + const functionData = { + input: "test data", + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(200, { + processed: true, + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.processed).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle API errors gracefully", async () => { + const functionName = "failingFunction"; + const functionData = { + param: "value", + }; + + // Mock the API error response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(500, { + error: "Internal server error", + code: "INTERNAL_ERROR", + }); + + // Call the function and expect it to throw + await expect( + base44.functions.invoke(functionName, functionData) + ).rejects.toThrow(); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle 404 errors for non-existent functions", async () => { + const functionName = "nonExistentFunction"; + const functionData = { + param: "value", + }; + + // Mock the API 404 response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(404, { + error: "Function not found", + code: "FUNCTION_NOT_FOUND", + }); + + // Call the function and expect it to throw + await expect( + base44.functions.invoke(functionName, functionData) + ).rejects.toThrow(); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle null and undefined values in data", async () => { + const functionName = "handleNullValues"; + const functionData = { + stringValue: "test", + nullValue: null, + undefinedValue: undefined, + emptyString: "", + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(200, { + received: true, + values: functionData, + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.received).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should handle array values in data", async () => { + const functionName = "processArray"; + const functionData = { + numbers: [1, 2, 3, 4, 5], + strings: ["a", "b", "c"], + mixed: [1, "two", { three: 3 }], + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .reply(200, { + processed: true, + count: 3, + }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.processed).toBe(true); + expect(result.data.count).toBe(3); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should create FormData correctly when files are present", async () => { + const functionName = "uploadFile"; + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + const functionData = { + file: file, + description: "Test file upload", + category: "documents", + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`) + .matchHeader("Content-Type", /^multipart\/form-data/) + .reply(200, { success: true }); + + // Call the function + const result = await base44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test("should create FormData correctly when FormData is passed directly", async () => { + const functionName = "submitForm"; + const formData = new FormData(); + formData.append("name", "John Doe"); + formData.append("email", "john@example.com"); + + // Mock the API response + scope + .post(`/api/apps/${appId}/functions/${functionName}`) + .matchHeader("Content-Type", /^multipart\/form-data/) + .reply(200, { success: true }); + + // Call the function + const result = await base44.functions.invoke(functionName, formData); + + // Verify the response + expect(result.data.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 46eb5e5..d3a67f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ environment: "node", globals: true, setupFiles: ["./tests/setup.js"], - include: ["tests/**/*.test.js"], + include: ["tests/**/*.test.js", "tests/**/*.test.ts"], coverage: { reporter: ["text", "json", "html"], },