From 7c1b2e25d233a5a1c567a88a97c3f57d2b826007 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Wed, 7 Oct 2026 13:53:01 -0600 Subject: [PATCH 1/2] feat(i18n): ofrecer en en el backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultimo paso de la tarea 12 en el servidor: `en` pasa de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` (`("es", "en")`, con `es` primero porque es el idioma de reserva). Desde #73 la interfaz ya lo ofrece sola; con esto las dos capas coinciden: el PATCH del locale propio, el de la sesion de portal, `Accept-Language` en el registro y el locale por defecto del workspace aceptan `en` y `en-US`, y el PDF y los correos salen en ingles para quien lo eligio. `UNRELEASED_LOCALES` queda vacio, no se elimina: es el sitio donde entra el siguiente idioma mientras se traduce. Tests: - `test_locale_resolution.py`: «el catalogo de hoy es solo espanol» pasa a fijar `("es", "en")` y que el idioma por defecto sea el primero; el caso «idioma sin catalogo» usa `fr-FR` y no `en-US`. - `test_i18n_catalogue.py`: el test de «un idioma no ofrecido no se alcanza solo» ejercita el mecanismo con una lista explicita (`supported=("es",)`), porque ya no hay ninguno sin ofrecer; uno nuevo comprueba que `en` se ofrece y se alcanza por usuario y por `Accept-Language`. - `test_locale.py`: el detalle del rechazo lista `["es", "en"]`; nuevos: PATCH con `en_us` se guarda como `en-US` y es el locale efectivo, una sesion de portal acepta `en`, y registrarse con un navegador en ingles crea un workspace `en-US`. - `test_english_artifacts.py`: solo el docstring, que decia que el idioma no se ofrecia. Los recuentos documentados pasan de 928 a 932 tests (48 archivos, sin cambio), como exige `scripts/ci/check_test_count.py`. Lo que sigue sin seguir al idioma: fechas e importes de los PDF y los correos (tarea 10), el PDF de cotizacion `sales/pdf.py` y la prosa en espanol de algunas respuestas del portal. Con `en` ofrecido pasan de limitacion latente a visibles para quien lo elija. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 ++-- backend/app/core/i18n/__init__.py | 21 ++++++------ backend/tests/test_english_artifacts.py | 7 ++-- backend/tests/test_i18n_catalogue.py | 22 +++++++++--- backend/tests/test_locale.py | 45 +++++++++++++++++++++---- backend/tests/test_locale_resolution.py | 15 +++++---- docs/CAPABILITY_MATURITY_SCORECARD.md | 2 +- docs/IMPLEMENTATION_ALIGNMENT.md | 4 +-- 8 files changed, 85 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a338db3..527aa08 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ El sistema está construido como un **monolito modular** con límites de dominio Discovery → Lead → Opportunity → Quote → Order → Inventory → Portal → Invoice → Payment ``` -**928 tests** (contrato + integración + e2e) en 48 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). +**932 tests** (contrato + integración + e2e) en 48 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). --- @@ -106,7 +106,7 @@ cbos-platform/ │ │ │ ├── accounting/ Facturación, pagos, overdue scanner │ │ │ └── analytics/ Agregación cross-módulo │ │ └── main.py Lifespan, routers, CORS, background tasks -│ ├── tests/ 928 tests (pytest-asyncio) +│ ├── tests/ 932 tests (pytest-asyncio) │ ├── alembic/ Migraciones de base de datos │ └── requirements.txt ├── composable-os/ React 18 + Vite frontend @@ -232,7 +232,7 @@ docker compose exec backend pytest --tb=short -q cd backend && pytest --tb=short -q ``` -**928 tests** en 48 archivos. Las categorías no se solapan: cada archivo cuenta +**932 tests** en 48 archivos. Las categorías no se solapan: cada archivo cuenta una sola vez y las filas suman 41. | Categoría | Archivos | Cobertura | diff --git a/backend/app/core/i18n/__init__.py b/backend/app/core/i18n/__init__.py index 571b773..2843ad2 100644 --- a/backend/app/core/i18n/__init__.py +++ b/backend/app/core/i18n/__init__.py @@ -27,17 +27,16 @@ # Idiomas que el producto ofrece: lo que acepta el PATCH del locale propio, lo # que lee `Accept-Language` al registrarse y lo que resuelve `resolve_locale`. -SUPPORTED_LOCALES: tuple[str, ...] = ("es",) - -# Idiomas con catalogo en el repo que aun no se ofrecen (tarea 12 del plan de i18n). -# Un idioma se ofrece cuando esta completo en las *dos* capas: el servidor -# (`locales//`, aqui) y la interfaz (`composable-os/src/locales`). Con -# solo el servidor listo, un usuario con el navegador en ingles se registraria con -# `en` y recibiria el PDF y los correos en ingles con la interfaz en espanol. -# `translate("...", "en")` ya funciona (los tests lo ejercitan); lo que no hace -# todavia es que nadie llegue a `en` solo. Pasar un idioma de aqui a -# `SUPPORTED_LOCALES` es el ultimo paso de la tarea 12. -UNRELEASED_LOCALES: tuple[str, ...] = ("en",) +SUPPORTED_LOCALES: tuple[str, ...] = ("es", "en") + +# Idiomas con catalogo en el repo que aun no se ofrecen. Hoy ninguno: `en` paso a +# `SUPPORTED_LOCALES` en el ultimo paso de la tarea 12, cuando estuvo completo en las +# *dos* capas, el servidor (`locales//`, aqui) y la interfaz +# (`composable-os/src/locales`). Con solo una de las dos listas, un usuario con el +# navegador en ese idioma se registraria con el y recibiria el PDF y los correos en un +# idioma que la interfaz no habla, o al reves. Un idioma nuevo vuelve a entrar aqui +# mientras se traduce y sale cuando esta completo en las dos capas. +UNRELEASED_LOCALES: tuple[str, ...] = () DEFAULT_LOCALE = "es" diff --git a/backend/tests/test_english_artifacts.py b/backend/tests/test_english_artifacts.py index aa69e56..27c451b 100644 --- a/backend/tests/test_english_artifacts.py +++ b/backend/tests/test_english_artifacts.py @@ -15,9 +15,10 @@ palabras menos las del `en`) no aparece en lo que sale en `en`. Un texto que se quedo en espanol tiene palabras que ningun catalogo ingles contiene. -Lo que NO cubre: fechas e importes siguen sin seguir al idioma (ver tarea 10), y el -idioma no se ofrece aun a nadie (`UNRELEASED_LOCALES`): se llega a el pasando -`locale="en"` directamente. +Lo que NO cubre: fechas e importes siguen sin seguir al idioma (ver tarea 10). El +idioma ya se ofrece (`SUPPORTED_LOCALES`), pero estos tests pasan `locale="en"` a +mano: no ejercitan la cadena de resolucion, que cubren `test_locale.py` y +`test_locale_resolution.py`. """ import re diff --git a/backend/tests/test_i18n_catalogue.py b/backend/tests/test_i18n_catalogue.py index ece53c3..aedf1c0 100644 --- a/backend/tests/test_i18n_catalogue.py +++ b/backend/tests/test_i18n_catalogue.py @@ -68,14 +68,26 @@ async def test_a_language_is_either_offered_or_unreleased_not_both(): assert not set(SUPPORTED_LOCALES) & set(UNRELEASED_LOCALES) -async def test_an_unreleased_language_is_not_reachable_on_its_own(): - # Tiene catalogo y `translate` lo sirve, pero nadie llega a el solo: ni por el - # PATCH del locale, ni por `Accept-Language`, ni por el workspace. - assert normalize_locale("en") is None - assert resolve_locale(user_locale="en", accept_language="en-US,en;q=0.9") == DEFAULT_LOCALE +async def test_a_language_outside_the_offered_list_is_not_reachable_on_its_own(): + # Un idioma con catalogo pero fuera de la lista ofrecida (el caso de `UNRELEASED_LOCALES` + # mientras se traduce) no lo alcanza nadie solo: ni por el PATCH del locale, ni por + # `Accept-Language`, ni por el workspace. Hoy no hay ninguno, asi que se ejercita con + # la lista explicita: lo que importa es el mecanismo, no que `en` siga sin ofrecerse. + only_spanish = ("es",) + assert normalize_locale("en", supported=only_spanish) is None + assert ( + resolve_locale(user_locale="en", accept_language="en-US,en;q=0.9", supported=only_spanish) + == DEFAULT_LOCALE + ) assert translate("invoice_pdf:title", "en") == "INVOICE" +async def test_english_is_offered_and_reachable(): + assert normalize_locale("en") == "en" + assert resolve_locale(accept_language="en-US,en;q=0.9") == "en-US" + assert resolve_locale(user_locale="en") == "en" + + async def test_every_catalogue_has_the_same_keys_as_the_default(): catalogues = catalogue.load_catalogues() expected = {d: flatten(t) for d, t in catalogues[DEFAULT_LOCALE].items()} diff --git a/backend/tests/test_locale.py b/backend/tests/test_locale.py index 8768e67..a364f22 100644 --- a/backend/tests/test_locale.py +++ b/backend/tests/test_locale.py @@ -5,10 +5,10 @@ `portal_sessions.locale`—, el PATCH del locale propio, el unico sitio donde `Accept-Language` importa (el registro) y el campo en `PortalSessionCreate`. -Hasta la tarea 12 el unico catalogo enviado es `es`: los casos "valido" usan -`es` y `es-MX`, y "no soportado" usa `fr`. Ese `fr` dejara de servir el dia que -se envie un segundo idioma; el test de `test_locale_resolution.py` que fija -`SUPPORTED_LOCALES == ("es",)` es el aviso. +Se ofrecen `es` y `en` (tarea 12): los casos "valido" usan `es`, `es-MX` y `en`, +y "no soportado" usa `fr`. Ese `fr` dejara de servir el dia que se envie un +tercer idioma; el test de `test_locale_resolution.py` que fija +`SUPPORTED_LOCALES == ("es", "en")` es el aviso. """ import pytest from httpx import AsyncClient @@ -139,7 +139,20 @@ async def test_patch_unsupported_locale_is_rejected_with_a_registered_code( assert resp.status_code == 422 err = resp.json()["error"] assert err["code"] == "IDENTITY_LOCALE_UNSUPPORTED" - assert err["detail"]["supported"] == ["es"] + assert err["detail"]["supported"] == ["es", "en"] + + +async def test_patch_own_locale_accepts_english_and_canonicalises_it( + client: AsyncClient, auth_headers: dict +): + # `en` se ofrece desde que esta completo en el servidor y en la interfaz. + resp = await client.patch(f"{AUTH}/me", json={"locale": "en_us"}, headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["locale"] == "en-US" + assert resp.json()["effective_locale"] == "en-US" + + again = await client.get(f"{AUTH}/me", headers=auth_headers) + assert again.json()["locale"] == "en-US" async def test_rejected_locale_does_not_change_the_stored_one( @@ -195,6 +208,15 @@ async def test_register_takes_the_default_from_accept_language(client: AsyncClie assert me["locale"] is None +async def test_register_with_an_english_browser_gets_an_english_workspace( + client: AsyncClient, +): + ws, me = await _workspace_and_me(client, await _register(client, "en-US,en;q=0.9")) + assert ws["default_locale"] == "en-US" + assert me["locale"] is None + assert me["effective_locale"] == "en-US" + + async def test_register_with_an_unshipped_language_falls_back_to_es( client: AsyncClient, ): @@ -262,4 +284,15 @@ async def test_portal_session_rejects_an_unsupported_locale( assert resp.status_code == 422 err = resp.json()["error"] assert err["code"] == "PORTAL_LOCALE_UNSUPPORTED" - assert err["detail"]["supported"] == ["es"] + assert err["detail"]["supported"] == ["es", "en"] + + +async def test_portal_session_accepts_english(client: AsyncClient, auth_headers: dict): + quote_id = await _quote(client, auth_headers) + resp = await client.post( + f"{PORTAL}/sessions", + json={"quote_id": quote_id, "locale": "en"}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["locale"] == "en" diff --git a/backend/tests/test_locale_resolution.py b/backend/tests/test_locale_resolution.py index d9698b8..71b2ca5 100644 --- a/backend/tests/test_locale_resolution.py +++ b/backend/tests/test_locale_resolution.py @@ -56,15 +56,18 @@ async def test_normalize_rejects_unsupported_or_malformed(raw): async def test_normalize_checks_the_base_language_against_supported(): - assert normalize_locale("en-US") is None - assert normalize_locale("en-US", supported=TWO) == "en-US" + # `fr` no tiene catalogo: ningun test debe depender de que un idioma "real" falte. + assert normalize_locale("fr-FR") is None + assert normalize_locale("fr-FR", supported=("es", "fr")) == "fr-FR" -async def test_shipped_catalogues_today_are_spanish_only(): - # Si esto cambia, la tarea 12 ya llego y varios tests de esta suite dejan - # de ser el caso limite que pretenden ser: hay que revisarlos. - assert SUPPORTED_LOCALES == ("es",) +async def test_shipped_catalogues_today_are_spanish_and_english(): + # Si esto cambia, se ha ofrecido o retirado un idioma y varios tests de esta + # suite dejan de ser el caso limite que pretenden ser: hay que revisarlos. + assert SUPPORTED_LOCALES == ("es", "en") assert DEFAULT_LOCALE == "es" + # El idioma por defecto sigue siendo el primero: es el de reserva de todo lo demas. + assert SUPPORTED_LOCALES[0] == DEFAULT_LOCALE # ── catalogue_for ──────────────────────────────────────────── diff --git a/docs/CAPABILITY_MATURITY_SCORECARD.md b/docs/CAPABILITY_MATURITY_SCORECARD.md index b84b1ea..98f0e5c 100644 --- a/docs/CAPABILITY_MATURITY_SCORECARD.md +++ b/docs/CAPABILITY_MATURITY_SCORECARD.md @@ -147,4 +147,4 @@ A module may advance from Tier 3 → Tier 2 or Tier 2 → Tier 1 when it achieve **12 modules Tier 1 — 12 total modules.** Full wedge funnel: Discovery → Identity → CRM → Sales → Inventory → Portal → Workflows → Accounting → Notifications → Contracts → Projects → HR. -**928 tests across 48 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. +**932 tests across 48 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index ab4fa8d..6706dfe 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -58,7 +58,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR ## Current Platform Snapshot - Active modules: identity, crm, sales, inventory, portal, discovery, workflows, notifications, accounting, analytics, contracts, projects, hr -- Test suite: 928 automated tests across 48 test files +- Test suite: 932 automated tests across 48 test files - Frontend active surface: API-backed pages aligned to the implemented backend wedge plus current Tier 1 modules - Public runtime surfaces: - `GET /health` @@ -78,7 +78,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | Frontend error surfacing | Closed and enforced by the toolchain rather than by review. All 47 `onError` handlers type the error as `Error` and pass it to `translateApiError`, which takes `unknown` and renders Spanish from the backend `code` per ADR 0014 — which also settles the old English-message inconsistency. The original bug was two handlers reading `e.response.data.detail`, an axios shape this codebase does not use, so the expression evaluated to `undefined` and the user saw a generic fallback; it survived because those handlers typed the error as `any`. Two things now stop it recurring: `@typescript-eslint/no-explicit-any` is an error and CI runs `npm run lint`, and `useUnknownInCatchVariables` closes the hole the linter cannot see — with `strict: false` a bare `catch (e)` was implicitly `any`, and reaching for a property on it now fails to compile. The only remaining `any` is scoped to `PortalBuilder.tsx` behind a justified file-level disable | Every mutation surfaces the backend's own message when it has one | Low | Keep it closed: the compiler and the linter enforce it. Lifting the `PortalBuilder.tsx` disable needs an explicit contract for block props first, and turning on full `strict` is a separate, much larger change — `strictNullChecks` is still off | | Runtime health signal | `GET /health` now checks Redis alongside the API and Postgres. It did not before, and that blind spot hid a real production outage: Redis was down on the deployed instance while `/health` reported `healthy`, because no ordinary HTTP route touches it — what fails silently is notifications, the workflow engine and the consumers. Verified both ways locally: stopping Redis turns the endpoint `unhealthy`, restarting it returns `healthy` | Health reflects every dependency whose loss degrades the product | Medium | When a new runtime dependency is added, add its probe in the same change set. A dependency only reached by background work is exactly the one an external probe cannot see | | Frontend alignment | Active routes are much cleaner than before, but docs still lag occasionally | UI backed by owned capabilities and current backend maturity | Medium | Refresh capability specs and route docs alongside feature delivery | -| Test coverage narrative | Codebase has 928 tests across 48 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | +| Test coverage narrative | Codebase has 932 tests across 48 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | | Contracts/Projects/HR production confirmation | Deploy readiness is locally verified, but no production-domain smoke evidence has been recorded in governance | Production dimension marked green only after deployed route smoke passes against the production domain and matching SHA | High | Run production smoke for `/contracts`, `/projects`, `/departments`, and `/employees` after deploy | | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | From 511f173ce954f86a3947165e970030317a77cbd1 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Wed, 7 Oct 2026 16:46:00 -0600 Subject: [PATCH 2/2] docs(i18n): marcar el backend de en como ofrecido en el plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El checklist de la tarea 12 tenia un solo punto para «ofrecer en» que mezclaba el backend (hecho en el commit anterior) con el selector del dialogo de compartir cotizacion (pendiente). Se separa en dos: el backend queda marcado y el selector sigue abierto. Tambien se quita «el backend sigue sin ofrecerlo», que ya no es cierto. Co-Authored-By: Claude Sonnet 5.5 --- docs/superpowers/plans/2026-08-07-i18n-full.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index 9b21917..cdf73ba 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -463,9 +463,12 @@ completa migrada no hay patron que seguir. pestanas de Ajustes) y `workflows` (lista, historial de ejecuciones y alta). Los 19 espacios estan traducidos. Como `en` ya tiene todos los de `es`, el frontend lo ofrece solo (`releasedLocales`): selector, deteccion del navegador y - `setLocale`. El backend sigue sin ofrecerlo; ver el punto siguiente. - - [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend - y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2). + `setLocale`. + - [x] Ofrecer `en` en el backend: `en` pasa de `UNRELEASED_LOCALES` a + `SUPPORTED_LOCALES` (`("es", "en")`); el PATCH del locale, la sesion de portal, + `Accept-Language` en el registro y el workspace lo aceptan. + - [ ] Selector de idioma en el dialogo de compartir cotizacion (pendiente desde la + tarea 2): el backend ya guarda el idioma en la sesion de portal. - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: cadenas concatenadas, orden de palabras, plurales, fechas e importes (decision de `babel` o tabla propia, pendiente desde la tarea 10).