/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { buildSchema } from "graphql"; import { describe, expect, it } from "vitest"; import { createSession, type QuerySession } from "../session.js"; import { normalizeOrderBy, promoteArg, promoteVariables } from "../variable-promotion.js"; // Mimics the relevant slice of the Salesforce UIAPI schema: a connection // field with `where`, `orderBy`, and `scope` args, where `where` is a // nested input type with operator wrappers. const schema = buildSchema(` type Query { uiapi: UIAPI! } type UIAPI { query: RecordQuery! } type RecordQuery { Account( first: Int where: Account_Filter orderBy: Account_OrderBy scope: RecordScope ): AccountConnection! } type AccountConnection { edges: [AccountEdge!]! } type AccountEdge { node: Account! } type Account { Id: ID! Name: StringValue } type StringValue { value: String } input Account_Filter { Status: PicklistOperators Industry: StringOperators Or: [Account_Filter!] } input PicklistOperators { eq: Picklist ne: Picklist in: [Picklist!] } input StringOperators { eq: String ne: String like: String } input Account_OrderBy { Name: OrderByEnum } enum OrderByEnum { ASC DESC } enum RecordScope { MINE TEAM } scalar Picklist `); const accountFieldPath = ["uiapi", "query", "Account"]; function makeSession(): QuerySession { return createSession("test-org", "query"); } describe("variable-promotion", () => { describe("promoteVariables: where filter (nested)", () => { it("registers a $var leaf inside a nested where filter", () => { const session = makeSession(); promoteVariables(session, schema, accountFieldPath, "where", { Status: { eq: "$status" }, }); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("status"); // PicklistOperators.eq has type Picklist; promoted vars drop the trailing `!`. expect(session.variables[0]!.type).toBe("Picklist"); }); it("registers multiple $vars at sibling paths", () => { const session = makeSession(); promoteVariables(session, schema, accountFieldPath, "where", { Status: { eq: "$status" }, Industry: { like: "$industry" }, }); const names = session.variables.map((v) => v.name).sort(); expect(names).toEqual(["industry", "status"]); const byName = Object.fromEntries(session.variables.map((v) => [v.name, v.type])); expect(byName.status).toBe("Picklist"); expect(byName.industry).toBe("String"); }); it("walks into arrays inside the value", () => { const session = makeSession(); // Or is `[Account_Filter!]` — array of filter objects. The walker must // descend into the array and find $vars in each element. promoteVariables(session, schema, accountFieldPath, "where", { Or: [{ Status: { eq: "$a" } }, { Industry: { eq: "$b" } }], }); const names = session.variables.map((v) => v.name).sort(); expect(names).toEqual(["a", "b"]); }); }); describe("promoteVariables: orderBy and scope", () => { it("works on orderBy values", () => { const session = makeSession(); promoteVariables(session, schema, accountFieldPath, "orderBy", { Name: "$direction", }); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("direction"); expect(session.variables[0]!.type).toBe("OrderByEnum"); }); it("works on scope (top-level $var leaf)", () => { const session = makeSession(); // `scope` is a single enum arg, not a nested object. The walker should // treat the value itself as the leaf. promoteVariables(session, schema, accountFieldPath, "scope", "$myScope"); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("myScope"); expect(session.variables[0]!.type).toBe("RecordScope"); }); }); describe("promoteVariables: error tolerance", () => { it("falls back to String when type inference fails", () => { const session = makeSession(); // Path that doesn't exist on the schema — inference will throw. The // helper must still register the variable (a wrong type is more useful // than a build that fails before the user sees their query). promoteVariables(session, schema, accountFieldPath, "where", { NotARealField: { eq: "$mystery" }, }); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("mystery"); expect(session.variables[0]!.type).toBe("String"); }); it("ignores plain string values that are not $var placeholders", () => { const session = makeSession(); promoteVariables(session, schema, accountFieldPath, "where", { Status: { eq: "Active" }, }); expect(session.variables).toHaveLength(0); }); it("warns on a type collision and keeps the first inferred type (first-wins)", () => { const session = makeSession(); const warnings: string[] = []; // The same $dup reference appears at two leaves with DIFFERENT inferred // types: Status.eq is `Picklist` (PicklistOperators.eq) and Industry.eq // is `String` (StringOperators.eq). The walker processes keys in // insertion order, so Status is seen first and wins; the later String // inference is reported as a collision and ignored. promoteVariables( session, schema, accountFieldPath, "where", { Status: { eq: "$dup" }, Industry: { eq: "$dup" }, }, warnings, ); // First-wins: only one variable, keeping the first (Picklist) type. expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("dup"); expect(session.variables[0]!.type).toBe("Picklist"); // A collision warning naming the variable was surfaced to the sink. const collisionWarning = warnings.find((w) => w.includes("type collision for $")); expect(collisionWarning).toBeDefined(); expect(collisionWarning).toContain("type collision for $dup"); // The warning explains which type was kept and which was ignored. expect(collisionWarning).toContain("Picklist"); expect(collisionWarning).toContain("String"); }); it("does not warn when the same $var is reused with the same inferred type", () => { const session = makeSession(); const warnings: string[] = []; // Both leaves are `String` (StringOperators.eq / .like), so reusing $dup // is consistent — no collision, no warning, one variable. promoteVariables( session, schema, accountFieldPath, "where", { Industry: { eq: "$dup", like: "$dup" }, }, warnings, ); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("dup"); expect(session.variables[0]!.type).toBe("String"); expect(warnings.some((w) => w.includes("type collision"))).toBe(false); }); it("strips a trailing ! when the inferred type is non-null", () => { // The schema has Account_Filter.Or: [Account_OrderBy!] — the nested // element is non-null. If a $var is placed where a non-null is required, // the promoted variable type must drop the `!` (query variables are // nullable by convention; callers add `!` explicitly via `var` if they // need it). const session = makeSession(); promoteVariables(session, schema, accountFieldPath, "where", { Or: ["$first"], }); expect(session.variables).toHaveLength(1); // Or's inner type is `Account_Filter!`; we strip the `!` and keep the // list wrapping that inferTypeFromArgsPath already applied. expect(session.variables[0]!.type.endsWith("!")).toBe(false); }); }); describe("promoteArg: whole-argument promotion", () => { it("promotes a whole-arg $var on where to the filter input type", () => { const session = makeSession(); const out = promoteArg(session, schema, accountFieldPath, "where", "$filter"); expect(out.rendered).toBe("$filter"); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("filter"); expect(session.variables[0]!.type).toBe("Account_Filter"); }); it("promotes a whole-arg $var on orderBy to the orderBy input type", () => { const session = makeSession(); const out = promoteArg(session, schema, accountFieldPath, "orderBy", "$sort"); expect(out.rendered).toBe("$sort"); expect(session.variables[0]!.name).toBe("sort"); expect(session.variables[0]!.type).toBe("Account_OrderBy"); }); it("delegates object values to leaf promotion and renders JSON", () => { const session = makeSession(); const out = promoteArg(session, schema, accountFieldPath, "where", { Status: { eq: "$status" }, }); expect(out.rendered).toBe(JSON.stringify({ Status: { eq: "$status" } })); expect(session.variables).toHaveLength(1); expect(session.variables[0]!.name).toBe("status"); expect(session.variables[0]!.type).toBe("Picklist"); }); it("renders literal object with no $vars and promotes nothing", () => { const session = makeSession(); const out = promoteArg(session, schema, accountFieldPath, "where", { Status: { eq: "Active" }, }); expect(out.rendered).toBe(JSON.stringify({ Status: { eq: "Active" } })); expect(session.variables).toHaveLength(0); }); it("falls back to String and warns when the arg type cannot be inferred", () => { const session = makeSession(); const warnings: string[] = []; // `bogusArg` is not an argument on the Account field — inference throws. const out = promoteArg(session, schema, accountFieldPath, "bogusArg", "$x", warnings); expect(out.rendered).toBe("$x"); expect(session.variables[0]!.name).toBe("x"); expect(session.variables[0]!.type).toBe("String"); expect(warnings.some((w) => w.includes("$x") && /could not infer/i.test(w))).toBe(true); }); }); describe("normalizeOrderBy", () => { it("collapses an array to its first element", () => { const result = normalizeOrderBy([{ Name: "ASC" }, { Industry: "DESC" }]); expect(result).toEqual({ Name: "ASC" }); }); it("returns undefined for an empty array", () => { expect(normalizeOrderBy([])).toBeUndefined(); }); it("passes through a singleton object unchanged", () => { const input = { Name: "ASC" }; expect(normalizeOrderBy(input)).toBe(input); }); it("returns undefined for undefined input", () => { expect(normalizeOrderBy(undefined)).toBeUndefined(); }); }); });