@saulwade/swl-ses 1.6.3 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/agentes/gh-fix-ci-swl.md +275 -0
  4. package/agentes/nemesis-auditor-swl.md +90 -1
  5. package/comandos/swl/exportar-vault.md +106 -14
  6. package/comandos/swl/nemesis.md +70 -3
  7. package/comandos/swl/release.md +62 -2
  8. package/comandos/swl/salud.md +32 -0
  9. package/comandos/swl/verificar.md +116 -2
  10. package/habilidades/agent-browser/SKILL.md +111 -4
  11. package/habilidades/agent-deep-links/SKILL.md +148 -0
  12. package/habilidades/backend-async-postgres-testing/SKILL.md +215 -0
  13. package/habilidades/backend-error-design/SKILL.md +221 -0
  14. package/habilidades/browser-interaction-patterns/SKILL.md +514 -0
  15. package/habilidades/browser-research-domains/SKILL.md +635 -0
  16. package/habilidades/changelog-generator/SKILL.md +172 -0
  17. package/habilidades/changelog-generator/scripts/parse-commits.js +354 -0
  18. package/habilidades/devsecops-pipeline-security/SKILL.md +3 -0
  19. package/habilidades/fastapi-experto/SKILL.md +49 -4
  20. package/habilidades/harness-claude-code/SKILL.md +4 -1
  21. package/habilidades/postgresql-experto/SKILL.md +80 -4
  22. package/habilidades/proceso-discovery-machote/SKILL.md +157 -0
  23. package/habilidades/proceso-modular-split/SKILL.md +256 -0
  24. package/habilidades/tdd-workflow/SKILL.md +12 -5
  25. package/hooks/extraccion-aprendizajes.js +8 -0
  26. package/hooks/lib/deep-links.js +185 -0
  27. package/hooks/lib/evolution-tracker.js +148 -20
  28. package/hooks/lib/gateway-notify.js +70 -7
  29. package/manifiestos/modulos.json +13 -3
  30. package/manifiestos/skills-lock.json +1247 -1191
  31. package/package.json +92 -92
  32. package/plugin.json +371 -362
  33. package/reglas/arquitectura.md +38 -0
  34. package/reglas/arreglar-al-detectar.md +93 -0
  35. package/reglas/auditorias-documentales-estructurales.md +38 -0
  36. package/reglas/registro-componentes-nuevos.md +14 -0
  37. package/reglas/tests-cleanup.md +220 -0
  38. package/scripts/instalador.js +72 -4
  39. package/scripts/lib/mcp_config.py +29 -14
  40. package/scripts/lib/notificaciones-telegram.js +14 -0
  41. package/scripts/lib/transformadores/codex.js +4 -0
  42. package/scripts/lib/transformadores/cursor.js +5 -0
  43. package/scripts/mcp-orchestrator.py +153 -131
  44. package/scripts/mcp-pool-manager.py +132 -107
  45. package/scripts/mcp-telemetry.py +139 -120
  46. package/scripts/verificar-release.js +199 -1
@@ -8,10 +8,15 @@ description: >
8
8
  cuando WebFetch devuelve contenido incompleto. También alimenta directamente
9
9
  el wiki de proyecto (raw/) con fuentes verificadas.
10
10
  user-invocable: false
11
- version: "1.0.0"
12
- herramientasPermitidas: [Read, Bash, WebFetch]
13
- evolved: false
14
- fuente: "Vercel Labs agent-browser — 26K+ GitHub stars (2026-04)"
11
+ version: "1.2.0"
12
+ herramientasPermitidas: [Read, Bash, WebFetch, Skill]
13
+ skillsInvocables: [browser-interaction-patterns, browser-research-domains]
14
+ evolved: true
15
+ evolved-from: "1.6.3"
16
+ evolved-at: "2026-05-18"
17
+ evolved-by: "aprender"
18
+ evolved-note: "Adoptar patrones CDP de browser-harness (MIT) — coordinate clicks, wait_for_network_idle, dialogs auto-detect; añadir skillsInvocables a 2 skills nuevos consolidados"
19
+ fuente: "Vercel Labs agent-browser — 26K+ GitHub stars (2026-04); patrones CDP avanzados adaptados de browser-use/browser-harness (MIT, 2026)"
15
20
  evolvable: true # default para skill estandar
16
21
  exclusiones:
17
22
  - "No cargar para scraping de páginas estáticas simples — usar WebFetch directamente, es más rápido y sin overhead."
@@ -216,3 +221,105 @@ puede consumir 50K tokens solo en overhead de scraping. Con agent-browser: ~9K t
216
221
  **Usar `agent-browser` en un loop con 10+ URLs causa que el proceso Chrome se quede sin memoria y falle silenciosamente**: el Chrome controlado no libera memoria entre páginas del mismo proceso. Causa: cada `open` reutiliza el mismo proceso Chrome sin reiniciarlo. Fix: para loops de investigación intensiva (>10 URLs), dividir en sub-lotes y reiniciar `agent-browser` entre grupos, o procesar en secuencia con pausa de 500ms entre páginas.
217
222
 
218
223
  **`agent-browser navigate` a una página de login redirecciona a la URL original sin error**: si el sitio detecta el browser headless y redirecciona al login, el agente no recibe señal de fallo — simplemente extrae el contenido del formulario de login en lugar de la página deseada. Causa: sites como LinkedIn, Cloudflare-protected pages bloquean headless browsers. Fix: verificar que el output extraído contiene el contenido esperado (>200 palabras reales), no un formulario. Usar `--headed` para hacer el login visible y manual cuando el sitio requiere autenticación humana.
224
+
225
+ ## Patrones avanzados de CDP (adoptados de browser-harness MIT)
226
+
227
+ Cuatro patrones que mejoran robustez y reducen tokens, adaptados del runtime
228
+ `browser-use/browser-harness`. Aplican aunque agent-browser sea otra
229
+ implementación (npm CLI vs Python CDP) — son patrones agnósticos al runtime.
230
+
231
+ ### 1. Coordinate clicks > selector clicks
232
+
233
+ Cuando la página tiene iframes cross-origin, shadow DOM, o componentes
234
+ complejos, los selectors CSS son frágiles. La alternativa robusta es operar
235
+ a nivel **compositor** de Chrome con eventos de mouse por coordenadas.
236
+
237
+ ```bash
238
+ # MAL — selector frágil cuando hay iframe o shadow DOM
239
+ agent-browser click "button.submit-btn"
240
+
241
+ # BIEN — leer coordenadas del screenshot y click por XY
242
+ agent-browser screenshot /tmp/page.png
243
+ # (visual: el botón está en ~ x=420, y=380)
244
+ agent-browser click-xy 420 380
245
+ agent-browser screenshot /tmp/after.png # verificar
246
+ ```
247
+
248
+ El click coordinate atraviesa iframes cross-origin y shadow DOM sin trabajo
249
+ extra porque sucede en el compositor antes de llegar al árbol DOM. Solo bajar
250
+ a DOM-level cuando el elemento NO tiene geometría visible.
251
+
252
+ ### 2. `wait_for_network_idle` > `wait` fijo
253
+
254
+ Esperar timeouts fijos (`wait 2000`) es ciego — desperdicia tiempo en páginas
255
+ rápidas y falla en páginas lentas. El patrón correcto es esperar evento-driven
256
+ hasta que NO haya requests pendientes por N ms.
257
+
258
+ ```bash
259
+ # MAL — timeout ciego
260
+ agent-browser open https://app-spa.com
261
+ agent-browser wait 2000
262
+ agent-browser get text "main"
263
+
264
+ # BIEN — esperar idle de red (event-driven)
265
+ agent-browser open https://app-spa.com
266
+ agent-browser wait-network-idle --timeout 10000 --idle-ms 500
267
+ agent-browser get text "main"
268
+ ```
269
+
270
+ Si tu agent-browser CLI no expone este helper, fallback razonable: `wait` con
271
+ verificación posterior (screenshot + assert que el contenido renderizó).
272
+
273
+ ### 3. Screenshots primero para verificación
274
+
275
+ Tras CUALQUIER acción que modifica estado (click, navigate, form submit), la
276
+ única señal confiable de éxito es re-capturar screenshot. El DOM puede
277
+ mentir durante transiciones; `page_info()` puede tener race conditions.
278
+
279
+ ```bash
280
+ # MAL — asumir que el click funcionó
281
+ agent-browser click-xy 420 380
282
+ agent-browser get text ".confirmation-msg" # puede estar vacío
283
+
284
+ # BIEN — screenshot intermedio verifica visualmente
285
+ agent-browser click-xy 420 380
286
+ agent-browser screenshot /tmp/after-click.png
287
+ # Inspeccionar visualmente o cargar a vision LLM para verificar
288
+ agent-browser get text ".confirmation-msg"
289
+ ```
290
+
291
+ ### 4. Auto-detección de dialogs antes de actuar
292
+
293
+ Los dialogs nativos (`alert`, `confirm`, `prompt`, `beforeunload`) **congelan
294
+ el thread de JS** del page. Cualquier acción siguiente cuelga hasta que el
295
+ dialog se resuelva. Patrón seguro: verificar `page_info()` antes de cada
296
+ acción tras navegación o form submit.
297
+
298
+ ```bash
299
+ agent-browser open https://site.com/form
300
+ agent-browser submit-form
301
+ # Antes del siguiente click, verificar
302
+ agent-browser page-info
303
+ # Si retorna {"dialog": {"type": "beforeunload", "message": "..."}},
304
+ # dismissar con accept o cancel antes de continuar
305
+ agent-browser dismiss-dialog --accept
306
+ ```
307
+
308
+ Si el CLI de agent-browser no expone helper de dialog, la opción CDP raw es
309
+ `Page.handleJavaScriptDialog`. Inyectar stubs JS (`window.alert = ...`) es
310
+ alternativa solo cuando NO hay `beforeunload` involucrado.
311
+
312
+ ## Cuándo cargar skills complementarios
313
+
314
+ Cuando este skill base no resuelve el caso:
315
+
316
+ - **`browser-interaction-patterns`** — escalas a un tropiezo específico:
317
+ iframes cross-origin, shadow DOM, dropdown que no responde, upload bloqueado,
318
+ cookies HttpOnly, network requests que no producen DOM change. 14 patrones
319
+ con código MAL/BIEN.
320
+ - **`browser-research-domains`** — el research toca github, arxiv, hackernews,
321
+ stackoverflow, pubmed, openalex o sec edgar. Para esos dominios, **NUNCA
322
+ abras un browser** — todo está en API. Reduce tokens 20-50× y latencia 5-20×.
323
+
324
+ Cargar con `Skill("browser-interaction-patterns")` o
325
+ `Skill("browser-research-domains")` según necesidad.
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: agent-deep-links
3
+ description: >
4
+ Constructor y validador de deep links para abrir archivos, líneas, carpetas y
5
+ ajustes directamente en IDEs y editores desde notificaciones, mensajes del
6
+ gateway o output de agentes. Cubre VS Code (vscode://), VS Code Insiders,
7
+ Cursor (cursor://), JetBrains (jetbrains://), Codex Desktop (codex://) y
8
+ fallbacks para apps sin esquema oficial. Cargar cuando un agente o hook deba
9
+ emitir referencias a archivo:línea que el usuario pueda abrir con un clic
10
+ desde Telegram, desktop notifications, Discord, Slack o e-mail.
11
+ version: 1.0.0
12
+ nivelRiesgo: BAJO
13
+ herramientasPermitidas: [Read]
14
+ exclusiones:
15
+ - "No invocar para generar URLs HTTP/HTTPS de navegación web — esto es solo para deep links a apps de escritorio."
16
+ - "No invocar para integraciones con servicios externos (Linear, Notion, Jira); este skill solo cubre IDEs y editores locales del desarrollador."
17
+ - "No invocar para enlaces a sesiones de Claude Code en la nube — esos son URLs claude.ai/code/..., no deep links."
18
+ ---
19
+
20
+ # /habilidades/agent-deep-links — Deep links a IDEs
21
+
22
+ ## Cuándo cargar
23
+
24
+ - Antes de emitir una notificación que incluya referencias a archivos del proyecto
25
+ desde `notificador-swl`, `hooks/lib/gateway-notify.js`, `inbox-aviso.js`,
26
+ `notificacion-sesion-stop.js` o cualquier hook que mencione `archivo:línea`.
27
+ - Al diseñar nuevos hooks o agentes que produzcan output con referencias a código
28
+ que el usuario pueda querer abrir directamente.
29
+ - Antes de generar mensajes desde el `documentador-swl` cuyo destino sea un canal
30
+ externo (Telegram, Discord, e-mail) y el receptor probablemente esté en su
31
+ IDE.
32
+
33
+ ## Cuándo NO cargar
34
+
35
+ - Cuando la notificación es solo texto plano sin referencias a archivos
36
+ (mensajes de status, métricas agregadas).
37
+ - Cuando el receptor es un agente, no un humano (los agentes no abren IDEs).
38
+ - Cuando estás generando URLs de navegación web (HTTP/HTTPS) — usa generación
39
+ estándar.
40
+ - Cuando el proyecto no expone su path absoluto al gateway (los deep links de
41
+ IDE requieren absolute paths).
42
+
43
+ ## Helper programático
44
+
45
+ La lógica del skill está implementada en `hooks/lib/deep-links.js`. El skill
46
+ documenta **qué** generar; el helper genera **cómo**. Para uso desde código:
47
+
48
+ ```js
49
+ const { construirDeepLink, soportaDeepLinks } = require('./lib/deep-links');
50
+
51
+ const url = construirDeepLink({
52
+ ide: 'cursor', // 'vscode' | 'vscode-insiders' | 'cursor' | 'codex' | 'jetbrains' | 'visualstudio'
53
+ rutaAbsoluta: '/abs/path/a/archivo.js',
54
+ linea: 42,
55
+ columna: 8,
56
+ });
57
+ // → 'cursor://file//abs/path/a/archivo.js:42:8'
58
+
59
+ if (!url) {
60
+ // El IDE solicitado no soporta deep links — usar fallback en texto plano
61
+ }
62
+ ```
63
+
64
+ ## Matriz de soporte por IDE
65
+
66
+ | IDE / App | Esquema | Soporte | Patrón canónico | Notas |
67
+ |---|---|---|---|---|
68
+ | **VS Code** | `vscode://` | Total | `vscode://file/<absoluteFile>:<line>:<column>` | Requiere VS Code instalado en máquina del receptor. |
69
+ | **VS Code Insiders** | `vscode-insiders://` | Total | `vscode-insiders://file/<absoluteFile>:<line>:<column>` | Esquema específico Insiders. |
70
+ | **Cursor** | `cursor://` | Total | `cursor://file/<absoluteFile>:<line>:<column>` | Fork de VS Code; misma forma. |
71
+ | **Codex Desktop** | `codex://` | Total | `codex://threads/<thread-uuid>`, `codex://settings` | Para hilos y settings; no archivos. |
72
+ | **JetBrains (IntelliJ/PyCharm/WebStorm/...)** | `jetbrains://` | Total | `jetbrains://<ide-id>/navigate/reference?project=<name>&path=<relPath>` | Requiere nombre del proyecto. Ver fila JetBrains abajo. |
73
+ | **JetBrains Toolbox URL** | `idea://` (deprecado), `jetbrains://idea/...` | Parcial | `jetbrains://idea/navigate/reference?project=<name>&path=<rel>:<line>` | Cada IDE tiene su `<ide-id>`: idea, pycharm, webstorm, goland, rubymine, etc. |
74
+ | **Visual Studio (Windows)** | (no estándar) | Sin esquema oficial | Usar CLI fallback: `devenv /edit <rutaAbsoluta>` | Solo Windows. |
75
+ | **Xcode** | `xcode://` | Parcial | `xcode://...` | Esquema existe; rutas de archivo no bien documentadas. NO confiable. |
76
+ | **Claude Desktop** | `claude://` | Desconocido | `claude://...` | Esquema registrado, rutas no documentadas. |
77
+ | **Codex CLI** (terminal) | n/a | No aplica | n/a | Es CLI; no abre archivos en IDE. Usar fallback texto plano. |
78
+ | **Cualquier app web** | n/a | No aplica | n/a | Si el destino es navegador, usar URL HTTP estándar. |
79
+
80
+ ## Reglas de construcción
81
+
82
+ 1. **Path absoluto obligatorio**. Deep links con rutas relativas no funcionan.
83
+ El helper rechaza `path.relative` y retorna `null`.
84
+ 2. **Encoding de paths con espacios**: aplicar `encodeURI` al path completo (no
85
+ solo al filename). El helper lo hace automáticamente.
86
+ 3. **Línea y columna son opcionales**: si no se especifican, el IDE abre al
87
+ inicio del archivo. Línea 1, columna 1 es válido pero redundante.
88
+ 4. **JetBrains requiere `proyecto`**: sin el nombre del proyecto, el deep link
89
+ no resuelve. Si no se conoce, usar `vscode://` como fallback (la mayoría de
90
+ los desarrolladores tienen VS Code instalado).
91
+ 5. **NUNCA inventar esquemas**: si la matriz dice "No aplica" o "Desconocido",
92
+ el helper retorna `null` y el caller debe usar texto plano.
93
+
94
+ ## Formato según receptor
95
+
96
+ | Receptor | Forma del enlace |
97
+ |---|---|
98
+ | **Telegram** (Markdown V2) | `[abrir en IDE](cursor://file//abs/path:42:8)` |
99
+ | **Discord** | `[abrir en IDE](cursor://file//abs/path:42:8)` |
100
+ | **Slack** | `<cursor://file//abs/path:42:8\|abrir en Cursor>` (pipe-separado) |
101
+ | **Desktop notification** (Linux/macOS) | URL plana sin label; el notificador maneja el clic |
102
+ | **E-mail HTML** | `<a href="cursor://file//abs/path:42:8">abrir en Cursor</a>` |
103
+ | **Texto plano / fallback** | `archivo.js:42 (abrir manualmente)` |
104
+
105
+ ## Gotchas observables
106
+
107
+ - **El receptor debe tener el IDE instalado**: un enlace `cursor://` no funciona
108
+ si el receptor no tiene Cursor instalado. NO hay forma de detectarlo desde
109
+ el emisor; documentar al usuario que el deep link es opt-in.
110
+ - **Slack tiene su propia sintaxis** `<url|label>`, no Markdown estándar. Mezclar
111
+ los formatos rompe el clic.
112
+ - **Windows usa backslashes**: el path absoluto en Windows es
113
+ `C:\Users\...\archivo.js`. El esquema `vscode://file/C:\\Users\\...` requiere
114
+ doble-backslash en algunos casos; el helper normaliza con `path.resolve` +
115
+ `encodeURI` para producir `vscode://file/C:/Users/...` (slashes forward) que
116
+ VS Code/Cursor sí aceptan correctamente.
117
+ - **JetBrains URL Handler debe estar habilitado**: en JetBrains está bajo
118
+ Settings → Tools → Web Browsers and Preview → "Use JetBrains as a
119
+ redirect..." NO está activado por defecto en todas las distribuciones.
120
+
121
+ ## Integración con SWL
122
+
123
+ Hoy se integra en `hooks/lib/gateway-notify.js` (opt-in): cuando un caller pasa
124
+ `payload.fileRef = { archivo, linea, columna }` Y existe `payload.idePreferido`,
125
+ el helper enriquece el mensaje con el deep link en el formato del adaptador
126
+ destino (Telegram / Discord / etc.).
127
+
128
+ Extensiones futuras (no implementadas hoy):
129
+
130
+ - `notificador-swl` (Telegram nativo): pasar `idePreferido` desde
131
+ `instintos/perfil-usuario.yaml § ide_preferido`.
132
+ - `documentador-swl`: enlaces a archivos citados en docs generados.
133
+ - `revisor-codigo-swl`: enlaces a archivo:línea en reportes de hallazgos.
134
+
135
+ ## Origen
136
+
137
+ Adaptado del skill `agent-deep-links` de
138
+ `temp/awesome-codex-skills-master/agent-deep-links/` (ComposioHQ, MIT) con:
139
+
140
+ - Frontmatter al estándar SWL (es-MX, `nivelRiesgo`, `exclusiones`,
141
+ `herramientasPermitidas`).
142
+ - Matriz extendida con JetBrains (no estaba) y Visual Studio fallback.
143
+ - Sección "Formato según receptor" agregada para Telegram/Discord además del
144
+ Slack original.
145
+ - Helper Node.js (`hooks/lib/deep-links.js`) que el repo origen no tenía.
146
+ - Sección "Integración con SWL" agregada.
147
+
148
+ Documentado en ADR-0029 (integración parcial awesome-codex-skills, Opción B).
@@ -0,0 +1,215 @@
1
+ ---
2
+ name: backend-async-postgres-testing
3
+ description: >
4
+ Testing de código backend Python que usa asyncpg con context managers
5
+ asíncronos (`async with conn.transaction()`). Cubre el gotcha crítico de
6
+ AsyncMock que NO genera __aenter__/__aexit__ automáticamente, fixtures
7
+ reusables para mock_conn, testing de race conditions con SELECT FOR UPDATE,
8
+ y patrón de testing para servicios que envuelven INSERTs/UPDATEs en
9
+ transactions. Cargar cuando se escriban tests de services que usan
10
+ `async with conn.transaction():`, cuando un test "deba pasar" pero falla
11
+ con `TypeError: object MagicMock is not async iterable`, o cuando se
12
+ agregue transaction wrapping a un service que tenía tests previos rotos.
13
+ version: "1.0.0"
14
+ herramientasPermitidas: [Read, Grep]
15
+ exclusiones:
16
+ - "No cargar para testing de SQLAlchemy async (AsyncSession) — esos usan `async with session.begin()` con un patrón distinto; cargar `fastapi-experto` que cubre el patrón con sessionmaker."
17
+ - "No cargar para testing de endpoints HTTP — el cliente httpx no requiere mock de transaction; cargar `testing-python` o `fastapi-experto`."
18
+ - "No cargar para testing de capa externa (S3, HTTP client) — esos requieren mock distinto (respx, moto); este skill es específico para BD asyncpg."
19
+ - "No cargar para testing E2E con Postgres real — si la suite levanta BD con testcontainers o docker-compose, NO se mockea transaction; cargar `testing-python`."
20
+ evolvable: true
21
+ ---
22
+
23
+ # Backend Async Postgres Testing
24
+
25
+ Patrones de mock para código que usa `async with conn.transaction()` de asyncpg.
26
+
27
+ ## Cuándo NO cargar
28
+
29
+ - El stack es SQLAlchemy async (`AsyncSession`), no asyncpg raw — cargar `fastapi-experto`.
30
+ - Los tests son E2E con BD real (testcontainers, docker-compose con Postgres) — NO se mockea, cargar `testing-python`.
31
+ - El código no envuelve queries en `async with conn.transaction():` — los mocks simples de `AsyncMock` bastan.
32
+ - Tests de capa externa (S3, HTTP) — esos usan respx/moto, no este patrón.
33
+
34
+ ## El gotcha: AsyncMock NO genera context managers async automáticamente
35
+
36
+ SIGM L-157 (2026-05-20). Tras introducir `async with conn.transaction():` en `service.py` para envolver SELECT-then-INSERT y prevenir race conditions, 3 tests existentes rompieron:
37
+
38
+ ```
39
+ TypeError: 'AsyncMock' object does not support the asynchronous context manager protocol
40
+ ```
41
+
42
+ El fixture original era:
43
+
44
+ ```python
45
+ # MAL — AsyncMock NO implementa __aenter__/__aexit__
46
+ @pytest.fixture
47
+ def _mock_conn():
48
+ conn = AsyncMock()
49
+ conn.fetchrow = AsyncMock(return_value={"id": 1})
50
+ conn.execute = AsyncMock(return_value="UPDATE 1")
51
+ return conn
52
+ ```
53
+
54
+ Al ejecutar:
55
+
56
+ ```python
57
+ async with conn.transaction(): # ← falla aquí
58
+ await conn.execute(...)
59
+ ```
60
+
61
+ `AsyncMock()` retorna un MagicMock cuando se llama, pero ese MagicMock NO es un context manager async — falta `__aenter__` y `__aexit__`.
62
+
63
+ ## Patrón correcto: configurar context manager explícitamente
64
+
65
+ ```python
66
+ from unittest.mock import AsyncMock, MagicMock
67
+
68
+ @pytest.fixture
69
+ def _mock_conn():
70
+ """Mock de asyncpg.Connection con transaction() configurado."""
71
+ conn = AsyncMock()
72
+
73
+ # Configurar transaction() para que retorne un context manager async
74
+ transaction_cm = AsyncMock()
75
+ transaction_cm.__aenter__ = AsyncMock(return_value=transaction_cm)
76
+ transaction_cm.__aexit__ = AsyncMock(return_value=None)
77
+ conn.transaction = MagicMock(return_value=transaction_cm)
78
+ # ↑ MagicMock — porque transaction() es sync (retorna el CM), no async
79
+
80
+ # Métodos comunes pre-configurados
81
+ conn.fetchrow = AsyncMock(return_value=None)
82
+ conn.fetch = AsyncMock(return_value=[])
83
+ conn.execute = AsyncMock(return_value="UPDATE 0")
84
+ conn.executemany = AsyncMock(return_value=None)
85
+
86
+ return conn
87
+ ```
88
+
89
+ ### Por qué `transaction` es `MagicMock` y no `AsyncMock`
90
+
91
+ `conn.transaction()` es una llamada sync que retorna un objeto context manager async. La firma real de asyncpg:
92
+
93
+ ```python
94
+ def transaction(self, *, isolation: str = None, ...) -> Transaction:
95
+ """Retorna un objeto Transaction (sync return)."""
96
+ ```
97
+
98
+ Si pones `conn.transaction = AsyncMock(...)`, entonces `conn.transaction(...)` retorna un coroutine — y `async with <coroutine>` falla con `TypeError: 'coroutine' object does not support the asynchronous context manager protocol`.
99
+
100
+ Regla:
101
+ - `transaction` (el método): `MagicMock` (sync return).
102
+ - El objeto retornado por `transaction()`: `AsyncMock` con `__aenter__` y `__aexit__` configurados.
103
+
104
+ ### Helper reutilizable
105
+
106
+ Para evitar repetir el setup en cada test, extraer a helper:
107
+
108
+ ```python
109
+ # tests/_helpers/asyncpg_mocks.py
110
+ from unittest.mock import AsyncMock, MagicMock
111
+
112
+ def crear_mock_conn(*, fetchrow=None, fetch=None, execute_tag="UPDATE 0") -> AsyncMock:
113
+ """Mock de asyncpg.Connection con transaction() configurado."""
114
+ conn = AsyncMock()
115
+
116
+ transaction_cm = AsyncMock()
117
+ transaction_cm.__aenter__ = AsyncMock(return_value=transaction_cm)
118
+ transaction_cm.__aexit__ = AsyncMock(return_value=None)
119
+ conn.transaction = MagicMock(return_value=transaction_cm)
120
+
121
+ conn.fetchrow = AsyncMock(return_value=fetchrow)
122
+ conn.fetch = AsyncMock(return_value=fetch or [])
123
+ conn.execute = AsyncMock(return_value=execute_tag)
124
+ return conn
125
+ ```
126
+
127
+ Uso:
128
+
129
+ ```python
130
+ async def test_crear_valuacion_vigente(monkeypatch):
131
+ conn = crear_mock_conn(
132
+ fetchrow={"id": uuid.uuid4(), "es_vigente": False},
133
+ execute_tag="UPDATE 1",
134
+ )
135
+ service = ValuacionService(conn=conn)
136
+ await service.crear_valuacion_vigente(predio_id, ejercicio, valor)
137
+
138
+ # Verificar que se entró a la transaction
139
+ conn.transaction.assert_called_once()
140
+ # Verificar que se actualizó la fila vigente previa
141
+ conn.execute.assert_any_call(
142
+ "UPDATE valuacion SET es_vigente=false WHERE predio_id=$1 AND ejercicio=$2",
143
+ predio_id, ejercicio,
144
+ )
145
+ ```
146
+
147
+ ## Testing de race conditions con SELECT FOR UPDATE
148
+
149
+ Cuando el código real usa `SELECT FOR UPDATE`, el mock debe simular el lock acquired retornando el valor "lockeado" en el primer `fetchrow`:
150
+
151
+ ```python
152
+ async def test_crear_valuacion_concurrente_serializada():
153
+ """Simula 2 peticiones concurrentes: la 2da debe esperar al lock."""
154
+ conn = crear_mock_conn()
155
+
156
+ # 1ra petición ve la fila vacía
157
+ conn.fetchrow.side_effect = [
158
+ None, # 1er SELECT FOR UPDATE: no hay vigente
159
+ {"id": uuid.uuid4(), "version": 1}, # 2do SELECT FOR UPDATE: ya hay vigente (la 1ra creó)
160
+ ]
161
+ conn.execute.return_value = "INSERT 0 1"
162
+
163
+ service = ValuacionService(conn=conn)
164
+ await service.crear_valuacion_vigente(predio_id=p, ejercicio=2026, valor=100)
165
+ await service.crear_valuacion_vigente(predio_id=p, ejercicio=2026, valor=200)
166
+
167
+ # Solo el primer INSERT debe haberse ejecutado
168
+ inserts = [call for call in conn.execute.call_args_list if "INSERT" in str(call)]
169
+ assert len(inserts) == 1
170
+ ```
171
+
172
+ Esta simulación NO valida que el lock realmente serialize en BD — eso requiere test E2E con Postgres real. Pero SÍ valida que el código de aplicación maneja correctamente el caso "la fila ya existe" cuando emerge del lock.
173
+
174
+ ## Verificar la entrada a transaction
175
+
176
+ Cuando es importante verificar que el código envuelve correctamente las queries en `async with conn.transaction():`, agregar aserción:
177
+
178
+ ```python
179
+ async def test_actualizar_estatus_dentro_de_transaction():
180
+ conn = crear_mock_conn(fetchrow={"id": 1, "estatus": "PENDIENTE"})
181
+ service = TramiteService(conn=conn)
182
+ await service.aprobar_tramite(tramite_id=1)
183
+
184
+ # Verificar que SELECT y UPDATE están dentro de la misma transaction
185
+ conn.transaction.assert_called_once()
186
+ # __aenter__ debe haberse llamado antes del primer fetchrow
187
+ transaction_cm = conn.transaction.return_value
188
+ assert transaction_cm.__aenter__.called
189
+ assert transaction_cm.__aexit__.called
190
+ ```
191
+
192
+ Si el código olvida envolver en transaction, `conn.transaction.assert_called_once()` falla — el test detecta el bug.
193
+
194
+ ## Anti-patrones
195
+
196
+ - **Usar `AsyncMock` para `conn.transaction`** — retorna coroutine que no es context manager. Fix: `MagicMock(return_value=AsyncMock_con_aenter_aexit)`.
197
+ - **Compartir `_mock_conn` entre tests sin reset de side_effect** — un test configura `fetchrow.side_effect = [...]` y el siguiente recibe el iterador agotado. Fix: `@pytest.fixture` recrea conn por test (scope `function`, default).
198
+ - **Mockear `fetchrow.return_value = dict` cuando el código real esperaba un `Record`** — los `Record` de asyncpg permiten acceso por índice (`row[0]`) Y por nombre (`row["id"]`). El dict solo permite por nombre. Si el código real usa `row[0]`, el mock con dict falla con `KeyError: 0`. Fix: si el código usa acceso posicional, mockear con un objeto que soporte ambos accesos, o cambiar el código a acceso por nombre.
199
+ - **Olvidar mockear `__aexit__` con `return_value=None`** — si retorna otra cosa, la transaction parece haber fallado y el código puede invocar lógica de rollback que no esperabas. Fix: `__aexit__ = AsyncMock(return_value=None)` explícito.
200
+
201
+ ## Gotchas / Errores comunes no obvios
202
+
203
+ - **`conn.transaction()` con `isolation=` o `readonly=` kwargs no se valida en mock**: el mock acepta cualquier kwarg. Si el código real espera `isolation="serializable"` y se cambia a `isolation="read committed"`, el test pasa pero el comportamiento cambia. Fix: aserción explícita `conn.transaction.assert_called_with(isolation="serializable")`.
204
+ - **`AsyncMock(side_effect=Exception)` dentro de transaction provoca llamada a `__aexit__` con exc_type/exc/tb**: para simular rollback, `__aexit__` recibe la excepción como args. Si tu mock tiene `__aexit__ = AsyncMock(return_value=None)`, la excepción se re-eleva. Si retorna `True`, la excepción se suprime (comportamiento normal de context manager). Fix: para tests de rollback, dejar `return_value=None` (deja que la excepción propague) y verificar con `pytest.raises(...)`.
205
+ - **`fetchrow.side_effect = [row, row]` se agota tras 2 llamadas**: en la 3ra llamada lanza `StopIteration`. Si el código real hace 3 fetchrows en distintas ramas, el test falla con error críptico. Fix: usar lista con suficientes elementos o `side_effect` callable que retorna según args (`lambda *args: row if args[0] == "SELECT..." else None`).
206
+ - **`MagicMock` (no `AsyncMock`) en `conn.transaction` permite `assert_called_with(*args)` natural** — `AsyncMock` también lo soporta, pero produce warnings de "coroutine never awaited" si el mock se invoca incorrectamente. La distinción importa para diagnóstico, no para corrección.
207
+ - **`pytest-asyncio` con `mode="auto"` puede no marcar el test como async si no detecta `async def`**: si el fixture es async pero el test es sync, el fixture nunca se await. Fix: explícito `@pytest.mark.asyncio` en el test, o usar `asyncio_mode = "auto"` en `pyproject.toml` y verificar que el test es `async def`.
208
+
209
+ ## Referencias
210
+
211
+ | Tema | Recurso |
212
+ |------|---------|
213
+ | Patrón general de async testing en Python | `Skill("testing-python")` |
214
+ | Endpoints FastAPI testing con httpx | `Skill("fastapi-experto")` § Testing |
215
+ | Race conditions en PostgreSQL (lado código) | `Skill("postgresql-experto")` § SELECT-then-INSERT |