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 | 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).