import { describe, it, expect } from "bun:test"; import { executeGraphQL, X3RequestError } from "../src/graphql/client.js"; import { getConfig } from "../src/config.js"; import type { X3Config } from "../src/types/index.js"; function sandboxConfig(): X3Config { return { url: "https://api-devna.dev-sagex3.com/demo/service/X3CLOUDV2_SEED/api", endpoint: undefined, clientId: undefined, secret: undefined, user: undefined, tokenLifetime: 600, mode: "sandbox", tlsRejectUnauthorized: true, }; } describe("X3RequestError", () => { it("stores statusCode and message", () => { const err = new X3RequestError("test error", 401); expect(err.message).toBe("test error"); expect(err.statusCode).toBe(401); expect(err.name).toBe("X3RequestError"); expect(err.details).toBeUndefined(); }); it("stores optional details", () => { const details = { errors: [{ message: "bad query" }] }; const err = new X3RequestError("GraphQL Error", 400, details); expect(err.statusCode).toBe(400); expect(err.details).toEqual(details); }); it("is an instance of Error", () => { const err = new X3RequestError("test", 500); expect(err instanceof Error).toBe(true); expect(err instanceof X3RequestError).toBe(true); }); }); describe("executeGraphQL - mutation rejection", () => { it("rejects mutations with X3RequestError (statusCode 0)", async () => { const config = sandboxConfig(); try { await executeGraphQL(config, "mutation { deleteAll { success } }"); expect(true).toBe(false); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect(e.statusCode).toBe(0); expect(e.message).toContain("Mutations are not supported"); expect(e.message).toContain("read-only"); } }); it("rejects mutations with leading whitespace", async () => { const config = sandboxConfig(); try { await executeGraphQL(config, " \n mutation { update { id } }"); expect(true).toBe(false); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); expect((error as X3RequestError).statusCode).toBe(0); } }); it("rejects case-insensitive MUTATION keyword", async () => { const config = sandboxConfig(); try { await executeGraphQL(config, "MUTATION { doSomething { result } }"); expect(true).toBe(false); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); } }); it("does NOT reject queries that contain the word 'mutation' in a string", async () => { const config = sandboxConfig(); const query = '{ __type(name: "mutation") { name } }'; try { await executeGraphQL(config, query); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); // If we get here, it should be a network/auth error, NOT a mutation rejection expect((error as X3RequestError).statusCode).not.toBe(0); } }, 10000); }); describe("executeGraphQL - network error handling", () => { it("wraps connection errors in X3RequestError with statusCode 0", async () => { const config: X3Config = { ...sandboxConfig(), url: "https://localhost:1/nonexistent", }; try { await executeGraphQL(config, "{ __typename }"); expect(true).toBe(false); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect(e.statusCode).toBe(0); expect(e.message).toContain("Network error"); } }, 10000); }); describe("Sandbox endpoint - graceful degradation", () => { const config = sandboxConfig(); it("introspect_schema: handles endpoint error gracefully", async () => { const introspectionQuery = `{ __schema { queryType { name } types { name kind } } }`; try { const result = await executeGraphQL(config, introspectionQuery); expect(result).toBeDefined(); expect(result.data).toBeDefined(); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; // Sandbox is down (401 expired password) or network error — both are acceptable expect([0, 401, 403, 500]).toContain(e.statusCode); } }, 15000); it("query_entities: builds correct query and handles endpoint error", async () => { const query = `{ xtremX3MasterData { businessPartner { query(first: 5) { edges { node { code description1 } } pageInfo { endCursor hasNextPage startCursor hasPreviousPage } } } } }`; try { const result = await executeGraphQL(config, query); expect(result).toBeDefined(); if (result.data) { expect(result.data).toBeDefined(); } } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect([0, 401, 403, 500]).toContain(e.statusCode); } }, 15000); it("read_entity: builds correct read query with _id parameter", async () => { const query = `{ xtremX3MasterData { businessPartner { read(_id: "AE003") { code description1 } } } }`; try { const result = await executeGraphQL(config, query); expect(result).toBeDefined(); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect([0, 401, 403, 500]).toContain(e.statusCode); } }, 15000); it("aggregate_entities: builds correct readAggregate query", async () => { const query = `{ xtremX3Products { product { readAggregate { productWeight { min max count } } } } }`; try { const result = await executeGraphQL(config, query); expect(result).toBeDefined(); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect([0, 401, 403, 500]).toContain(e.statusCode); } }, 15000); it("aggregate_entities: builds correct query with filter", async () => { const query = `{ xtremX3Products { product { readAggregate(filter: "{productCategory: {code: {_eq: 'RAW'}}}") { productWeight { min max } } } } }`; try { const result = await executeGraphQL(config, query); expect(result).toBeDefined(); } catch (error) { expect(error).toBeInstanceOf(X3RequestError); const e = error as X3RequestError; expect([0, 401, 403, 500]).toContain(e.statusCode); } }, 15000); }); describe("Query shape verification", () => { it("query_entities query follows expected structure", () => { const rootType = "xtremX3MasterData"; const entity = "businessPartner"; const fields = "code description1"; const args = 'first: 10, filter: "{code: {_eq: \'ABC\'}}"'; const query = `{ ${rootType} { ${entity} { query(${args}) { edges { node { ${fields} } } pageInfo { endCursor hasNextPage startCursor hasPreviousPage } } } } }`; expect(query).toContain("xtremX3MasterData"); expect(query).toContain("businessPartner"); expect(query).toContain("query("); expect(query).toContain("first: 10"); expect(query).toContain("edges { node {"); expect(query).toContain("pageInfo {"); }); it("read_entity query includes _id parameter", () => { const rootType = "xtremX3MasterData"; const entity = "businessPartner"; const id = "AE003"; const fields = "code description1 country { code }"; const query = `{ ${rootType} { ${entity} { read(_id: "${id}") { ${fields} } } } }`; expect(query).toContain(`read(_id: "${id}")`); expect(query).toContain("country { code }"); }); it("aggregate_entities query uses readAggregate", () => { const rootType = "xtremX3Products"; const entity = "product"; const aggregateSelection = "productWeight { min max count }"; const query = `{ ${rootType} { ${entity} { readAggregate { ${aggregateSelection} } } } }`; expect(query).toContain("readAggregate"); expect(query).toContain("productWeight { min max count }"); }); it("aggregate_entities query includes filter when provided", () => { const filter = "{category: {_eq: 'RAW'}}"; const query = `{ xtremX3Products { product { readAggregate(filter: "${filter}") { productWeight { min max } } } } }`; expect(query).toContain(`filter: "${filter}"`); expect(query).toContain("readAggregate("); }); it("nested fields expand correctly in query shape", () => { const fields = "code productCategory { code name } header { name type }"; const query = `{ xtremX3Products { product { query(first: 5) { edges { node { ${fields} } } } } } }`; expect(query).toContain("productCategory { code name }"); expect(query).toContain("header { name type }"); expect(query).toContain("code"); }); }); describe("getConfig integration", () => { it("default config points to sandbox URL", () => { const savedUrl = process.env.SAGE_X3_URL; const savedMode = process.env.SAGE_X3_MODE; delete process.env.SAGE_X3_URL; delete process.env.SAGE_X3_MODE; delete process.env.SAGE_X3_CLIENT_ID; delete process.env.SAGE_X3_SECRET; delete process.env.SAGE_X3_USER; try { const config = getConfig(); expect(config.url).toBe("https://api-devna.dev-sagex3.com/demo/service/X3CLOUDV2_SEED/api"); expect(config.mode).toBe("sandbox"); } finally { if (savedUrl) process.env.SAGE_X3_URL = savedUrl; if (savedMode) process.env.SAGE_X3_MODE = savedMode; } }); });