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
```

**974 tests** (contrato + integración + e2e) en 50 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones).
**972 tests** (contrato + integración + e2e) en 50 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/ 974 tests (pytest-asyncio)
│ ├── tests/ 972 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
```

**974 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta
**972 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta
una sola vez y las filas suman 41.

| Categoría | Archivos | Cobertura |
Expand Down
20 changes: 3 additions & 17 deletions backend/app/modules/notifications/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,9 @@
"InvoiceOverdue",
}

NOTIFY_LABELS = {
"WorkflowTriggered": "Workflow ejecutado",
"WorkflowCompleted": "Workflow completado",
"WorkflowFailed": "Workflow falló",
"InventoryLowThresholdDetected": "Stock bajo",
"QuoteAccepted": "Cotización aceptada",
"QuoteRejected": "Cotización rechazada",
"SalesOrderCreated": "Nueva orden de venta",
"CustomerActionPerformed": "Acción del cliente",
"OpportunityWon": "Deal ganado 🎉",
"OpportunityLost": "Deal perdido",
"PortalSessionCreated": "Portal compartido con cliente",
"InvoiceCreated": "Factura generada",
"InvoicePaid": "Factura pagada 💰",
"InvoiceOverdue": "Factura vencida ⚠️",
}
# El servidor manda el tipo de evento y la interfaz pone el texto en el idioma de
# quien lo lee (`layout:notifications.events.<EventType>`): ADR 0014. Un evento sin
# entrada alla se muestra con su nombre.


# ── Email Notification Preferences ─────────────────────────────────────
Expand Down Expand Up @@ -164,7 +151,6 @@ async def forward_events():
await websocket.send_json({
"type": "notification",
"event_type": event_type,
"title": NOTIFY_LABELS.get(event_type, event_type),
"payload": event.get("payload", {}),
Comment on lines 152 to 154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the title field during the WebSocket rollout

When this backend is deployed while a browser still has the previous frontend bundle open, that client continues rendering n.title; after the backend restart its built-in reconnect succeeds, but every new payload now lacks title, leaving notification headings blank until the user reloads. Keep a compatibility field (it can temporarily contain event_type) or version/phase the protocol change so existing sessions remain usable.

Useful? React with 👍 / 👎.

"entity_id": event.get("entity_id"),
"timestamp": event.get("timestamp"),
Expand Down
8 changes: 4 additions & 4 deletions backend/app/modules/portal/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ async def portal_accept(
return PortalActionResult(
success=False,
action=session.action,
message=f"Ya realizaste una acción ({session.action}) en esta cotización.",
message="This session already has a recorded action.",
)

quote = await _load_quote_with_lines(db, session.quote_id)
Expand Down Expand Up @@ -486,7 +486,7 @@ async def portal_accept(
return PortalActionResult(
success=True,
action="accepted",
message="Cotización aceptada. Tu orden de compra ha sido creada.",
message="Quote accepted; the sales order was created.",
order_number=order_number,
)

Expand All @@ -500,7 +500,7 @@ async def portal_reject(
return PortalActionResult(
success=False,
action=session.action,
message=f"Ya realizaste una acción ({session.action}) en esta cotización.",
message="This session already has a recorded action.",
)

quote = await _load_quote_with_lines(db, session.quote_id)
Expand Down Expand Up @@ -570,7 +570,7 @@ async def portal_reject(
return PortalActionResult(
success=True,
action="rejected",
message="Cotización rechazada. Hemos notificado al equipo.",
message="Quote rejected; the team was notified.",
)


Expand Down
13 changes: 5 additions & 8 deletions backend/tests/test_e2e_notifications_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from datetime import datetime, timezone

from app.events.types import Event
from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS
from app.modules.notifications.router import NOTIFY_EVENTS


# ── Override DB fixture (no DB needed for this file) ─────────────────────────
Expand Down Expand Up @@ -113,14 +113,13 @@ async def test_notify_event_forwarded_with_correct_shape():
message = {
"type": "notification",
"event_type": event_type,
"title": NOTIFY_LABELS.get(event_type, event_type),
"payload": event.get("payload", {}),
"entity_id": event.get("entity_id"),
"timestamp": event.get("timestamp"),
}

assert message["type"] == "notification"
assert message["title"] == NOTIFY_LABELS["QuoteAccepted"]
assert "title" not in message # el texto lo pone la interfaz (ADR 0014)
assert message["payload"]["total"] == 1500.0
assert message["entity_id"] == "quote-abc"

Expand Down Expand Up @@ -172,7 +171,7 @@ async def fake_listen():
sent = ws.send_json.call_args[0][0]
assert sent["type"] == "notification"
assert sent["event_type"] == "OpportunityWon"
assert sent["title"] == NOTIFY_LABELS["OpportunityWon"]
assert "title" not in sent # el texto lo pone la interfaz (ADR 0014)
assert sent["payload"]["deal_value"] == 50000.0
assert sent["entity_id"] == "opp-99"

Expand Down Expand Up @@ -291,13 +290,12 @@ async def mock_publish(channel, data):
ws_message = {
"type": "notification",
"event_type": event_type,
"title": NOTIFY_LABELS.get(event_type, event_type),
"payload": parsed.get("payload", {}),
"entity_id": parsed.get("entity_id"),
"timestamp": parsed.get("timestamp"),
}

assert ws_message["title"] == "Cotización aceptada"
assert "title" not in ws_message
assert ws_message["payload"]["total"] == 2500.0
assert ws_message["entity_id"] == "quote-xyz"
assert ws_message["type"] == "notification"
Expand Down Expand Up @@ -334,12 +332,11 @@ async def test_full_pipeline_inventory_low_threshold_reaches_ws():
ws_message = {
"type": "notification",
"event_type": parsed["event_type"],
"title": NOTIFY_LABELS.get(parsed["event_type"], parsed["event_type"]),
"payload": parsed.get("payload", {}),
"entity_id": parsed.get("entity_id"),
"timestamp": parsed.get("timestamp"),
}
assert ws_message["title"] == "Stock bajo"
assert "title" not in ws_message
assert ws_message["payload"]["current_stock"] == 3


Expand Down
27 changes: 3 additions & 24 deletions backend/tests/test_e2e_portal_ws_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,36 +100,22 @@ async def test_portal_session_created_is_notification_eligible():
)


async def test_portal_session_created_has_ws_label():
"""PortalSessionCreated must have a human-readable label in NOTIFY_LABELS."""
from app.modules.notifications.router import NOTIFY_LABELS

label = NOTIFY_LABELS.get("PortalSessionCreated")
assert label is not None, "No label for PortalSessionCreated in NOTIFY_LABELS"
assert len(label) > 0


# ── Layer 3: WS payload transform ───────────────────────────────────────────

async def test_portal_session_created_transforms_to_ws_payload():
"""The router's transform logic produces the expected WS payload shape."""
from app.modules.notifications.router import NOTIFY_LABELS

event = _portal_session_event()
label = NOTIFY_LABELS.get(event.event_type, event.event_type)

ws_payload = {
"type": "notification",
"event_type": event.event_type,
"title": label,
"entity_id": event.entity_id,
"payload": event.payload,
"timestamp": event.timestamp.isoformat(),
}

assert ws_payload["type"] == "notification"
assert ws_payload["event_type"] == "PortalSessionCreated"
assert ws_payload["title"] == label
assert ws_payload["entity_id"] == "session-abc-123"
assert ws_payload["payload"]["client_name"] == "Test Client"

Expand All @@ -142,10 +128,10 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client():
publish(PortalSessionCreated) → Redis pub/sub → ConnectionManager.send_to_workspace().

Verifies that a WS client subscribed to workspace ws-portal-001 would receive
a notification payload with title from NOTIFY_LABELS["PortalSessionCreated"].
a notification payload for PortalSessionCreated.
"""
from app.events.bus import publish
from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS
from app.modules.notifications.router import NOTIFY_EVENTS

workspace_id = "ws-portal-001"
event = _portal_session_event(workspace_id)
Expand All @@ -167,11 +153,9 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client():
event_type = parsed.get("event_type", "")
assert event_type in NOTIFY_EVENTS, f"{event_type} filtered out — not in NOTIFY_EVENTS"

label = NOTIFY_LABELS.get(event_type, event_type)
ws_payload = json.dumps({
"type": "notification",
"event_type": event_type,
"title": label,
"entity_id": parsed.get("entity_id"),
"payload": parsed.get("payload", {}),
"timestamp": parsed.get("timestamp"),
Expand All @@ -184,7 +168,6 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client():
delivered = json.loads(mock_manager.send_to_workspace.call_args[0][1])
assert delivered["type"] == "notification"
assert delivered["event_type"] == "PortalSessionCreated"
assert delivered["title"] == NOTIFY_LABELS["PortalSessionCreated"]
assert delivered["payload"]["quote_id"] == "quote-xyz-456"


Expand All @@ -198,7 +181,7 @@ async def test_portal_session_creation_triggers_notification_pipeline(
PortalSessionCreated is published to Redis (bus.publish mocked),
and that the pub/sub data is notification-eligible and correctly shaped.
"""
from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS
from app.modules.notifications.router import NOTIFY_EVENTS

# 1. Create a quote (Sales)
resp = await client.post("/api/v1/sales/quotes", headers=auth_headers, json={
Expand Down Expand Up @@ -261,7 +244,3 @@ async def capturing_publish(event: Event):
# 4. Verify it would pass the WS filter
assert emitted.event_type in NOTIFY_EVENTS

# 5. Verify it has a label for WS delivery
assert emitted.event_type in NOTIFY_LABELS
label = NOTIFY_LABELS[emitted.event_type]
assert len(label) > 0
Loading
Loading