mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
test(core): Add durable scheduler poll cursors e2e tests (no-changelog) (#35343)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Lorent Lempereur <lorent.lempereur@n8n.io>
This commit is contained in:
@@ -12,6 +12,12 @@ interface PollResponseBody {
|
||||
items?: IDataObject[];
|
||||
}
|
||||
|
||||
const highestItemId = (items: IDataObject[], startingFrom: number) =>
|
||||
items.reduce((highest, item) => {
|
||||
const id = Number(item.id);
|
||||
return Number.isFinite(id) && id > highest ? id : highest;
|
||||
}, startingFrom);
|
||||
|
||||
export class E2eTestPollingTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'E2E Test Polling Trigger',
|
||||
@@ -53,8 +59,23 @@ export class E2eTestPollingTrigger implements INodeType {
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
|
||||
if (!body.items?.length) return null;
|
||||
const items = body.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return [this.helpers.returnJsonArray(body.items)];
|
||||
const nodeStaticData = this.getWorkflowStaticData('node');
|
||||
const lastItemId =
|
||||
typeof nodeStaticData.lastItemId === 'number' ? nodeStaticData.lastItemId : null;
|
||||
|
||||
const newItems = items.filter((item) => {
|
||||
const id = Number(item.id);
|
||||
return Number.isFinite(id) && (lastItemId === null || id > lastItemId);
|
||||
});
|
||||
|
||||
// Set even when nothing new is emitted below, so the advance still persists.
|
||||
nodeStaticData.lastItemId = highestItemId(items, lastItemId ?? 0);
|
||||
|
||||
if (newItems.length === 0) return null;
|
||||
|
||||
return [this.helpers.returnJsonArray(newItems)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { IDataObject, INode, IPollFunctions, PollCursor } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { E2eTestPollingTrigger } from '../E2eTestPollingTrigger.node';
|
||||
|
||||
describe('E2eTestPollingTrigger', () => {
|
||||
const node: INode = {
|
||||
id: 'poll-node-id',
|
||||
name: 'E2E Test Polling Trigger',
|
||||
type: 'n8n-nodes-base.e2eTestPollingTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
let trigger: E2eTestPollingTrigger;
|
||||
let pollFunctions: Mocked<IPollFunctions>;
|
||||
let nodeStaticData: PollCursor;
|
||||
|
||||
const givenCursor = (cursor: PollCursor | null) => {
|
||||
nodeStaticData = cursor ? { ...cursor } : {};
|
||||
};
|
||||
|
||||
const givenResponse = (body: unknown) => {
|
||||
(pollFunctions.helpers.httpRequest as Mock).mockResolvedValue(body);
|
||||
};
|
||||
|
||||
const emittedJson = (result: Awaited<ReturnType<E2eTestPollingTrigger['poll']>>) =>
|
||||
result?.[0].map((item) => item.json);
|
||||
|
||||
beforeEach(() => {
|
||||
trigger = new E2eTestPollingTrigger();
|
||||
pollFunctions = mockDeep<IPollFunctions>();
|
||||
|
||||
pollFunctions.getNode.mockReturnValue(node);
|
||||
pollFunctions.getNodeParameter.mockReturnValue('http://poll.test/items');
|
||||
(pollFunctions.helpers.returnJsonArray as Mock).mockImplementation((data: IDataObject[]) =>
|
||||
data.map((json, index) => ({ json, pairedItem: { item: index } })),
|
||||
);
|
||||
givenCursor(null);
|
||||
pollFunctions.getWorkflowStaticData.mockImplementation(() => nodeStaticData);
|
||||
});
|
||||
|
||||
it('should emit every item and set the highest id on its first poll', async () => {
|
||||
givenResponse({ items: [{ id: 1 }, { id: 2 }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(emittedJson(result)).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 2 });
|
||||
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
|
||||
});
|
||||
|
||||
it('should never emit an item with a non-numeric or missing id, on a first poll', async () => {
|
||||
givenResponse({ items: [{ id: 1 }, { id: 'not-a-number' }, { foo: 'bar' }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(emittedJson(result)).toEqual([{ id: 1 }]);
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 1 });
|
||||
});
|
||||
|
||||
it('should never emit an item with a non-numeric or missing id, on a later poll', async () => {
|
||||
givenCursor({ lastItemId: 2 });
|
||||
givenResponse({ items: [{ id: 3 }, { id: 'not-a-number' }, { foo: 'bar' }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(emittedJson(result)).toEqual([{ id: 3 }]);
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 3 });
|
||||
});
|
||||
|
||||
it('should emit only the items above the cursor and advance it', async () => {
|
||||
givenCursor({ lastItemId: 2 });
|
||||
givenResponse({ items: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(emittedJson(result)).toEqual([{ id: 3 }, { id: 4 }]);
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 4 });
|
||||
});
|
||||
|
||||
it('should emit nothing and hold the cursor when the endpoint repeats known items', async () => {
|
||||
givenCursor({ lastItemId: 2 });
|
||||
givenResponse({ items: [{ id: 1 }, { id: 2 }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 2 });
|
||||
});
|
||||
|
||||
it('should emit nothing and leave the cursor untouched when the endpoint returns no items', async () => {
|
||||
givenCursor({ lastItemId: 2 });
|
||||
givenResponse({ items: [] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 2 });
|
||||
});
|
||||
|
||||
it('should treat an unusable cursor value as a first poll', async () => {
|
||||
givenCursor({ lastItemId: 'not-a-number' });
|
||||
givenResponse({ items: [{ id: 7 }] });
|
||||
|
||||
const result = await trigger.poll.call(pollFunctions);
|
||||
|
||||
expect(emittedJson(result)).toEqual([{ id: 7 }]);
|
||||
expect(nodeStaticData).toEqual({ lastItemId: 7 });
|
||||
});
|
||||
|
||||
it('should raise a node operation error when the endpoint fails', async () => {
|
||||
(pollFunctions.helpers.httpRequest as Mock).mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await expect(trigger.poll.call(pollFunctions)).rejects.toThrow(NodeOperationError);
|
||||
expect(nodeStaticData).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user