/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import fs from "node:fs"; import path from "node:path"; import { buildSchema, getIntrospectionQuery, graphqlSync, type IntrospectionQuery, validateSchema, } from "graphql"; import { describe, expect, it } from "vitest"; import { TEST_SCHEMA } from "../../__tests__/helpers/schema.js"; import { selectLeafInSession } from "../../commands/query.js"; import { clearOrgAuthCache } from "../auth.js"; import { getSchemaFilePath } from "../introspect.js"; import { createSession } from "../session.js"; import { validateQuery } from "../validator.js"; import { clearSchemaCache, clearSchemaCacheByUrl, filterDataCloudFields, type FieldInfo, getSchema, isDataCloudField, primeSchemaCache, resolveInputPath, resolvePath, } from "../walker.js"; describe("walker", () => { describe("schema cache", () => { it("uses instanceUrl from session as the schema cache key", () => { const alias = `test_${Math.random().toString(16).slice(2, 8)}`; const instanceUrl = "https://test.my.salesforce.com"; // Prime schema under the normalized URL key so getSchema(instanceUrl) hits cache. primeSchemaCache(instanceUrl.toLowerCase(), TEST_SCHEMA); clearOrgAuthCache(); // Session carries instanceUrl — getSessionSchema should use it directly, // hitting the in-process cache without calling sf auth. const session = createSession(alias, "query", instanceUrl); session.navigationPath = ["query", "viewer"]; expect(() => selectLeafInSession(session, "id")).not.toThrow(); expect(session.nodes.some((n) => n.kind === "field" && n.fieldName === "id")).toBe(true); }); it("uses cached schema within a process", () => { const alias = `test_${Math.random().toString(16).slice(2, 8)}`; const instanceUrl = `https://${alias}.my.salesforce.com`; primeSchemaCache(instanceUrl, TEST_SCHEMA); const session1 = createSession(alias, "query", instanceUrl); const session2 = createSession(alias, "query", instanceUrl); session1.navigationPath = ["query", "viewer"]; expect(() => selectLeafInSession(session1, "name")).not.toThrow(); // Leaf enforcement works on the same cached schema. session2.navigationPath = ["query", "accounts"]; expect(() => selectLeafInSession(session2, "edges")).toThrow(/Cannot select/); // Navigating deeper into a non-leaf and selecting a leaf also works. session2.navigationPath = ["query", "accounts", "edges", "node"]; expect(() => selectLeafInSession(session2, "id")).not.toThrow(); }); it("clearSchemaCacheByUrl drops the URL-keyed entry", () => { const instanceUrl = "https://refresh-test.my.salesforce.com"; primeSchemaCache(instanceUrl, TEST_SCHEMA); // Cached: getSchema by URL returns the primed schema without touching disk. expect(getSchema(instanceUrl)).toBe(TEST_SCHEMA); clearSchemaCacheByUrl(instanceUrl); // Evicted: the next read misses the in-memory cache and falls back to // disk, which fails because no introspection file exists for this URL. expect(() => getSchema(instanceUrl)).toThrow(); }); it("getSchema returns a primed schema by instance URL", () => { const url = "https://example.my.salesforce.com"; clearSchemaCache(); primeSchemaCache(url, TEST_SCHEMA); expect(getSchema(url)).toBe(TEST_SCHEMA); }); }); describe("resolveInputPath", () => { it("walks INPUT_OBJECT fields", () => { const result = resolveInputPath(TEST_SCHEMA, "AccountFilter", []); expect(result.typeName).toBe("AccountFilter"); expect(result.kind).toBe("INPUT_OBJECT"); expect(result.isLeaf).toBe(false); expect(result.inputFields.length).toBeGreaterThan(0); }); it("reaches scalar leaf", () => { const result = resolveInputPath(TEST_SCHEMA, "AccountFilter", ["minRevenue"]); expect(result.typeName).toBe("Int"); expect(result.isLeaf).toBe(true); }); it("walks nested input types", () => { const result = resolveInputPath(TEST_SCHEMA, "AccountFilter", ["name"]); expect(result.typeName).toBe("StringFilter"); expect(result.isLeaf).toBe(false); const leafResult = resolveInputPath(TEST_SCHEMA, "AccountFilter", ["name", "like"]); expect(leafResult.typeName).toBe("String"); expect(leafResult.isLeaf).toBe(true); }); it("throws for invalid field", () => { expect(() => resolveInputPath(TEST_SCHEMA, "AccountFilter", ["nonExistent"])).toThrow( /Field "nonExistent" not found on input type AccountFilter/, ); }); }); describe("Data Cloud field detection & filtering", () => { function mockField(name: string): FieldInfo { return { name, typeName: `${name}Connection`, typeKind: "OBJECT", isNonNull: false, isList: false, description: null, args: [], }; } it("isDataCloudField detects __dlm suffix", () => { expect(isDataCloudField(mockField("ssot__Account__dlm"))).toBe(true); expect(isDataCloudField(mockField("IndividualGDPRState__dlm"))).toBe(true); expect(isDataCloudField(mockField("Account"))).toBe(false); expect(isDataCloudField(mockField("ssot__Account"))).toBe(false); }); it("filterDataCloudFields hides __dlm fields by default", () => { const fields = [ mockField("Account"), mockField("ssot__Account__dlm"), mockField("Case"), mockField("ssot__Contact__dlm"), ]; const filtered = filterDataCloudFields(fields, false); expect(filtered).toHaveLength(2); expect(filtered.map((f) => f.name)).toEqual(["Account", "Case"]); }); it("filterDataCloudFields shows all when includeDataCloud is true", () => { const fields = [mockField("Account"), mockField("ssot__Account__dlm"), mockField("Case")]; const filtered = filterDataCloudFields(fields, true); expect(filtered).toHaveLength(3); }); }); // resolvePath is referenced indirectly by query-builder.spec.ts; import retained // to verify the export still exists. it("resolvePath is exported", () => { expect(resolvePath).toBeInstanceOf(Function); }); // Regression test for: Salesforce orgs that emit `INPUT_OBJECT` types with // empty `inputFields: []` (e.g. `*_SearchOrderBy` types and a few mutation // Representation types) used to crash `check`/`validate(schema, document)` // with "Input Object type X must define one or more fields" before the // user's query was ever inspected. The fix in // `lib/walker.ts:patchEmptyInputObjects` patches the introspection // in-memory by adding a synthetic `_placeholder: String` field. describe("empty input type patching", () => { function buildIntrospectionWithEmptyInput(): IntrospectionQuery { const schema = buildSchema(` type Query { account(filter: AccountFilter): Account } input AccountFilter { # Mirrors a real-world Salesforce shape: this field references # an empty input type (replicates *_SearchOrderBy emission). orderBy: AccountSearchOrderBy } input AccountSearchOrderBy { placeholder: String } type Account { id: ID! name: String } `); const result = graphqlSync({ schema, source: getIntrospectionQuery() }); const introspection = result.data as unknown as IntrospectionQuery; // Mutate the introspection to drop AccountSearchOrderBy's fields, // reproducing the broken Salesforce shape. Cast through `unknown` to // bypass the readonly array type from graphql-js. const types = introspection.__schema.types as unknown as { kind: string; name: string; inputFields?: unknown[]; }[]; for (const t of types) { if (t.kind === "INPUT_OBJECT" && t.name === "AccountSearchOrderBy") { t.inputFields = []; } } return introspection; } function seedFixtureSchema(instanceUrl: string): void { const introspection = buildIntrospectionWithEmptyInput(); const filePath = getSchemaFilePath(instanceUrl); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify({ data: introspection }), "utf-8"); } it("getSchema patches INPUT_OBJECT types with no fields (regression)", () => { const instanceUrl = "https://empty-input-fixture.test.salesforce.com"; seedFixtureSchema(instanceUrl); clearSchemaCache(); const schema = getSchema(instanceUrl); // Without the fix, validateSchema would report: // "Input Object type AccountSearchOrderBy must define one or more fields." const errors = validateSchema(schema); expect(errors.map((e) => e.message)).toEqual([]); // And validateQuery (which is what `check` calls) must work without // throwing on the schema before it even reaches the user's query. const queryErrors = validateQuery( schema, `query { account(filter: { orderBy: {} }) { id name } }`, ); // We don't assert the query is error-free — only that the schema // validation step didn't bail. (Querying with `{}` against the patched // type may itself report 0 errors since the placeholder is optional.) expect(Array.isArray(queryErrors)).toBe(true); }); it("patched empty input type retains its name and is referenceable", () => { const instanceUrl = "https://empty-input-fixture-2.test.salesforce.com"; seedFixtureSchema(instanceUrl); clearSchemaCache(); const schema = getSchema(instanceUrl); const t = schema.getType("AccountSearchOrderBy"); expect(t).toBeDefined(); expect(t?.name).toBe("AccountSearchOrderBy"); }); }); });