Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/ADR-015-convencion-de-respuesta-de-la-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ La puerta queda abierta: pasar de esta convención a la otra es aditivo del lado
- La regla se enuncia en una línea y no tiene excepciones que justificar.
- Ninguna colección queda con un array en la raíz, así que todas pudieron empezar a paginar sin romper su contrato. Fue la precondición de TESIS-108, que se hizo justo encima.
- El comentario-trampa del frontend se borra: lo que explicaba ya no pasa.
- `spec/requests/api/v1/api_contract_spec.rb` (TESIS-90) fija las tres formas **sobre los endpoints que enumera**, hoy incluido el registro. La lista está escrita a mano: un endpoint nuevo con otra forma no rompe nada hasta que se lo agrega ahí. Recorrer todas las rutas sería otra card; mientras tanto, sumar el endpoint al spec es parte de agregarlo.
- `spec/requests/api/v1/api_contract_spec.rb` (TESIS-90) fija las tres formas **sobre los endpoints que enumera**, hoy incluido el registro. La lista está escrita a mano: un endpoint nuevo con otra forma no rompe nada hasta que se lo agrega ahí. Desde TESIS-999031, `spec/requests/api/v1/every_listing_envelope_spec.rb` recorre las rutas: todo `index` de `/api/v1` tiene que responder `data` + `meta`, los vocabularios `data` sola, y una ruta GET nueva que no sea ninguna de las tres cosas hace fallar el spec hasta que se la clasifique.

**En contra**

Expand Down
100 changes: 100 additions & 0 deletions spec/requests/api/v1/every_listing_envelope_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require 'rails_helper'

# ADR-015 recorrido sobre las rutas, no sobre una lista escrita a mano.
#
# `api_contract_spec.rb` fija la forma de los endpoints que enumera, y el ADR
# lo deja dicho: «un endpoint nuevo con otra forma no rompe nada hasta que se
# lo agrega ahí. Recorrer todas las rutas sería otra card». Esta es esa card.
#
# Toma de `Rails.application.routes` cada GET de `/api/v1` y lo clasifica:
#
# - un `index` es una colección: viaja en `data` + `meta` (`page`, `per_page`,
# `total`), sin excepción (TESIS-108);
# - los vocabularios fijos van en `data` sin `meta` (ADR-015, «Excepciones»);
# - los recursos sueltos (`show`, `me`, `tenant-config`) se prueban en sus
# specs: acá sólo se verifica que estén clasificados.
#
# Una ruta GET nueva que no sea un `index` y no esté en ninguna lista hace
# fallar el spec: alguien tiene que decidir qué forma tiene.
#
# Las listas van en un módulo y no sueltas en el bloque: una constante de nivel
# superior en un spec es de `Object` para todo el proceso (mismo criterio que
# `ContratoDeLaApi`).
module FormaDeCadaRuta
VOCABULARIOS = %w[products#categories orders#provinces].freeze
# `reports#overview` (TESIS-999007) es un recurso calculado: va pelado. Está
# anotado acá aunque la ruta llegue en otra rama, para que el orden de merge
# no rompa este spec.
RECURSOS = %w[me#show tenant_config#show warehouses#show products#show orders#show
shipments#show reports#overview].freeze
end

RSpec.describe 'Every listing keeps the response envelope (ADR-015)', type: :request do
let(:company) { Company.create!(name: 'Norte', tax_id: '30-11111111-1') }
let(:user) { User.create!(email: 'norte@example.com', password: 'password123', company: company) }
let(:headers) { auth_headers(user) }
let(:product) do
Current.set(company_id: company.id) do
Product.create!(company: company, sku: 'ENV-1', name: 'Sobre')
end
end

def auth_headers(for_user)
post '/api/v1/auth/login', params: { email: for_user.email, password: 'password123' },
headers: { 'X-Tenant-Slug' => for_user.company.slug }
{ 'Authorization' => "Bearer #{response.parsed_body['token']}" }
end

# Las rutas GET de la API, como `controlador#acción` => path de ejemplo.
def api_get_routes
Rails.application.routes.routes.each_with_object({}) do |route, found|
path = route.path.spec.to_s
next unless route.verb == 'GET' && path.start_with?('/api/v1/')

action = "#{route.defaults[:controller].delete_prefix('api/v1/')}##{route.defaults[:action]}"
found[action] = path.delete_suffix('(.:format)')
end
end

def listings = api_get_routes.select { |action, _| action.end_with?('#index') }

def sample(path) = path.gsub(':product_id', product.id.to_s)

it 'finds the listings it walks (so an empty walk cannot pass)' do
expect(listings.keys).to include('orders#index', 'products#index', 'failed_events#index')
end

# `controlador#acción` => [status, claves del body, claves del meta].
def shapes_of_listings
listings.to_h do |action, path|
get sample(path), headers: headers
[action, [response.status, response.parsed_body.keys.sort, response.parsed_body['meta']&.keys&.sort]]
end
end

it 'answers every listing with data plus a page, per_page and total meta' do
expected = [200, %w[data meta], %w[page per_page total]]

expect(shapes_of_listings).to all(satisfy { |_action, shape| shape == expected })
end

it 'answers every vocabulary with data and no meta', :aggregate_failures do
api_get_routes.slice(*FormaDeCadaRuta::VOCABULARIOS).each do |action, path|
get path, headers: headers

expect(response.parsed_body.keys).to eq(['data']), "#{action} should be a bare vocabulary"
end
end

# Si falla, hay una ruta GET nueva sin clasificar: decidí si es una colección
# (que sea un `index`), un vocabulario o un recurso, y sumala a su lista.
it 'knows the shape of every other GET route' do
unclassified = api_get_routes.keys.reject do |action|
action.end_with?('#index') || FormaDeCadaRuta::VOCABULARIOS.include?(action) || FormaDeCadaRuta::RECURSOS.include?(action)
end

expect(unclassified).to be_empty
end
end
Loading