mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Confluence Node): Add CQL search operation (no-changelog) (#36589)
This commit is contained in:
@@ -315,7 +315,7 @@ export function parsePositiveInt(
|
||||
): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
throw new NodeOperationError(this.getNode(), `${label} must be a number of at least 1`, {
|
||||
throw new NodeOperationError(this.getNode(), `${label} must be a finite number of at least 1`, {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as attachment from './attachment';
|
||||
import * as page from './page';
|
||||
import * as search from './search';
|
||||
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
|
||||
|
||||
export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
@@ -42,10 +43,15 @@ export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
name: 'Page',
|
||||
value: 'page',
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
value: 'search',
|
||||
},
|
||||
],
|
||||
default: 'page',
|
||||
},
|
||||
...attachment.description,
|
||||
...page.description,
|
||||
...search.description,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import type { ConfluenceBodyFormat } from '../common';
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
extractNextCursor,
|
||||
optionalSpaceRLC,
|
||||
pageRLC,
|
||||
parsePositiveInt,
|
||||
resolvePageId,
|
||||
shapeBody,
|
||||
} from '../common';
|
||||
@@ -187,13 +187,12 @@ export const execute: ConfluenceOperation = async function (
|
||||
return shapeBody(page, bodyFormat);
|
||||
}
|
||||
|
||||
const rawMaxPages = this.getNodeParameter('maxPages', itemIndex, 100) as number;
|
||||
if (!Number.isFinite(rawMaxPages) || rawMaxPages < 1) {
|
||||
throw new NodeOperationError(this.getNode(), 'Max Pages must be a number of at least 1', {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
const maxPages = Math.floor(rawMaxPages);
|
||||
const maxPages = parsePositiveInt.call(
|
||||
this,
|
||||
this.getNodeParameter('maxPages', itemIndex, 100),
|
||||
'Max Pages',
|
||||
itemIndex,
|
||||
);
|
||||
const descendantIds = await collectDescendantPageIds.call(
|
||||
this,
|
||||
pageId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as attachment from './attachment';
|
||||
import * as page from './page';
|
||||
import * as search from './search';
|
||||
|
||||
/**
|
||||
* Compile-checked contract for operation modules. The router calls
|
||||
@@ -53,6 +54,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
case 'page:update':
|
||||
responseData = await page.update.execute.call(this, i);
|
||||
break;
|
||||
case 'search:query':
|
||||
responseData = await search.query.execute.call(this, i);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as query from './query.operation';
|
||||
|
||||
export { query };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['search'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Query',
|
||||
value: 'query',
|
||||
description: 'Search content with a CQL query',
|
||||
action: 'Perform a query',
|
||||
},
|
||||
],
|
||||
default: 'query',
|
||||
},
|
||||
...query.description,
|
||||
];
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { returnAllOrLimit } from '@utils/descriptions';
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import type { NextPageParam } from '../common';
|
||||
import { extractNextPageParam, parsePositiveInt } from '../common';
|
||||
import type { ConfluenceOperation } from '../router';
|
||||
|
||||
const SEARCH_PAGE_SIZE = 50;
|
||||
// Search post-filters results by permission, so empty pages mid-stream are legitimate
|
||||
const MAX_CONSECUTIVE_EMPTY_PAGES = 5;
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Query (CQL)',
|
||||
name: 'cql',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. type = page AND space = "DOCS" AND text ~ "roadmap"',
|
||||
description:
|
||||
'The CQL query to run. See <a href="https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/" target="_blank">Atlassian\'s CQL reference</a> for the syntax.',
|
||||
},
|
||||
...returnAllOrLimit,
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Additional Expand Fields',
|
||||
name: 'additionalExpandFields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. content.version,content.metadata.labels',
|
||||
description: 'Comma-separated list of extra fields to expand on each search result',
|
||||
},
|
||||
{
|
||||
displayName: 'Content Status',
|
||||
name: 'contentStatuses',
|
||||
type: 'multiOptions',
|
||||
default: [],
|
||||
description: 'Only match content in these statuses',
|
||||
options: [
|
||||
{ name: 'Archived', value: 'archived' },
|
||||
{ name: 'Current', value: 'current' },
|
||||
{ name: 'Draft', value: 'draft' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Fetch Full Page Content',
|
||||
name: 'fetchFullPageContent',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether each result carries its full storage-format body (content.body.storage), fetched on the same request. Only applies to content results such as pages; space and user results have no body.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['search'],
|
||||
operation: ['query'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
interface SearchOptions {
|
||||
additionalExpandFields?: string;
|
||||
contentStatuses?: string[];
|
||||
fetchFullPageContent?: boolean;
|
||||
}
|
||||
|
||||
function buildExpand(options: SearchOptions): string {
|
||||
const fields = (options.additionalExpandFields ?? '')
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field !== '');
|
||||
if (options.fetchFullPageContent === true) fields.unshift('content.body.storage');
|
||||
return [...new Set(fields)].join(',');
|
||||
}
|
||||
|
||||
export const execute: ConfluenceOperation = async function (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) {
|
||||
// Deliberately passed through untouched: in a raw CQL string the node cannot tell
|
||||
// values from syntax, so escaping here would corrupt valid queries. Where this node
|
||||
// composes CQL itself it backslash-escapes quotes and backslashes as Confluence's
|
||||
// CQL reference prescribes (see methods/listSearch.ts).
|
||||
const cql = String(this.getNodeParameter('cql', itemIndex, '')).trim();
|
||||
if (cql === '') {
|
||||
throw new NodeOperationError(this.getNode(), 'The CQL query must not be empty', { itemIndex });
|
||||
}
|
||||
|
||||
const returnAll = this.getNodeParameter('returnAll', itemIndex, false);
|
||||
const limit = returnAll
|
||||
? Infinity
|
||||
: parsePositiveInt.call(
|
||||
this,
|
||||
this.getNodeParameter('limit', itemIndex, 100),
|
||||
'Limit',
|
||||
itemIndex,
|
||||
);
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as SearchOptions;
|
||||
|
||||
const qs: IDataObject = { cql };
|
||||
const expand = buildExpand(options);
|
||||
if (expand !== '') qs.expand = expand;
|
||||
if (options.contentStatuses !== undefined && options.contentStatuses.length > 0) {
|
||||
qs.cqlcontext = JSON.stringify({ contentStatuses: options.contentStatuses });
|
||||
}
|
||||
|
||||
const results: IDataObject[] = [];
|
||||
let pageParam: NextPageParam | undefined;
|
||||
const seenPageParams = new Set<string>();
|
||||
let emptyPages = 0;
|
||||
for (;;) {
|
||||
const pageQs: IDataObject = {
|
||||
...qs,
|
||||
limit: Math.min(limit - results.length, SEARCH_PAGE_SIZE),
|
||||
};
|
||||
if (pageParam !== undefined) pageQs[pageParam.key] = pageParam.value;
|
||||
|
||||
const response = await confluenceApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
pageQs,
|
||||
);
|
||||
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
|
||||
results.push.apply(results, entries);
|
||||
if (results.length >= limit) break;
|
||||
|
||||
emptyPages = entries.length === 0 ? emptyPages + 1 : 0;
|
||||
if (emptyPages >= MAX_CONSECUTIVE_EMPTY_PAGES) break;
|
||||
|
||||
const next = extractNextPageParam(response);
|
||||
// A next link revisiting any earlier page would loop forever under Return All
|
||||
if (next === undefined || seenPageParams.has(`${next.key}:${next.value}`)) break;
|
||||
seenPageParams.add(`${next.key}:${next.value}`);
|
||||
pageParam = next;
|
||||
}
|
||||
|
||||
return returnAll ? results : results.slice(0, limit);
|
||||
};
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { Confluence } from '../Confluence.node';
|
||||
import { mockExecuteCtx } from './shared';
|
||||
|
||||
describe('Confluence Node', () => {
|
||||
const node = new Confluence();
|
||||
|
||||
const operationOptions = (resource: string) =>
|
||||
node.description.properties.find(
|
||||
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource),
|
||||
)?.options;
|
||||
|
||||
it('should stay hidden and off the AI-tool surface while operations land', () => {
|
||||
expect(node.description.hidden).toBe(true);
|
||||
expect(node.description.properties.length).toBeGreaterThan(0);
|
||||
expect(node.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose the attachment and page resources with their operations', () => {
|
||||
it('should expose the attachment, page and search resources with their operations', () => {
|
||||
const resource = node.description.properties.find((p) => p.name === 'resource');
|
||||
expect(resource?.options).toEqual([
|
||||
expect.objectContaining({ value: 'attachment' }),
|
||||
expect.objectContaining({ value: 'page' }),
|
||||
expect.objectContaining({ value: 'search' }),
|
||||
]);
|
||||
|
||||
const operations = node.description.properties.filter((p) => p.name === 'operation');
|
||||
const operationsFor = (resourceName: string) =>
|
||||
operations.find((p) => (p.displayOptions?.show?.resource ?? []).includes(resourceName));
|
||||
expect(operationsFor('attachment')?.options).toEqual([
|
||||
expect.objectContaining({ value: 'getMany' }),
|
||||
]);
|
||||
expect(operationsFor('page')?.options).toEqual([
|
||||
expect(operationOptions('attachment')).toEqual([expect.objectContaining({ value: 'getMany' })]);
|
||||
expect(operationOptions('page')).toEqual([
|
||||
expect.objectContaining({ value: 'append' }),
|
||||
expect.objectContaining({ value: 'create' }),
|
||||
expect.objectContaining({ value: 'delete' }),
|
||||
@@ -34,6 +34,7 @@ describe('Confluence Node', () => {
|
||||
expect.objectContaining({ value: 'getManyByLabel' }),
|
||||
expect.objectContaining({ value: 'update' }),
|
||||
]);
|
||||
expect(operationOptions('search')).toEqual([expect.objectContaining({ value: 'query' })]);
|
||||
});
|
||||
|
||||
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
|
||||
@@ -49,21 +50,7 @@ describe('Confluence Node', () => {
|
||||
});
|
||||
|
||||
it('should throw a NodeOperationError when executed without an operation', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(_name: string, _itemIndex?: number, fallback?: unknown) => fallback as never,
|
||||
);
|
||||
ctx.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
const promise = node.execute.call(ctx);
|
||||
const promise = node.execute.call(mockExecuteCtx({}));
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('The operation ":" is not supported');
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ describe('attachment:getMany', () => {
|
||||
it('rejects a non-positive limit from an expression', async () => {
|
||||
const promise = runGetMany({ limit: 0 });
|
||||
|
||||
await expect(promise).rejects.toThrow('Limit must be a number of at least 1');
|
||||
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ describe('Confluence page:get operation', () => {
|
||||
});
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(
|
||||
'Max Pages must be a number of at least 1',
|
||||
'Max Pages must be a finite number of at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+3
-1
@@ -237,7 +237,9 @@ describe('Confluence page:getManyByLabel operation', () => {
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow('Limit must be a number of at least 1');
|
||||
await expect(execute.call(ctx, 0)).rejects.toThrow(
|
||||
'Limit must be a finite number of at least 1',
|
||||
);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,15 @@ const getParams: Record<string, unknown> = {
|
||||
includeDescendants: false,
|
||||
};
|
||||
|
||||
const searchParams: Record<string, unknown> = {
|
||||
resource: 'search',
|
||||
operation: 'query',
|
||||
cql: 'type = page',
|
||||
returnAll: false,
|
||||
limit: 100,
|
||||
options: {},
|
||||
};
|
||||
|
||||
describe('Confluence router', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -166,6 +175,25 @@ describe('Confluence router', () => {
|
||||
expect(result).toEqual([[{ json: { id: '222' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('dispatches search:query and fans the results out into items', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [{ title: 'A' }, { title: 'B' }] });
|
||||
|
||||
const result = await router.call(mockExecuteCtx(searchParams));
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
{ cql: 'type = page', limit: 50 },
|
||||
);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { title: 'A' }, pairedItem: { item: 0 } },
|
||||
{ json: { title: 'B' }, pairedItem: { item: 0 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits an error item and continues with later items when continue-on-fail is on', async () => {
|
||||
const ctx = mockExecuteCtx(createParams, 2);
|
||||
ctx.continueOnFail.mockReturnValue(true);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { execute } from '../../../actions/search/query.operation';
|
||||
import { confluenceApiRequest } from '../../../transport';
|
||||
import { mockExecuteCtx } from '../../shared';
|
||||
|
||||
vi.mock('../../../transport', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
confluenceApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequest = confluenceApiRequest as unknown as Mock;
|
||||
|
||||
const baseParams: Record<string, unknown> = {
|
||||
cql: 'type = page',
|
||||
returnAll: false,
|
||||
limit: 100,
|
||||
options: {},
|
||||
};
|
||||
|
||||
const SEARCH_ENDPOINT = '/wiki/rest/api/search';
|
||||
|
||||
const runSearch = async (overrides: Record<string, unknown> = {}) =>
|
||||
await execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0);
|
||||
|
||||
const expectSearchRequest = (qs: unknown) =>
|
||||
expect(apiRequest).toHaveBeenCalledWith('GET', SEARCH_ENDPOINT, {}, qs);
|
||||
|
||||
const expectNthSearchRequest = (nth: number, qs: unknown) =>
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(nth, 'GET', SEARCH_ENDPOINT, {}, qs);
|
||||
|
||||
function searchPage(ids: string[], next?: string) {
|
||||
return {
|
||||
results: ids.map((id) => ({ id })),
|
||||
...(next === undefined ? {} : { _links: { next } }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('search:query', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue(searchPage([]));
|
||||
});
|
||||
|
||||
it('queries the v1 search endpoint with the CQL and page size', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [{ title: 'Hit' }] });
|
||||
|
||||
const result = await runSearch();
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expectSearchRequest({ cql: 'type = page', limit: 50 });
|
||||
expect(result).toEqual([{ title: 'Hit' }]);
|
||||
});
|
||||
|
||||
it('returns an empty array when nothing matches', async () => {
|
||||
expect(await runSearch()).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces a non-string query from an expression', async () => {
|
||||
await runSearch({ cql: 32 });
|
||||
|
||||
expectSearchRequest(expect.objectContaining({ cql: '32' }));
|
||||
});
|
||||
|
||||
it('rejects an empty query after trimming', async () => {
|
||||
const promise = runSearch({ cql: ' ' });
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('The CQL query must not be empty');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-positive limit from an expression', async () => {
|
||||
const promise = runSearch({ limit: 0 });
|
||||
|
||||
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-finite limit from an expression', async () => {
|
||||
const promise = runSearch({ limit: Infinity });
|
||||
|
||||
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns all pages by following the next cursor with the same query', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(
|
||||
searchPage(['1', '2'], '/rest/api/search?cql=type%3Dpage&cursor=abc&limit=50'),
|
||||
)
|
||||
.mockResolvedValueOnce(searchPage(['3']));
|
||||
|
||||
const result = await runSearch({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expectNthSearchRequest(2, { cql: 'type = page', limit: 50, cursor: 'abc' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
|
||||
});
|
||||
|
||||
it('falls back to the start offset when the next link carries no cursor', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(
|
||||
searchPage(['1'], '/rest/api/search?cql=type%3Dpage&start=25&limit=25'),
|
||||
)
|
||||
.mockResolvedValueOnce(searchPage([]));
|
||||
|
||||
await runSearch({ returnAll: true });
|
||||
|
||||
expectNthSearchRequest(2, expect.objectContaining({ start: '25' }));
|
||||
});
|
||||
|
||||
it('requests only the limit when it is below the page size', async () => {
|
||||
apiRequest.mockResolvedValue(searchPage(['1', '2'], '/rest/api/search?cursor=abc'));
|
||||
|
||||
const result = await runSearch({ limit: 1 });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expectSearchRequest({ cql: 'type = page', limit: 1 });
|
||||
expect(result).toEqual([{ id: '1' }]);
|
||||
});
|
||||
|
||||
it('stops fetching and truncates once the limit is met', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(searchPage(['1', '2'], '/rest/api/search?cursor=abc'))
|
||||
.mockResolvedValueOnce(searchPage(['3', '4'], '/rest/api/search?cursor=def'));
|
||||
|
||||
const result = await runSearch({ limit: 3 });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expectNthSearchRequest(2, { cql: 'type = page', limit: 1, cursor: 'abc' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
|
||||
});
|
||||
|
||||
it('coerces a numeric-string limit from an expression', async () => {
|
||||
apiRequest.mockResolvedValue(searchPage(['1', '2', '3']));
|
||||
|
||||
const result = await runSearch({ limit: '2' });
|
||||
|
||||
expectSearchRequest(expect.objectContaining({ limit: 2 }));
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('stops when the next link repeats the same cursor', async () => {
|
||||
apiRequest.mockResolvedValue(searchPage([], '/rest/api/search?cursor=same'));
|
||||
|
||||
const result = await runSearch({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops when the next links cycle between earlier cursors', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?cursor=a'))
|
||||
.mockResolvedValueOnce(searchPage(['2'], '/rest/api/search?cursor=b'))
|
||||
.mockResolvedValue(searchPage(['3'], '/rest/api/search?cursor=a'));
|
||||
|
||||
const result = await runSearch({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
|
||||
});
|
||||
|
||||
it('stops after a run of consecutive empty pages even when cursors keep changing', async () => {
|
||||
let page = 0;
|
||||
apiRequest.mockImplementation(async () => searchPage([], `/rest/api/search?cursor=c${++page}`));
|
||||
|
||||
const result = await runSearch({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(5);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops when the next link has no usable parameter', async () => {
|
||||
apiRequest.mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?next=true'));
|
||||
|
||||
const result = await runSearch({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([{ id: '1' }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the full-content toggle', { fetchFullPageContent: true }, 'content.body.storage'],
|
||||
[
|
||||
'additional expand fields, trimmed',
|
||||
{ additionalExpandFields: ' content.version , space ,' },
|
||||
'content.version,space',
|
||||
],
|
||||
[
|
||||
'both merged without duplicates',
|
||||
{
|
||||
fetchFullPageContent: true,
|
||||
additionalExpandFields: 'content.body.storage,content.version',
|
||||
},
|
||||
'content.body.storage,content.version',
|
||||
],
|
||||
])('sends expand for %s', async (_name, options, expand) => {
|
||||
await runSearch({ options });
|
||||
|
||||
expectSearchRequest(expect.objectContaining({ expand }));
|
||||
});
|
||||
|
||||
it('sends cqlcontext when content statuses are selected', async () => {
|
||||
await runSearch({ options: { contentStatuses: ['draft', 'archived'] } });
|
||||
|
||||
expectSearchRequest(
|
||||
expect.objectContaining({ cqlcontext: '{"contentStatuses":["draft","archived"]}' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits expand and cqlcontext when no options are set', async () => {
|
||||
await runSearch();
|
||||
|
||||
const qs = apiRequest.mock.calls[0][3] as Record<string, unknown>;
|
||||
expect(qs).not.toHaveProperty('expand');
|
||||
expect(qs).not.toHaveProperty('cqlcontext');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user