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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
21 changes: 10 additions & 11 deletions backend/app/core/i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<idioma>/`, 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/<idioma>/`, 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"

Expand Down
7 changes: 4 additions & 3 deletions backend/tests/test_english_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 17 additions & 5 deletions backend/tests/test_i18n_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
45 changes: 39 additions & 6 deletions backend/tests/test_locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
):
Expand Down Expand Up @@ -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"
15 changes: 9 additions & 6 deletions backend/tests/test_locale_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion docs/CAPABILITY_MATURITY_SCORECARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/IMPLEMENTATION_ALIGNMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 |
Expand Down
9 changes: 6 additions & 3 deletions docs/superpowers/plans/2026-08-07-i18n-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading