feat: everything
This commit is contained in:
95
tests/auth.test.ts
Normal file
95
tests/auth.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { generateJWT, getAuthHeaders } from "../src/auth/index.js";
|
||||
import type { X3Config } from "../src/config.js";
|
||||
|
||||
function makeConfig(overrides: Partial<X3Config> = {}): X3Config {
|
||||
return {
|
||||
url: "https://example.com/api",
|
||||
endpoint: "SEED",
|
||||
clientId: "test-client-id",
|
||||
secret: "test-secret-key-for-hs256",
|
||||
user: "TEST_USER",
|
||||
tokenLifetime: 600,
|
||||
mode: "authenticated",
|
||||
tlsRejectUnauthorized: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const parts = token.split(".");
|
||||
const payload = Buffer.from(parts[1], "base64url").toString();
|
||||
return JSON.parse(payload);
|
||||
}
|
||||
|
||||
describe("generateJWT", () => {
|
||||
it("produces a token with 3 dot-separated parts", async () => {
|
||||
const token = await generateJWT(makeConfig());
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(parts.every((p) => p.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes correct claims in the payload", async () => {
|
||||
const config = makeConfig({ tokenLifetime: 900 });
|
||||
const beforeTime = Math.floor(Date.now() / 1000) - 30;
|
||||
const token = await generateJWT(config);
|
||||
const afterTime = Math.floor(Date.now() / 1000) - 30;
|
||||
|
||||
const claims = decodeJwtPayload(token);
|
||||
|
||||
expect(claims.iss).toBe("test-client-id");
|
||||
expect(claims.sub).toBe("TEST_USER");
|
||||
expect(claims.aud).toBe("");
|
||||
|
||||
const iat = claims.iat as number;
|
||||
expect(iat).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(iat).toBeLessThanOrEqual(afterTime);
|
||||
|
||||
expect(claims.exp).toBe(iat + 900);
|
||||
});
|
||||
|
||||
it("throws when clientId is missing", async () => {
|
||||
expect(generateJWT(makeConfig({ clientId: undefined }))).rejects.toThrow(
|
||||
"clientId, secret, and user are required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when secret is missing", async () => {
|
||||
expect(generateJWT(makeConfig({ secret: undefined }))).rejects.toThrow(
|
||||
"clientId, secret, and user are required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when user is missing", async () => {
|
||||
expect(generateJWT(makeConfig({ user: undefined }))).rejects.toThrow(
|
||||
"clientId, secret, and user are required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthHeaders", () => {
|
||||
it("returns only Content-Type in sandbox mode", async () => {
|
||||
const headers = await getAuthHeaders(makeConfig({ mode: "sandbox" }));
|
||||
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
expect(headers["x-xtrem-endpoint"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns Authorization and x-xtrem-endpoint in authenticated mode", async () => {
|
||||
const headers = await getAuthHeaders(makeConfig());
|
||||
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
expect(headers["Authorization"]).toStartWith("Bearer ");
|
||||
expect(headers["x-xtrem-endpoint"]).toBe("SEED");
|
||||
});
|
||||
|
||||
it("sets x-xtrem-endpoint to empty string when endpoint is undefined", async () => {
|
||||
const headers = await getAuthHeaders(
|
||||
makeConfig({ endpoint: undefined }),
|
||||
);
|
||||
|
||||
expect(headers["x-xtrem-endpoint"]).toBe("");
|
||||
});
|
||||
});
|
||||
117
tests/config.test.ts
Normal file
117
tests/config.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, afterEach } from "bun:test";
|
||||
import { getConfig } from "../src/config.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"SAGE_X3_URL",
|
||||
"SAGE_X3_ENDPOINT",
|
||||
"SAGE_X3_CLIENT_ID",
|
||||
"SAGE_X3_SECRET",
|
||||
"SAGE_X3_USER",
|
||||
"SAGE_X3_TOKEN_LIFETIME",
|
||||
"SAGE_X3_TLS_REJECT_UNAUTHORIZED",
|
||||
"SAGE_X3_MODE",
|
||||
] as const;
|
||||
|
||||
function clearSageEnv() {
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
describe("getConfig", () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns sandbox defaults when no env vars are set", () => {
|
||||
clearSageEnv();
|
||||
const config = getConfig();
|
||||
|
||||
expect(config.mode).toBe("sandbox");
|
||||
expect(config.url).toBe(
|
||||
"https://api-devna.dev-sagex3.com/demo/service/X3CLOUDV2_SEED/api",
|
||||
);
|
||||
expect(config.tokenLifetime).toBe(600);
|
||||
expect(config.clientId).toBeUndefined();
|
||||
expect(config.secret).toBeUndefined();
|
||||
expect(config.user).toBeUndefined();
|
||||
expect(config.endpoint).toBeUndefined();
|
||||
expect(config.tlsRejectUnauthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("returns authenticated mode when all credentials are set", () => {
|
||||
clearSageEnv();
|
||||
process.env.SAGE_X3_URL = "https://my-x3.example.com/api";
|
||||
process.env.SAGE_X3_ENDPOINT = "SEED";
|
||||
process.env.SAGE_X3_CLIENT_ID = "test-client";
|
||||
process.env.SAGE_X3_SECRET = "test-secret";
|
||||
process.env.SAGE_X3_USER = "admin";
|
||||
process.env.SAGE_X3_TOKEN_LIFETIME = "300";
|
||||
process.env.SAGE_X3_TLS_REJECT_UNAUTHORIZED = "false";
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
expect(config.mode).toBe("authenticated");
|
||||
expect(config.url).toBe("https://my-x3.example.com/api");
|
||||
expect(config.endpoint).toBe("SEED");
|
||||
expect(config.clientId).toBe("test-client");
|
||||
expect(config.secret).toBe("test-secret");
|
||||
expect(config.user).toBe("admin");
|
||||
expect(config.tokenLifetime).toBe(300);
|
||||
expect(config.tlsRejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-detects authenticated mode when clientId, secret, and user are present", () => {
|
||||
clearSageEnv();
|
||||
process.env.SAGE_X3_CLIENT_ID = "cid";
|
||||
process.env.SAGE_X3_SECRET = "sec";
|
||||
process.env.SAGE_X3_USER = "usr";
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.mode).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("stays sandbox when only some credentials are set", () => {
|
||||
clearSageEnv();
|
||||
process.env.SAGE_X3_CLIENT_ID = "cid";
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.mode).toBe("sandbox");
|
||||
});
|
||||
|
||||
it("respects SAGE_X3_MODE override even with full credentials", () => {
|
||||
clearSageEnv();
|
||||
process.env.SAGE_X3_CLIENT_ID = "cid";
|
||||
process.env.SAGE_X3_SECRET = "sec";
|
||||
process.env.SAGE_X3_USER = "usr";
|
||||
process.env.SAGE_X3_MODE = "sandbox";
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.mode).toBe("sandbox");
|
||||
});
|
||||
|
||||
it("defaults TLS reject to true when env var is absent", () => {
|
||||
clearSageEnv();
|
||||
const config = getConfig();
|
||||
expect(config.tlsRejectUnauthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("sets TLS reject to false when env var is 'false'", () => {
|
||||
clearSageEnv();
|
||||
process.env.SAGE_X3_TLS_REJECT_UNAUTHORIZED = "false";
|
||||
const config = getConfig();
|
||||
expect(config.tlsRejectUnauthorized).toBe(false);
|
||||
});
|
||||
});
|
||||
179
tests/graphql-client.test.ts
Normal file
179
tests/graphql-client.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, mock, afterEach } from "bun:test";
|
||||
import { executeGraphQL, X3RequestError } from "../src/graphql/client.js";
|
||||
import type { X3Config } from "../src/config.js";
|
||||
|
||||
function makeConfig(overrides: Partial<X3Config> = {}): X3Config {
|
||||
return {
|
||||
url: "https://example.com/api",
|
||||
endpoint: "SEED",
|
||||
clientId: "test-client-id",
|
||||
secret: "test-secret-key-for-hs256",
|
||||
user: "TEST_USER",
|
||||
tokenLifetime: 600,
|
||||
mode: "authenticated",
|
||||
tlsRejectUnauthorized: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeGraphQL — mutation guard", () => {
|
||||
it("rejects queries starting with 'mutation'", () => {
|
||||
expect(
|
||||
executeGraphQL(makeConfig(), 'mutation { createItem(input: {}) { id } }'),
|
||||
).rejects.toThrow("read-only");
|
||||
});
|
||||
|
||||
it("rejects case-insensitive 'MUTATION'", () => {
|
||||
expect(
|
||||
executeGraphQL(makeConfig(), "MUTATION { deleteItem(id: 1) { ok } }"),
|
||||
).rejects.toThrow("read-only");
|
||||
});
|
||||
|
||||
it("rejects mutation with leading whitespace", () => {
|
||||
expect(
|
||||
executeGraphQL(makeConfig(), " mutation { updateItem { id } }"),
|
||||
).rejects.toThrow("read-only");
|
||||
});
|
||||
|
||||
it("rejects mixed-case 'Mutation'", () => {
|
||||
expect(
|
||||
executeGraphQL(makeConfig(), "Mutation { foo { bar } }"),
|
||||
).rejects.toThrow("read-only");
|
||||
});
|
||||
|
||||
it("throws an X3RequestError with statusCode 0", async () => {
|
||||
try {
|
||||
await executeGraphQL(makeConfig(), "mutation { foo }");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(X3RequestError);
|
||||
expect((err as X3RequestError).statusCode).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeGraphQL — valid queries", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("does not reject a standard query", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { test: true } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await executeGraphQL(makeConfig(), "{ xtremX3MasterData { company { edges { node { id } } } } }");
|
||||
expect(result.data).toEqual({ test: true });
|
||||
});
|
||||
|
||||
it("does not reject a query with 'query' keyword", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { items: [] } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await executeGraphQL(
|
||||
makeConfig(),
|
||||
"query { xtremX3Stock { stockDetail { edges { node { id } } } } }",
|
||||
);
|
||||
expect(result.data).toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeGraphQL — HTTP error handling", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("throws X3RequestError on 401", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response("Unauthorized", { status: 401 })),
|
||||
) as typeof fetch;
|
||||
|
||||
try {
|
||||
await executeGraphQL(makeConfig(), "{ test }");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(X3RequestError);
|
||||
expect((err as X3RequestError).statusCode).toBe(401);
|
||||
expect((err as X3RequestError).message).toContain("Authentication failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws X3RequestError on 500 with structured body", async () => {
|
||||
const errorBody = {
|
||||
$message: "Division by zero",
|
||||
$source: "X3RUNTIME",
|
||||
};
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(errorBody), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
try {
|
||||
await executeGraphQL(makeConfig(), "{ test }");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(X3RequestError);
|
||||
expect((err as X3RequestError).statusCode).toBe(500);
|
||||
expect((err as X3RequestError).message).toContain("Division by zero");
|
||||
expect((err as X3RequestError).message).toContain("X3RUNTIME");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws X3RequestError on 400 with GraphQL errors", async () => {
|
||||
const errorBody = {
|
||||
errors: [{ message: "Field 'foo' not found" }],
|
||||
};
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(errorBody), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
try {
|
||||
await executeGraphQL(makeConfig(), "{ foo }");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(X3RequestError);
|
||||
expect((err as X3RequestError).statusCode).toBe(400);
|
||||
expect((err as X3RequestError).message).toContain("Field 'foo' not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws X3RequestError on network failure", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error("ECONNREFUSED")),
|
||||
) as typeof fetch;
|
||||
|
||||
try {
|
||||
await executeGraphQL(makeConfig(), "{ test }");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(X3RequestError);
|
||||
expect((err as X3RequestError).statusCode).toBe(0);
|
||||
expect((err as X3RequestError).message).toContain("Network error");
|
||||
expect((err as X3RequestError).message).toContain("ECONNREFUSED");
|
||||
}
|
||||
});
|
||||
});
|
||||
270
tests/integration.test.ts
Normal file
270
tests/integration.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
235
tests/mcp-server.test.ts
Normal file
235
tests/mcp-server.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, afterAll } from "bun:test";
|
||||
import type { Subprocess } from "bun";
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
class McpTestClient {
|
||||
private proc: Subprocess<"pipe", "pipe", "pipe">;
|
||||
private buffer = "";
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.proc = Bun.spawn(["bun", "run", "src/index.ts"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, SAGE_X3_MODE: "sandbox" },
|
||||
});
|
||||
}
|
||||
|
||||
private async send(message: JsonRpcRequest): Promise<void> {
|
||||
const data = JSON.stringify(message) + "\n";
|
||||
this.proc.stdin.write(data);
|
||||
await this.proc.stdin.flush();
|
||||
}
|
||||
|
||||
// Reads chunks from stdout until a complete JSON-RPC line is found.
|
||||
// Uses racing with timeout to avoid blocking forever on a hung process.
|
||||
private async readResponse(timeoutMs = 10000): Promise<JsonRpcResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const reader = this.proc.stdout.getReader();
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const newlineIdx = this.buffer.indexOf("\n");
|
||||
if (newlineIdx !== -1) {
|
||||
const line = this.buffer.slice(0, newlineIdx).trim();
|
||||
this.buffer = this.buffer.slice(newlineIdx + 1);
|
||||
if (line.length > 0) {
|
||||
try {
|
||||
return JSON.parse(line) as JsonRpcResponse;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<{ done: true; value: undefined }>((resolve) =>
|
||||
setTimeout(() => resolve({ done: true, value: undefined }), Math.max(100, deadline - Date.now())),
|
||||
),
|
||||
]);
|
||||
|
||||
if (result.done && !result.value) continue;
|
||||
if (result.value) {
|
||||
this.buffer += new TextDecoder().decode(result.value);
|
||||
}
|
||||
if (result.done) break;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for JSON-RPC response after ${timeoutMs}ms. Buffer: ${this.buffer.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
async initialize(): Promise<JsonRpcResponse> {
|
||||
await this.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
const response = await this.readResponse();
|
||||
|
||||
// notifications/initialized has no id → no response expected
|
||||
await this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
||||
await Bun.sleep(100);
|
||||
|
||||
this.initialized = true;
|
||||
return response;
|
||||
}
|
||||
|
||||
async request(id: number, method: string, params?: Record<string, unknown>): Promise<JsonRpcResponse> {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Must call initialize() before sending requests");
|
||||
}
|
||||
await this.send({ jsonrpc: "2.0", id, method, params });
|
||||
return this.readResponse();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try { this.proc.stdin.end(); } catch { /* already closed */ }
|
||||
this.proc.kill();
|
||||
await Promise.race([this.proc.exited, Bun.sleep(3000)]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("MCP Server Protocol", () => {
|
||||
let client: McpTestClient;
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
it("starts and responds to initialize handshake", async () => {
|
||||
client = new McpTestClient();
|
||||
const response = await client.initialize();
|
||||
|
||||
expect(response.jsonrpc).toBe("2.0");
|
||||
expect(response.id).toBe(1);
|
||||
expect(response.error).toBeUndefined();
|
||||
expect(response.result).toBeDefined();
|
||||
|
||||
const result = response.result as {
|
||||
protocolVersion: string;
|
||||
capabilities: Record<string, unknown>;
|
||||
serverInfo: { name: string; version: string };
|
||||
};
|
||||
|
||||
expect(result.protocolVersion).toBeDefined();
|
||||
expect(result.serverInfo).toBeDefined();
|
||||
expect(result.serverInfo.name).toBe("sage-x3-graphql");
|
||||
expect(result.serverInfo.version).toBe("0.1.0");
|
||||
expect(result.capabilities).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it("returns 5 tools from tools/list", async () => {
|
||||
const response = await client.request(2, "tools/list");
|
||||
|
||||
expect(response.jsonrpc).toBe("2.0");
|
||||
expect(response.id).toBe(2);
|
||||
expect(response.error).toBeUndefined();
|
||||
|
||||
const result = response.result as { tools: Array<{ name: string; description: string }> };
|
||||
expect(result.tools).toBeDefined();
|
||||
expect(Array.isArray(result.tools)).toBe(true);
|
||||
expect(result.tools.length).toBe(5);
|
||||
|
||||
const toolNames = result.tools.map((t) => t.name).sort();
|
||||
expect(toolNames).toEqual([
|
||||
"aggregate_entities",
|
||||
"execute_graphql",
|
||||
"introspect_schema",
|
||||
"query_entities",
|
||||
"read_entity",
|
||||
]);
|
||||
|
||||
for (const tool of result.tools) {
|
||||
expect(tool.description).toBeTruthy();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("returns 5 resources from resources/list", async () => {
|
||||
const response = await client.request(3, "resources/list");
|
||||
|
||||
expect(response.jsonrpc).toBe("2.0");
|
||||
expect(response.id).toBe(3);
|
||||
expect(response.error).toBeUndefined();
|
||||
|
||||
const result = response.result as {
|
||||
resources: Array<{ name: string; uri: string; description?: string }>;
|
||||
};
|
||||
expect(result.resources).toBeDefined();
|
||||
expect(Array.isArray(result.resources)).toBe(true);
|
||||
expect(result.resources.length).toBe(5);
|
||||
|
||||
for (const resource of result.resources) {
|
||||
expect(resource.uri).toMatch(/^sage-x3:\/\//);
|
||||
expect(resource.name).toBeTruthy();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("returns 4 prompts from prompts/list", async () => {
|
||||
const response = await client.request(4, "prompts/list");
|
||||
|
||||
expect(response.jsonrpc).toBe("2.0");
|
||||
expect(response.id).toBe(4);
|
||||
expect(response.error).toBeUndefined();
|
||||
|
||||
const result = response.result as {
|
||||
prompts: Array<{ name: string; description?: string }>;
|
||||
};
|
||||
expect(result.prompts).toBeDefined();
|
||||
expect(Array.isArray(result.prompts)).toBe(true);
|
||||
expect(result.prompts.length).toBe(4);
|
||||
|
||||
const promptNames = result.prompts.map((p) => p.name).sort();
|
||||
expect(promptNames).toEqual([
|
||||
"analyze-data",
|
||||
"explore-data",
|
||||
"lookup-entity",
|
||||
"search-entities",
|
||||
]);
|
||||
}, 10000);
|
||||
|
||||
it("handles unknown method with error response", async () => {
|
||||
const response = await client.request(5, "nonexistent/method");
|
||||
|
||||
expect(response.jsonrpc).toBe("2.0");
|
||||
expect(response.id).toBe(5);
|
||||
expect(response.error).toBeDefined();
|
||||
expect(response.error!.code).toBeDefined();
|
||||
expect(response.error!.message).toBeTruthy();
|
||||
}, 10000);
|
||||
|
||||
it("tools have valid input schemas", async () => {
|
||||
const response = await client.request(6, "tools/list");
|
||||
const result = response.result as {
|
||||
tools: Array<{ name: string; inputSchema: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
for (const tool of result.tools) {
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
expect(tool.inputSchema.type).toBe("object");
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
Reference in New Issue
Block a user