Skip to content
Open
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
58 changes: 56 additions & 2 deletions src/adapters/task.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RoutingOptions } from '../utils/router';
import type { GenericScope, Address } from '../utils/constants';
import type { RoutingOptions, Page } from '../utils/router';
import type { GenericScope, GenericSearchOptions, Address } from '../utils/constants';

import Router from '../utils/router';
import { parseFilterInput } from '../utils/filter-parser';

export enum RETRY_POLICY {
DO_NOTHING = 'DO_NOTHING', // If the task fails, do nothing (this is the default)
Expand Down Expand Up @@ -326,3 +327,56 @@ export async function getTaskIn<
)
.then(({ body }) => body);
}


/**
* Queries for tasks
* Base URL: GET `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/task/search`
*
* Requires facilitator (or higher) privileges. Filterable/sortable fields include
* `task.taskKey`, `task.name`, `task.status`, `task.scopeBoundary`, `task.scopeKey`,
* `task.userKey`, `task.groupName`, `task.episodeName`, `task.nextExecution`,
* `task.failSafeExecution`, and `task.created`.
*
* @example
* import { taskAdapter } from 'epicenter-libs';
* const page = await taskAdapter.query({
* filter: [
* 'task.scopeKey=0000017dd3bf540e5ada5b1e058f08f20461', // tasks scoped to this group
* 'task.status=INITIALIZED', // that have not yet fired
* ],
* sort: ['-task.created'], // newest first
* max: 10, // page should only include the first 10 items
* });
*
* @param searchOptions Search options for the query
* @param [searchOptions.filter] Filters for searching
* @param [searchOptions.sort] Sorting criteria
* @param [searchOptions.first] The starting index of the page returned
* @param [searchOptions.max] The number of entries per page
* @param [optionals] Optional arguments; pass network call options overrides here.
* @returns promise that resolves to a page of tasks
*/
export async function query<
B extends object = TaskPayloadBody,
H extends object = TaskPayloadHeaders,
>(
searchOptions: GenericSearchOptions,
optionals: RoutingOptions = {},
Comment on lines +364 to +365

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since all properties of GenericSearchOptions are optional, callers should be able to invoke query() without passing any arguments (e.g., to fetch the first page of tasks with default settings). Currently, omitting searchOptions will cause a runtime TypeError when destructuring properties like filter and sort from undefined.

Providing a default empty object {} for searchOptions resolves this issue and makes the method safer and more convenient to use, especially in JavaScript environments.

Suggested change
searchOptions: GenericSearchOptions,
optionals: RoutingOptions = {},
searchOptions: GenericSearchOptions = {},
optionals: RoutingOptions = {},

): Promise<Page<TaskReadOutView<B, H>>> {
const { filter, sort = [], first, max } = searchOptions;

const searchParams = {
filter: parseFilterInput(filter),
sort: sort.join(';') || undefined,
first, max,
};

return await new Router()
.withSearchParams(searchParams)
.get('/task/search', {
paginated: true,
...optionals,
})
Comment on lines +377 to +380

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The query function is typed to return a Promise<Page<TaskReadOutView<B, H>>>. However, because ...optionals is spread after paginated: true, a caller could pass { paginated: false } in optionals, which would override the pagination setting and return a non-paginated response at runtime, violating the TypeScript return type contract.

Placing paginated: true after ...optionals ensures that pagination is always enforced for this endpoint.

Suggested change
.get('/task/search', {
paginated: true,
...optionals,
})
.get('/task/search', {
...optionals,
paginated: true,
})

.then(({ body }) => body);
}
52 changes: 52 additions & 0 deletions tests/task.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,58 @@ describe('taskAdapter', () => {
testedMethods.add('getTaskIn');
});

describe('taskAdapter.query', () => {
const OPTIONS = {
filter: [
'task.scopeKey=0000017dd3bf540e5ada5b1e058f08f20461',
'task.status=INITIALIZED',
],
sort: ['-task.created'],
first: 0,
max: 10,
};

it('Should do a GET', async () => {
await taskAdapter.query(OPTIONS);
const req = capturedRequests[capturedRequests.length - 1];
expect(req.options.method.toUpperCase()).toBe('GET');
});

it('Should have authorization', async () => {
await taskAdapter.query(OPTIONS);
const req = capturedRequests[capturedRequests.length - 1];
expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`);
});

it('Should use the task search URL', async () => {
await taskAdapter.query(OPTIONS);
const req = capturedRequests[capturedRequests.length - 1];
const url = req.url.split('?')[0];
expect(url).toBe(`https://${config.apiHost}/api/v${config.apiVersion}/${config.accountShortName}/${config.projectShortName}/task/search`);
});

it('Should support generic URL options', async () => {
await taskAdapter.query(OPTIONS, GENERIC_OPTIONS);
const req = capturedRequests[capturedRequests.length - 1];
const url = req.url.split('?')[0];
const { server, accountShortName, projectShortName } = GENERIC_OPTIONS;
expect(url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/task/search`);
});

it('Should pass in query options as a part of the search parameters (query string)', async () => {
await taskAdapter.query(OPTIONS);
const req = capturedRequests[capturedRequests.length - 1];
const search = req.url.split('?')[1];
const searchParams = new URLSearchParams(search);
expect(searchParams.get('filter')).toBe(OPTIONS.filter.join(';'));
expect(searchParams.get('sort')).toBe(OPTIONS.sort.join(';'));
expect(searchParams.get('first')).toBe(OPTIONS.first.toString());
expect(searchParams.get('max')).toBe(OPTIONS.max.toString());
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that calling query() with no arguments or empty options works correctly and does not regress in the future, we should add a unit test verifying this behavior.

        });

        it('Should support empty search options', async () => {
            await taskAdapter.query();
            const req = capturedRequests[capturedRequests.length - 1];
            const search = req.url.split('?')[1];
            const searchParams = new URLSearchParams(search);
            expect(searchParams.get('filter')).toBeNull();
            expect(searchParams.get('sort')).toBeNull();
            expect(searchParams.get('first')).toBeNull();
            expect(searchParams.get('max')).toBeNull();
        });


testedMethods.add('query');
});

it('Should not have any untested methods', () => {
const actualMethods = getFunctionKeys(taskAdapter);
expect(actualMethods).toEqual(testedMethods);
Expand Down