diff --git a/CHANGELOG.md b/CHANGELOG.md index b188b54..85fe482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - WireMock integration tests for contract testing for POST /2.0/users/{userId}/plans/{planId}/upgrade and POST /2.0/users/{userId}/plans/{planId}/downgrade - WireMock integration tests for contract testing for DELETE /2.0/users/{userId}/plans/{planId} endpoint - Remove trailing slashes from routes +- Support for POST /reports/{reportId}/scope endpoint +- Support for DELETE /reports/{reportId}/scope endpoint ### Updated - listAllUsers url generation - Folder structure for the Users related WireMock tests diff --git a/lib/reports/index.ts b/lib/reports/index.ts index b4fd0bb..912ac3a 100644 --- a/lib/reports/index.ts +++ b/lib/reports/index.ts @@ -1,4 +1,5 @@ import shareModule from '../share/share'; +import type { BaseResponseStatus } from '../types/BaseResponseStatus'; import type { CreateOptions } from '../types/CreateOptions'; import type { RequestCallback } from '../types/RequestCallback'; import type { RequestOptions } from '../types/RequestOptions'; @@ -16,6 +17,8 @@ import type { ReportPublish, SetReportPublishStatusOptions, SetReportPublishStatusResponse, + AddReportScopeOptions, + RemoveReportScopeOptions, } from './types'; import * as constants from '../utils/constants'; @@ -87,6 +90,22 @@ export function create(options: CreateOptions): ReportsApi { return requestor.put({ ...optionsToSend, ...urlOptions, ...putOptions }, callback); }; + const addReportScope = ( + postOptions: AddReportScopeOptions, + callback?: RequestCallback + ): Promise => { + const urlOptions = { url: options.apiUrls.reports + '/' + postOptions.reportId + '/scope' }; + return requestor.post({ ...optionsToSend, ...urlOptions, ...postOptions }, callback); + }; + + const removeReportScope = ( + deleteOptions: RemoveReportScopeOptions, + callback?: RequestCallback + ): Promise => { + const urlOptions = { url: options.apiUrls.reports + '/' + deleteOptions.reportId + '/scope' }; + return requestor.delete({ ...optionsToSend, ...urlOptions, ...deleteOptions }, callback); + }; + return { listReports, getReport, @@ -95,6 +114,8 @@ export function create(options: CreateOptions): ReportsApi { getReportAsCSV, getReportPublishStatus, setReportPublishStatus, + addReportScope, + removeReportScope, ...shares.create(options), }; } diff --git a/lib/reports/types.ts b/lib/reports/types.ts index 891cdb3..d56ffbe 100644 --- a/lib/reports/types.ts +++ b/lib/reports/types.ts @@ -175,6 +175,79 @@ export interface ReportsApi { options: SetReportPublishStatusOptions, callback?: RequestCallback ) => Promise; + + /** + * Add sheets and/or workspaces to a report's scope. + * + * This operation allows you to expand the data sources included in a report by adding + * sheets or workspaces. The report will then include data from these newly added sources. + * + * @param options - {@link AddReportScopeOptions} - Configuration options for the request + * @param callback - {@link RequestCallback}\<{@link BaseResponseStatus}\> - Optional callback function + * @returns Promise\<{@link BaseResponseStatus}\> + * + * @remarks + * **Who can use this operation:** + * - **Permissions:** ADMIN or OWNER access to the report + * + * **Additional notes:** + * - Maximum of 100 scope items can be added at once + * - Requires READ_SHEETS OAuth2 scope or API Token authentication + * + * It mirrors to the following Smartsheet REST API method: `POST /reports/{reportId}/scope` + * + * @example + * ```typescript + * const result = await client.reports.addReportScope({ + * reportId: 1234567890, + * body: [ + * { assetType: 'SHEET', assetId: 9876543210 }, + * { assetType: 'WORKSPACE', assetId: 1122334455 } + * ] + * }); + * console.log(result.message); // 'SUCCESS' + * ``` + */ + addReportScope: ( + options: AddReportScopeOptions, + callback?: RequestCallback + ) => Promise; + + /** + * Remove sheets and/or workspaces from a report's scope. + * + * This operation allows you to remove data sources from a report. The report will + * no longer include data from these removed sources. + * + * @param options - {@link RemoveReportScopeOptions} - Configuration options for the request + * @param callback - {@link RequestCallback}\<{@link BaseResponseStatus}\> - Optional callback function + * @returns Promise\<{@link BaseResponseStatus}\> + * + * @remarks + * **Who can use this operation:** + * - **Permissions:** ADMIN or OWNER access to the report + * + * **Additional notes:** + * - Maximum of 100 scope items can be removed at once + * - Requires READ_SHEETS OAuth2 scope or API Token authentication + * + * It mirrors to the following Smartsheet REST API method: `DELETE /reports/{reportId}/scope` + * + * @example + * ```typescript + * const result = await client.reports.removeReportScope({ + * reportId: 1234567890, + * body: [ + * { assetType: 'SHEET', assetId: 9876543210 } + * ] + * }); + * console.log(result.message); // 'SUCCESS' + * ``` + */ + removeReportScope: ( + options: RemoveReportScopeOptions, + callback?: RequestCallback + ) => Promise; } // ============================================================================ @@ -519,6 +592,30 @@ Only returned in a response if readOnlyFullEnabled = true. readOnlyFullShowToolbar?: boolean; } +/** + * Asset types that can be included in a report's scope. + */ +export enum ReportAssetType { + SHEET = 'SHEET', + WORKSPACE = 'WORKSPACE', +} + +/** + * Represents an asset (sheet or workspace) in a report's scope. + */ +export interface ReportScopeAsset { + /** + * The asset type to be included in the scope of the report. + * @see ReportAssetType + */ + assetType: ReportAssetType; + + /** + * The ID of the asset according to its assetType. + */ + assetId: number; +} + // ============================================================================ // List Reports // ============================================================================ @@ -722,3 +819,31 @@ export interface SetReportPublishStatusResponse extends BaseResponseStatus { failedItems?: FailedItem[]; result: ReportPublish; } + +// ============================================================================ +// Add Report Scope +// ============================================================================ + +/** + * Options for adding report scope. + */ +export interface AddReportScopeOptions extends RequestOptions { + /** + * Report ID. + */ + reportId: number; +} + +// ============================================================================ +// Remove Report Scope +// ============================================================================ + +/** + * Options for removing report scope. + */ +export interface RemoveReportScopeOptions extends RequestOptions { + /** + * Report ID. + */ + reportId: number; +} diff --git a/lib/utils/httpRequestor.js b/lib/utils/httpRequestor.js index 688f9ad..a4b57a1 100644 --- a/lib/utils/httpRequestor.js +++ b/lib/utils/httpRequestor.js @@ -92,7 +92,7 @@ export function create(requestorConfig) { const postFile = (options, callback) => methodRequest(options, request.post, 'POST', callback, getFileBody(options)); - const deleteFunc = (options, callback) => methodRequest(options, request.delete, 'DELETE', callback); + const deleteFunc = (options, callback) => methodRequest(options, request.delete, 'DELETE', callback, options.body); const put = (options, callback) => methodRequest(options, request.put, 'PUT', callback, options.body); @@ -150,6 +150,11 @@ export function create(requestorConfig) { return method(url, body, requestOptions); } + if (methodName === 'DELETE' && body) { + // For DELETE requests with a body, axios requires the body to be in config.data + return method(url, { ...requestOptions, data: body }); + } + return method(url, requestOptions); }; diff --git a/test/functional/client.spec.ts b/test/functional/client.spec.ts index 65375a8..b47b847 100644 --- a/test/functional/client.spec.ts +++ b/test/functional/client.spec.ts @@ -180,7 +180,7 @@ describe('Client Unit Tests', () => { describe('#reports', () => { it('should have reports object', () => { expect(smartsheet).toHaveProperty('reports'); - expect(Object.keys(smartsheet.reports)).toHaveLength(12); + expect(Object.keys(smartsheet.reports)).toHaveLength(14); }); it('should have get methods', () => { @@ -191,10 +191,18 @@ describe('Client Unit Tests', () => { expect(smartsheet.reports).toHaveProperty('getReportPublishStatus'); }); + it('should have create methods', () => { + expect(smartsheet.reports).toHaveProperty('addReportScope'); + }); + it('should have update methods', () => { expect(smartsheet.reports).toHaveProperty('setReportPublishStatus'); expect(smartsheet.reports).toHaveProperty('sendReportViaEmail'); }); + + it('should have delete methods', () => { + expect(smartsheet.reports).toHaveProperty('removeReportScope'); + }); }); describe('#server', () => { diff --git a/test/mock-api/reports/add_report_scope.spec.ts b/test/mock-api/reports/add_report_scope.spec.ts new file mode 100644 index 0000000..bbbebdd --- /dev/null +++ b/test/mock-api/reports/add_report_scope.spec.ts @@ -0,0 +1,111 @@ +import crypto from 'crypto'; +import { createClient, findWireMockRequest } from '../utils/utils'; +import { expect } from '@jest/globals'; +import { + TEST_REPORT_ID, + TEST_SHEET_ID, + TEST_WORKSPACE_ID, + TEST_ASSET_TYPE_SHEET, + TEST_ASSET_TYPE_WORKSPACE, + TEST_SUCCESS_MESSAGE, + TEST_SUCCESS_RESULT_CODE, + ERROR_500_STATUS_CODE, + ERROR_500_MESSAGE, + ERROR_400_STATUS_CODE, + ERROR_400_MESSAGE +} from './common_test_constants'; +import { ReportAssetType } from '@smartsheet/reports/types'; + +describe('Reports - addReportScope endpoint tests', () => { + const client = createClient(); + + it('addReportScope generated url is correct', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/add-report-scope/all-response-body-properties' + } + }; + await client.reports.addReportScope(options); + const matchedRequest = await findWireMockRequest(requestId); + + const parsedUrl = new URL(matchedRequest.absoluteUrl); + expect(parsedUrl.pathname).toEqual(`/2.0/reports/${TEST_REPORT_ID}/scope`); + }); + + it('addReportScope all response body properties', async () => { + const requestId = crypto.randomUUID(); + const testBody = [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID }, + { assetType: ReportAssetType.WORKSPACE, assetId: TEST_WORKSPACE_ID } + ]; + const options = { + reportId: TEST_REPORT_ID, + body: testBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/add-report-scope/all-response-body-properties' + } + }; + const response = await client.reports.addReportScope(options); + const matchedRequest = await findWireMockRequest(requestId); + + expect(response).toEqual({ + message: TEST_SUCCESS_MESSAGE, + resultCode: TEST_SUCCESS_RESULT_CODE + }); + + const body = JSON.parse(matchedRequest.body); + expect(body).toEqual([ + { assetType: TEST_ASSET_TYPE_SHEET, assetId: TEST_SHEET_ID }, + { assetType: TEST_ASSET_TYPE_WORKSPACE, assetId: TEST_WORKSPACE_ID } + ]); + }); + + it('addReportScope error 500 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/500-response' + } + }; + try { + await client.reports.addReportScope(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_500_STATUS_CODE); + expect(error.message).toBe(ERROR_500_MESSAGE); + } + }); + + it('addReportScope error 400 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/400-response' + } + }; + try { + await client.reports.addReportScope(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_400_STATUS_CODE); + expect(error.message).toBe(ERROR_400_MESSAGE); + } + }); +}); diff --git a/test/mock-api/reports/common_test_constants.ts b/test/mock-api/reports/common_test_constants.ts index 4bd9a09..21bd4cb 100644 --- a/test/mock-api/reports/common_test_constants.ts +++ b/test/mock-api/reports/common_test_constants.ts @@ -47,6 +47,12 @@ export const TEST_SOURCE_WORKSPACE_NAME = 'Source Workspace'; export const TEST_SOURCE_WORKSPACE_ACCESS_LEVEL = 'VIEWER'; export const TEST_SOURCE_WORKSPACE_PERMALINK = 'https://app.smartsheet.com/workspaces/source-workspace'; +// Report Scope Assets +export const TEST_SHEET_ID = 9876543210; +export const TEST_WORKSPACE_ID = 1122334455; +export const TEST_ASSET_TYPE_SHEET = 'SHEET'; +export const TEST_ASSET_TYPE_WORKSPACE = 'WORKSPACE'; + // Report Columns (Column type requires all these properties) export const TEST_COLUMN_1_ID = 1111111111111111; export const TEST_COLUMN_1_INDEX = 0; diff --git a/test/mock-api/reports/remove_report_scope.spec.ts b/test/mock-api/reports/remove_report_scope.spec.ts new file mode 100644 index 0000000..9ce3d49 --- /dev/null +++ b/test/mock-api/reports/remove_report_scope.spec.ts @@ -0,0 +1,110 @@ +import crypto from 'crypto'; +import { createClient, findWireMockRequest } from '../utils/utils'; +import { expect } from '@jest/globals'; +import { + TEST_REPORT_ID, + TEST_SHEET_ID, + TEST_WORKSPACE_ID, + TEST_ASSET_TYPE_SHEET, + TEST_ASSET_TYPE_WORKSPACE, + TEST_SUCCESS_MESSAGE, + TEST_SUCCESS_RESULT_CODE, + ERROR_500_STATUS_CODE, + ERROR_500_MESSAGE, + ERROR_400_STATUS_CODE, + ERROR_400_MESSAGE +} from './common_test_constants'; +import { ReportAssetType } from '@smartsheet/reports/types'; + +describe('Reports - removeReportScope endpoint tests', () => { + const client = createClient(); + + it('removeReportScope generated url is correct', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/remove-report-scope/all-response-body-properties' + } + }; + await client.reports.removeReportScope(options); + const matchedRequest = await findWireMockRequest(requestId); + + const parsedUrl = new URL(matchedRequest.absoluteUrl); + expect(parsedUrl.pathname).toEqual(`/2.0/reports/${TEST_REPORT_ID}/scope`); + }); + + it('removeReportScope all response body properties', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID }, + { assetType: ReportAssetType.WORKSPACE, assetId: TEST_WORKSPACE_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/remove-report-scope/all-response-body-properties' + } + }; + const response = await client.reports.removeReportScope(options); + const matchedRequest = await findWireMockRequest(requestId); + + expect(response).toEqual({ + message: TEST_SUCCESS_MESSAGE, + resultCode: TEST_SUCCESS_RESULT_CODE + }); + + const body = JSON.parse(matchedRequest.body); + expect(body).toEqual([ + { assetType: TEST_ASSET_TYPE_SHEET, assetId: TEST_SHEET_ID }, + { assetType: TEST_ASSET_TYPE_WORKSPACE, assetId: TEST_WORKSPACE_ID } + ]); + }); + + it('removeReportScope error 500 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/500-response' + } + }; + try { + await client.reports.removeReportScope(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_500_STATUS_CODE); + expect(error.message).toBe(ERROR_500_MESSAGE); + } + }); + + it('removeReportScope error 400 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: [ + { assetType: ReportAssetType.SHEET, assetId: TEST_SHEET_ID } + ], + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/400-response' + } + }; + try { + await client.reports.removeReportScope(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_400_STATUS_CODE); + expect(error.message).toBe(ERROR_400_MESSAGE); + } + }); +});