diff --git a/README.md b/README.md index 2dc4aae..ea8d305 100644 --- a/README.md +++ b/README.md @@ -280,14 +280,14 @@ List correspondents ``` USAGE $ ppls correspondents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--page ] [--page-size ] [--id-in | --name-contains ] - [--sort ] + --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort + ] FLAGS --id-in= Filter by id list (comma-separated) --name-contains= Filter by name substring --page= Page number to fetch - --page-size= Number of results per page + --page-size= [default: disable pagination, all results] Number of results per page --sort= Sort results by the provided field GLOBAL FLAGS @@ -457,14 +457,14 @@ List custom fields ``` USAGE $ ppls custom-fields list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--page ] [--page-size ] [--id-in | --name-contains ] - [--sort ] + --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort + ] FLAGS --id-in= Filter by id list (comma-separated) --name-contains= Filter by name substring --page= Page number to fetch - --page-size= Number of results per page + --page-size= [default: disable pagination, all results] Number of results per page --sort= Sort results by the provided field GLOBAL FLAGS @@ -632,14 +632,14 @@ List document types ``` USAGE $ ppls document-types list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--page ] [--page-size ] [--id-in | --name-contains ] - [--sort ] + --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort + ] FLAGS --id-in= Filter by id list (comma-separated) --name-contains= Filter by name substring --page= Page number to fetch - --page-size= Number of results per page + --page-size= [default: disable pagination, all results] Number of results per page --sort= Sort results by the provided field GLOBAL FLAGS @@ -852,14 +852,14 @@ List documents ``` USAGE $ ppls documents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--page ] [--page-size ] [--id-in | --name-contains ] - [--sort ] + --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort + ] FLAGS --id-in= Filter by id list (comma-separated) --name-contains= Filter by name substring --page= Page number to fetch - --page-size= Number of results per page + --page-size= [default: disable pagination, all results] Number of results per page --sort= Sort results by the provided field GLOBAL FLAGS @@ -1088,14 +1088,14 @@ List tags ``` USAGE $ ppls tags list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--page ] [--page-size ] [--id-in | --name-contains ] - [--sort ] + --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort + ] FLAGS --id-in= Filter by id list (comma-separated) --name-contains= Filter by name substring --page= Page number to fetch - --page-size= Number of results per page + --page-size= [default: disable pagination, all results] Number of results per page --sort= Sort results by the provided field GLOBAL FLAGS diff --git a/src/list-command.ts b/src/list-command.ts index e3ce041..1cc1267 100644 --- a/src/list-command.ts +++ b/src/list-command.ts @@ -2,14 +2,14 @@ import {Flags} from '@oclif/core' import type {ApiFlags} from './base-command.js' +import {BaseCommand} from './base-command.js' import {createValueFormatter, type TableColumn, type TableRow} from './helpers/table.js' -import {PaginatedCommand} from './paginated-command.js' type ListCommandFlags = ApiFlags & { 'id-in'?: string 'name-contains'?: string page?: number - 'page-size'?: number + 'page-size': number sort?: string } @@ -21,12 +21,16 @@ type ListOutputFlags = ListCommandFlags & { type TableColumnInput = string | TableColumn +type PaginatedResponse = { + results?: T[] +} + export abstract class ListCommand< TRaw extends TableRow = TableRow, TOutput extends TableRow = TRaw, -> extends PaginatedCommand { +> extends BaseCommand { static baseFlags = { - ...PaginatedCommand.baseFlags, + ...BaseCommand.baseFlags, 'id-in': Flags.string({ description: 'Filter by id list (comma-separated)', exclusive: ['name-contains'], @@ -35,6 +39,17 @@ export abstract class ListCommand< description: 'Filter by name substring', exclusive: ['id-in'], }), + page: Flags.integer({ + dependsOn: ['page-size'], + description: 'Page number to fetch', + min: 1, + }), + 'page-size': Flags.integer({ + default: async ({flags}) => (flags.page === undefined ? Number.MAX_SAFE_INTEGER : undefined), + defaultHelp: async () => 'disable pagination, all results', + description: 'Number of results per page', + min: 1, + }), sort: Flags.string({description: 'Sort results by the provided field'}), } protected abstract listPath: string @@ -46,7 +61,7 @@ export abstract class ListCommand< path: string }): Promise { const {flags, params = {}, path} = options - const url = this.buildPaginatedUrlFromFlags({ + const url = this.buildListUrl({ flags, params: { ordering: flags.sort, @@ -54,12 +69,14 @@ export abstract class ListCommand< }, path, }) + const spinner = this.startSpinner(`Fetching ${url.pathname}`) - return this.fetchPaginatedResultsFromFlags({ - autoPaginate: this.shouldAutoPaginate(flags), - flags, - url, - }) + try { + const payload = await this.fetchJson>(url, flags.token, flags.headers) + return payload.results ?? [] + } finally { + spinner?.stop() + } } protected listParams(flags: ListCommandFlags): Record { @@ -102,6 +119,7 @@ export abstract class ListCommand< public async run(): Promise { const {flags, metadata} = await this.parse() const {dateFormat, ...apiFlags} = await this.resolveGlobalFlags(flags, metadata) + const listFlags: ListCommandFlags = { headers: apiFlags.headers, hostname: apiFlags.hostname, @@ -133,10 +151,6 @@ export abstract class ListCommand< return results } - protected shouldAutoPaginate(flags: ListCommandFlags): boolean { - return flags.page === undefined && flags['page-size'] === undefined - } - protected transformResult(result: TRaw): TOutput { return result as unknown as TOutput } @@ -144,4 +158,20 @@ export abstract class ListCommand< protected transformResults(results: TRaw[]): TOutput[] { return results.map((result) => this.transformResult(result)) } + + private buildListUrl(options: { + flags: ListCommandFlags + params?: Record + path: string + }): URL { + const {flags, params = {}, path} = options + const {page} = flags + const pageSize = flags['page-size'] + + return this.buildApiUrl(flags.hostname, path, { + ...params, + page, + 'page_size': pageSize, + }) + } } diff --git a/src/paginated-command.ts b/src/paginated-command.ts deleted file mode 100644 index 64db4ef..0000000 --- a/src/paginated-command.ts +++ /dev/null @@ -1,166 +0,0 @@ -import {Flags} from '@oclif/core' - -import type {ApiFlags} from './base-command.js' - -import {BaseCommand} from './base-command.js' - -type PaginatedFlags = ApiFlags & { - page?: number - 'page-size'?: number -} - -type PaginatedResponse = { - next?: null | string - results?: T[] -} - -export abstract class PaginatedCommand extends BaseCommand { - static baseFlags = { - ...BaseCommand.baseFlags, - page: Flags.integer({description: 'Page number to fetch'}), - 'page-size': Flags.integer({description: 'Number of results per page'}), - } - - protected buildPaginatedUrl(options: { - hostname: string - page?: number - pageSize?: number - params?: Record - path: string - }): URL { - const {hostname, page, pageSize, params = {}, path} = options - - if (page !== undefined && page < 1) { - this.error('Invalid page value. Page must be 1 or greater.') - } - - if (pageSize !== undefined && pageSize < 1) { - this.error('Invalid page size. Page size must be 1 or greater.') - } - - return this.buildApiUrl(hostname, path, { - ...params, - page, - 'page_size': pageSize, - }) - } - - protected buildPaginatedUrlFromFlags(options: { - flags: PaginatedFlags - params?: Record - path: string - }): URL { - const {flags, params, path} = options - return this.buildPaginatedUrl({ - hostname: flags.hostname, - page: flags.page, - pageSize: flags['page-size'], - params, - path, - }) - } - - protected async fetchPaginatedResults(options: { - autoPaginate: boolean - headers: Record - spinnerText?: string - token: string - url: URL - }): Promise { - const {autoPaginate, headers, spinnerText, token, url} = options - const spinner = this.startSpinner(spinnerText ?? `Fetching ${url.pathname}`) - const results: T[] = [] - - try { - let nextUrl: null | URL = url - - while (nextUrl) { - const currentUrl = nextUrl - // eslint-disable-next-line no-await-in-loop -- pagination is sequential and depends on the next page URL. - const payload: PaginatedResponse = await this.fetchJson>( - currentUrl, - token, - headers, - ) - results.push(...(payload.results ?? [])) - - if (!autoPaginate || !payload.next) { - break - } - - try { - const parsedNext = new URL(payload.next, currentUrl) - nextUrl = this.normalizeNextUrl(parsedNext, currentUrl) - } catch { - this.error('API returned an invalid next URL for pagination.') - } - } - } finally { - spinner?.stop() - } - - return results - } - - protected async fetchPaginatedResultsFromFlags(options: { - autoPaginate: boolean - flags: {headers: Record; token: string} - spinnerText?: string - url: URL - }): Promise { - const {autoPaginate, flags, spinnerText, url} = options - return this.fetchPaginatedResults({ - autoPaginate, - headers: flags.headers, - spinnerText, - token: flags.token, - url, - }) - } - - protected normalizeNextUrl(nextUrl: URL, currentUrl: URL): URL { - if (currentUrl.protocol === 'https:' && nextUrl.protocol === 'http:' && nextUrl.hostname === currentUrl.hostname) { - nextUrl.protocol = currentUrl.protocol - if (currentUrl.port) { - nextUrl.port = currentUrl.port - } - } - - return nextUrl - } - - protected async *paginate( - url: URL, - tokenValue: string, - headers: Record, - autoPaginate: boolean, - ): AsyncGenerator { - let nextUrl: null | URL = url - - while (nextUrl) { - const currentUrl = nextUrl - // eslint-disable-next-line no-await-in-loop -- pagination is sequential and depends on the next page URL. - const payload: PaginatedResponse = await this.fetchJson>( - currentUrl, - tokenValue, - headers, - ) - const results = payload.results ?? [] - - for (const result of results) { - yield result - } - - if (!autoPaginate || !payload.next) { - break - } - - try { - const parsedNext = new URL(payload.next, currentUrl) - nextUrl = this.normalizeNextUrl(parsedNext, currentUrl) - } catch { - this.error('API returned an invalid next URL for pagination.') - } - } - } -} diff --git a/test/commands/correspondents/list.test.ts b/test/commands/correspondents/list.test.ts index 1af0338..73432f4 100644 --- a/test/commands/correspondents/list.test.ts +++ b/test/commands/correspondents/list.test.ts @@ -38,43 +38,29 @@ describe('correspondents:list', () => { globalThis.fetch = originalFetch }) - it('lists correspondent names across pages by default', async () => { - const responses = [ - { - body: { + it('requests a single page with the default page size', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ next: '/api/correspondents/?page=2', results: [{'document_count': 2, id: 1, name: 'Acme', slug: 'acme'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{'document_count': 5, id: 2, name: 'Umbrella Corp', slug: 'umbrella'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async (input) => { - requests.push(String(input)) - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) + ) } const {stdout} = await runCommand('correspondents:list') expect(stdout).to.contain('Name') expect(stdout).to.contain('Acme') - expect(stdout).to.contain('Umbrella Corp') - expect(requests).to.have.lengthOf(2) + expect(requests).to.have.lengthOf(1) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('page')).to.equal(null) + expect(requestUrl.searchParams.get('page_size')).to.equal(String(Number.MAX_SAFE_INTEGER)) }) it('respects page and page size flags', async () => { @@ -122,6 +108,28 @@ describe('correspondents:list', () => { expect(requestUrl.searchParams.get('ordering')).to.equal('name') }) + it('rejects non-positive page values', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('correspondents:list --page 0 --page-size 1') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.match(/greater than or equal to 1/i) + }) + + it('rejects non-positive page size values', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('correspondents:list --page-size 0') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.match(/greater than or equal to 1/i) + }) + it('supports id and name filters', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) @@ -170,7 +178,7 @@ describe('correspondents:list', () => { expect(requests).to.have.lengthOf(1) }) - it('respects page size without auto-pagination', async () => { + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) return new Response( @@ -194,40 +202,22 @@ describe('correspondents:list', () => { }) it('returns the payload when json is enabled', async () => { - const responses = [ - { - body: { + globalThis.fetch = async () => + new Response( + JSON.stringify({ next: 'https://paperless.example.test/api/correspondents/?page=2', results: [{name: 'Acme'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{name: 'Umbrella Corp'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async () => { - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) - } + ) const {stdout} = await runCommand('correspondents:list --json') const payload = JSON.parse(stdout) as Array<{name: string}> - expect(payload.map((item) => item.name)).to.deep.equal(['Acme', 'Umbrella Corp']) + expect(payload.map((item) => item.name)).to.deep.equal(['Acme']) }) it('renders a plain list when requested', async () => { diff --git a/test/commands/custom-fields/list.test.ts b/test/commands/custom-fields/list.test.ts index 1365d4c..c7a09c9 100644 --- a/test/commands/custom-fields/list.test.ts +++ b/test/commands/custom-fields/list.test.ts @@ -31,43 +31,29 @@ describe('custom-fields:list', () => { globalThis.fetch = originalFetch }) - it('lists custom fields across pages by default', async () => { - const responses = [ - { - body: { + it('requests a single page with the default page size', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ next: '/api/custom_fields/?page=2', results: [{'data_type': 'string', 'document_count': 2, id: 1, name: 'VIN'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{'data_type': 'integer', 'document_count': 5, id: 2, name: 'Mileage'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async (input) => { - requests.push(String(input)) - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) + ) } const {stdout} = await runCommand('custom-fields:list') expect(stdout).to.contain('Name') expect(stdout).to.contain('VIN') - expect(stdout).to.contain('Mileage') - expect(requests).to.have.lengthOf(2) + expect(requests).to.have.lengthOf(1) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('page')).to.equal(null) + expect(requestUrl.searchParams.get('page_size')).to.equal(String(Number.MAX_SAFE_INTEGER)) }) it('respects page and page size flags', async () => { @@ -115,7 +101,7 @@ describe('custom-fields:list', () => { expect(requestUrl.searchParams.get('ordering')).to.equal('name') }) - it('respects page size without auto-pagination', async () => { + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) return new Response( @@ -139,40 +125,22 @@ describe('custom-fields:list', () => { }) it('returns the payload when json is enabled', async () => { - const responses = [ - { - body: { + globalThis.fetch = async () => + new Response( + JSON.stringify({ next: 'https://paperless.example.test/api/custom_fields/?page=2', results: [{name: 'VIN'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{name: 'Mileage'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async () => { - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) - } + ) const {stdout} = await runCommand('custom-fields:list --json') const payload = JSON.parse(stdout) as Array<{name: string}> - expect(payload.map((item) => item.name)).to.deep.equal(['VIN', 'Mileage']) + expect(payload.map((item) => item.name)).to.deep.equal(['VIN']) }) it('renders a plain list when requested', async () => { diff --git a/test/commands/document-types/list.test.ts b/test/commands/document-types/list.test.ts index 1b63bda..fe32eef 100644 --- a/test/commands/document-types/list.test.ts +++ b/test/commands/document-types/list.test.ts @@ -31,43 +31,29 @@ describe('document-types:list', () => { globalThis.fetch = originalFetch }) - it('lists document types across pages by default', async () => { - const responses = [ - { - body: { + it('requests a single page with the default page size', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ next: '/api/document_types/?page=2', results: [{'document_count': 2, id: 1, name: 'Bill', slug: 'bill'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{'document_count': 5, id: 2, name: 'Receipt', slug: 'receipt'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async (input) => { - requests.push(String(input)) - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) + ) } const {stdout} = await runCommand('document-types:list') expect(stdout).to.contain('Name') expect(stdout).to.contain('Bill') - expect(stdout).to.contain('Receipt') - expect(requests).to.have.lengthOf(2) + expect(requests).to.have.lengthOf(1) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('page')).to.equal(null) + expect(requestUrl.searchParams.get('page_size')).to.equal(String(Number.MAX_SAFE_INTEGER)) }) it('respects page and page size flags', async () => { @@ -115,7 +101,7 @@ describe('document-types:list', () => { expect(requestUrl.searchParams.get('ordering')).to.equal('name') }) - it('respects page size without auto-pagination', async () => { + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) return new Response( @@ -139,40 +125,22 @@ describe('document-types:list', () => { }) it('returns the payload when json is enabled', async () => { - const responses = [ - { - body: { + globalThis.fetch = async () => + new Response( + JSON.stringify({ next: 'https://paperless.example.test/api/document_types/?page=2', results: [{name: 'Bill'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{name: 'Receipt'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async () => { - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) - } + ) const {stdout} = await runCommand('document-types:list --json') const payload = JSON.parse(stdout) as Array<{name: string}> - expect(payload.map((item) => item.name)).to.deep.equal(['Bill', 'Receipt']) + expect(payload.map((item) => item.name)).to.deep.equal(['Bill']) }) it('renders a plain list when requested', async () => { diff --git a/test/commands/documents/list.test.ts b/test/commands/documents/list.test.ts index 9dd7f92..593a2b7 100644 --- a/test/commands/documents/list.test.ts +++ b/test/commands/documents/list.test.ts @@ -31,48 +31,32 @@ describe('documents:list', () => { globalThis.fetch = originalFetch }) - it('lists documents across pages by default', async () => { - const responses = [ - { - body: { + it('requests a single page with the default page size', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ next: '/api/documents/?page=2', results: [ {added: '2024-01-02T01:02:03Z', correspondent: 1, created: '2024-01-01', 'document_type': 2, id: 1, title: 'First Doc'}, ], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [ - {added: '2024-01-03T01:02:03Z', correspondent: 2, created: '2024-01-02', 'document_type': 3, id: 2, title: 'Second Doc'}, - ], - }, - status: 200, - }, - ] - - globalThis.fetch = async (input) => { - requests.push(String(input)) - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) + ) } const {stdout} = await runCommand('documents:list') const normalized = stdout.replaceAll(/\s+/g, ' ') expect(normalized).to.contain('Title') expect(normalized).to.contain('First') - expect(normalized).to.contain('Second') - expect(requests).to.have.lengthOf(2) + expect(requests).to.have.lengthOf(1) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('page')).to.equal(null) + expect(requestUrl.searchParams.get('page_size')).to.equal(String(Number.MAX_SAFE_INTEGER)) }) it('respects page and page size flags', async () => { @@ -145,7 +129,7 @@ describe('documents:list', () => { expect(requestUrl.searchParams.get('title__icontains')).to.equal('BFPR100') }) - it('respects page size without auto-pagination', async () => { + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) return new Response( @@ -173,40 +157,22 @@ describe('documents:list', () => { }) it('returns the payload when json is enabled', async () => { - const responses = [ - { - body: { + globalThis.fetch = async () => + new Response( + JSON.stringify({ next: 'https://paperless.example.test/api/documents/?page=2', results: [{title: 'First Doc'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{title: 'Second Doc'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async () => { - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) - } + ) const {stdout} = await runCommand('documents:list --json') const payload = JSON.parse(stdout) as Array<{title: string}> - expect(payload.map((item) => item.title)).to.deep.equal(['First Doc', 'Second Doc']) + expect(payload.map((item) => item.title)).to.deep.equal(['First Doc']) }) it('renders a plain list when requested', async () => { diff --git a/test/commands/tags/list.test.ts b/test/commands/tags/list.test.ts index fee37ba..5b0d758 100644 --- a/test/commands/tags/list.test.ts +++ b/test/commands/tags/list.test.ts @@ -31,44 +31,29 @@ describe('tags:list', () => { globalThis.fetch = originalFetch }) - it('lists tags across pages by default', async () => { - const responses = [ - { - body: { + it('requests a single page with the default page size', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ next: 'http://paperless.example.test/api/tags/?page=2', results: [{children: [], 'document_count': 2, id: 1, name: 'Inbox', slug: 'inbox'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{children: [], 'document_count': 5, id: 2, name: 'Tax', slug: 'tax'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async (input) => { - requests.push(String(input)) - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) + ) } const {stdout} = await runCommand('tags:list') expect(stdout).to.contain('Name') expect(stdout).to.contain('Inbox') - expect(stdout).to.contain('Tax') - expect(requests).to.have.lengthOf(2) - expect(requests[1]).to.match(/^https:\/\//) + expect(requests).to.have.lengthOf(1) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('page')).to.equal(null) + expect(requestUrl.searchParams.get('page_size')).to.equal(String(Number.MAX_SAFE_INTEGER)) }) it('respects page and page size flags', async () => { @@ -95,6 +80,17 @@ describe('tags:list', () => { expect(requestUrl.searchParams.get('page_size')).to.equal('1') }) + it('requires page size when page is provided', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('tags:list --page 2') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.match(/page-size/i) + }) + it('supports sort ordering', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) @@ -116,7 +112,7 @@ describe('tags:list', () => { expect(requestUrl.searchParams.get('ordering')).to.equal('name') }) - it('respects page size without auto-pagination', async () => { + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input)) return new Response( @@ -140,40 +136,22 @@ describe('tags:list', () => { }) it('returns the payload when json is enabled', async () => { - const responses = [ - { - body: { + globalThis.fetch = async () => + new Response( + JSON.stringify({ next: 'https://paperless.example.test/api/tags/?page=2', results: [{children: [], name: 'Inbox'}], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, }, - status: 200, - }, - { - body: { - next: null, - results: [{children: [], name: 'Tax'}], - }, - status: 200, - }, - ] - - globalThis.fetch = async () => { - const response = responses.shift() - - if (!response) { - throw new Error('Unexpected fetch call') - } - - return new Response(JSON.stringify(response.body), { - headers: {'Content-Type': 'application/json'}, - status: response.status, - }) - } + ) const {stdout} = await runCommand('tags:list --json') const payload = JSON.parse(stdout) as Array<{name: string}> - expect(payload.map((item) => item.name)).to.deep.equal(['Inbox', 'Tax']) + expect(payload.map((item) => item.name)).to.deep.equal(['Inbox']) }) it('renders a plain list when requested', async () => {