From e5fa972ba6da7fa4d4b92ceb1e142786ade80bfb Mon Sep 17 00:00:00 2001 From: Rafael Tavares <26308880+Rafatcb@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:37:26 -0300 Subject: [PATCH 1/2] refactor(contents): update tests URL and a variable scope --- models/content.js | 2 +- .../api/v1/contents/[username]/get.test.js | 24 +++++----- tests/integration/api/v1/contents/get.test.js | 46 +++++++++---------- .../api/v1/contents/rss/get.test.js | 20 ++++---- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/models/content.js b/models/content.js index 20a24f2f8..3713e55c0 100644 --- a/models/content.js +++ b/models/content.js @@ -12,13 +12,13 @@ import queries from 'queries/rankingQueries'; async function findAll(values = {}, options = {}) { values = validateValues(values); await replaceOwnerUsernameWithOwnerId(values); - const offset = (values.page - 1) * values.per_page; const query = { values: [], }; if (!values.count) { + const offset = (values.page - 1) * values.per_page; query.values = [values.limit || values.per_page, offset]; } diff --git a/tests/integration/api/v1/contents/[username]/get.test.js b/tests/integration/api/v1/contents/[username]/get.test.js index 8033c29b3..e8bbf13e4 100644 --- a/tests/integration/api/v1/contents/[username]/get.test.js +++ b/tests/integration/api/v1/contents/[username]/get.test.js @@ -558,21 +558,21 @@ describe('GET /api/v1/contents/[username]', () => { per_page: '30', rel: 'first', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, }, next: { page: '2', per_page: '30', rel: 'next', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, }, }); @@ -599,21 +599,21 @@ describe('GET /api/v1/contents/[username]', () => { per_page: '30', rel: 'first', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, }, prev: { page: '1', per_page: '30', rel: 'prev', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=1&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'new', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=new&page=2&per_page=30`, }, }); @@ -700,21 +700,21 @@ describe('GET /api/v1/contents/[username]', () => { per_page: '30', rel: 'first', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, }, next: { page: '2', per_page: '30', rel: 'next', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, }, }); @@ -743,21 +743,21 @@ describe('GET /api/v1/contents/[username]', () => { per_page: '30', rel: 'first', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, }, prev: { page: '1', per_page: '30', rel: 'prev', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=1&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'relevant', - url: `http://localhost:3000/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, + url: `${orchestrator.webserverUrl}/api/v1/contents/${defaultUser.username}?strategy=relevant&page=2&per_page=30`, }, }); diff --git a/tests/integration/api/v1/contents/get.test.js b/tests/integration/api/v1/contents/get.test.js index 858e6a45c..2f6fa9b5e 100644 --- a/tests/integration/api/v1/contents/get.test.js +++ b/tests/integration/api/v1/contents/get.test.js @@ -39,7 +39,7 @@ describe('GET /api/v1/contents', () => { 'access-control-allow-headers': [ 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version', ], - link: ['; rel="first"'], + link: [`<${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=1&per_page=30>; rel="first"`], 'x-pagination-total-rows': ['0'], 'content-type': ['application/json; charset=utf-8'], etag: responseHeaders.etag, @@ -374,21 +374,21 @@ describe('GET /api/v1/contents', () => { per_page: '30', rel: 'first', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=30`, }, next: { page: '2', per_page: '30', rel: 'next', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=2&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=2&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=2&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=2&per_page=30`, }, }); @@ -735,21 +735,21 @@ describe('GET /api/v1/contents', () => { per_page: '30', rel: 'first', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=1&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=1&per_page=30`, }, next: { page: '2', per_page: '30', rel: 'next', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=2&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=2&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=2&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=2&per_page=30`, }, }); @@ -805,21 +805,21 @@ describe('GET /api/v1/contents', () => { per_page: '30', rel: 'first', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=1&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=1&per_page=30`, }, prev: { page: '1', per_page: '30', rel: 'prev', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=1&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=1&per_page=30`, }, last: { page: '2', per_page: '30', rel: 'last', strategy: 'relevant', - url: 'http://localhost:3000/api/v1/contents?strategy=relevant&page=2&per_page=30', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=relevant&page=2&per_page=30`, }, }); @@ -859,21 +859,21 @@ describe('GET /api/v1/contents', () => { per_page: '3', rel: 'first', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=3`, }, next: { page: '2', per_page: '3', rel: 'next', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=2&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=2&per_page=3`, }, last: { page: '3', per_page: '3', rel: 'last', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, }); @@ -896,28 +896,28 @@ describe('GET /api/v1/contents', () => { per_page: '3', rel: 'first', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=3`, }, prev: { page: '1', per_page: '3', rel: 'prev', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=3`, }, next: { page: '3', per_page: '3', rel: 'next', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, last: { page: '3', per_page: '3', rel: 'last', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, }); @@ -940,21 +940,21 @@ describe('GET /api/v1/contents', () => { per_page: '3', rel: 'first', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=3`, }, prev: { page: '2', per_page: '3', rel: 'prev', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=2&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=2&per_page=3`, }, last: { page: '3', per_page: '3', rel: 'last', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, }); @@ -1012,21 +1012,21 @@ describe('GET /api/v1/contents', () => { per_page: '3', rel: 'first', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=1&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=1&per_page=3`, }, prev: { page: '3', per_page: '3', rel: 'prev', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, last: { page: '3', per_page: '3', rel: 'last', strategy: 'new', - url: 'http://localhost:3000/api/v1/contents?strategy=new&page=3&per_page=3', + url: `${orchestrator.webserverUrl}/api/v1/contents?strategy=new&page=3&per_page=3`, }, }); diff --git a/tests/integration/api/v1/contents/rss/get.test.js b/tests/integration/api/v1/contents/rss/get.test.js index 8f884d9b7..ef8b44251 100644 --- a/tests/integration/api/v1/contents/rss/get.test.js +++ b/tests/integration/api/v1/contents/rss/get.test.js @@ -32,7 +32,7 @@ describe('GET /recentes/rss', () => { TabNews - http://localhost:3000/recentes/rss + ${orchestrator.webserverUrl}/recentes/rss Conteúdos para quem trabalha com Programação e Tecnologia ${lastBuildDateFromResponseBody} https://validator.w3.org/feed/docs/rss2.html @@ -40,8 +40,8 @@ describe('GET /recentes/rss', () => { pt TabNews - http://localhost:3000/favicon-mobile.png - http://localhost:3000/recentes/rss + ${orchestrator.webserverUrl}/favicon-mobile.png + ${orchestrator.webserverUrl}/recentes/rss `); @@ -84,7 +84,7 @@ describe('GET /recentes/rss', () => { TabNews - http://localhost:3000/recentes/rss + ${orchestrator.webserverUrl}/recentes/rss Conteúdos para quem trabalha com Programação e Tecnologia ${new Date(secondRootContent.published_at).toUTCString()} https://validator.w3.org/feed/docs/rss2.html @@ -92,21 +92,21 @@ describe('GET /recentes/rss', () => { pt TabNews - http://localhost:3000/favicon-mobile.png - http://localhost:3000/recentes/rss + ${orchestrator.webserverUrl}/favicon-mobile.png + ${orchestrator.webserverUrl}/recentes/rss <![CDATA[Conteúdo #2 (mais novo)]]> - http://localhost:3000/${secondRootContent.owner_username}/${secondRootContent.slug} - http://localhost:3000/${secondRootContent.owner_username}/${secondRootContent.slug} + ${orchestrator.webserverUrl}/${secondRootContent.owner_username}/${secondRootContent.slug} + ${orchestrator.webserverUrl}/${secondRootContent.owner_username}/${secondRootContent.slug} ${new Date(secondRootContent.published_at).toUTCString()}

Este é um corpo bastante longo, vamos ver como que a propriedade description irá reagir, pois por padrão ela deverá cortar após um número X de caracteres. Não vou tomar nota aqui da quantidade exata de caracteres, pois isso pode mudar ao longo do tempo.

]]>
<![CDATA[Conteúdo #1 (mais antigo)]]> - http://localhost:3000/${firstRootContent.owner_username}/${firstRootContent.slug} - http://localhost:3000/${firstRootContent.owner_username}/${firstRootContent.slug} + ${orchestrator.webserverUrl}/${firstRootContent.owner_username}/${firstRootContent.slug} + ${orchestrator.webserverUrl}/${firstRootContent.owner_username}/${firstRootContent.slug} ${new Date(firstRootContent.published_at).toUTCString()}

Corpo com HTML

É importante lidar corretamente com o HTML, incluindo estilos especiais do GFM.

]]>
From 3827aec60e25f45e2a6d49c65ca36c8e1d8b1834 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 2/2] feat(users): add pagination to users endpoint --- models/content.js | 30 +- 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, 527 insertions(+), 93 deletions(-) create mode 100644 models/pagination.js diff --git a/models/content.js b/models/content.js index 3713e55c0..55b8a35ff 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'; @@ -218,7 +219,7 @@ async function findWithStrategy(options = {}) { options.order = 'published_at DESC'; results.rows = await findAll(options); - options.totalRows = results.rows[0]?.total_rows; + options.total_rows = results.rows[0]?.total_rows; results.pagination = await getPagination(options); return results; @@ -229,7 +230,7 @@ async function findWithStrategy(options = {}) { options.order = 'published_at ASC'; results.rows = await findAll(options); - options.totalRows = results.rows[0]?.total_rows; + options.total_rows = results.rows[0]?.total_rows; results.pagination = await getPagination(options); return results; @@ -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()); + }); }); }); });