mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 02:59:27 +02:00
test: Strengthen getParentNodes tests to raise mutation score (#31759)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
91182ed02b
commit
6cfcc4c386
254
packages/workflow/test/get-parent-nodes.test.ts
Normal file
254
packages/workflow/test/get-parent-nodes.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { getParentNodes } from '../src/common/get-parent-nodes';
|
||||
import type { IConnections } from '../src/interfaces';
|
||||
import { NodeConnectionTypes } from '../src/interfaces';
|
||||
|
||||
// `getParentNodes` is a thin wrapper around `getConnectedNodes` operating on
|
||||
// destination-indexed connections. To pin its observable contract (and kill
|
||||
// the small but high-leverage set of Stryker mutants on this file — block
|
||||
// removal, default-value tampering on `type` and `depth`, argument passthrough),
|
||||
// we exercise every default explicitly and assert on identity, ordering and
|
||||
// depth boundaries rather than length alone.
|
||||
|
||||
describe('getParentNodes', () => {
|
||||
// A → B → C → D, indexed by destination so each node lists its incoming.
|
||||
const linearMainChain: IConnections = {
|
||||
D: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
C: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
B: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
it('returns an array', () => {
|
||||
// Kills BlockStatement mutation that removes the body and makes the
|
||||
// function implicitly return undefined.
|
||||
const result = getParentNodes(linearMainChain, 'D');
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses NodeConnectionTypes.Main as the default type', () => {
|
||||
// If the default were anything other than 'main' (the Main enum value),
|
||||
// MainParent would not be returned because it is wired through Main.
|
||||
const mixedTypes: IConnections = {
|
||||
Root: {
|
||||
[NodeConnectionTypes.Main]: [
|
||||
[{ node: 'MainParent', type: NodeConnectionTypes.Main, index: 0 }],
|
||||
],
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ToolParent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(mixedTypes, 'Root');
|
||||
|
||||
expect(result).toEqual(['MainParent']);
|
||||
expect(result).not.toContain('ToolParent');
|
||||
});
|
||||
|
||||
it('uses unlimited depth (-1) by default and traverses the full chain', () => {
|
||||
// Pins the default for `depth`: a mutation that flips `-1` to `+1`
|
||||
// or `0` would only return direct parents (or none), so we assert the
|
||||
// full chain back to the root.
|
||||
const result = getParentNodes(linearMainChain, 'D');
|
||||
|
||||
expect(result).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('returns direct parents (depth boundary) when depth is 1', () => {
|
||||
const result = getParentNodes(linearMainChain, 'D', NodeConnectionTypes.Main, 1);
|
||||
|
||||
expect(result).toEqual(['C']);
|
||||
});
|
||||
|
||||
it('returns parents up to depth 2', () => {
|
||||
const result = getParentNodes(linearMainChain, 'D', NodeConnectionTypes.Main, 2);
|
||||
|
||||
expect(result).toEqual(['B', 'C']);
|
||||
});
|
||||
|
||||
it('returns an empty array when depth is 0', () => {
|
||||
const result = getParentNodes(linearMainChain, 'D', NodeConnectionTypes.Main, 0);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the destination node has no incoming connections', () => {
|
||||
const result = getParentNodes(linearMainChain, 'A');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the destination node is absent from the connections map', () => {
|
||||
const result = getParentNodes(linearMainChain, 'NotAnyNodeInTheGraph');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by an explicit non-main connection type', () => {
|
||||
const mixedTypes: IConnections = {
|
||||
Root: {
|
||||
[NodeConnectionTypes.Main]: [
|
||||
[{ node: 'MainParent', type: NodeConnectionTypes.Main, index: 0 }],
|
||||
],
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ToolParent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(mixedTypes, 'Root', NodeConnectionTypes.AiTool);
|
||||
|
||||
expect(result).toEqual(['ToolParent']);
|
||||
expect(result).not.toContain('MainParent');
|
||||
});
|
||||
|
||||
it('returns parents across all types when type is "ALL"', () => {
|
||||
const mixedTypes: IConnections = {
|
||||
Root: {
|
||||
[NodeConnectionTypes.Main]: [
|
||||
[{ node: 'MainParent', type: NodeConnectionTypes.Main, index: 0 }],
|
||||
],
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ToolParent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(mixedTypes, 'Root', 'ALL');
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(['MainParent', 'ToolParent']));
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns only non-main parents when type is "ALL_NON_MAIN"', () => {
|
||||
const mixedTypes: IConnections = {
|
||||
Root: {
|
||||
[NodeConnectionTypes.Main]: [
|
||||
[{ node: 'MainParent', type: NodeConnectionTypes.Main, index: 0 }],
|
||||
],
|
||||
[NodeConnectionTypes.AiTool]: [
|
||||
[{ node: 'ToolParent', type: NodeConnectionTypes.AiTool, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(mixedTypes, 'Root', 'ALL_NON_MAIN');
|
||||
|
||||
expect(result).toEqual(['ToolParent']);
|
||||
expect(result).not.toContain('MainParent');
|
||||
});
|
||||
|
||||
it('passes the nodeName argument through (different names yield different parents)', () => {
|
||||
// Pins the `nodeName` argument-passthrough: if the call site dropped
|
||||
// or swapped this argument the per-node parents would not match.
|
||||
const branched: IConnections = {
|
||||
Leaf1: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Mid1', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Leaf2: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Mid2', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Mid1: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Root', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Mid2: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Root', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getParentNodes(branched, 'Leaf1')).toEqual(['Root', 'Mid1']);
|
||||
expect(getParentNodes(branched, 'Leaf2')).toEqual(['Root', 'Mid2']);
|
||||
});
|
||||
|
||||
it('handles diamond ancestors without duplicating shared parents', () => {
|
||||
// Root
|
||||
// / \
|
||||
// Mid1 Mid2
|
||||
// \ /
|
||||
// Leaf
|
||||
const diamond: IConnections = {
|
||||
Leaf: {
|
||||
[NodeConnectionTypes.Main]: [
|
||||
[
|
||||
{ node: 'Mid1', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'Mid2', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
Mid1: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Root', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Mid2: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'Root', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(diamond, 'Leaf');
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual(expect.arrayContaining(['Root', 'Mid1', 'Mid2']));
|
||||
// Shared ancestor is listed exactly once.
|
||||
expect(result.filter((n) => n === 'Root')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('terminates on cycles without revisiting nodes', () => {
|
||||
// A → B → A creates a cycle through main connections; the traversal
|
||||
// must stop rather than blow the stack and must not list duplicates.
|
||||
const cycle: IConnections = {
|
||||
A: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
B: {
|
||||
[NodeConnectionTypes.Main]: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getParentNodes(cycle, 'A');
|
||||
|
||||
expect(result).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('does not mutate the input connections map', () => {
|
||||
const before = JSON.stringify(linearMainChain);
|
||||
|
||||
getParentNodes(linearMainChain, 'D');
|
||||
|
||||
expect(JSON.stringify(linearMainChain)).toBe(before);
|
||||
});
|
||||
|
||||
it('treats an unknown explicit connection type as having no parents', () => {
|
||||
const result = getParentNodes(
|
||||
linearMainChain,
|
||||
'D',
|
||||
NodeConnectionTypes.AiTool /* no AiTool wired in this graph */,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects depth across a non-main connection type', () => {
|
||||
// Cross-checks that `type` and `depth` are both honoured when explicit:
|
||||
// a 3-hop AiTool chain should be fully traversed by default depth, and
|
||||
// truncated at depth 1.
|
||||
const aiToolChain: IConnections = {
|
||||
Z: {
|
||||
[NodeConnectionTypes.AiTool]: [[{ node: 'Y', type: NodeConnectionTypes.AiTool, index: 0 }]],
|
||||
},
|
||||
Y: {
|
||||
[NodeConnectionTypes.AiTool]: [[{ node: 'X', type: NodeConnectionTypes.AiTool, index: 0 }]],
|
||||
},
|
||||
X: {
|
||||
[NodeConnectionTypes.AiTool]: [[{ node: 'W', type: NodeConnectionTypes.AiTool, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getParentNodes(aiToolChain, 'Z', NodeConnectionTypes.AiTool)).toEqual(['W', 'X', 'Y']);
|
||||
expect(getParentNodes(aiToolChain, 'Z', NodeConnectionTypes.AiTool, 1)).toEqual(['Y']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user