From d83515246ea83f4e072281ce87957eaa56aea20b Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Tue, 3 Feb 2026 11:40:24 +0100 Subject: [PATCH 1/4] test: add integration test infrastructure for server and analytics plugins Signed-off-by: Jorge Calvar --- .../server/tests/server.integration.test.ts | 228 ---------- .../integration/analytics.integration.test.ts | 393 ++++++++++++++++++ .../integration/server.integration.test.ts | 241 +++++++++++ .../src/tests/integration/test-server.ts | 198 +++++++++ tools/test-helpers.ts | 112 +++++ 5 files changed, 944 insertions(+), 228 deletions(-) delete mode 100644 packages/appkit/src/server/tests/server.integration.test.ts create mode 100644 packages/appkit/src/tests/integration/analytics.integration.test.ts create mode 100644 packages/appkit/src/tests/integration/server.integration.test.ts create mode 100644 packages/appkit/src/tests/integration/test-server.ts diff --git a/packages/appkit/src/server/tests/server.integration.test.ts b/packages/appkit/src/server/tests/server.integration.test.ts deleted file mode 100644 index f897b13..0000000 --- a/packages/appkit/src/server/tests/server.integration.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { Server } from "node:http"; -import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; - -// Set required env vars BEFORE imports that use them -process.env.DATABRICKS_APP_PORT = "8000"; -process.env.FLASK_RUN_HOST = "0.0.0.0"; - -import { ServiceContext } from "../../context/service-context"; -import { createApp } from "../../core"; -import { Plugin, toPlugin } from "../../plugin"; -import { server as serverPlugin } from "../index"; - -// Integration tests - actually start server and make HTTP requests -describe("ServerPlugin Integration", () => { - let server: Server; - let baseUrl: string; - let serviceContextMock: Awaited>; - const TEST_PORT = 9876; // Use non-standard port to avoid conflicts - - beforeAll(async () => { - setupDatabricksEnv(); - ServiceContext.reset(); - serviceContextMock = await mockServiceContext(); - - const app = await createApp({ - plugins: [ - serverPlugin({ - port: TEST_PORT, - host: "127.0.0.1", - autoStart: false, - }), - ], - }); - - // Start server manually - await app.server.start(); - server = app.server.getServer(); - baseUrl = `http://127.0.0.1:${TEST_PORT}`; - - // Wait a bit for server to be ready - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(async () => { - serviceContextMock?.restore(); - if (server) { - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - } - }); - - describe("health endpoint", () => { - test("GET /health returns 200 with status ok", async () => { - const response = await fetch(`${baseUrl}/health`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ status: "ok" }); - }); - }); - - describe("API routing", () => { - test("unknown API route returns 404", async () => { - const response = await fetch(`${baseUrl}/api/nonexistent`); - - expect(response.status).toBe(404); - }); - }); - - describe("server lifecycle", () => { - test("server is listening on correct port", () => { - const address = server.address(); - - expect(address).not.toBeNull(); - if (typeof address === "object" && address !== null) { - expect(address.port).toBe(TEST_PORT); - } - }); - }); -}); - -describe("ServerPlugin with custom plugin", () => { - let server: Server; - let baseUrl: string; - let serviceContextMock: Awaited>; - const TEST_PORT = 9877; - - beforeAll(async () => { - setupDatabricksEnv(); - ServiceContext.reset(); - serviceContextMock = await mockServiceContext(); - - // Create a simple test plugin - class TestPlugin extends Plugin { - name = "test-plugin" as const; - envVars: string[] = []; - - injectRoutes(router: any) { - router.get("/echo", (_req: any, res: any) => { - res.json({ message: "hello from test plugin" }); - }); - - router.post("/echo", (req: any, res: any) => { - res.json({ received: req.body }); - }); - } - } - - const testPlugin = toPlugin( - TestPlugin, - "test-plugin", - ); - - const app = await createApp({ - plugins: [ - serverPlugin({ - port: TEST_PORT, - host: "127.0.0.1", - autoStart: false, - }), - testPlugin({}), - ], - }); - - await app.server.start(); - server = app.server.getServer(); - baseUrl = `http://127.0.0.1:${TEST_PORT}`; - - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(async () => { - serviceContextMock?.restore(); - if (server) { - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - } - }); - - test("GET /api/test-plugin/echo returns plugin response", async () => { - const response = await fetch(`${baseUrl}/api/test-plugin/echo`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ message: "hello from test plugin" }); - }); - - test("POST /api/test-plugin/echo returns posted body", async () => { - const response = await fetch(`${baseUrl}/api/test-plugin/echo`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ foo: "bar" }), - }); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ received: { foo: "bar" } }); - }); -}); - -describe("ServerPlugin with extend()", () => { - let server: Server; - let baseUrl: string; - let serviceContextMock: Awaited>; - const TEST_PORT = 9878; - - beforeAll(async () => { - setupDatabricksEnv(); - ServiceContext.reset(); - serviceContextMock = await mockServiceContext(); - - const app = await createApp({ - plugins: [ - serverPlugin({ - port: TEST_PORT, - host: "127.0.0.1", - autoStart: false, - }), - ], - }); - - // Add custom route via extend() - app.server.extend((expressApp) => { - expressApp.get("/custom", (_req, res) => { - res.json({ custom: true }); - }); - }); - - await app.server.start(); - server = app.server.getServer(); - baseUrl = `http://127.0.0.1:${TEST_PORT}`; - - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - afterAll(async () => { - serviceContextMock?.restore(); - if (server) { - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - } - }); - - test("custom route via extend() works", async () => { - const response = await fetch(`${baseUrl}/custom`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ custom: true }); - }); -}); diff --git a/packages/appkit/src/tests/integration/analytics.integration.test.ts b/packages/appkit/src/tests/integration/analytics.integration.test.ts new file mode 100644 index 0000000..b01077b --- /dev/null +++ b/packages/appkit/src/tests/integration/analytics.integration.test.ts @@ -0,0 +1,393 @@ +import { + createFailedSQLResponse, + createSuccessfulSQLResponse, + parseSSEResponse, +} from "@tools/test-helpers"; +import { sql } from "shared"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; +import { analytics } from "../../analytics"; +import { createTestServer, type TestServerResult } from "./test-server"; + +describe("Analytics Plugin Integration", () => { + let testServer: TestServerResult; + + beforeAll(async () => { + testServer = await createTestServer({ plugins: [analytics({})] }); + }); + + afterAll(async () => { + await testServer.cleanup(); + }); + + beforeEach(() => { + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockReset(); + testServer.mockWorkspaceClient.statementExecution.getStatement.mockReset(); + testServer.getAppQueryMock.mockReset(); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( + { + status: { state: "SUCCEEDED" }, + statement_id: "stmt-default", + result: { data_array: [] }, + manifest: { schema: { columns: [] } }, + }, + ); + }); + + describe("Query Execution - Success", () => { + test("should execute query and return transformed data", async () => { + const testQuery = "SELECT name, age FROM users"; + const mockData = [ + ["Alice", "30"], + ["Bob", "25"], + ]; + const mockColumns = [ + { name: "name", type_name: "STRING" }, + { name: "age", type_name: "STRING" }, + ]; + + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse(mockData, mockColumns), + ); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/test_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + + const sseData = await parseSSEResponse(response); + expect(sseData.eventType).toBe("result"); + expect(sseData.type).toBe("result"); + expect(sseData.data).toEqual([ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(1); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledWith( + expect.objectContaining({ + statement: testQuery, + warehouse_id: "test-warehouse-id", + parameters: [], + }), + expect.anything(), + ); + }); + + test("should pass SQL parameters correctly to SDK", async () => { + const testQuery = + "SELECT * FROM users WHERE id = :user_id AND active = :active"; + const mockData = [["Alice", "123", "true"]]; + const mockColumns = [ + { name: "name", type_name: "STRING" }, + { name: "id", type_name: "STRING" }, + { name: "active", type_name: "STRING" }, + ]; + + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse(mockData, mockColumns), + ); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/user_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parameters: { + user_id: sql.string("123"), + active: sql.boolean(true), + }, + }), + }, + ); + + expect(response.status).toBe(200); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(1); + + const callArgs = + testServer.mockWorkspaceClient.statementExecution.executeStatement.mock + .calls[0][0]; + expect(callArgs.statement).toBe(testQuery); + expect(callArgs.warehouse_id).toBe("test-warehouse-id"); + expect(callArgs.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "user_id", + value: "123", + type: "STRING", + }), + expect.objectContaining({ + name: "active", + value: "true", + type: "BOOLEAN", + }), + ]), + ); + expect(callArgs.parameters).toHaveLength(2); + }); + }); + + describe("Query Execution - Not Found", () => { + test("should return 404 when query file does not exist", async () => { + testServer.getAppQueryMock.mockResolvedValueOnce(null); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/nonexistent_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(404); + + const data = await response.json(); + expect(data).toEqual({ error: "Query not found" }); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(0); + }); + }); + + describe("Query Execution - Error Handling", () => { + test("should handle SDK execution failure", async () => { + const testQuery = "SELECT * FROM broken_table"; + + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + // Use mockResolvedValue (not Once) to ensure retries also fail + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( + createFailedSQLResponse("Table not found: broken_table"), + ); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/broken_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + // SSE always returns 200 initially, errors come as events + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + + const text = await response.text(); + expect(text).toContain("event: error"); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalled(); + }); + + test("should handle SDK throwing an exception", async () => { + const testQuery = "SELECT * FROM users"; + + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + // Use mockRejectedValue (not Once) to ensure retries also fail + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockRejectedValue( + new Error("Network timeout"), + ); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/timeout_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("event: error"); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalled(); + }); + }); + + describe("Query Execution - Caching", () => { + test("should cache results and not call SDK on second request", async () => { + const testQuery = "SELECT * FROM cached_data"; + const mockData = [["cached_value"]]; + const mockColumns = [{ name: "value", type_name: "STRING" }]; + + testServer.getAppQueryMock.mockResolvedValue({ + query: testQuery, + isAsUser: false, + }); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( + createSuccessfulSQLResponse(mockData, mockColumns), + ); + + const response1 = await fetch( + `${testServer.baseUrl}/api/analytics/query/cached_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + const data1 = await parseSSEResponse(response1); + + const response2 = await fetch( + `${testServer.baseUrl}/api/analytics/query/cached_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + const data2 = await parseSSEResponse(response2); + + expect(data1.data).toEqual([{ value: "cached_value" }]); + expect(data2.data).toEqual([{ value: "cached_value" }]); + + // SDK called only once - second request uses cache + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(1); + }); + + test("should use different cache keys for different parameters", async () => { + const testQuery = "SELECT * FROM users WHERE id = :id"; + + testServer.getAppQueryMock.mockResolvedValue({ + query: testQuery, + isAsUser: false, + }); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse([["Alice"]], [{ name: "name" }]), + ); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse([["Bob"]], [{ name: "name" }]), + ); + + const response1 = await fetch( + `${testServer.baseUrl}/api/analytics/query/user_by_id`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: { id: sql.string("1") } }), + }, + ); + const data1 = await parseSSEResponse(response1); + + const response2 = await fetch( + `${testServer.baseUrl}/api/analytics/query/user_by_id`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: { id: sql.string("2") } }), + }, + ); + const data2 = await parseSSEResponse(response2); + + expect(data1.data).toEqual([{ name: "Alice" }]); + expect(data2.data).toEqual([{ name: "Bob" }]); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(2); + }); + }); + + describe("Query Execution - Data Type Transformation", () => { + test("should correctly transform objects and array", async () => { + const testQuery = "SELECT * FROM mixed_data"; + const mockData = [ + ["text", "123", "45.67", "true", '{"key":"value"}', '["a","b"]'], + ]; + const mockColumns = [ + { name: "string_col", type_name: "STRING" }, + { name: "int_col", type_name: "STRING" }, + { name: "float_col", type_name: "STRING" }, + { name: "bool_col", type_name: "STRING" }, + { name: "json_obj", type_name: "STRING" }, + { name: "json_arr", type_name: "STRING" }, + ]; + + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse(mockData, mockColumns), + ); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/mixed_data`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + const sseData = await parseSSEResponse(response); + + expect(sseData.data).toHaveLength(1); + expect(sseData.data[0]).toEqual({ + string_col: "text", + int_col: "123", + float_col: "45.67", + bool_col: "true", + json_obj: { key: "value" }, + json_arr: ["a", "b"], + }); + + expect( + testServer.mockWorkspaceClient.statementExecution.executeStatement, + ).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/appkit/src/tests/integration/server.integration.test.ts b/packages/appkit/src/tests/integration/server.integration.test.ts new file mode 100644 index 0000000..347b43a --- /dev/null +++ b/packages/appkit/src/tests/integration/server.integration.test.ts @@ -0,0 +1,241 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { analytics } from "../../analytics"; +import { Plugin, toPlugin } from "../../plugin"; +import { createTestServer, type TestServerResult } from "./test-server"; + +describe("Server Plugin Integration", () => { + let testServer: TestServerResult; + + beforeAll(async () => { + testServer = await createTestServer({ plugins: [analytics({})] }); + }); + + afterAll(async () => { + await testServer.cleanup(); + }); + + describe("Health Endpoint", () => { + test("GET /health returns 200 with status ok", async () => { + const response = await fetch(`${testServer.baseUrl}/health`); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ status: "ok" }); + }); + + test("GET /health returns correct Content-Type", async () => { + const response = await fetch(`${testServer.baseUrl}/health`); + + expect(response.headers.get("Content-Type")).toMatch(/application\/json/); + }); + }); + + describe("Plugin Routing", () => { + test("analytics plugin is mounted at /api/analytics", async () => { + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: "SELECT 1", + isAsUser: false, + }); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/test`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + }); + }); + + describe("404 Handling", () => { + test("unknown API route returns 404", async () => { + const response = await fetch(`${testServer.baseUrl}/api/nonexistent`); + + expect(response.status).toBe(404); + }); + + test("unknown root route returns 404", async () => { + const response = await fetch(`${testServer.baseUrl}/unknown-path`); + + expect(response.status).toBe(404); + }); + + test("unknown analytics sub-route returns 404", async () => { + const response = await fetch( + `${testServer.baseUrl}/api/analytics/unknown-endpoint`, + ); + + expect(response.status).toBe(404); + }); + }); + + describe("Request Methods", () => { + test("analytics query endpoint only accepts POST", async () => { + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/test`, + { + method: "GET", + }, + ); + + expect(response.status).toBe(404); + }); + + test("health endpoint only accepts GET", async () => { + const response = await fetch(`${testServer.baseUrl}/health`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(404); + }); + }); + + describe("JSON Body Parsing", () => { + test("server parses JSON request bodies", async () => { + testServer.getAppQueryMock.mockResolvedValueOnce({ + query: "SELECT :value", + isAsUser: false, + }); + + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/json_test`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parameters: { value: { __sql_type: "STRING", value: "test" } }, + }), + }, + ); + + expect(response.status).toBe(200); + }); + + test("server handles invalid JSON gracefully", async () => { + const response = await fetch( + `${testServer.baseUrl}/api/analytics/query/invalid_json`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{ invalid json }", + }, + ); + + expect(response.status).toBe(400); + }); + }); +}); + +describe("Server Lifecycle", () => { + test("server cleanup properly closes connections", async () => { + const server = await createTestServer({ plugins: [] }); + const baseUrl = server.baseUrl; + await server.cleanup(); + + await expect(fetch(`${baseUrl}/health`)).rejects.toThrow(); + }); + + test("server is listening on correct port", async () => { + const testServer = await createTestServer({ plugins: [] }); + + try { + const address = testServer.server.address(); + + expect(address).not.toBeNull(); + if (typeof address === "object" && address !== null) { + expect(address.port).toBe(testServer.port); + } + } finally { + await testServer.cleanup(); + } + }); +}); + +describe("Custom Plugin Routing", () => { + let testServer: TestServerResult; + + class TestPlugin extends Plugin { + name = "test-plugin" as const; + envVars: string[] = []; + + injectRoutes(router: any) { + router.get("/echo", (_req: any, res: any) => { + res.json({ message: "hello from test plugin" }); + }); + + router.post("/echo", (req: any, res: any) => { + res.json({ received: req.body }); + }); + } + } + + const testPlugin = toPlugin( + TestPlugin, + "test-plugin", + ); + + beforeAll(async () => { + testServer = await createTestServer({ plugins: [testPlugin({})] }); + }); + + afterAll(async () => { + await testServer.cleanup(); + }); + + test("GET /api/test-plugin/echo returns plugin response", async () => { + const response = await fetch(`${testServer.baseUrl}/api/test-plugin/echo`); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ message: "hello from test plugin" }); + }); + + test("POST /api/test-plugin/echo returns posted body", async () => { + const response = await fetch(`${testServer.baseUrl}/api/test-plugin/echo`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ foo: "bar" }), + }); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ received: { foo: "bar" } }); + }); +}); + +describe("Server extend() API", () => { + let testServer: TestServerResult; + + beforeAll(async () => { + testServer = await createTestServer({ + plugins: [], + extend: (expressApp) => { + expressApp.get("/custom", (_req, res) => { + res.json({ custom: true }); + }); + }, + }); + }); + + afterAll(async () => { + await testServer.cleanup(); + }); + + test("custom route via extend() works", async () => { + const response = await fetch(`${testServer.baseUrl}/custom`); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ custom: true }); + }); +}); diff --git a/packages/appkit/src/tests/integration/test-server.ts b/packages/appkit/src/tests/integration/test-server.ts new file mode 100644 index 0000000..eee7435 --- /dev/null +++ b/packages/appkit/src/tests/integration/test-server.ts @@ -0,0 +1,198 @@ +import type { Server } from "node:http"; +import { + setupDatabricksEnv, + type TestContextOptions, +} from "@tools/test-helpers"; +import type { Mock } from "vitest"; +import { vi } from "vitest"; +import { AppManager } from "../../app"; +import { ServiceContext } from "../../context/service-context"; +import { createApp } from "../../core"; +import { server as serverPlugin } from "../../server"; + +export interface TestServerConfig { + port?: number; + executeStatementResponse?: any; + getStatementResponse?: any; + contextOptions?: TestContextOptions; + /** Plugins to include (server plugin is always added automatically) */ + plugins: any[]; + /** Function to extend the server via server.extend() before starting */ + extend?: (app: import("express").Application) => void; +} + +export interface MockWorkspaceClient { + statementExecution: { + executeStatement: Mock; + getStatement: Mock; + }; +} + +export interface TestServerResult { + server: Server; + baseUrl: string; + port: number; + mockWorkspaceClient: MockWorkspaceClient; + cleanup: () => Promise; + getAppQueryMock: Mock; +} + +const usedPorts = new Set(); + +function getAvailablePort(): number { + let port = 10000 + Math.floor(Math.random() * 10000); + while (usedPorts.has(port)) { + port++; + } + usedPorts.add(port); + return port; +} + +export function createTestWorkspaceClient( + config: TestServerConfig = {}, +): MockWorkspaceClient { + const defaultExecuteResponse = { + status: { state: "SUCCEEDED" }, + statement_id: "stmt-test-123", + result: { + data_array: [], + }, + manifest: { + schema: { + columns: [], + }, + }, + }; + + const defaultGetStatementResponse = { + status: { state: "SUCCEEDED" }, + statement_id: "stmt-test-123", + result: { + external_links: [], + }, + manifest: { + schema: { + columns: [], + }, + }, + }; + + return { + statementExecution: { + executeStatement: vi + .fn() + .mockResolvedValue( + config.executeStatementResponse ?? defaultExecuteResponse, + ), + getStatement: vi + .fn() + .mockResolvedValue( + config.getStatementResponse ?? defaultGetStatementResponse, + ), + }, + }; +} + +export async function createTestServer( + config: TestServerConfig = {}, +): Promise { + const port = config.port ?? getAvailablePort(); + + setupDatabricksEnv(); + ServiceContext.reset(); + + const mockWorkspaceClient = createTestWorkspaceClient(config); + + // Mock getAppQuery before creating the app so all plugin instances use it + const getAppQueryMock = vi.fn().mockResolvedValue(null); + const getAppQuerySpy = vi + .spyOn(AppManager.prototype, "getAppQuery") + .mockImplementation(getAppQueryMock); + + const contextModule = await import("../../context/service-context"); + + const serviceContext = { + client: mockWorkspaceClient as any, + serviceUserId: config.contextOptions?.serviceUserId ?? "test-service-user", + warehouseId: Promise.resolve( + config.contextOptions?.warehouseId ?? "test-warehouse-id", + ), + workspaceId: Promise.resolve( + config.contextOptions?.workspaceId ?? "test-workspace-id", + ), + }; + + const getSpy = vi + .spyOn(contextModule.ServiceContext, "get") + .mockReturnValue(serviceContext); + + const initSpy = vi + .spyOn(contextModule.ServiceContext, "initialize") + .mockResolvedValue(serviceContext); + + const isInitializedSpy = vi + .spyOn(contextModule.ServiceContext, "isInitialized") + .mockReturnValue(true); + + const createUserContextSpy = vi + .spyOn(contextModule.ServiceContext, "createUserContext") + .mockImplementation( + (_token: string, userId: string, userName?: string) => ({ + client: mockWorkspaceClient as any, + userId, + userName, + warehouseId: serviceContext.warehouseId, + workspaceId: serviceContext.workspaceId, + isUserContext: true, + }), + ); + + const plugins: any[] = [ + serverPlugin({ + port, + host: "127.0.0.1", + autoStart: false, + }), + ...config.plugins, + ]; + + const app = await createApp({ plugins }); + + if (config.extend) { + app.server.extend(config.extend); + } + + await app.server.start(); + const server = app.server.getServer(); + const baseUrl = `http://127.0.0.1:${port}`; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const cleanup = async () => { + getAppQuerySpy.mockRestore(); + getSpy.mockRestore(); + initSpy.mockRestore(); + isInitializedSpy.mockRestore(); + createUserContextSpy.mockRestore(); + + usedPorts.delete(port); + + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }; + + return { + server, + baseUrl, + port, + mockWorkspaceClient, + cleanup, + getAppQueryMock, + }; +} diff --git a/tools/test-helpers.ts b/tools/test-helpers.ts index 4154bc1..a83500b 100644 --- a/tools/test-helpers.ts +++ b/tools/test-helpers.ts @@ -312,3 +312,115 @@ export async function runWithRequestContext( mocks.restore(); } } + +/** + * Parses SSE response. Format: "event: result\ndata: {...}\n\n" + */ +export async function parseSSEResponse(response: Response): Promise { + const text = await response.text(); + const lines = text.split("\n"); + + let eventType: string | null = null; + let dataLine: string | null = null; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventType = line.substring(7).trim(); + } else if (line.startsWith("data: ")) { + dataLine = line.substring(6); + } + } + + if (!dataLine) { + throw new Error(`No data found in SSE response: ${text}`); + } + + const parsed = JSON.parse(dataLine); + return { + eventType, + ...parsed, + }; +} + +export interface ConfigurableMockOptions { + executeStatementResponse?: any; + getStatementResponse?: any; +} + +export function createConfigurableMockWorkspaceClient( + options: ConfigurableMockOptions = {}, +) { + const defaultExecuteResponse = { + status: { state: "SUCCEEDED" }, + statement_id: "stmt-test-123", + result: { data_array: [] }, + manifest: { schema: { columns: [] } }, + }; + + const defaultGetStatementResponse = { + status: { state: "SUCCEEDED" }, + statement_id: "stmt-test-123", + result: { external_links: [] }, + manifest: { schema: { columns: [] } }, + }; + + const executeStatement = vi + .fn() + .mockResolvedValue( + options.executeStatementResponse ?? defaultExecuteResponse, + ); + + const getStatement = vi + .fn() + .mockResolvedValue( + options.getStatementResponse ?? defaultGetStatementResponse, + ); + + const client = { + statementExecution: { + executeStatement, + getStatement, + }, + }; + + return { + client, + mocks: { + executeStatement, + getStatement, + }, + }; +} + +export function createSuccessfulSQLResponse( + data: any[][], + columns: Array<{ name: string; type_name?: string }>, +) { + return { + status: { state: "SUCCEEDED" }, + statement_id: `stmt-${Date.now()}`, + result: { + data_array: data, + }, + manifest: { + schema: { + columns: columns.map((col) => ({ + name: col.name, + type_name: col.type_name ?? "STRING", + })), + }, + }, + }; +} + +export function createFailedSQLResponse(errorMessage: string) { + return { + status: { + state: "FAILED", + error: { + message: errorMessage, + }, + }, + statement_id: `stmt-${Date.now()}`, + }; +} From d4ec65d5cd61f3995dcda3b19b23237f13667507 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Tue, 3 Feb 2026 11:59:19 +0100 Subject: [PATCH 2/4] fix: typecheck errors in test-server.ts Signed-off-by: Jorge Calvar --- .../src/tests/integration/test-server.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/appkit/src/tests/integration/test-server.ts b/packages/appkit/src/tests/integration/test-server.ts index eee7435..5067532 100644 --- a/packages/appkit/src/tests/integration/test-server.ts +++ b/packages/appkit/src/tests/integration/test-server.ts @@ -3,6 +3,7 @@ import { setupDatabricksEnv, type TestContextOptions, } from "@tools/test-helpers"; +import type { Application } from "express"; import type { Mock } from "vitest"; import { vi } from "vitest"; import { AppManager } from "../../app"; @@ -10,13 +11,20 @@ import { ServiceContext } from "../../context/service-context"; import { createApp } from "../../core"; import { server as serverPlugin } from "../../server"; +/** Minimal type for server plugin exports used in tests */ +interface ServerPluginExports { + start: () => Promise; + extend: (fn: (app: Application) => void) => void; + getServer: () => Server; +} + export interface TestServerConfig { port?: number; executeStatementResponse?: any; getStatementResponse?: any; contextOptions?: TestContextOptions; /** Plugins to include (server plugin is always added automatically) */ - plugins: any[]; + plugins?: any[]; /** Function to extend the server via server.extend() before starting */ extend?: (app: import("express").Application) => void; } @@ -153,17 +161,18 @@ export async function createTestServer( host: "127.0.0.1", autoStart: false, }), - ...config.plugins, + ...(config.plugins ?? []), ]; const app = await createApp({ plugins }); + const serverExports = app.server as unknown as ServerPluginExports; if (config.extend) { - app.server.extend(config.extend); + serverExports.extend(config.extend); } - await app.server.start(); - const server = app.server.getServer(); + await serverExports.start(); + const server = serverExports.getServer(); const baseUrl = `http://127.0.0.1:${port}`; await new Promise((resolve) => setTimeout(resolve, 50)); @@ -179,7 +188,7 @@ export async function createTestServer( if (server) { await new Promise((resolve, reject) => { - server.close((err) => { + server.close((err: Error | undefined) => { if (err) reject(err); else resolve(); }); From 31dd57b3cbf8cb38c4664cadf4d9ce89abb5c9c5 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Thu, 5 Feb 2026 12:35:45 +0100 Subject: [PATCH 3/4] test: move integration tests to colocated locations Address PR review feedback: - Delete centralized integration test infrastructure (src/tests/integration/) - Keep original simple server.integration.test.ts (from main) - Add analytics.integration.test.ts colocated with analytics plugin - Use existing mockServiceContext pattern for simpler test setup --- .../tests/analytics.integration.test.ts | 274 ++++++++++++ .../integration/analytics.integration.test.ts | 393 ------------------ .../integration/server.integration.test.ts | 241 ----------- .../src/tests/integration/test-server.ts | 207 --------- tools/test-helpers.ts | 36 +- 5 files changed, 277 insertions(+), 874 deletions(-) create mode 100644 packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts delete mode 100644 packages/appkit/src/tests/integration/analytics.integration.test.ts delete mode 100644 packages/appkit/src/tests/integration/server.integration.test.ts delete mode 100644 packages/appkit/src/tests/integration/test-server.ts diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts new file mode 100644 index 0000000..3a42eb2 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts @@ -0,0 +1,274 @@ +import type { Server } from "node:http"; +import { + createConfigurableMockWorkspaceClient, + createFailedSQLResponse, + createSuccessfulSQLResponse, + mockServiceContext, + parseSSEResponse, + setupDatabricksEnv, +} from "@tools/test-helpers"; +import { sql } from "shared"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { AppManager } from "../../../app"; +import { ServiceContext } from "../../../context/service-context"; +import { createApp } from "../../../core"; +import { server as serverPlugin } from "../../server"; +import { analytics } from "../index"; + +const getAppQuerySpy = vi.spyOn(AppManager.prototype, "getAppQuery"); + +describe("Analytics Plugin Integration", () => { + let server: Server; + let baseUrl: string; + let serviceContextMock: Awaited>; + let mockClient: ReturnType; + const TEST_PORT = 9879; + + beforeAll(async () => { + setupDatabricksEnv(); + ServiceContext.reset(); + + mockClient = createConfigurableMockWorkspaceClient(); + serviceContextMock = await mockServiceContext({ + serviceDatabricksClient: mockClient.client, + }); + + const app = await createApp({ + plugins: [ + serverPlugin({ + port: TEST_PORT, + host: "127.0.0.1", + autoStart: false, + }), + analytics({}), + ], + }); + + await app.server.start(); + server = app.server.getServer(); + baseUrl = `http://127.0.0.1:${TEST_PORT}`; + + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + afterAll(async () => { + getAppQuerySpy?.mockRestore(); + serviceContextMock?.restore(); + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); + + beforeEach(() => { + mockClient.mocks.executeStatement.mockReset(); + mockClient.mocks.getStatement.mockReset(); + getAppQuerySpy.mockReset(); + }); + + describe("Query Execution", () => { + test("should execute query and return transformed data", async () => { + const testQuery = "SELECT name, age FROM users"; + const mockData = [ + ["Alice", "30"], + ["Bob", "25"], + ]; + const mockColumns = [ + { name: "name", type_name: "STRING" }, + { name: "age", type_name: "STRING" }, + ]; + + getAppQuerySpy.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + mockClient.mocks.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse(mockData, mockColumns), + ); + + const response = await fetch( + `${baseUrl}/api/analytics/query/test_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + + const sseData = await parseSSEResponse(response); + expect(sseData.eventType).toBe("result"); + expect(sseData.data).toEqual([ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]); + + expect(mockClient.mocks.executeStatement).toHaveBeenCalledTimes(1); + expect(mockClient.mocks.executeStatement).toHaveBeenCalledWith( + expect.objectContaining({ + statement: testQuery, + warehouse_id: "test-warehouse-id", + }), + expect.anything(), + ); + }); + + test("should pass SQL parameters correctly", async () => { + const testQuery = "SELECT * FROM users WHERE id = :user_id"; + + getAppQuerySpy.mockResolvedValueOnce({ + query: testQuery, + isAsUser: false, + }); + + mockClient.mocks.executeStatement.mockResolvedValueOnce( + createSuccessfulSQLResponse([["Alice"]], [{ name: "name" }]), + ); + + const response = await fetch( + `${baseUrl}/api/analytics/query/user_query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parameters: { + user_id: sql.string("123"), + }, + }), + }, + ); + + expect(response.status).toBe(200); + + const callArgs = mockClient.mocks.executeStatement.mock.calls[0][0]; + expect(callArgs.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "user_id", + value: "123", + type: "STRING", + }), + ]), + ); + }); + }); + + describe("Query Not Found", () => { + test("should return 404 when query does not exist", async () => { + getAppQuerySpy.mockResolvedValueOnce(null); + + const response = await fetch( + `${baseUrl}/api/analytics/query/nonexistent`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + + expect(response.status).toBe(404); + const data = await response.json(); + expect(data).toEqual({ error: "Query not found" }); + + expect(mockClient.mocks.executeStatement).not.toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + test("should handle SQL execution failure", async () => { + getAppQuerySpy.mockResolvedValueOnce({ + query: "SELECT * FROM broken", + isAsUser: false, + }); + + mockClient.mocks.executeStatement.mockResolvedValue( + createFailedSQLResponse("Table not found"), + ); + + const response = await fetch(`${baseUrl}/api/analytics/query/broken`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("event: error"); + }); + + test("should handle SDK exceptions", async () => { + getAppQuerySpy.mockResolvedValueOnce({ + query: "SELECT 1", + isAsUser: false, + }); + + mockClient.mocks.executeStatement.mockRejectedValue( + new Error("Network error"), + ); + + const response = await fetch(`${baseUrl}/api/analytics/query/error`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("event: error"); + }); + }); + + describe("Caching", () => { + test("should cache results for identical requests", async () => { + const testQuery = "SELECT * FROM cached"; + + getAppQuerySpy.mockResolvedValue({ + query: testQuery, + isAsUser: false, + }); + + mockClient.mocks.executeStatement.mockResolvedValue( + createSuccessfulSQLResponse([["cached_value"]], [{ name: "value" }]), + ); + + const response1 = await fetch( + `${baseUrl}/api/analytics/query/cache_test`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + const data1 = await parseSSEResponse(response1); + + const response2 = await fetch( + `${baseUrl}/api/analytics/query/cache_test`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parameters: {} }), + }, + ); + const data2 = await parseSSEResponse(response2); + + expect(data1.data).toEqual([{ value: "cached_value" }]); + expect(data2.data).toEqual([{ value: "cached_value" }]); + expect(mockClient.mocks.executeStatement).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/appkit/src/tests/integration/analytics.integration.test.ts b/packages/appkit/src/tests/integration/analytics.integration.test.ts deleted file mode 100644 index b01077b..0000000 --- a/packages/appkit/src/tests/integration/analytics.integration.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { - createFailedSQLResponse, - createSuccessfulSQLResponse, - parseSSEResponse, -} from "@tools/test-helpers"; -import { sql } from "shared"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; -import { analytics } from "../../analytics"; -import { createTestServer, type TestServerResult } from "./test-server"; - -describe("Analytics Plugin Integration", () => { - let testServer: TestServerResult; - - beforeAll(async () => { - testServer = await createTestServer({ plugins: [analytics({})] }); - }); - - afterAll(async () => { - await testServer.cleanup(); - }); - - beforeEach(() => { - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockReset(); - testServer.mockWorkspaceClient.statementExecution.getStatement.mockReset(); - testServer.getAppQueryMock.mockReset(); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( - { - status: { state: "SUCCEEDED" }, - statement_id: "stmt-default", - result: { data_array: [] }, - manifest: { schema: { columns: [] } }, - }, - ); - }); - - describe("Query Execution - Success", () => { - test("should execute query and return transformed data", async () => { - const testQuery = "SELECT name, age FROM users"; - const mockData = [ - ["Alice", "30"], - ["Bob", "25"], - ]; - const mockColumns = [ - { name: "name", type_name: "STRING" }, - { name: "age", type_name: "STRING" }, - ]; - - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: testQuery, - isAsUser: false, - }); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( - createSuccessfulSQLResponse(mockData, mockColumns), - ); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/test_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("text/event-stream"); - - const sseData = await parseSSEResponse(response); - expect(sseData.eventType).toBe("result"); - expect(sseData.type).toBe("result"); - expect(sseData.data).toEqual([ - { name: "Alice", age: "30" }, - { name: "Bob", age: "25" }, - ]); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(1); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledWith( - expect.objectContaining({ - statement: testQuery, - warehouse_id: "test-warehouse-id", - parameters: [], - }), - expect.anything(), - ); - }); - - test("should pass SQL parameters correctly to SDK", async () => { - const testQuery = - "SELECT * FROM users WHERE id = :user_id AND active = :active"; - const mockData = [["Alice", "123", "true"]]; - const mockColumns = [ - { name: "name", type_name: "STRING" }, - { name: "id", type_name: "STRING" }, - { name: "active", type_name: "STRING" }, - ]; - - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: testQuery, - isAsUser: false, - }); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( - createSuccessfulSQLResponse(mockData, mockColumns), - ); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/user_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - parameters: { - user_id: sql.string("123"), - active: sql.boolean(true), - }, - }), - }, - ); - - expect(response.status).toBe(200); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(1); - - const callArgs = - testServer.mockWorkspaceClient.statementExecution.executeStatement.mock - .calls[0][0]; - expect(callArgs.statement).toBe(testQuery); - expect(callArgs.warehouse_id).toBe("test-warehouse-id"); - expect(callArgs.parameters).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "user_id", - value: "123", - type: "STRING", - }), - expect.objectContaining({ - name: "active", - value: "true", - type: "BOOLEAN", - }), - ]), - ); - expect(callArgs.parameters).toHaveLength(2); - }); - }); - - describe("Query Execution - Not Found", () => { - test("should return 404 when query file does not exist", async () => { - testServer.getAppQueryMock.mockResolvedValueOnce(null); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/nonexistent_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - expect(response.status).toBe(404); - - const data = await response.json(); - expect(data).toEqual({ error: "Query not found" }); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(0); - }); - }); - - describe("Query Execution - Error Handling", () => { - test("should handle SDK execution failure", async () => { - const testQuery = "SELECT * FROM broken_table"; - - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: testQuery, - isAsUser: false, - }); - - // Use mockResolvedValue (not Once) to ensure retries also fail - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( - createFailedSQLResponse("Table not found: broken_table"), - ); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/broken_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - // SSE always returns 200 initially, errors come as events - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("text/event-stream"); - - const text = await response.text(); - expect(text).toContain("event: error"); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalled(); - }); - - test("should handle SDK throwing an exception", async () => { - const testQuery = "SELECT * FROM users"; - - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: testQuery, - isAsUser: false, - }); - - // Use mockRejectedValue (not Once) to ensure retries also fail - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockRejectedValue( - new Error("Network timeout"), - ); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/timeout_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toContain("event: error"); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalled(); - }); - }); - - describe("Query Execution - Caching", () => { - test("should cache results and not call SDK on second request", async () => { - const testQuery = "SELECT * FROM cached_data"; - const mockData = [["cached_value"]]; - const mockColumns = [{ name: "value", type_name: "STRING" }]; - - testServer.getAppQueryMock.mockResolvedValue({ - query: testQuery, - isAsUser: false, - }); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValue( - createSuccessfulSQLResponse(mockData, mockColumns), - ); - - const response1 = await fetch( - `${testServer.baseUrl}/api/analytics/query/cached_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - const data1 = await parseSSEResponse(response1); - - const response2 = await fetch( - `${testServer.baseUrl}/api/analytics/query/cached_query`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - const data2 = await parseSSEResponse(response2); - - expect(data1.data).toEqual([{ value: "cached_value" }]); - expect(data2.data).toEqual([{ value: "cached_value" }]); - - // SDK called only once - second request uses cache - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(1); - }); - - test("should use different cache keys for different parameters", async () => { - const testQuery = "SELECT * FROM users WHERE id = :id"; - - testServer.getAppQueryMock.mockResolvedValue({ - query: testQuery, - isAsUser: false, - }); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( - createSuccessfulSQLResponse([["Alice"]], [{ name: "name" }]), - ); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( - createSuccessfulSQLResponse([["Bob"]], [{ name: "name" }]), - ); - - const response1 = await fetch( - `${testServer.baseUrl}/api/analytics/query/user_by_id`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: { id: sql.string("1") } }), - }, - ); - const data1 = await parseSSEResponse(response1); - - const response2 = await fetch( - `${testServer.baseUrl}/api/analytics/query/user_by_id`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: { id: sql.string("2") } }), - }, - ); - const data2 = await parseSSEResponse(response2); - - expect(data1.data).toEqual([{ name: "Alice" }]); - expect(data2.data).toEqual([{ name: "Bob" }]); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(2); - }); - }); - - describe("Query Execution - Data Type Transformation", () => { - test("should correctly transform objects and array", async () => { - const testQuery = "SELECT * FROM mixed_data"; - const mockData = [ - ["text", "123", "45.67", "true", '{"key":"value"}', '["a","b"]'], - ]; - const mockColumns = [ - { name: "string_col", type_name: "STRING" }, - { name: "int_col", type_name: "STRING" }, - { name: "float_col", type_name: "STRING" }, - { name: "bool_col", type_name: "STRING" }, - { name: "json_obj", type_name: "STRING" }, - { name: "json_arr", type_name: "STRING" }, - ]; - - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: testQuery, - isAsUser: false, - }); - - testServer.mockWorkspaceClient.statementExecution.executeStatement.mockResolvedValueOnce( - createSuccessfulSQLResponse(mockData, mockColumns), - ); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/mixed_data`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - const sseData = await parseSSEResponse(response); - - expect(sseData.data).toHaveLength(1); - expect(sseData.data[0]).toEqual({ - string_col: "text", - int_col: "123", - float_col: "45.67", - bool_col: "true", - json_obj: { key: "value" }, - json_arr: ["a", "b"], - }); - - expect( - testServer.mockWorkspaceClient.statementExecution.executeStatement, - ).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/appkit/src/tests/integration/server.integration.test.ts b/packages/appkit/src/tests/integration/server.integration.test.ts deleted file mode 100644 index 347b43a..0000000 --- a/packages/appkit/src/tests/integration/server.integration.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { analytics } from "../../analytics"; -import { Plugin, toPlugin } from "../../plugin"; -import { createTestServer, type TestServerResult } from "./test-server"; - -describe("Server Plugin Integration", () => { - let testServer: TestServerResult; - - beforeAll(async () => { - testServer = await createTestServer({ plugins: [analytics({})] }); - }); - - afterAll(async () => { - await testServer.cleanup(); - }); - - describe("Health Endpoint", () => { - test("GET /health returns 200 with status ok", async () => { - const response = await fetch(`${testServer.baseUrl}/health`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ status: "ok" }); - }); - - test("GET /health returns correct Content-Type", async () => { - const response = await fetch(`${testServer.baseUrl}/health`); - - expect(response.headers.get("Content-Type")).toMatch(/application\/json/); - }); - }); - - describe("Plugin Routing", () => { - test("analytics plugin is mounted at /api/analytics", async () => { - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: "SELECT 1", - isAsUser: false, - }); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/test`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parameters: {} }), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("text/event-stream"); - }); - }); - - describe("404 Handling", () => { - test("unknown API route returns 404", async () => { - const response = await fetch(`${testServer.baseUrl}/api/nonexistent`); - - expect(response.status).toBe(404); - }); - - test("unknown root route returns 404", async () => { - const response = await fetch(`${testServer.baseUrl}/unknown-path`); - - expect(response.status).toBe(404); - }); - - test("unknown analytics sub-route returns 404", async () => { - const response = await fetch( - `${testServer.baseUrl}/api/analytics/unknown-endpoint`, - ); - - expect(response.status).toBe(404); - }); - }); - - describe("Request Methods", () => { - test("analytics query endpoint only accepts POST", async () => { - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/test`, - { - method: "GET", - }, - ); - - expect(response.status).toBe(404); - }); - - test("health endpoint only accepts GET", async () => { - const response = await fetch(`${testServer.baseUrl}/health`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(response.status).toBe(404); - }); - }); - - describe("JSON Body Parsing", () => { - test("server parses JSON request bodies", async () => { - testServer.getAppQueryMock.mockResolvedValueOnce({ - query: "SELECT :value", - isAsUser: false, - }); - - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/json_test`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - parameters: { value: { __sql_type: "STRING", value: "test" } }, - }), - }, - ); - - expect(response.status).toBe(200); - }); - - test("server handles invalid JSON gracefully", async () => { - const response = await fetch( - `${testServer.baseUrl}/api/analytics/query/invalid_json`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{ invalid json }", - }, - ); - - expect(response.status).toBe(400); - }); - }); -}); - -describe("Server Lifecycle", () => { - test("server cleanup properly closes connections", async () => { - const server = await createTestServer({ plugins: [] }); - const baseUrl = server.baseUrl; - await server.cleanup(); - - await expect(fetch(`${baseUrl}/health`)).rejects.toThrow(); - }); - - test("server is listening on correct port", async () => { - const testServer = await createTestServer({ plugins: [] }); - - try { - const address = testServer.server.address(); - - expect(address).not.toBeNull(); - if (typeof address === "object" && address !== null) { - expect(address.port).toBe(testServer.port); - } - } finally { - await testServer.cleanup(); - } - }); -}); - -describe("Custom Plugin Routing", () => { - let testServer: TestServerResult; - - class TestPlugin extends Plugin { - name = "test-plugin" as const; - envVars: string[] = []; - - injectRoutes(router: any) { - router.get("/echo", (_req: any, res: any) => { - res.json({ message: "hello from test plugin" }); - }); - - router.post("/echo", (req: any, res: any) => { - res.json({ received: req.body }); - }); - } - } - - const testPlugin = toPlugin( - TestPlugin, - "test-plugin", - ); - - beforeAll(async () => { - testServer = await createTestServer({ plugins: [testPlugin({})] }); - }); - - afterAll(async () => { - await testServer.cleanup(); - }); - - test("GET /api/test-plugin/echo returns plugin response", async () => { - const response = await fetch(`${testServer.baseUrl}/api/test-plugin/echo`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ message: "hello from test plugin" }); - }); - - test("POST /api/test-plugin/echo returns posted body", async () => { - const response = await fetch(`${testServer.baseUrl}/api/test-plugin/echo`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ foo: "bar" }), - }); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ received: { foo: "bar" } }); - }); -}); - -describe("Server extend() API", () => { - let testServer: TestServerResult; - - beforeAll(async () => { - testServer = await createTestServer({ - plugins: [], - extend: (expressApp) => { - expressApp.get("/custom", (_req, res) => { - res.json({ custom: true }); - }); - }, - }); - }); - - afterAll(async () => { - await testServer.cleanup(); - }); - - test("custom route via extend() works", async () => { - const response = await fetch(`${testServer.baseUrl}/custom`); - - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ custom: true }); - }); -}); diff --git a/packages/appkit/src/tests/integration/test-server.ts b/packages/appkit/src/tests/integration/test-server.ts deleted file mode 100644 index 5067532..0000000 --- a/packages/appkit/src/tests/integration/test-server.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { Server } from "node:http"; -import { - setupDatabricksEnv, - type TestContextOptions, -} from "@tools/test-helpers"; -import type { Application } from "express"; -import type { Mock } from "vitest"; -import { vi } from "vitest"; -import { AppManager } from "../../app"; -import { ServiceContext } from "../../context/service-context"; -import { createApp } from "../../core"; -import { server as serverPlugin } from "../../server"; - -/** Minimal type for server plugin exports used in tests */ -interface ServerPluginExports { - start: () => Promise; - extend: (fn: (app: Application) => void) => void; - getServer: () => Server; -} - -export interface TestServerConfig { - port?: number; - executeStatementResponse?: any; - getStatementResponse?: any; - contextOptions?: TestContextOptions; - /** Plugins to include (server plugin is always added automatically) */ - plugins?: any[]; - /** Function to extend the server via server.extend() before starting */ - extend?: (app: import("express").Application) => void; -} - -export interface MockWorkspaceClient { - statementExecution: { - executeStatement: Mock; - getStatement: Mock; - }; -} - -export interface TestServerResult { - server: Server; - baseUrl: string; - port: number; - mockWorkspaceClient: MockWorkspaceClient; - cleanup: () => Promise; - getAppQueryMock: Mock; -} - -const usedPorts = new Set(); - -function getAvailablePort(): number { - let port = 10000 + Math.floor(Math.random() * 10000); - while (usedPorts.has(port)) { - port++; - } - usedPorts.add(port); - return port; -} - -export function createTestWorkspaceClient( - config: TestServerConfig = {}, -): MockWorkspaceClient { - const defaultExecuteResponse = { - status: { state: "SUCCEEDED" }, - statement_id: "stmt-test-123", - result: { - data_array: [], - }, - manifest: { - schema: { - columns: [], - }, - }, - }; - - const defaultGetStatementResponse = { - status: { state: "SUCCEEDED" }, - statement_id: "stmt-test-123", - result: { - external_links: [], - }, - manifest: { - schema: { - columns: [], - }, - }, - }; - - return { - statementExecution: { - executeStatement: vi - .fn() - .mockResolvedValue( - config.executeStatementResponse ?? defaultExecuteResponse, - ), - getStatement: vi - .fn() - .mockResolvedValue( - config.getStatementResponse ?? defaultGetStatementResponse, - ), - }, - }; -} - -export async function createTestServer( - config: TestServerConfig = {}, -): Promise { - const port = config.port ?? getAvailablePort(); - - setupDatabricksEnv(); - ServiceContext.reset(); - - const mockWorkspaceClient = createTestWorkspaceClient(config); - - // Mock getAppQuery before creating the app so all plugin instances use it - const getAppQueryMock = vi.fn().mockResolvedValue(null); - const getAppQuerySpy = vi - .spyOn(AppManager.prototype, "getAppQuery") - .mockImplementation(getAppQueryMock); - - const contextModule = await import("../../context/service-context"); - - const serviceContext = { - client: mockWorkspaceClient as any, - serviceUserId: config.contextOptions?.serviceUserId ?? "test-service-user", - warehouseId: Promise.resolve( - config.contextOptions?.warehouseId ?? "test-warehouse-id", - ), - workspaceId: Promise.resolve( - config.contextOptions?.workspaceId ?? "test-workspace-id", - ), - }; - - const getSpy = vi - .spyOn(contextModule.ServiceContext, "get") - .mockReturnValue(serviceContext); - - const initSpy = vi - .spyOn(contextModule.ServiceContext, "initialize") - .mockResolvedValue(serviceContext); - - const isInitializedSpy = vi - .spyOn(contextModule.ServiceContext, "isInitialized") - .mockReturnValue(true); - - const createUserContextSpy = vi - .spyOn(contextModule.ServiceContext, "createUserContext") - .mockImplementation( - (_token: string, userId: string, userName?: string) => ({ - client: mockWorkspaceClient as any, - userId, - userName, - warehouseId: serviceContext.warehouseId, - workspaceId: serviceContext.workspaceId, - isUserContext: true, - }), - ); - - const plugins: any[] = [ - serverPlugin({ - port, - host: "127.0.0.1", - autoStart: false, - }), - ...(config.plugins ?? []), - ]; - - const app = await createApp({ plugins }); - const serverExports = app.server as unknown as ServerPluginExports; - - if (config.extend) { - serverExports.extend(config.extend); - } - - await serverExports.start(); - const server = serverExports.getServer(); - const baseUrl = `http://127.0.0.1:${port}`; - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const cleanup = async () => { - getAppQuerySpy.mockRestore(); - getSpy.mockRestore(); - initSpy.mockRestore(); - isInitializedSpy.mockRestore(); - createUserContextSpy.mockRestore(); - - usedPorts.delete(port); - - if (server) { - await new Promise((resolve, reject) => { - server.close((err: Error | undefined) => { - if (err) reject(err); - else resolve(); - }); - }); - } - }; - - return { - server, - baseUrl, - port, - mockWorkspaceClient, - cleanup, - getAppQueryMock, - }; -} diff --git a/tools/test-helpers.ts b/tools/test-helpers.ts index a83500b..9967df7 100644 --- a/tools/test-helpers.ts +++ b/tools/test-helpers.ts @@ -342,39 +342,9 @@ export async function parseSSEResponse(response: Response): Promise { }; } -export interface ConfigurableMockOptions { - executeStatementResponse?: any; - getStatementResponse?: any; -} - -export function createConfigurableMockWorkspaceClient( - options: ConfigurableMockOptions = {}, -) { - const defaultExecuteResponse = { - status: { state: "SUCCEEDED" }, - statement_id: "stmt-test-123", - result: { data_array: [] }, - manifest: { schema: { columns: [] } }, - }; - - const defaultGetStatementResponse = { - status: { state: "SUCCEEDED" }, - statement_id: "stmt-test-123", - result: { external_links: [] }, - manifest: { schema: { columns: [] } }, - }; - - const executeStatement = vi - .fn() - .mockResolvedValue( - options.executeStatementResponse ?? defaultExecuteResponse, - ); - - const getStatement = vi - .fn() - .mockResolvedValue( - options.getStatementResponse ?? defaultGetStatementResponse, - ); +export function createConfigurableMockWorkspaceClient() { + const executeStatement = vi.fn(); + const getStatement = vi.fn(); const client = { statementExecution: { From a41fac5def4ba788400bf95dfff57db32b65f7fe Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Feb 2026 10:27:28 +0100 Subject: [PATCH 4/4] test: removed unneeded wait line --- .../src/plugins/analytics/tests/analytics.integration.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts index 3a42eb2..cb73394 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts @@ -55,8 +55,6 @@ describe("Analytics Plugin Integration", () => { await app.server.start(); server = app.server.getServer(); baseUrl = `http://127.0.0.1:${TEST_PORT}`; - - await new Promise((resolve) => setTimeout(resolve, 100)); }); afterAll(async () => {