diff --git a/bun.lock b/bun.lock index 2a2799c..14a4a9e 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^4.1.4", "@vitest/ui": "^4.0.18", - "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.6", + "absmartly-api-mocks": "^1.0.9", "dotenv": "^16.4.7", "eslint": "^9.19.0", "jsdom": "^28.0.0", @@ -307,7 +307,7 @@ "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="], - "absmartly-api-mocks": ["@absmartly/api-mocks@1.0.6", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.2.2", "@faker-js/faker": "^10.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.1" }, "peerDependencies": { "msw": "^2.0.0" } }, "sha512-dhLFeIjrZ2D+JGIo9JPKTnKzLLwIkNze1s24ZPs/8H3KqvEC52TuDuWC04PY15q9kjE5G9P3GeyWF/XeqFF9ug=="], + "absmartly-api-mocks": ["@absmartly/api-mocks@1.0.9", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.2.2", "@faker-js/faker": "^10.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.1" }, "peerDependencies": { "msw": "^2.0.0" } }, "sha512-y9U9eqDuQinssHLPGliw5WhhPeQ2OShr0Geh0eFGVUof8+D0kz6ibpXANkCaCEEogOIkQvXivRofFhdpioHziQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], diff --git a/package.json b/package.json index 565ce4e..d54e546 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/cli", - "version": "1.14.0", + "version": "1.15.0", "description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans", "type": "module", "main": "./dist/index.js", @@ -112,7 +112,7 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^4.1.4", "@vitest/ui": "^4.0.18", - "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.6", + "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.9", "dotenv": "^16.4.7", "eslint": "^9.19.0", "jsdom": "^28.0.0", diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index 739ae38..6ba982c 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -1016,9 +1016,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { }); it('should check for new notifications', async () => { - server.use( - http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json({ has_new: true })) - ); + server.use(http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json(true))); expect(await client.hasNewNotifications()).toBe(true); }); }); @@ -1193,6 +1191,30 @@ describe.skipIf(isLiveMode)('APIClient core', () => { expect(await client.listCustomSections()).toEqual([]); }); + it('should list custom sections with the legacy positional type string', async () => { + server.use( + http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('type')).toBe('active'); + return HttpResponse.json({ experiment_custom_sections: [{ id: 1, type: 'active' }] }); + }) + ); + expect(await client.listCustomSections('active')).toEqual([{ id: 1, type: 'active' }]); + }); + + it('should list custom sections with the options-object form', async () => { + server.use( + http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('type')).toBe('active'); + expect(url.searchParams.get('items')).toBe('5'); + expect(url.searchParams.get('page')).toBe('2'); + return HttpResponse.json({ experiment_custom_sections: [] }); + }) + ); + await client.listCustomSections({ type: 'active', items: 5, page: 2 }); + }); + it('should reorder custom sections', async () => { server.use( http.put(`${BASE_URL}/experiment_custom_sections/order`, () => @@ -1205,9 +1227,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { describe('insights', () => { it('should get velocity insights', async () => { - server.use( - http.get(`${BASE_URL}/insights/velocity/summary`, () => HttpResponse.json({ data: [] })) - ); + server.use(http.get(`${BASE_URL}/insights/summary`, () => HttpResponse.json({ data: [] }))); const result = await client.getVelocityInsights({ from: 1000, to: 2000, @@ -1399,7 +1419,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { it('should list access control policies', async () => { server.use( http.get(`${BASE_URL}/access_control_policies`, () => - HttpResponse.json({ access_control_policies: [] }) + HttpResponse.json({ access_control_policy: [] }) ) ); expect(await client.listAccessControlPolicies()).toEqual([]); diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 625bada..c7466dc 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1025,10 +1025,23 @@ export class APIClient { }); } - async listCustomSections(type?: string): Promise { - const params: Record = {}; - if (type) params.type = type; - const response = await this.request('GET', '/experiment_custom_sections', { params }); + async listCustomSections( + typeOrParams?: + | string + | { + type?: string | undefined; + items?: number | undefined; + page?: number | undefined; + } + ): Promise { + const params = typeof typeOrParams === 'string' ? { type: typeOrParams } : (typeOrParams ?? {}); + const queryParams: Record = {}; + if (params.type) queryParams.type = params.type; + if (params.items !== undefined) queryParams.items = String(params.items); + if (params.page !== undefined) queryParams.page = String(params.page); + const response = await this.request('GET', '/experiment_custom_sections', { + params: queryParams, + }); return this.validateListResponse( response, 'experiment_custom_sections', @@ -1402,13 +1415,21 @@ export class APIClient { await this.request('DELETE', `/roles/${id}`); } - async listPermissions(): Promise { - const response = await this.request('GET', '/permissions'); + async listPermissions(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/permissions', { params }); return this.validateListResponse(response, 'permissions', 'listPermissions'); } - async listPermissionCategories(): Promise { - const response = await this.request('GET', '/permission_categories'); + async listPermissionCategories( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/permission_categories', { params }); return this.validateListResponse( response, 'permission_categories', @@ -1600,14 +1621,15 @@ export class APIClient { async hasNewNotifications(lastNotificationId?: number): Promise { const params: Record = {}; if (lastNotificationId !== undefined) params.last_notification_id = lastNotificationId; - const response = await this.request>('GET', '/notifications/has-new', { + const response = await this.request('GET', '/notifications/has-new', { params, }); - const data = response.data; - if (!data || typeof data !== 'object') { - throw new Error('Invalid API response for hasNewNotifications: Expected object'); + if (typeof response.data !== 'boolean') { + throw new Error( + `Invalid API response for hasNewNotifications: Expected boolean, got ${typeof response.data}` + ); } - return Boolean((data as Record).has_new); + return response.data; } async listExperimentAccessUsers(id: ExperimentId): Promise { @@ -1756,8 +1778,11 @@ export class APIClient { await this.request('DELETE', `/goals/${id}/asset_role_teams/${teamId}/${assetRoleId}`); } - async listAssetRoles(): Promise { - const response = await this.request('GET', '/asset_roles'); + async listAssetRoles(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/asset_roles', { params }); return this.validateListResponse(response, 'asset_roles', 'listAssetRoles'); } @@ -1934,7 +1959,7 @@ export class APIClient { team_ids?: number[]; owner_ids?: number[]; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary', { + const response = await this.request('GET', '/insights/summary', { params: this.buildInsightParams(params), }); return response.data; @@ -1959,11 +1984,16 @@ export class APIClient { return this.validateListResponse(response, 'webhook_events', 'listWebhookEvents'); } - async listAccessControlPolicies(): Promise { - const response = await this.request('GET', '/access_control_policies'); + async listAccessControlPolicies( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/access_control_policies', { params }); return this.validateListResponse( response, - 'access_control_policies', + 'access_control_policy', 'listAccessControlPolicies' ); } @@ -1983,8 +2013,11 @@ export class APIClient { return this.validateEntityResponse(response, 'config', 'updatePlatformConfig'); } - async listCorsOrigins(): Promise { - const response = await this.request('GET', '/cors'); + async listCorsOrigins(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/cors', { params }); return this.validateListResponse(response, 'cors_allowed_origins', 'listCorsOrigins'); } @@ -2016,8 +2049,11 @@ export class APIClient { this.validateOkResponse(response, 'deleteCorsOrigin'); } - async listDatasources(): Promise { - const response = await this.request('GET', '/datasources'); + async listDatasources(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/datasources', { params }); return this.validateListResponse( response, 'event_datasource_configs', @@ -2071,9 +2107,17 @@ export class APIClient { this.validateOkResponse(response, 'validateDatasourceQuery'); } - async listExportConfigs(params?: { statuses?: string }): Promise { + async listExportConfigs(params?: { + statuses?: string | undefined; + items?: number | undefined; + page?: number | undefined; + }): Promise { + const queryParams: Record = {}; + if (params?.statuses !== undefined) queryParams.statuses = params.statuses; + if (params?.items !== undefined) queryParams.items = String(params.items); + if (params?.page !== undefined) queryParams.page = String(params.page); const response = await this.request('GET', '/export_configs', { - params: params as Record, + params: queryParams, }); return this.validateListResponse( response, @@ -2119,8 +2163,11 @@ export class APIClient { ); } - async listUpdateSchedules(): Promise { - const response = await this.request('GET', '/experiment_update_schedules'); + async listUpdateSchedules(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/experiment_update_schedules', { params }); return this.validateListResponse( response, 'experiment_update_schedules', @@ -2372,7 +2419,7 @@ export class APIClient { teams?: string; applications?: string; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary/detail', { + const response = await this.request('GET', '/insights/summary/detail', { params: this.buildInsightParams(params), }); return response.data; @@ -2418,8 +2465,11 @@ export class APIClient { return response.data as { matrix: number[][] }; } - async listStorageConfigs(): Promise { - const response = await this.request('GET', '/storage_configs'); + async listStorageConfigs(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/storage_configs', { params }); return this.validateListResponse(response, 'storage_configs', 'listStorageConfigs'); } @@ -2531,8 +2581,13 @@ export class APIClient { return this.validateEntityResponse(response, 'user', 'updateCurrentUser'); } - async listExperimentActionDialogFields(): Promise { - const response = await this.request('GET', '/experiment_action_dialog_fields'); + async listExperimentActionDialogFields( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/experiment_action_dialog_fields', { params }); return this.validateListResponse( response, 'experiment_action_dialog_fields', diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 56c97c4..c73087a 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -15,6 +15,7 @@ import { summarizeMetricCategoryRow, summarizeNamedEntityRow, summarizeWebhookRow, + summarizeWebhook, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -370,6 +371,64 @@ describe('summarizeNamedEntityRow', () => { }); }); +describe('summarizeWebhook', () => { + it('should include detail fields and join event subscription names', () => { + const result = summarizeWebhook({ + id: 3, + name: 'Slack notify', + url: 'https://hooks.example.com/abc', + enabled: true, + ordered: false, + max_retries: 2, + archived: false, + description: 'notifies slack', + events: [ + { + webhook_id: 3, + webhook_event_id: 1, + enabled: true, + event: { id: 1, name: 'ExperimentCreated', description: 'Experiment created' }, + }, + { + webhook_id: 3, + webhook_event_id: 2, + enabled: true, + event: { id: 2, name: 'ExperimentDevelopment', description: 'Experiment started' }, + }, + ], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', + secret: 'super-secret-should-be-dropped', + }); + expect(result).toMatchObject({ + id: 3, + name: 'Slack notify', + url: 'https://hooks.example.com/abc', + enabled: true, + description: 'notifies slack', + events: 'ExperimentCreated, ExperimentDevelopment', + }); + expect(result).not.toHaveProperty('secret'); + }); + + it('should not throw on missing/empty events and should fall back to empty string', () => { + const result = summarizeWebhook({ id: 4, name: 'No events' }); + expect(result.events).toBe(''); + + const emptyResult = summarizeWebhook({ id: 5, name: 'Empty events', events: [] }); + expect(emptyResult.events).toBe(''); + }); + + it('should still join plain string events for backward compatibility', () => { + const result = summarizeWebhook({ + id: 6, + name: 'Legacy shape', + events: ['experiment.created'], + }); + expect(result.events).toBe('experiment.created'); + }); +}); + describe('summarizeWebhookRow', () => { it('curates webhook-specific columns and summarizes user fields', () => { expect( diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index efaed93..56c6599 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -268,6 +268,41 @@ export function summarizeNamedEntityRow(e: Record): Record { + if (typeof e === 'string') return e; + const name = (e as Record | null)?.event as + | Record + | undefined; + return typeof name?.name === 'string' ? name.name : undefined; + }) + .filter((name): name is string => typeof name === 'string') + .join(', '); +} + +export function summarizeWebhook(w: Record): Record { + return { + id: w.id, + name: w.name ?? '', + url: w.url ?? '', + description: w.description ?? '', + enabled: w.enabled ?? false, + ordered: w.ordered ?? false, + max_retries: w.max_retries ?? 0, + events: summarizeWebhookEvents(w.events), + archived: w.archived ?? false, + created_at: formatDate(w.created_at), + created_by: formatOwner(w.created_by as Record | undefined), + updated_at: formatDate(w.updated_at), + updated_by: formatOwner(w.updated_by as Record | undefined), + }; +} + export function summarizeWebhookRow(w: Record): Record { return { id: w.id, diff --git a/src/commands/actiondialogfields/actiondialogfields.test.ts b/src/commands/actiondialogfields/actiondialogfields.test.ts index ee0ac28..547a85d 100644 --- a/src/commands/actiondialogfields/actiondialogfields.test.ts +++ b/src/commands/actiondialogfields/actiondialogfields.test.ts @@ -51,10 +51,30 @@ describe('actiondialogfields command', () => { it('should list action dialog fields', async () => { await actionDialogFieldsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalled(); + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list action dialog fields with pagination', async () => { + await actionDialogFieldsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 10, + page: 2, + }); + }); + it('should get action dialog field by id', async () => { await actionDialogFieldsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/actiondialogfields/index.ts b/src/commands/actiondialogfields/index.ts index a4dafc9..0417860 100644 --- a/src/commands/actiondialogfields/index.ts +++ b/src/commands/actiondialogfields/index.ts @@ -7,6 +7,7 @@ import { withErrorHandling, } from '../../lib/utils/api-helper.js'; import { validateJSON } from '../../lib/utils/validators.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; function parsePositiveInt(value: string): number { const parsed = parseInt(value, 10); @@ -26,12 +27,23 @@ export const actionDialogFieldsCommand = new Command('action-dialog-fields') .aliases(['actiondialogfields']) .description('Action dialog field management'); -const listCommand = new Command('list').description('List action dialog fields').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List action dialog fields') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListActionDialogFields(client); + const result = await coreListActionDialogFields(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/assetroles/assetroles.test.ts b/src/commands/assetroles/assetroles.test.ts index 5543d7e..4a29ea0 100644 --- a/src/commands/assetroles/assetroles.test.ts +++ b/src/commands/assetroles/assetroles.test.ts @@ -52,10 +52,16 @@ describe('asset-roles command', () => { it('should list asset roles', async () => { mockClient.listAssetRoles.mockResolvedValue([{ id: 1, name: 'editor' }]); await assetRolesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listAssetRoles).toHaveBeenCalled(); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalledWith([{ id: 1, name: 'editor' }], expect.anything()); }); + it('should list asset roles with pagination', async () => { + mockClient.listAssetRoles.mockResolvedValue([]); + await assetRolesCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get asset role by id', async () => { mockClient.getAssetRole.mockResolvedValue({ id: 1, name: 'editor' }); await assetRolesCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/assetroles/index.ts b/src/commands/assetroles/index.ts index d19948d..150ba55 100644 --- a/src/commands/assetroles/index.ts +++ b/src/commands/assetroles/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseAssetRoleId } from '../../lib/utils/validators.js'; import type { AssetRoleId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,20 @@ export const assetRolesCommand = new Command('asset-roles') .alias('assetroles') .description('Asset role commands'); -const listCommand = new Command('list').description('List all asset roles').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all asset roles') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listAssetRoles(client); + const result = await listAssetRoles(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/cors/cors.test.ts b/src/commands/cors/cors.test.ts index b1a59ce..e71f7a0 100644 --- a/src/commands/cors/cors.test.ts +++ b/src/commands/cors/cors.test.ts @@ -52,10 +52,16 @@ describe('cors command', () => { it('should list CORS origins', async () => { await corsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listCorsOrigins).toHaveBeenCalled(); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list CORS origins with pagination', async () => { + await corsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get CORS origin by id', async () => { await corsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/cors/index.ts b/src/commands/cors/index.ts index 9cfb07a..69c66f4 100644 --- a/src/commands/cors/index.ts +++ b/src/commands/cors/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseCorsOriginId } from '../../lib/utils/validators.js'; import type { CorsOriginId } from '../../lib/api/branded-types.js'; import { @@ -18,12 +19,20 @@ import { export const corsCommand = new Command('cors').description('CORS origins management'); -const listCommand = new Command('list').description('List CORS allowed origins').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List CORS allowed origins') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listCorsOrigins(client); + const result = await listCorsOrigins(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/customsections/index.test.ts b/src/commands/customsections/index.test.ts index 06aa258..218f21c 100644 --- a/src/commands/customsections/index.test.ts +++ b/src/commands/customsections/index.test.ts @@ -55,9 +55,33 @@ describe('custom-sections command', () => { await customSectionsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listCustomSections).toHaveBeenCalled(); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalledWith([{ id: 1 }, { id: 2 }], expect.any(Object)); }); + + it('should list custom sections with pagination', async () => { + mockClient.listCustomSections.mockResolvedValue([]); + + await customSectionsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 10, + page: 2, + }); + }); }); describe('create', () => { diff --git a/src/commands/customsections/index.ts b/src/commands/customsections/index.ts index 2055db4..0c0e3b0 100644 --- a/src/commands/customsections/index.ts +++ b/src/commands/customsections/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseCustomSectionId } from '../../lib/utils/validators.js'; import type { CustomSectionId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,23 @@ export const customSectionsCommand = new Command('custom-sections') .alias('customsections') .description('Experiment custom section commands'); -const listCommand = new Command('list').description('List all experiment custom sections').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all experiment custom sections') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listCustomSections(client); + const result = await listCustomSections(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 0466e3a..d1ab9a4 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -77,10 +77,16 @@ describe('datasources command', () => { it('should list datasources', async () => { await datasourcesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listDatasources).toHaveBeenCalled(); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list datasources with pagination', async () => { + await datasourcesCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get datasource by id', async () => { await datasourcesCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index 14fdbfe..dd2f4d6 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -9,6 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseDatasourceId, validateJSON } from '../../lib/utils/validators.js'; import { readStdinText } from '../../lib/utils/stdin.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import type { DatasourceId } from '../../lib/api/branded-types.js'; import { listDatasources as coreListDatasources, @@ -33,12 +34,20 @@ export const datasourcesCommand = new Command('datasources') .aliases(['datasource', 'ds']) .description('Datasource management'); -const listCommand = new Command('list').description('List datasources').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List datasources') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListDatasources(client); + const result = await coreListDatasources(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 839e5e2..9d1fe8c 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -67,7 +67,7 @@ describe('events command', () => { it('should list events', async () => { await eventsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listEvents).toHaveBeenCalledWith({}); + expect(mockClient.listEvents).toHaveBeenCalledWith({ take: 100 }); expect(printFormatted).toHaveBeenCalled(); }); @@ -101,6 +101,7 @@ describe('events command', () => { await eventsCommand.parseAsync(['node', 'test', 'list', '--valid-exposures']); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: true }, + take: 100, }); }); @@ -108,6 +109,7 @@ describe('events command', () => { await eventsCommand.parseAsync(['node', 'test', 'list', '--invalid-exposures']); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: false }, + take: 100, }); }); diff --git a/src/commands/exportconfigs/exportconfigs.test.ts b/src/commands/exportconfigs/exportconfigs.test.ts index ddcd1f3..c90d769 100644 --- a/src/commands/exportconfigs/exportconfigs.test.ts +++ b/src/commands/exportconfigs/exportconfigs.test.ts @@ -55,10 +55,24 @@ describe('export-configs command', () => { it('should list export configs', async () => { await exportConfigsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listExportConfigs).toHaveBeenCalled(); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list export configs with pagination', async () => { + await exportConfigsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 10, + page: 2, + }); + }); + it('should get export config by id', async () => { await exportConfigsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/exportconfigs/index.ts b/src/commands/exportconfigs/index.ts index 0e0683d..4ddb876 100644 --- a/src/commands/exportconfigs/index.ts +++ b/src/commands/exportconfigs/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseExportConfigId, validateJSON } from '../../lib/utils/validators.js'; import type { ExportConfigId } from '../../lib/api/branded-types.js'; import { @@ -23,12 +24,23 @@ export const exportConfigsCommand = new Command('export-configs') .aliases(['exportconfigs', 'export-config']) .description('Export configuration management'); -const listCommand = new Command('list').description('List export configurations').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List export configurations') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListExportConfigs(client); + const result = await coreListExportConfigs(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/goals/index.ts b/src/commands/goals/index.ts index f2a36ae..fc9495a 100644 --- a/src/commands/goals/index.ts +++ b/src/commands/goals/index.ts @@ -9,11 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseGoalId } from '../../lib/utils/validators.js'; import type { GoalId } from '../../lib/api/branded-types.js'; -import { - applyShowExclude, - summarizeGoal, - summarizeGoalRow, -} from '../../api-client/entity-summary.js'; +import { summarizeGoalRow } from '../../api-client/entity-summary.js'; import { createListCommand } from '../../lib/utils/list-command.js'; import { getGoal } from '../../core/goals/get.js'; import { createGoal } from '../../core/goals/create.js'; @@ -47,17 +43,8 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await getGoal(client, { id }); - const data = globalOptions.raw - ? result.data - : applyShowExclude( - summarizeGoal(result.data as Record), - result.data as Record, - show, - exclude, - showOnly - ); - printFormatted(data, globalOptions); + const result = await getGoal(client, { id, show, exclude, showOnly, raw: globalOptions.raw }); + printFormatted(result.data, globalOptions); }) ); diff --git a/src/commands/metrics/index.ts b/src/commands/metrics/index.ts index 857cbf9..25b6bc9 100644 --- a/src/commands/metrics/index.ts +++ b/src/commands/metrics/index.ts @@ -9,11 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseMetricId } from '../../lib/utils/validators.js'; import type { MetricId } from '../../lib/api/branded-types.js'; -import { - applyShowExclude, - summarizeMetric, - summarizeMetricRow, -} from '../../api-client/entity-summary.js'; +import { summarizeMetricRow } from '../../api-client/entity-summary.js'; import { createListCommand } from '../../lib/utils/list-command.js'; import { listMetrics as coreListMetrics, listAllMetrics } from '../../core/metrics/list.js'; import { @@ -119,17 +115,14 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await getMetric(client, { id }); - const data = globalOptions.raw - ? result.data - : applyShowExclude( - summarizeMetric(result.data as Record), - result.data as Record, - show, - exclude, - showOnly - ); - printFormatted(data, globalOptions); + const result = await getMetric(client, { + id, + show, + exclude, + showOnly, + raw: globalOptions.raw, + }); + printFormatted(result.data, globalOptions); }) ); diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index d0af5cc..4b382e3 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -19,15 +19,34 @@ export const notificationsCommand = new Command('notifications') .alias('notif') .description('Notifications commands'); +function parsePositiveIntFlag(value: string, flag: string): number { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`Invalid value for ${flag}: "${value}". Expected a positive integer.`); + } + return parsed; +} + const listCommand = new Command('list') .description('List notifications') .option('--cursor ', 'pagination cursor', parseInt) + .option('--limit ', 'max number of notifications to show') .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListNotifications(client, { cursor: options.cursor }); + const limit = + options.limit !== undefined ? parsePositiveIntFlag(options.limit, '--limit') : undefined; + const result = await coreListNotifications(client, { + cursor: options.cursor, + limit, + }); printFormatted(result.data, globalOptions); + if (result.warnings && globalOptions.output !== 'json' && globalOptions.output !== 'yaml') { + for (const w of result.warnings) { + console.log(chalk.gray(w)); + } + } }) ); diff --git a/src/commands/notifications/notifications.test.ts b/src/commands/notifications/notifications.test.ts index a16b107..9e40e28 100644 --- a/src/commands/notifications/notifications.test.ts +++ b/src/commands/notifications/notifications.test.ts @@ -61,6 +61,42 @@ describe('notifications command', () => { expect(mockClient.getNotifications).toHaveBeenCalledWith(100); }); + it('should not print truncation warnings when output is json', async () => { + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'json' } as any); + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + await notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '20']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).not.toContain('Showing 20 of 50 notifications'); + }); + + it('should print truncation warnings for table output', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + await notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '20']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).toContain('Showing 20 of 50 notifications'); + }); + + it('should reject a negative --limit instead of returning an inverted slice', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + await expect( + notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '-1']) + ).rejects.toThrow('process.exit: 1'); + const errorOutput = consoleErrorSpy.mock.calls.flat().join(' '); + expect(errorOutput).toContain('Invalid value for --limit'); + expect(mockClient.getNotifications).not.toHaveBeenCalled(); + }); + + it('should reject a non-numeric --limit instead of silently returning nothing', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + await expect( + notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', 'abc']) + ).rejects.toThrow('process.exit: 1'); + const errorOutput = consoleErrorSpy.mock.calls.flat().join(' '); + expect(errorOutput).toContain('Invalid value for --limit'); + }); + it('should mark notifications as seen', async () => { mockClient.markNotificationsSeen.mockResolvedValue(undefined); await notificationsCommand.parseAsync(['node', 'test', 'mark-seen']); diff --git a/src/commands/permissions/index.ts b/src/commands/permissions/index.ts index f6b3ae4..5196452 100644 --- a/src/commands/permissions/index.ts +++ b/src/commands/permissions/index.ts @@ -1,47 +1,87 @@ import { Command } from 'commander'; +import chalk from 'chalk'; import { getAPIClientFromOptions, getGlobalOptions, printFormatted, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { listPermissions, listPermissionCategories, listAccessControlPolicies, } from '../../core/permissions/list.js'; +function printWarnings(warnings: string[] | undefined, output: string): void { + if (warnings && output !== 'json' && output !== 'yaml') { + for (const w of warnings) { + console.log(chalk.gray(w)); + } + } +} + export const permissionsCommand = new Command('permissions') .aliases(['permission', 'perms', 'perm']) .description('Permission commands'); -const listCommand = new Command('list').description('List all permissions').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all permissions') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listPermissions(client); + const result = await listPermissions(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); -const categoriesCommand = new Command('categories') - .aliases(['cats', 'cat']) - .description('List permission categories') - .action( - withErrorHandling(async () => { - const globalOptions = getGlobalOptions(categoriesCommand); - const client = await getAPIClientFromOptions(globalOptions); - const result = await listPermissionCategories(client); - printFormatted(result.data, globalOptions); - }) - ); +const categoriesCommand = addPaginationOptions( + new Command('categories').aliases(['cats', 'cat']).description('List permission categories') +).action( + withErrorHandling(async (options) => { + const globalOptions = getGlobalOptions(categoriesCommand); + const client = await getAPIClientFromOptions(globalOptions); + const result = await listPermissionCategories(client, { + items: options.items, + page: options.page, + }); + printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); + }) +); -const policiesCommand = new Command('policies').description('List access control policies').action( - withErrorHandling(async () => { +const policiesCommand = addPaginationOptions( + new Command('policies').description('List access control policies') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(policiesCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listAccessControlPolicies(client); + const result = await listAccessControlPolicies(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/permissions/permissions.test.ts b/src/commands/permissions/permissions.test.ts index d42a5eb..5661d0a 100644 --- a/src/commands/permissions/permissions.test.ts +++ b/src/commands/permissions/permissions.test.ts @@ -70,4 +70,55 @@ describe('permissions command', () => { expect(mockClient.listAccessControlPolicies).toHaveBeenCalled(); expect(printFormatted).toHaveBeenCalled(); }); + + it('should list permissions with pagination', async () => { + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listPermissions).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + + it('should list permission categories with pagination', async () => { + await permissionsCommand.parseAsync([ + 'node', + 'test', + 'categories', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listPermissionCategories).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + + it('should list access control policies with pagination', async () => { + await permissionsCommand.parseAsync([ + 'node', + 'test', + 'policies', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + + it('should warn when the server returns more results than the page size', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '20', '--page', '1']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).toContain('Showing 20 of 30 results'); + }); + + it('should not print the results warning when output is json', async () => { + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'json' } as any); + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '20', '--page', '1']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).not.toContain('Showing 20 of 30 results'); + }); }); diff --git a/src/commands/storageconfigs/index.ts b/src/commands/storageconfigs/index.ts index 1f2c309..bfb50a8 100644 --- a/src/commands/storageconfigs/index.ts +++ b/src/commands/storageconfigs/index.ts @@ -7,6 +7,7 @@ import { withErrorHandling, } from '../../lib/utils/api-helper.js'; import { validateJSON } from '../../lib/utils/validators.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { listStorageConfigs, getStorageConfig, @@ -19,12 +20,23 @@ export const storageConfigsCommand = new Command('storage-configs') .aliases(['storageconfigs', 'storage-config']) .description('Storage config management'); -const listCommand = new Command('list').description('List storage configs').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List storage configs') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listStorageConfigs(client); + const result = await listStorageConfigs(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/storageconfigs/storageconfigs.test.ts b/src/commands/storageconfigs/storageconfigs.test.ts index ab0af90..eee9ee8 100644 --- a/src/commands/storageconfigs/storageconfigs.test.ts +++ b/src/commands/storageconfigs/storageconfigs.test.ts @@ -52,10 +52,24 @@ describe('storage-configs command', () => { it('should list storage configs', async () => { await storageConfigsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listStorageConfigs).toHaveBeenCalled(); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list storage configs with pagination', async () => { + await storageConfigsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get storage config by id', async () => { await storageConfigsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/updateschedules/index.ts b/src/commands/updateschedules/index.ts index 753ae6e..09e714a 100644 --- a/src/commands/updateschedules/index.ts +++ b/src/commands/updateschedules/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseUpdateScheduleId, validateJSON } from '../../lib/utils/validators.js'; import type { UpdateScheduleId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,23 @@ export const updateSchedulesCommand = new Command('update-schedules') .aliases(['updateschedules']) .description('Experiment update schedule management'); -const listCommand = new Command('list').description('List experiment update schedules').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List experiment update schedules') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listUpdateSchedules(client); + const result = await listUpdateSchedules(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/updateschedules/updateschedules.test.ts b/src/commands/updateschedules/updateschedules.test.ts index 262e76a..43095eb 100644 --- a/src/commands/updateschedules/updateschedules.test.ts +++ b/src/commands/updateschedules/updateschedules.test.ts @@ -52,10 +52,24 @@ describe('update-schedules command', () => { it('should list update schedules', async () => { await updateSchedulesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listUpdateSchedules).toHaveBeenCalled(); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list update schedules with pagination', async () => { + await updateSchedulesCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get update schedule by id', async () => { await updateSchedulesCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/users/index.ts b/src/commands/users/index.ts index 3fc142c..e35e677 100644 --- a/src/commands/users/index.ts +++ b/src/commands/users/index.ts @@ -319,7 +319,7 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await coreGetUser(client, { id }); + const result = await coreGetUser(client, { id, raw: true }); const user = result.data; const data = globalOptions.raw ? user diff --git a/src/commands/users/users.test.ts b/src/commands/users/users.test.ts index a43e279..a8a402f 100644 --- a/src/commands/users/users.test.ts +++ b/src/commands/users/users.test.ts @@ -262,6 +262,27 @@ describe('users command', () => { ); }); + it('requests raw data from coreGetUser so its own summarization sees all fields', async () => { + mockClient.getUser.mockResolvedValueOnce({ + id: 1, + email: 'a@b.com', + first_name: 'Jane', + last_name: 'Doe', + roles: [{ id: 1, permissions: ['x'] }], + avatar: { base_url: '/avatars/1' }, + }); + + await usersCommand.parseAsync(['node', 'test', 'get', '1']); + + // If coreGetUser weren't asked for raw:true, its own default summarization would + // strip first_name/last_name before this command re-derives `name` from them, + // collapsing the name to ''. + expect(printFormatted).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, name: 'Jane Doe' }), + expect.anything() + ); + }); + it('should create a user', async () => { await usersCommand.parseAsync([ 'node', diff --git a/src/commands/webhooks/index.ts b/src/commands/webhooks/index.ts index 2381867..3605fe0 100644 --- a/src/commands/webhooks/index.ts +++ b/src/commands/webhooks/index.ts @@ -42,7 +42,14 @@ const getCommand = new Command('get') withErrorHandling(async (id: WebhookId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await getWebhook(client, { id }); + const { show = [], exclude = [], showOnly } = globalOptions; + const result = await getWebhook(client, { + id, + show, + exclude, + showOnly, + raw: globalOptions.raw, + }); printFormatted(result.data, globalOptions); }) ); diff --git a/src/core/actiondialogfields/actiondialogfields.test.ts b/src/core/actiondialogfields/actiondialogfields.test.ts index f66c7b1..6bcec24 100644 --- a/src/core/actiondialogfields/actiondialogfields.test.ts +++ b/src/core/actiondialogfields/actiondialogfields.test.ts @@ -16,14 +16,28 @@ const mockClient = { beforeEach(() => vi.clearAllMocks()); describe('listActionDialogFields', () => { - it('should call client.listExperimentActionDialogFields and return data', async () => { + it('should default items/page and forward to client', async () => { const mockData = [{ id: 1 }, { id: 2 }]; mockClient.listExperimentActionDialogFields.mockResolvedValue(mockData); - const result = await listActionDialogFields(mockClient); + const result = await listActionDialogFields(mockClient, {}); - expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledOnce(); - expect(result).toEqual({ data: mockData }); + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 20, + page: 1, + }); + expect(result.data).toEqual(mockData); + }); + + it('should forward explicit items/page', async () => { + mockClient.listExperimentActionDialogFields.mockResolvedValue([]); + + await listActionDialogFields(mockClient, { items: 5, page: 3 }); + + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 5, + page: 3, + }); }); }); diff --git a/src/core/actiondialogfields/actiondialogfields.ts b/src/core/actiondialogfields/actiondialogfields.ts index 0e95137..5088e8a 100644 --- a/src/core/actiondialogfields/actiondialogfields.ts +++ b/src/core/actiondialogfields/actiondialogfields.ts @@ -1,9 +1,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listActionDialogFields(client: APIClient): Promise> { - const data = await client.listExperimentActionDialogFields(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListActionDialogFieldsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listActionDialogFields( + client: APIClient, + params: ListActionDialogFieldsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listExperimentActionDialogFields({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetActionDialogFieldParams { diff --git a/src/core/assetroles/assetroles.test.ts b/src/core/assetroles/assetroles.test.ts index 86566bf..0b2b882 100644 --- a/src/core/assetroles/assetroles.test.ts +++ b/src/core/assetroles/assetroles.test.ts @@ -18,14 +18,22 @@ const mockClient = { beforeEach(() => vi.clearAllMocks()); describe('listAssetRoles', () => { - it('should call client.listAssetRoles and return data', async () => { + it('should default items/page and forward to client', async () => { const mockData = [{ id: 1 }, { id: 2 }]; mockClient.listAssetRoles.mockResolvedValue(mockData); - const result = await listAssetRoles(mockClient); + const result = await listAssetRoles(mockClient, {}); - expect(mockClient.listAssetRoles).toHaveBeenCalledOnce(); - expect(result).toEqual({ data: mockData }); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 20, page: 1 }); + expect(result.data).toEqual(mockData); + }); + + it('should forward explicit items/page', async () => { + mockClient.listAssetRoles.mockResolvedValue([]); + + await listAssetRoles(mockClient, { items: 5, page: 3 }); + + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 5, page: 3 }); }); }); diff --git a/src/core/assetroles/assetroles.ts b/src/core/assetroles/assetroles.ts index e875dbf..e5b4d97 100644 --- a/src/core/assetroles/assetroles.ts +++ b/src/core/assetroles/assetroles.ts @@ -3,9 +3,21 @@ import type { AssetRoleId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; -export async function listAssetRoles(client: APIClient): Promise> { - const data = await client.listAssetRoles(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListAssetRolesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listAssetRoles( + client: APIClient, + params: ListAssetRolesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listAssetRoles({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetAssetRoleParams { diff --git a/src/core/cors/cors.test.ts b/src/core/cors/cors.test.ts index 11a992b..ec2738b 100644 --- a/src/core/cors/cors.test.ts +++ b/src/core/cors/cors.test.ts @@ -16,13 +16,19 @@ describe('cors', () => { deleteCorsOrigin: vi.fn(), }; - it('should list cors origins', async () => { + it('should default items/page and forward to client', async () => { mockClient.listCorsOrigins.mockResolvedValue([{ id: 1 }]); - const result = await listCorsOrigins(mockClient as any); - expect(mockClient.listCorsOrigins).toHaveBeenCalled(); + const result = await listCorsOrigins(mockClient as any, {}); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listCorsOrigins.mockResolvedValue([]); + await listCorsOrigins(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get cors origin by id', async () => { mockClient.getCorsOrigin.mockResolvedValue({ id: 1, origin: 'http://example.com' }); const result = await getCorsOrigin(mockClient as any, { id: 1 as any }); diff --git a/src/core/cors/cors.ts b/src/core/cors/cors.ts index 9506666..5e482cd 100644 --- a/src/core/cors/cors.ts +++ b/src/core/cors/cors.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CorsOriginId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listCorsOrigins(client: APIClient): Promise> { - const data = await client.listCorsOrigins(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListCorsOriginsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listCorsOrigins( + client: APIClient, + params: ListCorsOriginsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listCorsOrigins({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetCorsOriginParams { diff --git a/src/core/customsections/customsections.test.ts b/src/core/customsections/customsections.test.ts index f7d6af7..46fde7a 100644 --- a/src/core/customsections/customsections.test.ts +++ b/src/core/customsections/customsections.test.ts @@ -16,13 +16,27 @@ describe('customsections', () => { reorderCustomSections: vi.fn(), }; - it('should list custom sections', async () => { + it('should default items/page and forward to client', async () => { mockClient.listCustomSections.mockResolvedValue([{ id: 1 }]); - const result = await listCustomSections(mockClient as any); - expect(mockClient.listCustomSections).toHaveBeenCalled(); + const result = await listCustomSections(mockClient as any, {}); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 20, + page: 1, + }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit type/items/page', async () => { + mockClient.listCustomSections.mockResolvedValue([]); + await listCustomSections(mockClient as any, { type: 'text', items: 5, page: 3 }); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: 'text', + items: 5, + page: 3, + }); + }); + it('should create custom section', async () => { mockClient.createCustomSection.mockResolvedValue({ id: 2, name: 'new', type: 'text' }); const result = await createCustomSection(mockClient as any, { name: 'new', type: 'text' }); diff --git a/src/core/customsections/customsections.ts b/src/core/customsections/customsections.ts index 6c69e58..5444ed0 100644 --- a/src/core/customsections/customsections.ts +++ b/src/core/customsections/customsections.ts @@ -3,9 +3,22 @@ import type { CustomSectionId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; -export async function listCustomSections(client: APIClient): Promise> { - const data = await client.listCustomSections(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListCustomSectionsParams { + type?: string | undefined; + items?: number | undefined; + page?: number | undefined; +} + +export async function listCustomSections( + client: APIClient, + params: ListCustomSectionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listCustomSections({ type: params.type, items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface CreateCustomSectionParams { diff --git a/src/core/datasources/datasources.test.ts b/src/core/datasources/datasources.test.ts index ca79853..4269adb 100644 --- a/src/core/datasources/datasources.test.ts +++ b/src/core/datasources/datasources.test.ts @@ -36,13 +36,19 @@ describe('datasources', () => { previewDatasourceJsonLayouts: vi.fn(), }; - it('should list datasources', async () => { + it('should default items/page and forward to client', async () => { mockClient.listDatasources.mockResolvedValue([{ id: 1 }]); - const result = await listDatasources(mockClient as any); - expect(mockClient.listDatasources).toHaveBeenCalled(); + const result = await listDatasources(mockClient as any, {}); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listDatasources.mockResolvedValue([]); + await listDatasources(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get datasource by id', async () => { mockClient.getDatasource.mockResolvedValue({ id: 1, name: 'test' }); const result = await getDatasource(mockClient as any, { id: 1 as any }); diff --git a/src/core/datasources/datasources.ts b/src/core/datasources/datasources.ts index 4829231..156a9b3 100644 --- a/src/core/datasources/datasources.ts +++ b/src/core/datasources/datasources.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { DatasourceId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listDatasources(client: APIClient): Promise> { - const data = await client.listDatasources(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListDatasourcesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listDatasources( + client: APIClient, + params: ListDatasourcesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listDatasources({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetDatasourceParams { diff --git a/src/core/events/events.test.ts b/src/core/events/events.test.ts index d6499f9..d531c48 100644 --- a/src/core/events/events.test.ts +++ b/src/core/events/events.test.ts @@ -107,7 +107,7 @@ describe('events', () => { it('should omit empty filters', async () => { mockClient.listEvents.mockResolvedValue([]); await listEvents(mockClient as any, {}); - expect(mockClient.listEvents).toHaveBeenCalledWith({}); + expect(mockClient.listEvents).toHaveBeenCalledWith({ take: 100 }); }); it('should send effective_exposures=true when validExposures is true', async () => { @@ -115,6 +115,7 @@ describe('events', () => { await listEvents(mockClient as any, { validExposures: true }); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: true }, + take: 100, }); }); @@ -123,13 +124,28 @@ describe('events', () => { await listEvents(mockClient as any, { validExposures: false }); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: false }, + take: 100, }); }); it('should omit effective_exposures when validExposures is undefined', async () => { mockClient.listEvents.mockResolvedValue([]); await listEvents(mockClient as any, { from: 1 }); - expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { from: 1 } }); + expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { from: 1 }, take: 100 }); + }); + }); + + describe('listEvents default take', () => { + it('should apply a default take when not specified', async () => { + mockClient.listEvents.mockResolvedValue({ columnNames: [], rows: [] }); + await listEvents(mockClient as any, {}); + expect(mockClient.listEvents).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); + }); + + it('should respect an explicit take', async () => { + mockClient.listEvents.mockResolvedValue({ columnNames: [], rows: [] }); + await listEvents(mockClient as any, { take: 500 }); + expect(mockClient.listEvents).toHaveBeenCalledWith(expect.objectContaining({ take: 500 })); }); }); diff --git a/src/core/events/events.ts b/src/core/events/events.ts index 322a43c..45b0a4a 100644 --- a/src/core/events/events.ts +++ b/src/core/events/events.ts @@ -55,6 +55,8 @@ export function formatEventRowTimestamps( }); } +const DEFAULT_EVENTS_TAKE = 100; + export interface ListEventsParams { from?: number | undefined; to?: number | undefined; @@ -88,7 +90,7 @@ export async function listEvents( const body: Record = {}; if (Object.keys(filters).length > 0) body.filters = filters; - if (params.take !== undefined) body.take = params.take; + body.take = params.take ?? DEFAULT_EVENTS_TAKE; if (params.skip !== undefined) body.skip = params.skip; const data = await client.listEvents(body as Parameters[0]); diff --git a/src/core/exportconfigs/exportconfigs.test.ts b/src/core/exportconfigs/exportconfigs.test.ts index a8d997d..62fbd83 100644 --- a/src/core/exportconfigs/exportconfigs.test.ts +++ b/src/core/exportconfigs/exportconfigs.test.ts @@ -22,13 +22,27 @@ describe('exportconfigs', () => { cancelExportHistory: vi.fn(), }; - it('should list export configs', async () => { + it('should default items/page and forward to client', async () => { mockClient.listExportConfigs.mockResolvedValue([{ id: 1 }]); - const result = await listExportConfigs(mockClient as any); - expect(mockClient.listExportConfigs).toHaveBeenCalled(); + const result = await listExportConfigs(mockClient as any, {}); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 20, + page: 1, + }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit statuses/items/page', async () => { + mockClient.listExportConfigs.mockResolvedValue([]); + await listExportConfigs(mockClient as any, { statuses: 'COMPLETED', items: 5, page: 3 }); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: 'COMPLETED', + items: 5, + page: 3, + }); + }); + it('should get export config by id', async () => { mockClient.getExportConfig.mockResolvedValue({ id: 1, name: 'test' }); const result = await getExportConfig(mockClient as any, { id: 1 as any }); diff --git a/src/core/exportconfigs/exportconfigs.ts b/src/core/exportconfigs/exportconfigs.ts index 4a9ac17..8ed827e 100644 --- a/src/core/exportconfigs/exportconfigs.ts +++ b/src/core/exportconfigs/exportconfigs.ts @@ -2,9 +2,22 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { ExportConfigId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listExportConfigs(client: APIClient): Promise> { - const data = await client.listExportConfigs(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListExportConfigsParams { + statuses?: string | undefined; + items?: number | undefined; + page?: number | undefined; +} + +export async function listExportConfigs( + client: APIClient, + params: ListExportConfigsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listExportConfigs({ statuses: params.statuses, items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetExportConfigParams { diff --git a/src/core/goals/get.ts b/src/core/goals/get.ts index ebac4a9..dc7f5f1 100644 --- a/src/core/goals/get.ts +++ b/src/core/goals/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { GoalId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeGoal } from '../../api-client/entity-summary.js'; export interface GetGoalParams { id: GoalId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getGoal( client: APIClient, params: GetGoalParams ): Promise> { - const data = await client.getGoal(params.id); + const goal = await client.getGoal(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? goal + : applyShowExclude( + summarizeGoal(goal as Record), + goal as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/goals/goals.test.ts b/src/core/goals/goals.test.ts index b51f663..9147072 100644 --- a/src/core/goals/goals.test.ts +++ b/src/core/goals/goals.test.ts @@ -50,6 +50,21 @@ describe('listGoals', () => { expect(result.pagination!.hasMore).toBe(true); }); + + it('should include summarized rows', async () => { + const goals = [ + { id: 1, name: 'G1', archived: false, tags: [{ goal_tag: { tag: 'core' } }] }, + { id: 2, name: 'G2', archived: true, tags: [] }, + ]; + mockClient.listGoals.mockResolvedValue(goals); + + const result = await listGoals(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { id: 1, name: 'G1', tags: 'core', archived: false }, + { id: 2, name: 'G2', tags: '', archived: true }, + ]); + }); }); describe('getGoal', () => { @@ -60,7 +75,19 @@ describe('getGoal', () => { const result = await getGoal(mockClient, { id: 5 as any }); expect(mockClient.getGoal).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: goal }); + expect(result.data).toMatchObject({ id: 5, name: 'Goal' }); + }); + + it('should summarize by default and honor raw', async () => { + const goal = { id: 5, name: 'Goal', archived: false, big_field: 'lots of data' }; + mockClient.getGoal.mockResolvedValue(goal); + + const summarized = await getGoal(mockClient, { id: 5 as any }); + expect(summarized.data).not.toHaveProperty('big_field'); + expect(summarized.data).toMatchObject({ id: 5, name: 'Goal' }); + + const raw = await getGoal(mockClient, { id: 5 as any, raw: true }); + expect(raw.data).toEqual(goal); }); }); diff --git a/src/core/goals/list.ts b/src/core/goals/list.ts index 496b19e..c770d59 100644 --- a/src/core/goals/list.ts +++ b/src/core/goals/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeGoalRow } from '../../api-client/entity-summary.js'; export interface ListGoalsParams { items: number; @@ -13,6 +14,7 @@ export async function listGoals( const data = await client.listGoals({ items: params.items, page: params.page }); return { data, + rows: (data as Array>).map(summarizeGoalRow), pagination: { page: params.page, items: params.items, hasMore: data.length >= params.items }, }; } diff --git a/src/core/metrics/get.ts b/src/core/metrics/get.ts index 328db54..bd04fa2 100644 --- a/src/core/metrics/get.ts +++ b/src/core/metrics/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { MetricId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeMetric } from '../../api-client/entity-summary.js'; export interface GetMetricParams { id: MetricId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getMetric( client: APIClient, params: GetMetricParams ): Promise> { - const data = await client.getMetric(params.id); + const metric = await client.getMetric(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? metric + : applyShowExclude( + summarizeMetric(metric as Record), + metric as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/metrics/list.ts b/src/core/metrics/list.ts index 26f6865..bb2d81d 100644 --- a/src/core/metrics/list.ts +++ b/src/core/metrics/list.ts @@ -1,6 +1,7 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import { resolveOwnerIds, resolveTeamIds } from '../resolve.js'; +import { summarizeMetricRow } from '../../api-client/entity-summary.js'; export { resolveOwnerIds, resolveTeamIds }; @@ -41,6 +42,7 @@ export async function listMetrics( return { data, + rows: (data as Array>).map(summarizeMetricRow), pagination: { page: params.page, items: params.items, hasMore: data.length >= params.items }, }; } diff --git a/src/core/metrics/metrics.test.ts b/src/core/metrics/metrics.test.ts index eb5ffe6..a45d0fa 100644 --- a/src/core/metrics/metrics.test.ts +++ b/src/core/metrics/metrics.test.ts @@ -164,7 +164,7 @@ describe('getMetric', () => { const result = await getMetric(mockClient, { id: 5 as any }); expect(mockClient.getMetric).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: metric }); + expect(result.data).toBeDefined(); }); }); @@ -812,6 +812,69 @@ describe('validateMetricFields', () => { }); }); +describe('listMetrics rows', () => { + it('should include summarized rows', async () => { + const metrics = [ + { id: 1, name: 'M1', type: 'conversion', lifecycle_status: 'active', goal_id: 5 }, + { id: 2, name: 'M2', type: 'revenue', lifecycle_status: 'draft', goal: { name: 'Goal B' } }, + ]; + mockClient.listMetrics.mockResolvedValue(metrics); + + const result = await listMetrics(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { + id: 1, + name: 'M1', + type: 'conversion', + effect: '', + status: 'active', + goal: 5, + category: '', + }, + { + id: 2, + name: 'M2', + type: 'revenue', + effect: '', + status: 'draft', + goal: 'Goal B', + category: '', + }, + ]); + expect(result.data).toEqual(metrics); + }); +}); + +describe('getMetric summarization', () => { + const metric = { + id: 5, + name: 'Signup Rate', + type: 'conversion', + lifecycle_status: 'active', + definition: { some: 'large blob' }, + }; + + it('returns summarized data by default', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('definition'); + expect(result.data).toMatchObject({ id: 5, name: 'Signup Rate', type: 'conversion' }); + }); + + it('returns raw data when raw=true', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(metric); + }); + + it('includes an extra field via show', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any, show: ['definition'] }); + expect(result.data).toHaveProperty('definition', { some: 'large blob' }); + }); +}); + describe('listAllMetrics', () => { it('pages through results until a short page is returned', async () => { const client = { diff --git a/src/core/notifications/notifications.test.ts b/src/core/notifications/notifications.test.ts index f46c147..eeecb7e 100644 --- a/src/core/notifications/notifications.test.ts +++ b/src/core/notifications/notifications.test.ts @@ -62,4 +62,55 @@ describe('notifications', () => { expect(mockClient.hasNewNotifications).toHaveBeenCalledWith(undefined); expect(result.data).toEqual({ hasNew: false }); }); + + describe('listNotifications limit', () => { + it('should cap results to the default limit and warn when truncated', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + + const result = await listNotifications(mockClient as any, {}); + + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual([ + 'Showing 20 of 50 notifications. Use --limit to show more.', + ]); + }); + + it('should not warn when under the limit', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + const result = await listNotifications(mockClient as any, {}); + expect(result.warnings).toBeUndefined(); + }); + + it('should respect an explicit limit', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: 5 }); + expect((result.data as unknown[]).length).toBe(5); + }); + + it('should fall back to the default limit for a negative value instead of an inverted slice', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: -1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual([ + 'Showing 20 of 50 notifications. Use --limit to show more.', + ]); + }); + + it('should fall back to the default limit for a non-integer value instead of silently returning nothing', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: NaN }); + expect((result.data as unknown[]).length).toBe(20); + }); + + it('should fall back to the default limit for zero', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: 0 }); + expect((result.data as unknown[]).length).toBe(20); + }); + }); }); diff --git a/src/core/notifications/notifications.ts b/src/core/notifications/notifications.ts index f648026..f1c1562 100644 --- a/src/core/notifications/notifications.ts +++ b/src/core/notifications/notifications.ts @@ -1,16 +1,30 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +const DEFAULT_NOTIFICATIONS_LIMIT = 20; + export interface ListNotificationsParams { cursor?: number | undefined; + limit?: number | undefined; } export async function listNotifications( client: APIClient, params: ListNotificationsParams ): Promise> { - const data = await client.getNotifications(params.cursor); - return { data }; + const all = await client.getNotifications(params.cursor); + const limit = + params.limit !== undefined && Number.isInteger(params.limit) && params.limit > 0 + ? params.limit + : DEFAULT_NOTIFICATIONS_LIMIT; + const data = all.slice(0, limit); + return { + data, + warnings: + all.length > limit + ? [`Showing ${limit} of ${all.length} notifications. Use --limit to show more.`] + : undefined, + }; } export async function markNotificationsSeen(client: APIClient): Promise> { diff --git a/src/core/permissions/list.ts b/src/core/permissions/list.ts index 8cab042..556e701 100644 --- a/src/core/permissions/list.ts +++ b/src/core/permissions/list.ts @@ -1,19 +1,76 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listPermissions(client: APIClient): Promise> { - const data = await client.listPermissions(); - return { data }; +const DEFAULT_PERMISSIONS_PAGE_SIZE = 20; + +export interface ListPermissionsParams { + items?: number | undefined; + page?: number | undefined; +} + +// permissions/permission_categories/access_control_policies have no +// server-side items/page support (confirmed against the real backend: +// each route calls its model's getAll() with no query args), unlike the +// other pagination groups this ticket touched. Slicing client-side and +// warning on truncation mirrors this codebase's own notifications +// precedent — without it, `hasMore` would always be true past the first +// page and every subsequent --page would silently re-return the same +// full list. +function paginateClientSide( + data: unknown[], + page: number, + items: number +): { pageData: unknown[]; warnings: string[] | undefined } { + const start = (page - 1) * items; + const pageData = data.slice(start, start + items); + const warnings = + page * items < data.length + ? [`Showing ${pageData.length} of ${data.length} results. Use --page to see more.`] + : undefined; + return { pageData, warnings }; } -export async function listPermissionCategories(client: APIClient): Promise> { - const data = await client.listPermissionCategories(); - return { data }; +export async function listPermissions( + client: APIClient, + params: ListPermissionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const all = await client.listPermissions({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; +} + +export async function listPermissionCategories( + client: APIClient, + params: ListPermissionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const all = await client.listPermissionCategories({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; } export async function listAccessControlPolicies( - client: APIClient + client: APIClient, + params: ListPermissionsParams = {} ): Promise> { - const data = await client.listAccessControlPolicies(); - return { data }; + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const all = await client.listAccessControlPolicies({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; } diff --git a/src/core/permissions/permissions.test.ts b/src/core/permissions/permissions.test.ts index a5f20fc..b3d7639 100644 --- a/src/core/permissions/permissions.test.ts +++ b/src/core/permissions/permissions.test.ts @@ -29,3 +29,79 @@ describe('permissions', () => { expect(result.data).toEqual([{ id: 1, name: 'default' }]); }); }); + +describe('listPermissions pagination', () => { + const mockClient = { listPermissions: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listPermissions.mockResolvedValue([]); + await listPermissions(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listPermissions).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + expect(result.pagination?.hasMore).toBe(true); + }); + + it('should return the correct slice for page 2 and report hasMore false at the end', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 2 }); + expect((result.data as unknown[]).length).toBe(10); + expect(result.pagination?.hasMore).toBe(false); + }); + + it('should not warn to see more on the final page even though the full list exceeds items', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 2 }); + expect(result.warnings).toBeUndefined(); + }); + + it('should not warn when everything fits on one page', async () => { + mockClient.listPermissions.mockResolvedValue([{ id: 1 }]); + const result = await listPermissions(mockClient as any, { items: 20, page: 1 }); + expect(result.warnings).toBeUndefined(); + }); +}); + +describe('listPermissionCategories pagination', () => { + const mockClient = { listPermissionCategories: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listPermissionCategories.mockResolvedValue([]); + await listPermissionCategories(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listPermissionCategories).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissionCategories.mockResolvedValue(all); + const result = await listPermissionCategories(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + }); +}); + +describe('listAccessControlPolicies pagination', () => { + const mockClient = { listAccessControlPolicies: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listAccessControlPolicies.mockResolvedValue([]); + await listAccessControlPolicies(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listAccessControlPolicies.mockResolvedValue(all); + const result = await listAccessControlPolicies(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + }); +}); diff --git a/src/core/storageconfigs/storageconfigs.test.ts b/src/core/storageconfigs/storageconfigs.test.ts index 61527be..7951a4f 100644 --- a/src/core/storageconfigs/storageconfigs.test.ts +++ b/src/core/storageconfigs/storageconfigs.test.ts @@ -16,13 +16,19 @@ describe('storageconfigs', () => { testStorageConfig: vi.fn(), }; - it('should list storage configs', async () => { + it('should default items/page and forward to client', async () => { mockClient.listStorageConfigs.mockResolvedValue([{ id: 1 }]); - const result = await listStorageConfigs(mockClient as any); - expect(mockClient.listStorageConfigs).toHaveBeenCalled(); + const result = await listStorageConfigs(mockClient as any, {}); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listStorageConfigs.mockResolvedValue([]); + await listStorageConfigs(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get storage config by id', async () => { mockClient.getStorageConfig.mockResolvedValue({ id: 1, type: 's3' }); const result = await getStorageConfig(mockClient as any, { id: 1 }); diff --git a/src/core/storageconfigs/storageconfigs.ts b/src/core/storageconfigs/storageconfigs.ts index c507c45..9c6fc2a 100644 --- a/src/core/storageconfigs/storageconfigs.ts +++ b/src/core/storageconfigs/storageconfigs.ts @@ -1,9 +1,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listStorageConfigs(client: APIClient): Promise> { - const data = await client.listStorageConfigs(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListStorageConfigsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listStorageConfigs( + client: APIClient, + params: ListStorageConfigsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listStorageConfigs({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetStorageConfigParams { diff --git a/src/core/tags/tags.test.ts b/src/core/tags/tags.test.ts index 37eb276..9bea57e 100644 --- a/src/core/tags/tags.test.ts +++ b/src/core/tags/tags.test.ts @@ -50,4 +50,24 @@ describe('tags', () => { expect(mockClient.deleteExperimentTag).toHaveBeenCalledWith(1); expect(result.data).toEqual({ id: 1 }); }); + + describe('listTags rows', () => { + it('should include summarized rows', async () => { + const tags = [ + { + id: 1, + tag: 'mobile', + archived: false, + created_at: '2024-01-01T00:00:00Z', + created_by: { id: 9, first_name: 'A', last_name: 'B' }, + }, + ]; + mockClient.listExperimentTags.mockResolvedValue(tags); + + const result = await listTags(mockClient as any, { items: 10, page: 1 }); + + expect(result.rows).toBeDefined(); + expect(result.rows![0]).toMatchObject({ id: 1, tag: 'mobile', archived: false }); + }); + }); }); diff --git a/src/core/tags/tags.ts b/src/core/tags/tags.ts index 7a537f5..e14fe62 100644 --- a/src/core/tags/tags.ts +++ b/src/core/tags/tags.ts @@ -2,6 +2,7 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; +import { summarizeTagRow } from '../../api-client/entity-summary.js'; export interface ListTagsParams { items: number; @@ -13,7 +14,7 @@ export async function listTags( params: ListTagsParams ): Promise> { const data = await client.listExperimentTags({ items: params.items, page: params.page }); - return { data }; + return { data, rows: (data as Array>).map(summarizeTagRow) }; } export interface GetTagParams { diff --git a/src/core/updateschedules/updateschedules.test.ts b/src/core/updateschedules/updateschedules.test.ts index 7fba169..c0321a9 100644 --- a/src/core/updateschedules/updateschedules.test.ts +++ b/src/core/updateschedules/updateschedules.test.ts @@ -16,13 +16,19 @@ describe('updateschedules', () => { deleteUpdateSchedule: vi.fn(), }; - it('should list update schedules', async () => { + it('should default items/page and forward to client', async () => { mockClient.listUpdateSchedules.mockResolvedValue([{ id: 1 }]); - const result = await listUpdateSchedules(mockClient as any); - expect(mockClient.listUpdateSchedules).toHaveBeenCalled(); + const result = await listUpdateSchedules(mockClient as any, {}); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listUpdateSchedules.mockResolvedValue([]); + await listUpdateSchedules(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get update schedule by id', async () => { mockClient.getUpdateSchedule.mockResolvedValue({ id: 1, cron: '* * * * *' }); const result = await getUpdateSchedule(mockClient as any, { id: 1 as any }); diff --git a/src/core/updateschedules/updateschedules.ts b/src/core/updateschedules/updateschedules.ts index f30582f..dea2d8e 100644 --- a/src/core/updateschedules/updateschedules.ts +++ b/src/core/updateschedules/updateschedules.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { UpdateScheduleId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listUpdateSchedules(client: APIClient): Promise> { - const data = await client.listUpdateSchedules(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListUpdateSchedulesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listUpdateSchedules( + client: APIClient, + params: ListUpdateSchedulesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listUpdateSchedules({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetUpdateScheduleParams { diff --git a/src/core/users/get.ts b/src/core/users/get.ts index 4bf2b0c..35f8e95 100644 --- a/src/core/users/get.ts +++ b/src/core/users/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { UserId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeUserDetail } from '../../api-client/entity-summary.js'; export interface GetUserParams { id: UserId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getUser( client: APIClient, params: GetUserParams ): Promise> { - const data = await client.getUser(params.id); + const user = await client.getUser(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? user + : applyShowExclude( + summarizeUserDetail(user as Record), + user as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/users/list.ts b/src/core/users/list.ts index 3d1e051..72571df 100644 --- a/src/core/users/list.ts +++ b/src/core/users/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeUserRow } from '../../api-client/entity-summary.js'; export interface ListUsersParams { items?: number | undefined; @@ -34,6 +35,7 @@ export async function listUsers( const data = await client.listUsers(opts); return { data, + rows: (data as Array>).map(summarizeUserRow), pagination: { page: params.page ?? 1, items: params.items ?? 25, diff --git a/src/core/users/users.test.ts b/src/core/users/users.test.ts index 42c498d..3f50dec 100644 --- a/src/core/users/users.test.ts +++ b/src/core/users/users.test.ts @@ -85,7 +85,31 @@ describe('getUser', () => { const result = await getUser(mockClient, { id: 5 as any }); expect(mockClient.getUser).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: user }); + expect(result.data).toMatchObject({ id: 5, email: 'a@b.com' }); + }); +}); + +describe('getUser summarization', () => { + const user = { + id: 5, + email: 'u@x.com', + first_name: 'U', + last_name: 'Ser', + archived: false, + roles: [{ id: 1, permissions: ['huge array'] }], + }; + + it('returns summarized data by default', async () => { + mockClient.getUser.mockResolvedValue(user); + const result = await getUser(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('roles'); + expect(result.data).toMatchObject({ id: 5, email: 'u@x.com', name: 'U Ser' }); + }); + + it('returns raw data when raw=true', async () => { + mockClient.getUser.mockResolvedValue(user); + const result = await getUser(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(user); }); }); @@ -146,6 +170,39 @@ describe('listUsers', () => { }); }); +describe('listUsers rows', () => { + it('should include summarized rows', async () => { + const users = [ + { id: 1, email: 'a@x.com', first_name: 'A', last_name: 'One', archived: false }, + { id: 2, email: 'b@x.com', first_name: 'B', last_name: 'Two', archived: true }, + ]; + mockClient.listUsers.mockResolvedValue(users); + + const result = await listUsers(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { + id: 1, + email: 'a@x.com', + name: 'A One', + department: '', + job_title: '', + archived: false, + last_login: '', + }, + { + id: 2, + email: 'b@x.com', + name: 'B Two', + department: '', + job_title: '', + archived: true, + last_login: '', + }, + ]); + }); +}); + describe('resetUserPassword', () => { it('should reset password and return new password', async () => { mockClient.resetUserPassword.mockResolvedValue({ password: 'newpass123' }); diff --git a/src/core/webhooks/get.ts b/src/core/webhooks/get.ts index 9802951..6972464 100644 --- a/src/core/webhooks/get.ts +++ b/src/core/webhooks/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import type { WebhookId } from '../../lib/api/branded-types.js'; +import { applyShowExclude, summarizeWebhook } from '../../api-client/entity-summary.js'; export interface GetWebhookParams { id: WebhookId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getWebhook( client: APIClient, params: GetWebhookParams ): Promise> { - const data = await client.getWebhook(params.id); + const webhook = await client.getWebhook(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? webhook + : applyShowExclude( + summarizeWebhook(webhook as Record), + webhook as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/webhooks/list.ts b/src/core/webhooks/list.ts index 1824daa..361815f 100644 --- a/src/core/webhooks/list.ts +++ b/src/core/webhooks/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeWebhookRow } from '../../api-client/entity-summary.js'; export interface ListWebhooksParams { items: number; @@ -11,5 +12,5 @@ export async function listWebhooks( params: ListWebhooksParams ): Promise> { const data = await client.listWebhooks({ items: params.items, page: params.page }); - return { data }; + return { data, rows: (data as Array>).map(summarizeWebhookRow) }; } diff --git a/src/core/webhooks/webhooks.test.ts b/src/core/webhooks/webhooks.test.ts index 7dae7b3..e44f795 100644 --- a/src/core/webhooks/webhooks.test.ts +++ b/src/core/webhooks/webhooks.test.ts @@ -25,19 +25,39 @@ describe('listWebhooks', () => { const result = await listWebhooks(mockClient, { items: 10, page: 1 }); expect(mockClient.listWebhooks).toHaveBeenCalledWith({ items: 10, page: 1 }); - expect(result).toEqual({ data: hooks }); + expect(result.data).toEqual(hooks); + }); + + it('should include summarized rows', async () => { + const hooks = [{ id: 1, name: 'H1', url: 'https://a.com', enabled: true, archived: false }]; + mockClient.listWebhooks.mockResolvedValue(hooks); + + const result = await listWebhooks(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toBeDefined(); + expect(result.rows![0]).toMatchObject({ + id: 1, + name: 'H1', + url: 'https://a.com', + enabled: true, + }); }); }); describe('getWebhook', () => { - it('should get webhook by id', async () => { - const hook = { id: 5, name: 'hook' }; - mockClient.getWebhook.mockResolvedValue(hook); + const hook = { id: 5, name: 'hook', url: 'https://x.com', enabled: true, secret: 'shh' }; + it('should return summarized data by default', async () => { + mockClient.getWebhook.mockResolvedValue(hook); const result = await getWebhook(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('secret'); + expect(result.data).toMatchObject({ id: 5, name: 'hook', url: 'https://x.com' }); + }); - expect(mockClient.getWebhook).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: hook }); + it('should return raw data when raw=true', async () => { + mockClient.getWebhook.mockResolvedValue(hook); + const result = await getWebhook(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(hook); }); });