/** * 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, vi } from "vitest"; import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js"; import { type ObjectInfoResult, clearObjectInfoCache, setCachedObjectInfo, } from "../../lib/object-info.js"; import * as sessionModule from "../../lib/session.js"; import { primeSchemaCache } from "../../lib/walker.js"; import { buildAggregate } from "../build-aggregate.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! aggregate: RecordQueryAggregate! } type RecordQuery { Account(first: Int, after: String, where: Account_Filter, orderBy: Account_OrderBy): AccountConnection! } type RecordQueryAggregate { Account(first: Int, after: String, where: Account_Filter, orderBy: Account_OrderBy, groupBy: Account_GroupBy): AccountAggregateConnection Order(first: Int, after: String, where: Order_Filter, orderBy: Order_OrderBy, groupBy: Order_GroupBy): OrderAggregateConnection } input Account_Filter { Industry: PicklistOperators, AnnualRevenue: DoubleOperators } input Account_OrderBy { Name: OrderByClause, Industry: OrderByClause, Description: AggregateOrderByStringClause } input Order_OrderBy { Status: OrderByClause, Amount: AggregateOrderByNumberClause, CreatedDate: OrderByClause } input Order_Filter { Status: PicklistOperators, Amount: DoubleOperators, CreatedDate: DateTimeOperators } input Account_GroupBy { Industry: GroupByClause, Name: GroupByClause } input Order_GroupBy { Status: GroupByClause, CreatedDate: GroupByDateFunction } input DateTimeOperators { gte: String, lte: String } input GroupByDateFunction { function: GroupByFunction } enum GroupByFunction { CALENDAR_MONTH CALENDAR_QUARTER CALENDAR_YEAR DAY_IN_MONTH DAY_IN_WEEK DAY_IN_YEAR FISCAL_MONTH FISCAL_QUARTER FISCAL_YEAR HOUR_IN_DAY CALENDAR_MONTH_IN_YEAR FISCAL_MONTH_IN_YEAR WEEK_IN_YEAR } input PicklistOperators { eq: String, ne: String, in: [String!] } input DoubleOperators { eq: Float, gt: Float, lt: Float } input OrderByClause { order: Order!, nulls: NullsOrder } input AggregateOrderByStringClause { function: AggregateOrderByStringFunction!, order: ResultsOrder!, nulls: NullsOrder } input AggregateOrderByNumberClause { function: AggregateOrderByNumberFunction!, order: ResultsOrder!, nulls: NullsOrder } enum AggregateOrderByStringFunction { COUNT COUNT_DISTINCT MAX MIN } enum AggregateOrderByNumberFunction { AVG COUNT COUNT_DISTINCT MAX MIN SUM } enum ResultsOrder { ASC DESC } input GroupByClause { group: Boolean } enum Order { ASC DESC } enum NullsOrder { FIRST LAST } type AccountConnection { edges: [AccountEdge!]!, pageInfo: PageInfo! } type AccountEdge { node: Account! } type AccountAggregateConnection { edges: [AccountAggregateEdge!]! pageInfo: PageInfo! } type AccountAggregateEdge { node: AccountResult!, cursor: String! } type AccountResult { aggregate: AccountAggregate } type AccountAggregate { Id: IDAggregate Name: StringAggregate Industry: PicklistAggregate AnnualRevenue: DoubleAggregate } type OrderAggregateConnection { edges: [OrderAggregateEdge!]! pageInfo: PageInfo! } type OrderAggregateEdge { node: OrderResult!, cursor: String! } type OrderResult { aggregate: OrderAggregate } type OrderAggregate { Id: IDAggregate Status: PicklistAggregate Amount: DoubleAggregate CreatedDate: DateTimeAggregate } type DateTimeAggregate { value: String count: LongValue } type IDAggregate { value: ID count: LongValue countDistinct: LongValue min: IDValue max: IDValue } type StringAggregate { value: String count: LongValue countDistinct: LongValue min: StringValue max: StringValue } type DoubleAggregate { value: Float count: LongValue countDistinct: LongValue sum: DoubleValue avg: DoubleValue min: DoubleValue max: DoubleValue } type PicklistAggregate { value: String count: LongValue countDistinct: LongValue min: PicklistValue max: PicklistValue } type Account { Id: ID! Name: StringValue } type LongValue { value: Float } type IDValue { value: ID } type StringValue { value: String } type DoubleValue { value: Float } type PicklistValue { value: String } type PageInfo { hasNextPage: Boolean!, endCursor: String } `; const ORG = "test-aggregate"; const ORG_URL = "https://test-aggregate.my.salesforce.com"; const SCHEMA = buildSchema(SCHEMA_SDL); primeSchemaCache(ORG, SCHEMA); primeSchemaCache(ORG_URL, SCHEMA); const noopPrimeDeps = () => makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); describe("intent/build-aggregate", () => { it("default aggregation is count over Id (FR-8.2)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/\bquery\s+AccountAggregate\b/); // path: uiapi.aggregate.Account.edges.node.aggregate.Id.count.value // aliased as countId on Id (FR-8.5 default key) expect(out.query).toMatch( /aggregate\s*\{[^}]*countId\s*:\s*Id\s*\{\s*count\s*\{\s*value\s*\}\s*\}/s, ); }); it("two aggregations on same field render as separate aliased projections", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [ { function: "sum", field: "Amount" }, { function: "avg", field: "Amount" }, ], }, noopPrimeDeps(), ); expect(out.query).toMatch(/sumAmount\s*:\s*Amount\s*\{\s*sum\s*\{\s*value/s); expect(out.query).toMatch(/avgAmount\s*:\s*Amount\s*\{\s*avg\s*\{\s*value/s); }); it("respects custom operationName", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], operationName: "MyAccountAgg", }, noopPrimeDeps(), ); expect(out.query).toMatch(/\bquery\s+MyAccountAgg\b/); }); it("countDistinct over default Id renders as countDistinctId", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "countDistinct" }], }, noopPrimeDeps(), ); expect(out.query).toMatch( /countDistinctId\s*:\s*Id\s*\{\s*countDistinct\s*\{\s*value\s*\}\s*\}/s, ); }); it.each([ ["sum", "sumAmount"], ["avg", "avgAmount"], ["min", "minAmount"], ["max", "maxAmount"], ] as const)("%s over Amount renders under its function segment", async (fn, alias) => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: fn, field: "Amount" }], }, noopPrimeDeps(), ); const re = new RegExp(`${alias}\\s*:\\s*Amount\\s*\\{\\s*${fn}\\s*\\{\\s*value`, "s"); expect(out.query).toMatch(re); }); it.each(["sum", "avg", "min", "max"] as const)("%s without field throws (FR-8.3)", async (fn) => { await expect( buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: fn }], }, noopPrimeDeps(), ), ).rejects.toThrow(/requires.*field/i); }); it("explicit alias overrides default key", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "sum", field: "Amount", alias: "totalRev" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/totalRev\s*:\s*Amount\s*\{\s*sum\s*\{\s*value/s); expect(out.query).not.toMatch(/sumAmount/); }); it("default-key collision throws (FR-8.4)", async () => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [ { function: "sum", field: "Amount" }, { function: "sum", field: "Amount" }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate/i); }); it("collision avoided when each entry has unique alias", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [ { function: "sum", field: "Amount", alias: "a" }, { function: "sum", field: "Amount", alias: "b" }, ], }, noopPrimeDeps(), ); expect(out.query).toMatch(/\ba\s*:\s*Amount\s*\{\s*sum/s); expect(out.query).toMatch(/\bb\s*:\s*Amount\s*\{\s*sum/s); }); it("explicit-alias collision also throws", async () => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [ { function: "sum", field: "Amount", alias: "x" }, { function: "avg", field: "Amount", alias: "x" }, ], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate/i); }); it("PascalCase strips __c suffix in default key", async () => { const out = await buildAggregate( { org: ORG, object: "Custom", groupBy: [], aggregations: [{ function: "count", field: "Foo_Bar__c" }], }, noopPrimeDeps(), ).catch(() => null); // We just want the key derivation; if the schema lacks Custom.aggregate, that's fine — // this is a placeholder until a __c-bearing fixture is added in groupBy step. // Skip when schema rejects. if (out) { expect(out.query).toMatch(/countFooBar/); } }); it('groupBy ["Industry"] sets groupBy arg and selects Industry { value }', async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/groupBy\s*:\s*\{\s*Industry\s*:\s*\{\s*group\s*:\s*true\s*\}\s*\}/s); expect(out.query).toMatch(/aggregate\s*\{[^}]*\bIndustry\s*\{\s*value\s*\}/s); }); it("groupBy with multiple fields sets each key and selects each value", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry", "Name"], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/Industry\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).toMatch(/Name\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).toMatch(/\bIndustry\s*\{\s*value\s*\}/s); expect(out.query).toMatch(/\bName\s*\{\s*value\s*\}/s); }); it("empty groupBy does not emit groupBy arg", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).not.toMatch(/groupBy\s*:/); }); it("date-function groupBy emits { function: CALENDAR_MONTH }", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [{ field: "CreatedDate", function: "CALENDAR_MONTH" }], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch( /groupBy\s*:\s*\{\s*CreatedDate\s*:\s*\{\s*function\s*:\s*CALENDAR_MONTH\s*\}\s*\}/s, ); expect(out.query).not.toMatch(/CreatedDate.*group.*true/s); expect(out.query).toMatch(/CreatedDate\s*\{\s*value\s*\}/s); }); it("mixed groupBy: scalar string + date-function object", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: ["Status", { field: "CreatedDate", function: "CALENDAR_QUARTER" }], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/Status\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).toMatch(/CreatedDate\s*:\s*\{\s*function\s*:\s*CALENDAR_QUARTER\s*\}/s); expect(out.query).toMatch(/Status\s*\{\s*value\s*\}/s); expect(out.query).toMatch(/CreatedDate\s*\{\s*value\s*\}/s); }); it("date-function groupBy field collides with aggregation alias (FR-8.4)", async () => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [{ field: "CreatedDate", function: "CALENDAR_MONTH" }], aggregations: [{ function: "count", alias: "CreatedDate" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate aggregation key 'CreatedDate'/); }); it("duplicate groupBy field rejected", async () => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: ["Status", "Status"], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate groupBy field 'Status'/); }); it("duplicate groupBy field rejected (mixed scalar + date-function)", async () => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [ { field: "CreatedDate", function: "CALENDAR_MONTH" }, { field: "CreatedDate", function: "CALENDAR_YEAR" }, ], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate groupBy field 'CreatedDate'/); }); it("dotted groupBy field rejected (flat-only v1)", async () => { await expect( buildAggregate( { org: ORG, object: "Account", groupBy: ["Owner.Name"], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/dotted/i); }); it("filter promotes $vars and emits where arg (FR-8.6)", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "sum", field: "Amount" }], filter: { Status: { eq: "$status" }, Amount: { gt: "$min" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch(/where\s*:\s*\{\s*Status\s*:\s*\{\s*eq\s*:\s*\$status\s*\}/s); expect(out.query).toMatch(/Amount\s*:\s*\{\s*gt\s*:\s*\$min\s*\}/s); // promoted as typed query variables expect(out.query).toMatch(/\$status\s*:\s*String/); expect(out.query).toMatch(/\$min\s*:\s*Float/); }); it("promotes a whole-argument $filter to a typed _Filter variable", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], filter: "$filter", }, noopPrimeDeps(), ); const filter = out.variables.find((v) => v.name === "filter"); expect(filter).toBeDefined(); expect(filter!.type).toBe("Account_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 buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], orderBy: "$sort", }, noopPrimeDeps(), ); const sort = out.variables.find((v) => v.name === "sort"); expect(sort).toBeDefined(); expect(sort!.type).toBe("Account_OrderBy"); expect(sort!.required).toBe(false); expect(out.query).toMatch(/orderBy\s*:\s*\$sort\b/); }); it("promotes a whole-argument $first to a typed Int variable", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], 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 after whole-arg support", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], first: 5, }, noopPrimeDeps(), ); expect(out.variables.find((x) => x.name === "first")).toBeUndefined(); expect(out.query).toMatch(/first\s*:\s*5\b/); }); it("leaf filter promotion still works after whole-arg support", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "count" }], 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("filter without $vars renders inline values", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "count" }], filter: { Status: { eq: "Open" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch(/where\s*:\s*\{\s*Status\s*:\s*\{\s*eq\s*:\s*"Open"\s*\}/s); }); it("orderBy emits on the aggregate connection", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: ["Status"], aggregations: [{ function: "count" }], orderBy: { Status: { order: "DESC" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch(/orderBy\s*:\s*\{\s*Status\s*:\s*\{\s*order\s*:\s*DESC\s*\}\s*\}/s); }); it("orderBy array collapsed to first element with warning", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: ["Status"], aggregations: [{ function: "count" }], orderBy: [{ Status: { order: "ASC" } }, { Amount: { order: "DESC" } }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/orderBy\s*:\s*\{\s*Status\s*:\s*\{\s*order\s*:\s*ASC\s*\}\s*\}/s); expect(out.query).not.toMatch(/Amount.*order/s); expect(out.warnings.some((w) => w.includes("array collapsed to first element"))).toBe(true); }); it("single-element orderBy array does not warn", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: ["Status"], aggregations: [{ function: "count" }], orderBy: [{ Status: { order: "DESC" } }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/orderBy/); expect(out.warnings.some((w) => w.includes("array collapsed"))).toBe(false); }); it("first emits on the aggregate connection", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], first: 5, }, noopPrimeDeps(), ); expect(out.query).toMatch(/first\s*:\s*5/); }); it("pageInfo { hasNextPage endCursor } always emitted", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/pageInfo\s*\{\s*hasNextPage\s*\n?\s*endCursor\s*\}/s); }); it("$after variable always declared", async () => { const out = await buildAggregate( { org: ORG, object: "Account", aggregations: [{ function: "count" }], }, noopPrimeDeps(), ); expect(out.query).toMatch(/\$after\s*:\s*String/); expect(out.query).toMatch(/after\s*:\s*\$after/); }); it("top-N composition: first + orderBy + groupBy", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], orderBy: { Name: { order: "DESC" } }, first: 5, }, noopPrimeDeps(), ); expect(out.query).toMatch(/first\s*:\s*5/); expect(out.query).toMatch(/orderBy\s*:\s*\{/s); expect(out.query).toMatch(/groupBy\s*:\s*\{/s); expect(out.query).toMatch(/pageInfo\s*\{/s); }); it("aggregate orderBy with function (AggregateOrderByStringClause)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count" }], orderBy: { Description: { function: "COUNT", order: "DESC" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch( /orderBy\s*:\s*\{\s*Description\s*:\s*\{\s*function\s*:\s*COUNT\s*,\s*order\s*:\s*DESC\s*\}\s*\}/s, ); }); it("aggregate orderBy with number function (AggregateOrderByNumberClause)", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: ["Status"], aggregations: [{ function: "sum", field: "Amount" }], orderBy: { Amount: { function: "SUM", order: "DESC" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch( /orderBy\s*:\s*\{\s*Amount\s*:\s*\{\s*function\s*:\s*SUM\s*,\s*order\s*:\s*DESC\s*\}\s*\}/s, ); }); it("top-N with date-function groupBy + aggregate orderBy", async () => { const out = await buildAggregate( { org: ORG, object: "Order", groupBy: [{ field: "CreatedDate", function: "CALENDAR_MONTH" }], aggregations: [{ function: "sum", field: "Amount" }], orderBy: { Amount: { function: "SUM", order: "DESC" } }, first: 5, }, noopPrimeDeps(), ); expect(out.query).toMatch(/first\s*:\s*5/); expect(out.query).toMatch(/CreatedDate\s*:\s*\{\s*function\s*:\s*CALENDAR_MONTH\s*\}/s); expect(out.query).toMatch(/Amount\s*:\s*\{\s*function\s*:\s*SUM\s*,\s*order\s*:\s*DESC\s*\}/s); expect(out.query).toMatch(/pageInfo\s*\{/s); }); it("multi-field groupBy with aggregate orderBy", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry", "Name"], aggregations: [{ function: "count" }], orderBy: { Description: { function: "COUNT", order: "DESC" } }, }, noopPrimeDeps(), ); expect(out.query).toMatch(/Industry\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).toMatch(/Name\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).toMatch( /Description\s*:\s*\{\s*function\s*:\s*COUNT\s*,\s*order\s*:\s*DESC\s*\}/s, ); }); it("no aggregations → empty aggregate selection (no error)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], }, noopPrimeDeps(), ); // groupBy still applies; no count entries expect(out.query).toMatch(/Industry\s*:\s*\{\s*group\s*:\s*true\s*\}/s); expect(out.query).not.toMatch(/count\s*\{\s*value/); }); it("no aggregations + no groupBy → defaults to count over Id (FR-8.2)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", }, noopPrimeDeps(), ); expect(out.query).toMatch( /aggregate\s*\{[^}]*countId\s*:\s*Id\s*\{\s*count\s*\{\s*value\s*\}\s*\}/s, ); }); it("no aggregations + empty groupBy array → defaults to count over Id (FR-8.2)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [], }, noopPrimeDeps(), ); expect(out.query).toMatch( /aggregate\s*\{[^}]*countId\s*:\s*Id\s*\{\s*count\s*\{\s*value\s*\}\s*\}/s, ); }); it("alias colliding with a groupBy field throws (FR-8.4 cross-source)", async () => { await expect( buildAggregate( { org: ORG, object: "Account", groupBy: ["Industry"], aggregations: [{ function: "count", alias: "Industry" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/duplicate aggregation key 'Industry'/); }); it.each(["", "1foo", "}}__schema{types{name", "foo-bar", "foo bar"] as const)( "alias '%s' is rejected as not a valid GraphQL Name", async (alias) => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "sum", field: "Amount", alias }], }, noopPrimeDeps(), ), ).rejects.toThrow(/not a valid GraphQL Name/); }, ); it.each([" Industry", "Industry ", "", "1Industry"] as const)( "groupBy entry '%s' is rejected as not a valid GraphQL Name", async (field) => { await expect( buildAggregate( { org: ORG, object: "Account", groupBy: [field], aggregations: [{ function: "count" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/not a valid GraphQL Name/); }, ); // W-23204027: `aggregations[].field` was the second unguarded sink (the // schema boundary types it as `z.string()` with no charset). It must be // rejected at the builder layer like `alias`/`groupBy`, not just by the // renderField fail-safe. `Amount } injectedAlias: Name { value` is the // selection-set-breakout payload class from W-22735537. it.each([ "Amount } injectedAlias: Name { value", "}}__schema{types{name", "1foo", "foo bar", ] as const)("aggregation field '%s' is rejected as not a valid GraphQL Name", async (field) => { await expect( buildAggregate( { org: ORG, object: "Order", groupBy: [], aggregations: [{ function: "sum", field }], }, noopPrimeDeps(), ), ).rejects.toThrow(/buildAggregate: field .* is not a valid GraphQL Name/); }); it("rejects an operationName that is not a valid GraphQL Name", async () => { await expect( buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], operationName: "has spaces", }, noopPrimeDeps(), ), ).rejects.toThrow(/buildAggregate: operationName 'has spaces' is not a valid GraphQL Name/); }); it("rejects an object that is not a valid GraphQL Name", async () => { await expect( buildAggregate( { org: ORG, object: "Order Item", groupBy: [], aggregations: [{ function: "count" }] }, noopPrimeDeps(), ), ).rejects.toThrow(/buildAggregate: object 'Order Item' is not a valid GraphQL Name/); }); it("malformed $-prefix in filter surfaces a Variable: warning (no silent failure)", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], filter: { Industry: { eq: "$1var" } }, }, noopPrimeDeps(), ); expect(out.warnings.some((w) => w.startsWith("Variable:") && w.includes("$1var"))).toBe(true); }); // 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 // `buildAggregate` threads into `warnings`. Account_Filter.Industry is a // PicklistOperators (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 buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], filter: { Industry: { eq: "$x" }, AnnualRevenue: { gt: "$x" } }, }, noopPrimeDeps(), ); // A collision warning naming $x is surfaced (not silently dropped). 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(/Industry\s*:\s*\{\s*eq\s*:\s*\$x\s*\}/s); expect(out.query).toMatch(/AnnualRevenue\s*:\s*\{\s*gt\s*:\s*\$x\s*\}/s); }); // C1 (W-22697670): a filter reusing the reserved cursor variable $after must keep // its String type (first-wins) so pagination stays valid, and surface a collision // warning — regression guard for the addVariable("after") ordering in buildAggregate. it("a filter reusing $after keeps String (first-wins) and warns", async () => { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }], filter: { AnnualRevenue: { gt: "$after" } }, }, noopPrimeDeps(), ); // $after stays String (not overwritten to the filter's Float), so pagination is valid. 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); }); // FR-10.2 + FR-10.5: picklist `min`/`max` must emit the picklist literal // union under each aggregator wrapper, not `string | null`. Documented as // e2e Gap 4 — `tryEnrichPicklist` previously bailed when it found // `min`/`max` children on a `PicklistAggregate` and codegen fell back to // the default machinery (string). // // This test seeds ObjectInfo for `Account.Industry` directly via // `setCachedObjectInfo` because the MCP intent layer does not yet pre-warm // ObjectInfo on its own (W-22694063, follow-up PR). With prewarming in // place this test would assert the same expectation without the manual // seed. describe("FR-10.2 picklist literal unions on aggregate min/max", () => { const ACCOUNT_INDUSTRY_VALUES = ["Banking", "Technology", "Energy"]; function seedAccountIndustry(): void { const info: ObjectInfoResult = { apiName: "Account", label: "Account", labelPlural: "Accounts", createable: true, deletable: true, updateable: true, queryable: true, searchable: true, custom: false, keyPrefix: "001", nameFields: ["Name"], defaultRecordTypeId: null, fields: [], childRelationships: [], recordTypeInfos: [], picklists: [ { apiName: "Industry", label: "Industry", required: false, values: ACCOUNT_INDUSTRY_VALUES.map((v) => ({ value: v, label: v })), }, ], fetchedAt: new Date().toISOString(), }; setCachedObjectInfo(ORG, "Account", info); } // Aggregate aliases wrap the function-named child, which itself wraps the // `value`. So picklist min/max emits, e.g. // minIndustry: { min: { value: AccountIndustry | null } | null } | null; // per FR-10.5's "underlying field's wrapper type" combined with FR-10.2's // picklist literal union mandate. it("min/max on Industry emit `{ : { value: AccountIndustry | null } | null } | null`", async () => { clearObjectInfoCache(ORG); seedAccountIndustry(); try { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [ { function: "min", field: "Industry" }, { function: "max", field: "Industry" }, ], }, noopPrimeDeps(), ); expect(out.types).toMatch( /AccountIndustry\s*=\s*"Banking"\s*\|\s*"Technology"\s*\|\s*"Energy"/, ); expect(out.types).toMatch( /minIndustry\s*:\s*\{\s*min\s*:\s*\{\s*value\s*:\s*AccountIndustry\s*\|\s*null\s*\}\s*\|\s*null;\s*\}\s*\|\s*null;/, ); expect(out.types).toMatch( /maxIndustry\s*:\s*\{\s*max\s*:\s*\{\s*value\s*:\s*AccountIndustry\s*\|\s*null\s*\}\s*\|\s*null;\s*\}\s*\|\s*null;/, ); expect(out.types).not.toMatch(/min\s*:\s*\{\s*value\s*:\s*string\s*\|\s*null/); } finally { clearObjectInfoCache(ORG); } }); it("count on a picklist-typed field stays number-shaped", async () => { clearObjectInfoCache(ORG); seedAccountIndustry(); try { const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count", field: "Industry" }], }, noopPrimeDeps(), ); expect(out.types).toMatch( /countIndustry\s*:\s*\{\s*count\s*:\s*\{\s*value\s*:\s*number\s*\|\s*null\s*\}\s*\|\s*null;\s*\}\s*\|\s*null;/, ); } finally { clearObjectInfoCache(ORG); } }); it("ObjectInfo missing → falls back to string (no throw, no union)", async () => { clearObjectInfoCache(ORG); const out = await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "min", field: "Industry" }], }, noopPrimeDeps(), ); expect(out.types).not.toMatch(/AccountIndustry\s*=/); // Degraded shape — value typed as string under the wrapper, not the // picklist literal union. Confirms the fix is dormant without // ObjectInfo and that there's no throw/regression in the absence of // the planned prewarm helper (W-22694063). expect(out.types).toMatch(/min\s*:\s*\{\s*value\s*:\s*string\s*\|\s*null/); }); }); it("threads instanceUrl as 3rd arg to createSession", async () => { const spy = vi.mocked(sessionModule.createSession); spy.mockClear(); await buildAggregate( { org: ORG, object: "Account", groupBy: [], aggregations: [{ function: "count" }] }, noopPrimeDeps(), ); expect(spy).toHaveBeenCalledWith(ORG, "aggregate", ORG_URL); }); }); describe("intent/build-aggregate — selection-set injection (W-22735537)", () => { it("rejects an aggregations[].field breakout", async () => { await expect( buildAggregate( { org: ORG, object: "Account", aggregations: [{ function: "sum", field: "Amount } evil { value" }], }, noopPrimeDeps(), ), ).rejects.toThrow(/buildAggregate: field .* is not a valid GraphQL Name/); }); it("rejects a filter object-KEY breakout", async () => { await expect( buildAggregate( { org: ORG, object: "Account", aggregations: [{ function: "count" }], filter: { "Industry } evil { value": { eq: "x" } }, }, noopPrimeDeps(), ), ).rejects.toThrow(/key '.*' is not a valid GraphQL Name/); }); });