From af05babe209b03f8cae4fc29ff240adc8d286437 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 02:31:56 -0300 Subject: [PATCH 1/2] test: [TESIS-999031] walk every API route to keep the response envelope ADR-015 left it written down: the contract spec checks the shape of the endpoints it lists by hand, so a new endpoint with another shape breaks nothing until someone adds it there, and walking every route would be another card. The new spec takes every GET under /api/v1 from the routes: each index must answer data plus a meta with page, per_page and total, each fixed vocabulary must answer data alone, and any other GET route that is not a known bare resource fails the spec until someone decides its shape. Removing the meta of a listing now fails naming the route. Co-Authored-By: Claude Opus 5.5 --- ...R-015-convencion-de-respuesta-de-la-api.md | 2 +- .../api/v1/every_listing_envelope_spec.rb | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 spec/requests/api/v1/every_listing_envelope_spec.rb diff --git a/docs/adr/ADR-015-convencion-de-respuesta-de-la-api.md b/docs/adr/ADR-015-convencion-de-respuesta-de-la-api.md index 0e2354f..0204cb3 100644 --- a/docs/adr/ADR-015-convencion-de-respuesta-de-la-api.md +++ b/docs/adr/ADR-015-convencion-de-respuesta-de-la-api.md @@ -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** diff --git a/spec/requests/api/v1/every_listing_envelope_spec.rb b/spec/requests/api/v1/every_listing_envelope_spec.rb new file mode 100644 index 0000000..1f51051 --- /dev/null +++ b/spec/requests/api/v1/every_listing_envelope_spec.rb @@ -0,0 +1,97 @@ +# 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 + RECURSOS = %w[me#show tenant_config#show warehouses#show products#show orders#show + shipments#show].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 From 6d9dc3599398f914a7b55d71be17048beef4c849 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 02:32:48 -0300 Subject: [PATCH 2/2] test: [TESIS-999031] classify the reports overview ahead of its merge Co-Authored-By: Claude Opus 5.5 --- spec/requests/api/v1/every_listing_envelope_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/requests/api/v1/every_listing_envelope_spec.rb b/spec/requests/api/v1/every_listing_envelope_spec.rb index 1f51051..5854910 100644 --- a/spec/requests/api/v1/every_listing_envelope_spec.rb +++ b/spec/requests/api/v1/every_listing_envelope_spec.rb @@ -24,8 +24,11 @@ # `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].freeze + shipments#show reports#overview].freeze end RSpec.describe 'Every listing keeps the response envelope (ADR-015)', type: :request do