/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import path from "node:path"; import { buildSchema, introspectionFromSchema } from "graphql"; import { describe, expect, it, vi } from "vitest"; import { atomicWriteJson } from "../../lib/fs-utils.js"; import { schemaCacheKeyForInstanceUrl, schemaDir, type SchemaMetadata, } from "../../lib/introspect.js"; import { type PrimeDeps } from "../../lib/prime-schema.js"; import * as sessionModule from "../../lib/session.js"; import { primeSchemaCache } from "../../lib/walker.js"; import { buildDetail } from "../build-detail.js"; vi.mock("../../lib/session.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createSession: vi.fn(actual.createSession) }; }); // SDL mirrors build-list.spec.ts but extends every `_Filter` with an // `Id: IDOperators` field so the FR-5.5 `where: { Id: { eq: $id } }` binding // validates cleanly. Without it, the rendered query would still be correct // but graphql-js validate() would warn — that's fine in production (real // UIAPI filters all expose Id), and we don't want spurious warnings noise // in test assertions. 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 { Id: IDOperators, Industry: PicklistOperators, Name: StringOperators } input Account_OrderBy { Name: OrderByClause, Industry: OrderByClause } input Case_Filter { Id: IDOperators, Status: PicklistOperators, Priority: PicklistOperators } input Case_OrderBy { CreatedDate: OrderByClause } input IDOperators { eq: ID, ne: ID, in: [ID!] } input PicklistOperators { eq: String, ne: String, in: [String!] } input StringOperators { eq: String, like: String } 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 { Id: IDOperators, Title: StringOperators } 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-detail"; const ORG_URL = "https://test-detail.my.salesforce.com"; const SCHEMA = buildSchema(SCHEMA_SDL); primeSchemaCache(ORG, SCHEMA); primeSchemaCache(ORG_URL, SCHEMA); function noopPrimeDeps(): PrimeDeps { return { getOrgAuth: async () => ({ alias: ORG, username: "u", instanceUrl: ORG_URL, accessToken: "t", orgId: "00D", }), downloadSchema: async (auth) => { const cacheKey = schemaCacheKeyForInstanceUrl(auth.instanceUrl); const filePath = path.join(schemaDir(), `${cacheKey}.json`); atomicWriteJson(filePath, { data: introspectionFromSchema(SCHEMA) }); const meta: SchemaMetadata = { cacheKey, instanceUrl: auth.instanceUrl, typeCount: 0, downloadedAt: new Date().toISOString(), filePath, }; return meta; }, }; } describe("intent/build-detail", () => { it("declares $id: ID! and binds where { Id: { eq: $id } } with first: 1 (FR-5.5)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id", "Name"] }, noopPrimeDeps(), ); const id = out.variables.find((v) => v.name === "id"); expect(id).toBeDefined(); expect(id!.type).toBe("ID!"); expect(id!.required).toBe(true); expect(out.query).toMatch(/\$id\s*:\s*ID!/); expect(out.query).toMatch(/where\s*:\s*\{\s*Id\s*:\s*\{\s*eq\s*:\s*\$id\s*\}/); expect(out.query).toMatch(/first\s*:\s*1\b/); }); it("respects custom idVariable name", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], idVariable: "accountId" }, noopPrimeDeps(), ); const accountId = out.variables.find((v) => v.name === "accountId"); expect(accountId).toBeDefined(); expect(accountId!.type).toBe("ID!"); expect(out.query).toMatch(/\$accountId\s*:\s*ID!/); expect(out.query).toMatch(/where\s*:\s*\{\s*Id\s*:\s*\{\s*eq\s*:\s*\$accountId\s*\}/); // And no leftover `$id` from the default. expect(out.variables.find((v) => v.name === "id")).toBeUndefined(); }); it("scalar fields select { value } except Id (FR-4.1)", async () => { const out = await buildDetail( { 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("default operationName is Detail", async () => { const out = await buildDetail({ org: ORG, object: "Case", fields: ["Id"] }, noopPrimeDeps()); expect(out.query).toMatch(/\bquery\s+CaseDetail\b/); }); it("respects custom operationName", async () => { const out = await buildDetail( { org: ORG, object: "Case", fields: ["Id"], operationName: "GetMyCase" }, noopPrimeDeps(), ); expect(out.query).toMatch(/\bquery\s+GetMyCase\b/); }); it("rejects an operationName that is not a valid GraphQL Name", async () => { await expect( buildDetail( { org: ORG, object: "Account", fields: ["Id"], operationName: "has spaces" }, noopPrimeDeps(), ), ).rejects.toThrow(/buildDetail: operationName 'has spaces' is not a valid GraphQL Name/); }); it("rejects an object that is not a valid GraphQL Name", async () => { await expect( buildDetail({ org: ORG, object: "Order Item", fields: ["Id"] }, noopPrimeDeps()), ).rejects.toThrow(/buildDetail: object 'Order Item' is not a valid GraphQL Name/); }); it("parentFields walks dotted path with value wrapping (FR-4.2)", async () => { const out = await buildDetail( { 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 buildDetail( { 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 buildDetail( { 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 with first/orderBy (FR-4.4)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id", "LastName"], first: 5, orderBy: { LastName: { order: "ASC" } }, }, ], }, noopPrimeDeps(), ); expect(out.query).toMatch(/Contacts\s*\([^)]*first:\s*5/); expect(out.query).toMatch(/Contacts\s*\([^)]*orderBy:\s*\{\s*LastName/s); // Id inside the child node is FLS-exempt — selected bare, never @optional. expect(out.query).not.toMatch( /Contacts\s*\([^)]*\)\s*\{\s*edges\s*\{\s*node\s*\{[^}]*Id\s+@optional\b/s, ); expect(out.query).toMatch( /Contacts\s*\([^)]*\)\s*\{\s*edges\s*\{\s*node\s*\{[^}]*LastName\s+@optional\s*\{\s*value\s*\}/s, ); }); it("childRelationships filter $varName promotes typed variable (FR-5.1, 5.2)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: { Title: { like: "$titlePattern" } }, }, ], }, noopPrimeDeps(), ); const titlePattern = out.variables.find((v) => v.name === "titlePattern"); expect(titlePattern).toBeDefined(); expect(titlePattern!.required).toBe(false); // Both the ID! and the promoted nullable variable coexist. expect(out.variables.find((v) => v.name === "id")?.required).toBe(true); }); it("does NOT declare $after or select pageInfo (negative vs sf_gql_list)", async () => { const out = await buildDetail({ org: ORG, object: "Account", fields: ["Id"] }, noopPrimeDeps()); expect(out.variables.find((v) => v.name === "after")).toBeUndefined(); expect(out.query).not.toMatch(/\$after\b/); expect(out.query).not.toMatch(/pageInfo\b/); }); it("rendered query passes graphql-js validation (no Validation: warnings)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id", "Name"] }, noopPrimeDeps(), ); const validationWarnings = out.warnings.filter((w) => w.startsWith("Validation:")); expect(validationWarnings).toEqual([]); }); it("rejects empty idVariable rather than rendering $: ID!", async () => { await expect( buildDetail({ org: ORG, object: "Account", fields: ["Id"], idVariable: "" }, noopPrimeDeps()), ).rejects.toThrow(/idVariable must be a non-empty string/); }); it("throws when idVariable collides with a $varName already promoted by a child filter", async () => { // User's child filter promotes `$id: String` (nullable, inferred). The // default idVariable also wants `id` but as `ID!`. Without the guard, // `addVariable` would silently overwrite the type and corrupt the // child-filter binding. await expect( buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: { Title: { like: "$id" } }, }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/idVariable "id" collides with a \$id reference/); }); it("does NOT throw when same name appears as ID! (e.g., user explicitly references $id in a child filter as the same record)", async () => { // The collision guard fires only when the existing type differs from `ID!`. // A child filter that references the *same* `$id` is unusual but legal. // This test pins that we don't over-trigger. const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], idVariable: "accountId" }, noopPrimeDeps(), ); expect(out.variables.find((v) => v.name === "accountId")?.type).toBe("ID!"); }); it("threads instanceUrl as 3rd arg to createSession", async () => { const spy = vi.mocked(sessionModule.createSession); spy.mockClear(); await buildDetail({ org: ORG, object: "Account", fields: ["Id", "Name"] }, noopPrimeDeps()); expect(spy).toHaveBeenCalledWith(ORG, "query", ORG_URL); }); it("promotes a whole-argument $orderBy on a child relationship (the $commentsOrderBy gap)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], orderBy: "$commentsOrderBy" }, ], }, noopPrimeDeps(), ); const v = out.variables.find((x) => x.name === "commentsOrderBy"); expect(v).toBeDefined(); expect(v!.type).toMatch(/_OrderBy$/); expect(out.query).toMatch(/orderBy\s*:\s*\$commentsOrderBy\b/); }); it("promotes a whole-argument $filter on a child relationship", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: "$contactFilter" }, ], }, noopPrimeDeps(), ); const v = out.variables.find((x) => x.name === "contactFilter"); expect(v).toBeDefined(); expect(v!.type).toMatch(/_Filter$/); expect(out.query).toMatch(/where\s*:\s*\$contactFilter\b/); }); it("promotes a whole-argument $first on a child relationship", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], first: "$childFirst" }, ], }, noopPrimeDeps(), ); const v = out.variables.find((x) => x.name === "childFirst"); expect(v).toBeDefined(); expect(v!.type).toBe("Int"); expect(out.query).toMatch(/first\s*:\s*\$childFirst\b/); }); }); describe("intent/build-detail — selection-set injection (W-22735537)", () => { it("rejects a fields[] breakout", async () => { await expect( buildDetail( { 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( buildDetail( { 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[].relationshipName breakout", async () => { await expect( buildDetail( { 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 childRelationships[].fields breakout", async () => { await expect( buildDetail( { 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[].filter object-KEY breakout", async () => { await expect( buildDetail( { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], filter: { "Title } evil { value": { eq: "x" } }, }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/); }); it("accepts a legit dotted parentField (Owner.Name)", async () => { const out = await buildDetail( { org: ORG, object: "Account", fields: ["Id"], parentFields: ["Owner.Name"] }, 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 buildDetail( { org: ORG, object: "Account", fields: ["Id", "Amount__c"] }, noopPrimeDeps(), ); expect(out.query).toContain("Amount__c"); }); });