/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { buildSchema, parse } from "graphql"; import { describe, expect, it, vi } from "vitest"; import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js"; import * as sessionModule from "../../lib/session.js"; import { primeSchemaCache } from "../../lib/walker.js"; import { buildList } from "../build-list.js"; vi.mock("../../lib/session.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createSession: vi.fn(actual.createSession) }; }); const SCHEMA_SDL = ` type Query { uiapi: UIAPI! } type UIAPI { query: RecordQuery! } type RecordQuery { Account(first: Int, after: String, where: Account_Filter, orderBy: Account_OrderBy, scope: Scope): AccountConnection! Case(first: Int, after: String, where: Case_Filter, orderBy: Case_OrderBy, scope: Scope): CaseConnection! } enum Scope { MINE EVERYTHING } enum Order { ASC DESC } input Account_Filter { Industry: PicklistOperators, Name: StringOperators, AnnualRevenue: DoubleOperators } input Account_OrderBy { Name: OrderByClause, Industry: OrderByClause } input Case_Filter { Status: PicklistOperators, Priority: PicklistOperators } input Case_OrderBy { CreatedDate: OrderByClause } input PicklistOperators { eq: String, ne: String, in: [String!] } input StringOperators { eq: String, like: String } input DoubleOperators { eq: Float, gt: Float, lt: Float } input OrderByClause { order: Order!, nulls: NullsOrder } enum NullsOrder { FIRST LAST } type AccountConnection { edges: [AccountEdge!]! pageInfo: PageInfo! totalCount: Int } type AccountEdge { node: Account! } type CaseConnection { edges: [CaseEdge!]! pageInfo: PageInfo! } type CaseEdge { node: Case! } type PageInfo { hasNextPage: Boolean!, endCursor: String } type Account { Id: ID! Name: StringValue Industry: StringValue Amount__c: StringValue Owner: OwnerUnion Contacts(first: Int, where: Contact_Filter, orderBy: Contact_OrderBy): ContactConnection } type Case { Id: ID! Subject: StringValue Status: StringValue Owner: OwnerUnion Account: Account } type ContactConnection { edges: [ContactEdge!]! } type ContactEdge { node: Contact! } input Contact_Filter { Title: StringOperators, Rank: DoubleOperators } input Contact_OrderBy { LastName: OrderByClause } type Contact { Id: ID! LastName: StringValue Title: StringValue } union OwnerUnion = User | Group type User { Id: ID!, Name: StringValue, Email: StringValue } type Group { Id: ID!, Name: StringValue } type StringValue { value: String } `; const ORG = "test-list"; const ORG_URL = "https://test-list.my.salesforce.com"; const SCHEMA = buildSchema(SCHEMA_SDL); // Prime the in-memory schema cache by both alias and URL so getSchema() // resolves either key without touching disk or the keychain. primeSchemaCache(ORG, SCHEMA); primeSchemaCache(ORG_URL, SCHEMA); const noopPrimeDeps = () => makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); describe("intent/build-list", () => { it("scalar fields select { value } except Id (FR-4.1)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id", "Name"] }, noopPrimeDeps(), ); // Id is FLS-exempt: selected bare, never @optional (W-22818723). expect(out.query).not.toMatch(/\bId\s+@optional\b/); expect(out.query).toMatch(/Name\s+@optional\s*\{\s*value\s*\}/); }); it("marks selected record fields @optional for FLS-safe degradation (W-22818723)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id", "Name", "Industry"] }, noopPrimeDeps(), ); // FLS-gateable record fields (fields on `node`) get @optional expect(out.query).toMatch(/\bName\s+@optional\s*\{/); expect(out.query).toMatch(/\bIndustry\s+@optional\s*\{/); // Id is FLS-exempt — selected bare, never @optional expect(out.query).not.toMatch(/\bId\s+@optional\b/); // Structural plumbing (edges, node, pageInfo, etc.) does NOT get @optional expect(out.query).not.toMatch(/edges\s+@optional/); expect(out.query).not.toMatch(/node\s+@optional/); expect(out.query).not.toMatch(/pageInfo\s+@optional/); }); it("default operationName is List", async () => { const out = await buildList({ org: ORG, object: "Case", fields: ["Id"] }, noopPrimeDeps()); expect(out.query).toMatch(/\bquery\s+CaseList\b/); }); it("respects custom operationName", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], operationName: "MyCases" }, noopPrimeDeps(), ); expect(out.query).toMatch(/\bquery\s+MyCases\b/); }); it("rejects an operationName that is not a valid GraphQL Name", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], operationName: "has spaces" }, noopPrimeDeps(), ), ).rejects.toThrow(/buildList: operationName 'has spaces' is not a valid GraphQL Name/); }); it("rejects an object that is not a valid GraphQL Name", async () => { await expect( buildList({ org: ORG, object: "Order Item", fields: ["Id"] }, noopPrimeDeps()), ).rejects.toThrow(/buildList: object 'Order Item' is not a valid GraphQL Name/); }); it("declares $after and selects pageInfo (FR-7)", async () => { const out = await buildList({ org: ORG, object: "Case", fields: ["Id"] }, noopPrimeDeps()); expect(out.variables.find((v) => v.name === "after")).toBeDefined(); expect(out.query).toMatch(/\$after\s*:\s*String\b/); expect(out.query).toMatch(/pageInfo\s*\{[^}]*hasNextPage[^}]*endCursor/s); expect(out.query).toMatch(/first\s*:\s*10\b/); }); it("parentFields walks dotted path with value wrapping (FR-4.2)", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], parentFields: ["Account.Name"] }, noopPrimeDeps(), ); expect(out.query).toMatch(/Account\s*\{[^}]*Name\s+@optional\s*\{\s*value\s*\}/s); }); it("parentFields expands polymorphic union into inline fragments (FR-4.3)", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], parentFields: ["Owner.Name"] }, noopPrimeDeps(), ); expect(out.query).toMatch(/\.\.\.\s+on\s+User\s*\{[^}]*Name\s+@optional\s*\{\s*value\s*\}/s); expect(out.query).toMatch(/\.\.\.\s+on\s+Group\s*\{[^}]*Name\s+@optional\s*\{\s*value\s*\}/s); }); it("parentFields skips union members lacking the field (FR-4.3)", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], parentFields: ["Owner.Email"] }, noopPrimeDeps(), ); expect(out.query).toMatch(/\.\.\.\s+on\s+User\s*\{[^}]*Email/s); expect(out.query).not.toMatch(/\.\.\.\s+on\s+Group\s*\{[^}]*Email/s); }); it("childRelationships rendered as edges/node connection (FR-4.4)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id", "LastName"], first: 5 }, ], }, noopPrimeDeps(), ); expect(out.query).toMatch(/Contacts\s*\([^)]*first:\s*5/); expect(out.query).toMatch( /Contacts\s*\([^)]*\)\s*\{\s*edges\s*\{\s*node\s*\{[^}]*LastName\s+@optional\s*\{\s*value\s*\}/s, ); }); it("filter with $varName promotes typed variable (FR-5.1, 5.2)", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], filter: { Status: { eq: "$status" } }, }, noopPrimeDeps(), ); const status = out.variables.find((v) => v.name === "status"); expect(status).toBeDefined(); expect(status!.required).toBe(false); expect(out.query).toMatch(/\$status\b/); }); it("literal filter values are inlined, no variable promoted", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], filter: { Status: { eq: "New" } }, }, noopPrimeDeps(), ); expect(out.variables.find((v) => v.name === "status")).toBeUndefined(); expect(out.query).toMatch(/Status\s*:\s*\{\s*eq\s*:\s*"New"/); }); it("orderBy emitted as singleton object (FR-6.1)", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], orderBy: { CreatedDate: { order: "ASC" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch(/orderBy\s*:\s*\{\s*CreatedDate/); expect(out.query).not.toMatch(/orderBy\s*:\s*\[/); }); it("orderBy array is collapsed to first element (FR-6.2)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], orderBy: [{ Name: { order: "ASC" } }, { Industry: { order: "DESC" } }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/orderBy\s*:\s*\{\s*Name/); expect(out.query).not.toMatch(/Industry/); }); it("scope literal is set on connection", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], scope: "MINE" }, noopPrimeDeps(), ); expect(out.query).toMatch(/scope\s*:\s*MINE/); }); it("$varName scope promotes a variable", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], scope: "$myScope" }, noopPrimeDeps(), ); expect(out.variables.find((v) => v.name === "myScope")).toBeDefined(); expect(out.query).toMatch(/scope\s*:\s*\$myScope/); }); it("unresolvable filter input path falls back to String (FR-5.4)", async () => { // `Account_Filter` has `Industry` and `Name`, but no `BogusField` — // inferTypeFromArgsPath will throw, and promoteVariables falls back to String. const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], filter: { BogusField: { eq: "$bogus" } }, }, noopPrimeDeps(), ); const bogus = out.variables.find((v) => v.name === "bogus"); expect(bogus).toBeDefined(); expect(bogus!.type).toBe("String"); }); it("threads instanceUrl as 3rd arg to createSession", async () => { const spy = vi.mocked(sessionModule.createSession); spy.mockClear(); await buildList({ org: ORG, object: "Case", fields: ["Id"] }, noopPrimeDeps()); expect(spy).toHaveBeenCalledWith(ORG, "query", ORG_URL); }); // GAP 1 (W-22697670) — the same `$x` referenced under two filter fields of // differing GraphQL types infers two different types. `addVariable` keeps the // first-declared type (first-wins) and reports the conflict, which `buildList` // now threads into `warnings`. Account_Filter.Name is a StringOperators (String // operands) and Account_Filter.AnnualRevenue is a DoubleOperators (Float // operands), so `$x` is inferred as String then Float. it("type collision on a reused $var keeps first type and surfaces a warning (GAP 1)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], filter: { Name: { eq: "$x" }, AnnualRevenue: { gt: "$x" } }, }, noopPrimeDeps(), ); // A collision warning naming $x is surfaced (not silently dropped). This // also proves buildList now threads `warnings` through promoteVariables. expect( out.warnings.some( (w) => w.startsWith("Variable:") && w.includes("type collision for $x") && w.includes("String") && w.includes("Float"), ), ).toBe(true); // First-wins: $x is declared exactly once, with the first-inferred type (String). const declarations = out.query.match(/\$x\s*:/g) ?? []; expect(declarations).toHaveLength(1); expect(out.query).toMatch(/\$x\s*:\s*String/); expect(out.query).not.toMatch(/\$x\s*:\s*Float/); // Both references still render as the bare variable in the where arg. expect(out.query).toMatch(/Name\s*:\s*\{\s*eq\s*:\s*\$x\s*\}/s); expect(out.query).toMatch(/AnnualRevenue\s*:\s*\{\s*gt\s*:\s*\$x\s*\}/s); }); // GAP 2 (W-22697670) — a `$`-prefixed string whose remainder is not a valid // GraphQL Name (e.g. `$1var`) is not a real variable placeholder. It must NOT // be promoted nor rendered as a bare `$1var`; it is quoted as a literal and a // `Variable:` warning is surfaced so the typo is not silently swallowed. it("invalid $-placeholder renders as a quoted literal and surfaces a warning (GAP 2)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], filter: { Name: { eq: "$1var" } }, }, noopPrimeDeps(), ); // The invalid placeholder warning is surfaced (proves the warnings sink is threaded). expect(out.warnings.some((w) => w.startsWith("Variable:") && w.includes("$1var"))).toBe(true); // Not promoted to a query variable. expect(out.variables.find((v) => v.name === "1var")).toBeUndefined(); expect(out.query).not.toMatch(/\$1var\s*:/); // Rendered as a quoted literal, not a bare variable reference. expect(out.query).toMatch(/Name\s*:\s*\{\s*eq\s*:\s*"\$1var"\s*\}/s); expect(out.query).not.toMatch(/eq\s*:\s*\$1var\b/); }); // C2 (W-22697670): warnings from childRelationship filters must surface too — // selectChildRelationship now threads the warnings sink through promoteVariables. it("surfaces $-placeholder warnings from a childRelationship filter", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: { Title: { eq: "$1var" } } }, ], }, noopPrimeDeps(), ); // The invalid placeholder warning surfaces from the CHILD filter (not dropped). expect(out.warnings.some((w) => w.startsWith("Variable:") && w.includes("$1var"))).toBe(true); // And the child filter renders the quoted literal, not a bare reference. expect(out.query).toMatch(/Title\s*:\s*\{\s*eq\s*:\s*"\$1var"\s*\}/s); }); // C1 (W-22697670): a childRelationship filter reusing the reserved $after cursor // must keep String (first-wins) so pagination stays valid, and surface a warning. // Regression guard: $after is now declared before the childRelationships loop. it("childRelationship filter reusing $after keeps String (first-wins) and warns", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: { Rank: { gt: "$after" } } }, ], }, noopPrimeDeps(), ); // Pagination's $after stays String, not overwritten to the child's Float. expect(out.query).toMatch(/\$after\s*:\s*String/); expect(out.query).not.toMatch(/\$after\s*:\s*Float/); expect( out.warnings.some( (w) => w.startsWith("Variable:") && w.includes("type collision for $after"), ), ).toBe(true); }); it("promotes a whole-argument $filter to a typed _Filter variable", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], filter: "$filter" }, noopPrimeDeps(), ); const filter = out.variables.find((v) => v.name === "filter"); expect(filter).toBeDefined(); expect(filter!.type).toBe("Case_Filter"); expect(filter!.required).toBe(false); expect(out.query).toMatch(/where\s*:\s*\$filter\b/); }); it("promotes a whole-argument $orderBy to a typed _OrderBy variable", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], orderBy: "$sort" }, noopPrimeDeps(), ); const sort = out.variables.find((v) => v.name === "sort"); expect(sort).toBeDefined(); expect(sort!.type).toBe("Case_OrderBy"); expect(out.query).toMatch(/orderBy\s*:\s*\$sort\b/); }); it("leaf filter promotion still works after whole-arg support", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], filter: { Status: { eq: "$status" } } }, noopPrimeDeps(), ); expect(out.variables.find((v) => v.name === "status")).toBeDefined(); expect(out.query).toMatch(/\$status\b/); expect(out.query).not.toMatch(/where\s*:\s*\$status\b/); // leaf, not whole-arg }); it("promotes a whole-argument $first to a typed Int variable", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], first: "$first" }, noopPrimeDeps(), ); const v = out.variables.find((x) => x.name === "first"); expect(v).toBeDefined(); expect(v!.type).toBe("Int"); expect(v!.required).toBe(false); expect(out.query).toMatch(/first\s*:\s*\$first\b/); }); it("numeric first still renders as a literal", async () => { const out = await buildList( { org: ORG, object: "Case", fields: ["Id"], first: 25 }, noopPrimeDeps(), ); expect(out.variables.find((x) => x.name === "first")).toBeUndefined(); expect(out.query).toMatch(/first\s*:\s*25\b/); }); it("omitted first defaults to the literal 10", async () => { const out = await buildList({ org: ORG, object: "Case", fields: ["Id"] }, noopPrimeDeps()); expect(out.query).toMatch(/first\s*:\s*10\b/); }); }); describe("intent/build-list — selection-set injection (W-22735537)", () => { it("rejects a fields[] selection-set breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id } injectedAlias: Name { value"] }, noopPrimeDeps(), ), ).rejects.toThrow(/fields entry .* is not a valid field path/); }); it("rejects a parentFields[] breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], parentFields: ["Owner.Name } evil { value"], }, noopPrimeDeps(), ), ).rejects.toThrow(/parentFields entry .* is not a valid field path/); }); it("rejects a childRelationships[].fields breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id } evil: LastName { value"] }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/childRelationships fields entry .* is not a valid field path/); }); it("rejects a childRelationships[].relationshipName breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts } injectedSibling: Account { Id", fields: ["Id"] }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/selectChildRelationship: relationshipName .* is not a valid GraphQL Name/); }); it("rejects a filter object-KEY breakout (argument-position injection)", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], filter: { "Industry } evilField { value } sib: Name(x": { eq: "Tech" } }, }, noopPrimeDeps(), ), ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/); }); it("rejects an orderBy object-KEY breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], orderBy: { "Name } evil { value": { order: "ASC" } }, }, noopPrimeDeps(), ), ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/); }); it("accepts a legit dotted parentField (Owner.Name) and a legit filter", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id", "Name"], parentFields: ["Owner.Name"], filter: { Industry: { eq: "Tech" } }, }, noopPrimeDeps(), ); expect(out.query).toContain("Owner"); }); // PR #678 review (Ciaran Hannigan): the __c custom-field accept-case the // charset tests assert in isolation, exercised end-to-end through the builder. it("accepts a Salesforce custom field (__c) and renders it", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id", "Amount__c"] }, noopPrimeDeps(), ); expect(out.query).toContain("Amount__c"); }); it("rejects a scope argument breakout", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], scope: "{}) { Id } injectedSibling: Account(scope: MINE", }, noopPrimeDeps(), ), ).rejects.toThrow(/buildList: scope .* is not a valid GraphQL Name/); }); it("accepts a legit scope enum token and a $var scope", async () => { const enumOut = await buildList( { org: ORG, object: "Account", fields: ["Id"], scope: "EVERYTHING" }, noopPrimeDeps(), ); expect(enumOut.query).toContain("scope: EVERYTHING"); const varOut = await buildList( { org: ORG, object: "Account", fields: ["Id"], scope: "$myScope" }, noopPrimeDeps(), ); expect(varOut.query).toContain("$myScope"); }); it("rejects a NESTED filter object-KEY breakout (recursion guard)", async () => { await expect( buildList( { org: ORG, object: "Account", fields: ["Id"], filter: { Industry: { "eq } evil { value": "Tech" } }, }, noopPrimeDeps(), ), ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/); }); it("renders a parseable document with no injected sibling (graphql-js reparse)", async () => { const out = await buildList( { org: ORG, object: "Account", fields: ["Id", "Name"], filter: { Industry: { eq: "Tech" } } }, noopPrimeDeps(), ); expect(() => parse(out.query)).not.toThrow(); expect(out.query).not.toContain("injectedAlias"); expect(out.query).not.toContain("evil"); }); });