Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 94 additions & 7 deletions src/methods/validate-fixture-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isAbstractType,
isObjectType,
getNamedType,
GraphQLCompositeType,
} from "graphql";
import { inlineNamedFragmentSpreads } from "../utils/inline-named-fragment-spreads.js";

Expand Down Expand Up @@ -37,6 +38,7 @@ export function validateFixtureInput(
const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST);
const typeInfo = new TypeInfo(schema);
const valueStack: any[][] = [[value]];
const expectedFieldsStack: Set<string>[] = [new Set()]; // Initial set tracks root level fields
const errors: string[] = [];
visit(
inlineFragmentSpreadsAst,
Expand All @@ -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;

Expand All @@ -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)) {
Expand Down Expand Up @@ -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`);
Expand All @@ -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<string>());
}

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: {
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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>
): 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;
}
Loading