From 9fa7efd842e0ab74aa85afbf2092ba0f7ab8e7f2 Mon Sep 17 00:00:00 2001 From: Rafael Tavares <26308880+Rafatcb@users.noreply.github.com> Date: Sat, 20 Apr 2024 21:34:02 -0300 Subject: [PATCH] feat(users): add pagination to users endpoint --- models/content.js | 26 +- models/controller.js | 29 +- models/pagination.js | 26 ++ models/user.js | 51 +++ pages/api/v1/users/index.public.js | 22 +- tests/integration/api/v1/users/get.test.js | 462 ++++++++++++++++++--- 6 files changed, 525 insertions(+), 91 deletions(-) create mode 100644 models/pagination.js diff --git a/models/content.js b/models/content.js index 3713e55c0..753da9d8e 100644 --- a/models/content.js +++ b/models/content.js @@ -4,6 +4,7 @@ import { v4 as uuidV4 } from 'uuid'; import { ForbiddenError, ValidationError } from 'errors'; import database from 'infra/database.js'; import balance from 'models/balance.js'; +import pagination from 'models/pagination.js'; import prestige from 'models/prestige'; import user from 'models/user.js'; import validator from 'models/validator.js'; @@ -252,34 +253,17 @@ async function findWithStrategy(options = {}) { results.rows = rankContentListByRelevance(contentList); } - values.totalRows = results.rows[0]?.total_rows; + values.total_rows = results.rows[0]?.total_rows; results.pagination = await getPagination(values, options); return results; } } -async function getPagination(values, options = {}) { +async function getPagination(values, options) { values.count = true; - - const totalRows = values.totalRows ?? (await findAll(values, options))[0]?.total_rows ?? 0; - const perPage = values.per_page; - const firstPage = 1; - const lastPage = Math.ceil(totalRows / values.per_page); - const nextPage = values.page >= lastPage ? null : values.page + 1; - const previousPage = values.page <= 1 ? null : values.page > lastPage ? lastPage : values.page - 1; - const strategy = values.strategy; - - return { - currentPage: values.page, - totalRows: totalRows, - perPage: perPage, - firstPage: firstPage, - nextPage: nextPage, - previousPage: previousPage, - lastPage: lastPage, - strategy: strategy, - }; + values.total_rows = values.total_rows ?? (await findAll(values, options))[0]?.total_rows ?? 0; + return pagination.get(values); } async function create(postedContent, options = {}) { diff --git a/models/controller.js b/models/controller.js index c5ba335ff..1e930b168 100644 --- a/models/controller.js +++ b/models/controller.js @@ -125,22 +125,27 @@ function logRequest(request, response, next) { function injectPaginationHeaders(pagination, endpoint, response) { const links = []; - const baseUrl = `${webserver.host}${endpoint}?strategy=${pagination.strategy}`; + const baseUrl = `${webserver.host}${endpoint}`; - if (pagination.firstPage) { - links.push(`<${baseUrl}&page=${pagination.firstPage}&per_page=${pagination.perPage}>; rel="first"`); - } - - if (pagination.previousPage) { - links.push(`<${baseUrl}&page=${pagination.previousPage}&per_page=${pagination.perPage}>; rel="prev"`); - } + const searchParams = new URLSearchParams(); - if (pagination.nextPage) { - links.push(`<${baseUrl}&page=${pagination.nextPage}&per_page=${pagination.perPage}>; rel="next"`); + if (pagination.strategy) { + searchParams.set('strategy', pagination.strategy); } - if (pagination.lastPage) { - links.push(`<${baseUrl}&page=${pagination.lastPage}&per_page=${pagination.perPage}>; rel="last"`); + const pages = [ + { page: pagination.firstPage, rel: 'first' }, + { page: pagination.previousPage, rel: 'prev' }, + { page: pagination.nextPage, rel: 'next' }, + { page: pagination.lastPage, rel: 'last' }, + ]; + + for (const { page, rel } of pages) { + if (page) { + searchParams.set('page', page); + searchParams.set('per_page', pagination.perPage); + links.push(`<${baseUrl}?${searchParams.toString()}>; rel="${rel}"`); + } } const linkHeaderString = links.join(', '); diff --git a/models/pagination.js b/models/pagination.js new file mode 100644 index 000000000..a283364c4 --- /dev/null +++ b/models/pagination.js @@ -0,0 +1,26 @@ +async function get({ total_rows, page, per_page, strategy }) { + const firstPage = 1; + const lastPage = Math.ceil(total_rows / per_page); + const nextPage = page >= lastPage ? null : page + 1; + const previousPage = page <= 1 ? null : page > lastPage ? lastPage : page - 1; + + const pagination = { + currentPage: page, + totalRows: total_rows, + perPage: per_page, + firstPage: firstPage, + nextPage: nextPage, + previousPage: previousPage, + lastPage: lastPage, + }; + + if (strategy) { + pagination.strategy = strategy; + } + + return pagination; +} + +export default Object.freeze({ + get, +}); diff --git a/models/user.js b/models/user.js index d6d0723f1..553d2d725 100644 --- a/models/user.js +++ b/models/user.js @@ -3,6 +3,7 @@ import database from 'infra/database.js'; import authentication from 'models/authentication.js'; import balance from 'models/balance.js'; import emailConfirmation from 'models/email-confirmation.js'; +import pagination from 'models/pagination.js'; import validator from 'models/validator.js'; async function findAll() { @@ -25,6 +26,55 @@ async function findAll() { return results.rows; } +async function findAllWithPagination(values) { + const offset = (values.page - 1) * values.per_page; + + const query = { + text: ` + WITH user_window AS ( + SELECT + COUNT(*) OVER()::INTEGER as total_rows, + id + FROM users + ORDER BY updated_at DESC + LIMIT $1 OFFSET $2 + ) + + SELECT + * + FROM + users + INNER JOIN + user_window ON users.id = user_window.id + CROSS JOIN LATERAL ( + SELECT + get_user_current_tabcoins(users.id) as tabcoins, + get_user_current_tabcash(users.id) as tabcash + ) as balance + ORDER BY updated_at DESC + `, + values: [values.limit || values.per_page, offset], + }; + + const queryResults = await database.query(query); + + const results = { + rows: queryResults.rows, + }; + + values.total_rows = results.rows[0]?.total_rows ?? (await countTotalRows()); + + results.pagination = await pagination.get(values); + + return results; +} + +async function countTotalRows() { + const countQuery = `SELECT COUNT(*) OVER()::INTEGER as total_rows FROM users`; + const countResult = await database.query(countQuery); + return countResult.rows[0].total_rows; +} + async function findOneByUsername(username, options = {}) { const baseQuery = ` WITH user_found AS ( @@ -446,6 +496,7 @@ async function updateRewardedAt(userId, options) { export default Object.freeze({ create, findAll, + findAllWithPagination, findOneByUsername, findOneByEmail, findOneById, diff --git a/pages/api/v1/users/index.public.js b/pages/api/v1/users/index.public.js index 08fc999b9..9a9f39ae7 100644 --- a/pages/api/v1/users/index.public.js +++ b/pages/api/v1/users/index.public.js @@ -19,7 +19,7 @@ export default nextConnect({ .use(authentication.injectAnonymousOrUser) .use(controller.logRequest) .use(cacheControl.noCache) - .get(authorization.canRequest('read:user:list'), getHandler) + .get(getValidationHandler, authorization.canRequest('read:user:list'), getHandler) .post( postValidationHandler, authorization.canRequest('create:user'), @@ -27,13 +27,31 @@ export default nextConnect({ postHandler, ); +function getValidationHandler(request, response, next) { + const cleanValues = validator(request.query, { + page: 'optional', + per_page: 'optional', + }); + + request.query = cleanValues; + + next(); +} + async function getHandler(request, response) { const userTryingToList = request.context.user; - const userList = await user.findAll(); + const results = await user.findAllWithPagination({ + page: request.query.page, + per_page: request.query.per_page, + }); + + const userList = results.rows; const secureOutputValues = authorization.filterOutput(userTryingToList, 'read:user:list', userList); + controller.injectPaginationHeaders(results.pagination, '/api/v1/users', response); + return response.status(200).json(secureOutputValues); } diff --git a/tests/integration/api/v1/users/get.test.js b/tests/integration/api/v1/users/get.test.js index 46dd5d503..aa65f5ab1 100644 --- a/tests/integration/api/v1/users/get.test.js +++ b/tests/integration/api/v1/users/get.test.js @@ -1,45 +1,55 @@ import fetch from 'cross-fetch'; +import parseLinkHeader from 'parse-link-header'; import { version as uuidVersion } from 'uuid'; import orchestrator from 'tests/orchestrator.js'; +let firstUser; +let secondUser; +let privilegedUser; +let privilegedUserSession; +let defaultUser; + beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.dropAllTables(); await orchestrator.runPendingMigrations(); + + firstUser = await orchestrator.createUser(); + firstUser = await orchestrator.activateUser(firstUser); + defaultUser = firstUser; + + secondUser = await orchestrator.createUser(); + await orchestrator.activateUser(secondUser); + secondUser = await orchestrator.addFeaturesToUser(secondUser, ['read:user:list']); + privilegedUser = secondUser; + privilegedUserSession = await orchestrator.createSession(privilegedUser); }); describe('GET /api/v1/users', () => { describe('Anonymous user', () => { test('Anonymous user trying to retrieve user list', async () => { - let defaultUser = await orchestrator.createUser(); - await orchestrator.activateUser(defaultUser); - let privilegedUser = await orchestrator.createUser(); - privilegedUser = await orchestrator.activateUser(privilegedUser); - await orchestrator.addFeaturesToUser(privilegedUser, ['read:user:list']); - const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`); const responseBody = await response.json(); expect(response.status).toEqual(403); - expect(responseBody.name).toEqual('ForbiddenError'); - expect(responseBody.message).toEqual('Usuário não pode executar esta operação.'); - expect(responseBody.action).toEqual('Verifique se este usuário possui a feature "read:user:list".'); - expect(responseBody.status_code).toEqual(403); + + expect(responseBody).toStrictEqual({ + name: 'ForbiddenError', + message: 'Usuário não pode executar esta operação.', + action: 'Verifique se este usuário possui a feature "read:user:list".', + status_code: 403, + error_id: responseBody.error_id, + request_id: responseBody.request_id, + error_location_code: 'MODEL:AUTHORIZATION:CAN_REQUEST:FEATURE_NOT_FOUND', + }); expect(uuidVersion(responseBody.error_id)).toEqual(4); expect(uuidVersion(responseBody.request_id)).toEqual(4); - expect(responseBody.error_location_code).toEqual('MODEL:AUTHORIZATION:CAN_REQUEST:FEATURE_NOT_FOUND'); }); }); describe('Default user', () => { test('User without "read:user:list" feature', async () => { - let defaultUser = await orchestrator.createUser(); - defaultUser = await orchestrator.activateUser(defaultUser); - let privilegedUser = await orchestrator.createUser(); - privilegedUser = await orchestrator.activateUser(privilegedUser); - await orchestrator.addFeaturesToUser(privilegedUser, ['read:user:list']); - let defaultUserSession = await orchestrator.createSession(defaultUser); const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, { @@ -52,46 +62,155 @@ describe('GET /api/v1/users', () => { const responseBody = await response.json(); expect(response.status).toEqual(403); - expect(responseBody.name).toEqual('ForbiddenError'); - expect(responseBody.message).toEqual('Usuário não pode executar esta operação.'); - expect(responseBody.action).toEqual('Verifique se este usuário possui a feature "read:user:list".'); - expect(responseBody.status_code).toEqual(403); + + expect(responseBody).toStrictEqual({ + name: 'ForbiddenError', + message: 'Usuário não pode executar esta operação.', + action: 'Verifique se este usuário possui a feature "read:user:list".', + status_code: 403, + error_id: responseBody.error_id, + request_id: responseBody.request_id, + error_location_code: 'MODEL:AUTHORIZATION:CAN_REQUEST:FEATURE_NOT_FOUND', + }); expect(uuidVersion(responseBody.error_id)).toEqual(4); expect(uuidVersion(responseBody.request_id)).toEqual(4); - expect(responseBody.error_location_code).toEqual('MODEL:AUTHORIZATION:CAN_REQUEST:FEATURE_NOT_FOUND'); }); }); describe('User with "read:user:list" feature', () => { - test('Retrieving user list with users', async () => { - let defaultUser = await orchestrator.createUser(); - defaultUser = await orchestrator.activateUser(defaultUser); - let privilegedUser = await orchestrator.createUser(); - privilegedUser = await orchestrator.activateUser(privilegedUser); - privilegedUser = await orchestrator.addFeaturesToUser(privilegedUser, ['read:user:list']); - - let privilegedUserSession = await orchestrator.createSession(privilegedUser); - - const firstUser = { - id: defaultUser.id, - username: defaultUser.username, - description: defaultUser.description, - features: defaultUser.features, - tabcoins: 0, - tabcash: 0, - created_at: defaultUser.created_at.toISOString(), - updated_at: defaultUser.updated_at.toISOString(), - }; - const secondUser = { - id: privilegedUser.id, - username: privilegedUser.username, - description: privilegedUser.description, - features: privilegedUser.features, - tabcoins: 0, - tabcash: 0, - created_at: privilegedUser.created_at.toISOString(), - updated_at: privilegedUser.updated_at.toISOString(), - }; + test('With a large value for "per_page"', async () => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users?per_page=150`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + const responseBody = await response.json(); + + expect(response.status).toEqual(400); + expect(responseBody).toStrictEqual({ + name: 'ValidationError', + message: '"per_page" deve possuir um valor máximo de 100.', + action: 'Ajuste os dados enviados e tente novamente.', + status_code: 400, + error_id: responseBody.error_id, + request_id: responseBody.request_id, + error_location_code: 'MODEL:VALIDATOR:FINAL_SCHEMA', + key: 'per_page', + type: 'number.max', + }); + + expect(uuidVersion(responseBody.error_id)).toEqual(4); + expect(uuidVersion(responseBody.request_id)).toEqual(4); + }); + + test('With an invalid value for "page"', async () => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/contents?page=first`); + const responseBody = await response.json(); + + expect(response.status).toEqual(400); + + expect(responseBody).toStrictEqual({ + name: 'ValidationError', + message: '"page" deve ser do tipo Number.', + action: 'Ajuste os dados enviados e tente novamente.', + status_code: 400, + error_id: responseBody.error_id, + request_id: responseBody.request_id, + error_location_code: 'MODEL:VALIDATOR:FINAL_SCHEMA', + key: 'page', + type: 'number.base', + }); + + expect(uuidVersion(responseBody.error_id)).toEqual(4); + expect(uuidVersion(responseBody.request_id)).toEqual(4); + }); + + test('Retrieving user list with 2 users', async () => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + const responseBody = await response.json(); + + const responseLinkHeader = parseLinkHeader(response.headers.get('Link')); + const responseTotalRowsHeader = response.headers.get('X-Pagination-Total-Rows'); + + expect(response.status).toEqual(200); + expect(responseTotalRowsHeader).toEqual('2'); + expect(responseLinkHeader).toStrictEqual({ + first: { + page: '1', + per_page: '30', + rel: 'first', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + last: { + page: '1', + per_page: '30', + rel: 'last', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + }); + + expect(responseBody).toStrictEqual([ + { + id: secondUser.id, + username: secondUser.username, + description: secondUser.description, + features: secondUser.features, + tabcoins: 0, + tabcash: 0, + created_at: secondUser.created_at.toISOString(), + updated_at: secondUser.updated_at.toISOString(), + }, + { + id: firstUser.id, + username: firstUser.username, + description: firstUser.description, + features: firstUser.features, + tabcoins: 0, + tabcash: 0, + created_at: firstUser.created_at.toISOString(), + updated_at: firstUser.updated_at.toISOString(), + }, + ]); + + expect(uuidVersion(responseBody[0].id)).toEqual(4); + expect(Date.parse(responseBody[0].created_at)).not.toEqual(NaN); + expect(Date.parse(responseBody[0].updated_at)).not.toEqual(NaN); + + expect(uuidVersion(responseBody[1].id)).toEqual(4); + expect(Date.parse(responseBody[1].created_at)).not.toEqual(NaN); + expect(Date.parse(responseBody[1].updated_at)).not.toEqual(NaN); + }); + + test('Retrieving user list with TabCoins and TabCash', async () => { + await orchestrator.createBalance({ + balanceType: 'user:tabcoin', + recipientId: firstUser.id, + amount: 8, + }); + await orchestrator.createBalance({ + balanceType: 'user:tabcash', + recipientId: firstUser.id, + amount: 3, + }); + + await orchestrator.createBalance({ + balanceType: 'user:tabcoin', + recipientId: secondUser.id, + amount: -2, + }); + await orchestrator.createBalance({ + balanceType: 'user:tabcash', + recipientId: secondUser.id, + amount: 200, + }); const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, { method: 'GET', @@ -104,19 +223,250 @@ describe('GET /api/v1/users', () => { expect(response.status).toEqual(200); - expect(responseBody).toStrictEqual(expect.arrayContaining([firstUser, secondUser])); + expect(responseBody).toStrictEqual([ + { + id: secondUser.id, + username: secondUser.username, + description: secondUser.description, + features: secondUser.features, + tabcoins: -2, + tabcash: 200, + created_at: secondUser.created_at.toISOString(), + updated_at: secondUser.updated_at.toISOString(), + }, + { + id: firstUser.id, + username: firstUser.username, + description: firstUser.description, + features: firstUser.features, + tabcoins: 8, + tabcash: 3, + created_at: firstUser.created_at.toISOString(), + updated_at: firstUser.updated_at.toISOString(), + }, + ]); expect(uuidVersion(responseBody[0].id)).toEqual(4); expect(Date.parse(responseBody[0].created_at)).not.toEqual(NaN); expect(Date.parse(responseBody[0].updated_at)).not.toEqual(NaN); - expect(responseBody[0]).not.toHaveProperty('password'); - expect(responseBody[0]).not.toHaveProperty('email'); expect(uuidVersion(responseBody[1].id)).toEqual(4); expect(Date.parse(responseBody[1].created_at)).not.toEqual(NaN); expect(Date.parse(responseBody[1].updated_at)).not.toEqual(NaN); - expect(responseBody[1]).not.toHaveProperty('password'); - expect(responseBody[1]).not.toHaveProperty('email'); + }); + + test('With a "page" out of bounds', async () => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users?page=5`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + const responseBody = await response.json(); + + const responseLinkHeader = parseLinkHeader(response.headers.get('Link')); + const responseTotalRowsHeader = response.headers.get('X-Pagination-Total-Rows'); + + expect(response.status).toEqual(200); + expect(responseTotalRowsHeader).toEqual('2'); + expect(responseLinkHeader).toStrictEqual({ + first: { + page: '1', + per_page: '30', + rel: 'first', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + prev: { + page: '1', + per_page: '30', + rel: 'prev', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + last: { + page: '1', + per_page: '30', + rel: 'last', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + }); + + expect(responseBody).toStrictEqual([]); + }); + }); + + describe('User with "read:user:list" feature (dropAllTables beforeAll)', () => { + describe('With 60 users', () => { + const sortedByRecentlyUpdated = []; + + let privilegedUserSession; + + beforeAll(async () => { + await orchestrator.dropAllTables(); + await orchestrator.runPendingMigrations(); + + const numberOfUsers = 60; + const sortedByNew = []; + + for (let index = 0; index < numberOfUsers; index++) { + const user = await orchestrator.createUser({ + username: `user${index + 1}`, + }); + sortedByNew.unshift({ + id: user.id, + username: user.username, + description: user.description, + features: user.features, + tabcoins: 0, + tabcash: 0, + created_at: user.created_at.toISOString(), + updated_at: user.updated_at.toISOString(), + }); + } + + // Oldest user will have 'read:user:list' feature + await orchestrator.activateUser(sortedByNew.at(-1)); + let privilegedUser = await orchestrator.addFeaturesToUser(sortedByNew.at(-1), ['read:user:list']); + privilegedUserSession = await orchestrator.createSession(privilegedUser); + privilegedUser = { + id: privilegedUser.id, + username: privilegedUser.username, + description: privilegedUser.description, + features: privilegedUser.features, + tabcoins: 0, + tabcash: 0, + created_at: privilegedUser.created_at.toISOString(), + updated_at: privilegedUser.updated_at.toISOString(), + }; + + sortedByNew.pop(); + sortedByNew.push(privilegedUser); + + let updatedUser50 = await orchestrator.activateUser(sortedByNew.at(-49)); + updatedUser50 = { + ...sortedByNew.at(-49), + features: updatedUser50.features, + updated_at: updatedUser50.updated_at.toISOString(), + }; + + sortedByRecentlyUpdated.push(...sortedByNew); + + const indexOfUpdatedUser1 = sortedByRecentlyUpdated.findIndex((user) => user.id === privilegedUser.id); + const indexOfUpdatedUser50 = sortedByRecentlyUpdated.findIndex((user) => user.id === updatedUser50.id); + + sortedByRecentlyUpdated.splice(indexOfUpdatedUser1, 1); + sortedByRecentlyUpdated.splice(indexOfUpdatedUser50, 1); + + sortedByRecentlyUpdated.unshift(privilegedUser); + sortedByRecentlyUpdated.unshift(updatedUser50); + }); + + test('Navigating to next page', async () => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + const responseBody = await response.json(); + + const responseLinkHeader = parseLinkHeader(response.headers.get('Link')); + const responseTotalRowsHeader = response.headers.get('X-Pagination-Total-Rows'); + + expect(response.status).toEqual(200); + expect(responseTotalRowsHeader).toEqual('60'); + expect(responseLinkHeader).toStrictEqual({ + first: { + page: '1', + per_page: '30', + rel: 'first', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + next: { + page: '2', + per_page: '30', + rel: 'next', + url: `${orchestrator.webserverUrl}/api/v1/users?page=2&per_page=30`, + }, + last: { + page: '2', + per_page: '30', + rel: 'last', + url: `${orchestrator.webserverUrl}/api/v1/users?page=2&per_page=30`, + }, + }); + + expect(responseBody).toStrictEqual(sortedByRecentlyUpdated.slice(0, 30)); + + const page2Response = await fetch(responseLinkHeader.next.url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + const page2ResponseBody = await page2Response.json(); + + const page2ResponseLinkHeader = parseLinkHeader(page2Response.headers.get('Link')); + const page2ResponseTotalRowsHeader = page2Response.headers.get('X-Pagination-Total-Rows'); + + expect(page2Response.status).toEqual(200); + expect(page2ResponseTotalRowsHeader).toEqual('60'); + expect(page2ResponseLinkHeader).toStrictEqual({ + first: { + page: '1', + per_page: '30', + rel: 'first', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + prev: { + page: '1', + per_page: '30', + rel: 'prev', + url: `${orchestrator.webserverUrl}/api/v1/users?page=1&per_page=30`, + }, + last: { + page: '2', + per_page: '30', + rel: 'last', + url: `${orchestrator.webserverUrl}/api/v1/users?page=2&per_page=30`, + }, + }); + + expect(page2ResponseBody).toStrictEqual(sortedByRecentlyUpdated.slice(30)); + }); + + test.each([ + { + content: 'most recently updated users first', + params: [], + getExpected: () => sortedByRecentlyUpdated.slice(0, 30), + }, + { + content: 'first 15 users', + params: ['per_page=15'], + getExpected: () => sortedByRecentlyUpdated.slice(0, 15), + }, + { + content: 'second page with 10 users', + params: ['per_page=10', 'page=2'], + getExpected: () => sortedByRecentlyUpdated.slice(10, 20), + }, + ])('Retrieving $content with params: $params', async ({ params, getExpected }) => { + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users?${params.join('&')}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + + const responseBody = await response.json(); + + expect(response.status).toEqual(200); + expect(responseBody).toStrictEqual(getExpected()); + }); }); }); });