diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index c551274..11d489c 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -10,6 +10,7 @@ import { isAbstractType, isObjectType, getNamedType, + GraphQLCompositeType, } from "graphql"; import { inlineNamedFragmentSpreads } from "../utils/inline-named-fragment-spreads.js"; @@ -37,6 +38,7 @@ export function validateFixtureInput( const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST); const typeInfo = new TypeInfo(schema); const valueStack: any[][] = [[value]]; + const expectedFieldsStack: Set[] = [new Set()]; // Initial set tracks root level fields const errors: string[] = []; visit( inlineFragmentSpreadsAst, @@ -48,6 +50,9 @@ export function validateFixtureInput( const responseKey = node.alias?.value || node.name.value; + // Track this field as expected in the parent's set + expectedFieldsStack[expectedFieldsStack.length - 1].add(responseKey); + const fieldDefinition = typeInfo.getFieldDef(); const fieldType = fieldDefinition?.type; @@ -56,7 +61,15 @@ export function validateFixtureInput( // Field is missing from fixture if (valueForResponseKey === undefined) { - errors.push(`Missing expected fixture data for ${responseKey}`); + const parentType = typeInfo.getParentType(); + if (!parentType) { + // This shouldn't happen with a valid query and schema - TypeInfo should always + // provide parent type information when traversing fields. This check is here to + // satisfy TypeScript's type requirements (getParentType() can return null). + errors.push(`Cannot validate ${responseKey}: missing parent type information`); + } else if (isValueExpectedForType(currentValue, parentType)) { + errors.push(`Missing expected fixture data for ${responseKey}`); + } } // Scalars and Enums (including wrapped types) else if (isInputType(fieldType)) { @@ -100,10 +113,6 @@ export function validateFixtureInput( } } // Objects - validate and add to traversal stack - // Note: Abstract types (unions/interfaces) are handled in a limited way. - // We add them to the traversal stack but don't use __typename to discriminate - // between concrete types. This works for simple cases where all items are the - // same type, but doesn't support mixed-type arrays (see skipped test). else if (isObjectType(unwrappedFieldType) || isAbstractType(unwrappedFieldType)) { if (valueForResponseKey === null) { errors.push(`Expected object for ${responseKey}, but got null`); @@ -124,10 +133,21 @@ export function validateFixtureInput( } } + // If this field has nested selections, prepare to track expected child fields + if (node.selectionSet) { + expectedFieldsStack.push(new Set()); + } + valueStack.push(nestedValues); }, - leave() { - valueStack.pop(); + leave(node) { + const nestedValues = valueStack.pop()!; + + // If this field had nested selections, check for extra fields + if (node.selectionSet) { + const expectedFields = expectedFieldsStack.pop()!; + errors.push(...checkForExtraFields(nestedValues, expectedFields)); + } }, }, SelectionSet: { @@ -155,6 +175,11 @@ export function validateFixtureInput( }, }) ); + + // The query's root SelectionSet has no parent Field node, so there's no Field.leave event to check it. + // We manually perform the same check here that would happen in Field.leave for nested objects. + errors.push(...checkForExtraFields(valueStack[0], expectedFieldsStack[0])); + return { errors }; } @@ -219,4 +244,66 @@ function processNestedArrays( } return { values: result, errors }; +} + +/** + * Determines if a fixture value is expected for a given parent type based on its __typename. + * + * @param fixtureValue - The fixture value to check + * @param parentType - The parent type from typeInfo (concrete type if inside inline fragment, abstract if on union/interface) + * @returns True if the value is expected for the parent type, false otherwise + * + * @remarks + * When the parent type is abstract (union/interface), all values are expected. + * When the parent type is concrete (inside an inline fragment), only values + * whose __typename matches the concrete type are expected. + */ +function isValueExpectedForType( + fixtureValue: any, + parentType: GraphQLCompositeType +): boolean { + // If parent type is abstract (union/interface), all values are expected. + // This means we're validating a field selected directly on the abstract type (e.g., __typename on a union), + // so it should be present on all values regardless of their concrete type. + if (isAbstractType(parentType)) { + return true; + } + + // Parent is a concrete type - check if fixture value's __typename matches + const valueTypename = fixtureValue.__typename; + if (!valueTypename) { + // No __typename in value - can't discriminate, so expect it + return true; + } + + // Only expect the value for this type if its __typename matches the parent type + return valueTypename === parentType.name; +} + +/** + * Checks fixture objects for fields that are not present in the GraphQL query. + * + * @param fixtureObjects - Array of fixture objects to validate + * @param expectedFields - Set of field names that are expected based on the query + * @returns Array of error messages for any extra fields found (empty if valid) + * + * @remarks + * Only validates object types - skips null values and arrays. + */ +function checkForExtraFields( + fixtureObjects: any[], + expectedFields: Set +): string[] { + const errors: string[] = []; + for (const fixtureObject of fixtureObjects) { + if (typeof fixtureObject === "object" && fixtureObject !== null && !Array.isArray(fixtureObject)) { + const fixtureFields = Object.keys(fixtureObject); + for (const fixtureField of fixtureFields) { + if (!expectedFields.has(fixtureField)) { + errors.push(`Extra field "${fixtureField}" found in fixture data not in query`); + } + } + } + } + return errors; } \ No newline at end of file diff --git a/test/methods/validate-fixture-input.test.ts b/test/methods/validate-fixture-input.test.ts index 51b4d5f..0d696b0 100644 --- a/test/methods/validate-fixture-input.test.ts +++ b/test/methods/validate-fixture-input.test.ts @@ -74,16 +74,12 @@ describe("validateFixtureInput", () => { firstItems: [ { id: "gid://test/Item/1", - count: 5, - details: { - name: "First Item" - } + count: 5 } ], secondItems: [ { id: "gid://test/Item/1", - count: 5, details: { name: "First Item" } @@ -129,11 +125,7 @@ describe("validateFixtureInput", () => { expect(result.errors).toHaveLength(0); }); - // This test is skipped because the validator doesn't yet support unions where - // different items in the array can be different types. Currently, it expects - // all fields from all inline fragments to be present in every item, instead of - // filtering by __typename. - it.skip("handles inline fragments with multiple types in union", () => { + it("handles inline fragments with multiple types in union", () => { const queryAST = parse(` query { data { @@ -174,6 +166,37 @@ describe("validateFixtureInput", () => { expect(result.errors).toHaveLength(0); }); + it("handles single inline fragment on union without __typename", () => { + const queryAST = parse(` + query { + data { + searchResults { + ... on Item { + id + count + } + } + } + } + `); + + const fixtureInput = { + data: { + searchResults: [ + { + id: "gid://test/Item/1", + count: 5 + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // With only one inline fragment, no __typename is needed for discrimination + expect(result.errors).toHaveLength(0); + }); + it("handles nested inline fragments", () => { const queryAST = parse(` query { @@ -725,40 +748,6 @@ describe("validateFixtureInput", () => { ); }); - // This test is skipped because the validator doesn't yet detect extra fields - // in fixture data that aren't present in the query. Currently, it only validates - // that all required fields from the query are present in the fixture, but doesn't - // flag additional fields that shouldn't be there. - it.skip("detects extra fields not in query", () => { - const queryAST = parse(` - query Query { - data { - items { - id - } - } - } - `); - - const fixtureInput = { - data: { - items: [ - { - id: "gid://test/Item/1", - count: 5, - } - ] - } - }; - - const result = validateFixtureInput(queryAST, schema, fixtureInput); - - // When implemented, should detect that 'count' is not in the query - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('count'); - expect(result.errors[0]).toContain('not in query'); - }); - it("detects type mismatches (object vs scalar)", () => { const queryAST = parse(` query Query { @@ -938,5 +927,311 @@ describe("validateFixtureInput", () => { expect(result.errors).toHaveLength(1); expect(result.errors[0]).toBe('Cannot validate nonExistentField: missing type information'); }); + + it("detects missing fields when __typename is not selected in union with inline fragments", () => { + const queryAST = parse(` + query { + data { + searchResults { + ... on Item { + id + count + } + ... on Metadata { + email + phone + } + } + } + } + `); + + const fixtureInput = { + data: { + searchResults: [ + { + id: "gid://test/Item/1", + count: 5 + }, + { + email: "test@example.com", + phone: "555-0001" + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Without __typename, we can't discriminate which fields are expected for each object + // So the validator conservatively expects all fields from all inline fragments + // First error: Missing __typename field for abstract type (required for discrimination) + // First object is missing email and phone (from Metadata fragment) + // Second object is missing id and count (from Item fragment) + expect(result.errors).toHaveLength(5); + expect(result.errors[0]).toBe("Missing __typename field for abstract type SearchResult"); + expect(result.errors[1]).toBe("Missing expected fixture data for id"); + expect(result.errors[2]).toBe("Missing expected fixture data for count"); + expect(result.errors[3]).toBe("Missing expected fixture data for email"); + expect(result.errors[4]).toBe("Missing expected fixture data for phone"); + }); + + it("detects extra fields not in query", () => { + const queryAST = parse(` + query Query { + data { + items { + id + } + } + } + `); + + const fixtureInput = { + data: { + items: [ + { + id: "gid://test/Item/1", + count: 5, + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect that 'count' is not in the query + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields with multiple aliases for the same field", () => { + const queryAST = parse(` + query Query { + data { + firstItems: items { + id + count + } + secondItems: items { + id + details { + name + } + } + } + } + `); + + const fixtureInput = { + data: { + firstItems: [ + { + id: "gid://test/Item/1", + count: 5, + details: { + name: "First Item" + } + } + ], + secondItems: [ + { + id: "gid://test/Item/1", + count: 5, + details: { + name: "First Item" + } + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Each alias is validated independently, so extra fields in each should be detected + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toBe('Extra field "details" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields in inline fragments with __typename discrimination", () => { + const queryAST = parse(` + query { + data { + searchResults { + __typename + ... on Item { + id + } + ... on Metadata { + email + nonExistentField + } + } + } + } + `); + + const fixtureInput = { + data: { + searchResults: [ + { + __typename: "Item", + id: "gid://test/Item/1", + count: 5 + }, + { + __typename: "Metadata", + email: "test@example.com", + phone: "555-0001", + nonExistentField: "not a real field" + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect: + // 1. nonExistentField in query but not in schema (missing type information) + // 2. count field in Item fixture but not selected in query (extra field) + // 3. phone field in Metadata fixture but not selected in query (extra field) + expect(result.errors).toHaveLength(3); + expect(result.errors[0]).toBe('Cannot validate nonExistentField: missing type information'); + expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "phone" found in fixture data not in query'); + }); + + it("detects extra fields in nested objects", () => { + const queryAST = parse(` + query { + data { + items { + id + details { + name + } + } + } + } + `); + + const fixtureInput = { + data: { + items: [ + { + id: "gid://test/Item/1", + details: { + name: "Test Item", + id: "gid://test/ItemDetails/1" + } + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect 'id' field in details object that wasn't selected + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe('Extra field "id" found in fixture data not in query'); + }); + + it("detects extra fields at root level", () => { + const queryAST = parse(` + query { + data { + items { + id + } + } + } + `); + + const fixtureInput = { + data: { + items: [ + { + id: "gid://test/Item/1" + } + ] + }, + extraRootField: "should not be here" + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect 'extraRootField' at the actual root level (same level as 'data') + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe('Extra field "extraRootField" found in fixture data not in query'); + }); + + it("detects extra fields in nested arrays [[Item]]", () => { + const queryAST = parse(` + query { + data { + itemMatrix { + id + } + } + } + `); + + const fixtureInput = { + data: { + itemMatrix: [ + [ + { id: "1", count: 10 }, + { id: "2", count: 20 } + ], + null, + [ + { id: "3", count: 30 } + ] + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect 'count' field in all nested items + expect(result.errors).toHaveLength(3); + expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields with named fragments", () => { + const queryAST = parse(` + fragment ItemFields on Item { + id + } + + query { + data { + items { + ...ItemFields + } + } + } + `); + + const fixtureInput = { + data: { + items: [ + { + id: "gid://test/Item/1", + count: 5 + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect 'count' field that wasn't selected in fragment + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + }); }); }); diff --git a/test/methods/validate-test-assets.test.ts b/test/methods/validate-test-assets.test.ts index e05c2b6..0984e87 100644 --- a/test/methods/validate-test-assets.test.ts +++ b/test/methods/validate-test-assets.test.ts @@ -140,10 +140,11 @@ describe('validateTestAssets', () => { expect(result.inputQuery.errors).toHaveLength(0); - // Input fixture should be invalid due to missing fields - expect(result.inputFixture.errors.length).toBe(2); + // Input fixture should be invalid due to missing fields and extra field + expect(result.inputFixture.errors.length).toBe(3); expect(result.inputFixture.errors[0]).toBe('Missing expected fixture data for details'); - expect(result.inputFixture.errors[1]).toBe('Missing expected fixture data for metadata'); + expect(result.inputFixture.errors[1]).toBe('Extra field "invalidField" found in fixture data not in query'); + expect(result.inputFixture.errors[2]).toBe('Missing expected fixture data for metadata'); expect(result.outputFixture.errors).toHaveLength(0); });