# proposals

id: proposal_1775083225381497218
kind: new_function
title: dashboard_shell: layout de dashboard con header, filtros y secciones grid
evidence: {}
status: pending
created_by: reactive_loop
created_at: 2026-04-01T22:40:25Z
updated_at: 2026-04-15T00:04:14Z
description:
  Componente React de layout para dashboards: header con titulo, FilterBar interactivo (select, date_range, text con debounce), secciones colapsables con grid CSS responsive.

  Specs: recibe DashboardConfig, filter state, getData callback. Cada seccion tiene su propio grid columns override. Widgets se posicionan con span/row_span. Layout denso para maximizar datos visibles.

  Referencia: implementado en apps/rapid_dashboards/frontend/src/components/DashboardShell.tsx + Section.tsx + FilterBar.tsx.


id: proposal_1775083218894134980
kind: new_function
title: widget_renderer: componente generico de fetch+render+countdown para widgets de dashboard
evidence: {}
status: pending
created_by: reactive_loop
created_at: 2026-04-01T22:40:18Z
updated_at: 2026-04-15T00:04:14Z
description:
  Componente React que encapsula: fetch de datos via callback, manejo de loading/error, auto-refetch por intervalo, countdown visual hasta proximo refresh, y delegacion de render a un widget registrado.

  Specs: recibe widgetDef, queryDef (con refreshMs), filters, getData callback. Muestra countdown en esquina superior derecha. Cache de datos por JSON serialization para evitar re-renders innecesarios.

  Referencia: implementado en apps/rapid_dashboards/frontend/src/components/WidgetRenderer.tsx.


id: proposal_1775083211082150334
kind: improve_function
target_id: sqlite_open_go_infra
title: sqlite_open: resolver paths relativos desde un base_dir configurable
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:40:11Z
updated_at: 2026-04-15T00:04:14Z
description:
  SQLiteOpen recibe un path que se resuelve relativo al cwd del proceso. Cuando la config (YAML) define paths relativos a su propia ubicacion, el resultado es incorrecto si el binario corre desde otro directorio.

  Propuesta: anadir parametro opcional basePath. Si se provee, resolver path relativo a basePath en lugar de cwd. filepath.Join(basePath, path) si path no es absoluto.

  Evidencia: en rapid_dashboards, el YAML tenia path: ../../../registry.db (relativo al YAML en examples/) pero el binario corria desde apps/rapid_dashboards/. Resultado: abria un SQLite vacio sin tablas.


id: proposal_1775083198546778981
kind: improve_function
target_id: card_ts_ui
title: card: variante borderless/ghost para dashboards dark
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:39:58Z
updated_at: 2026-04-15T00:04:14Z
description:
  El componente Card tiene border por defecto via CSS. En dashboards dark los bordes blancos/claros rompen la estetica. Se necesita una variante borderless o ghost.

  Fix: anadir prop variant='default'|'borderless'|'ghost' que controle border y shadow. O exponer data-slot para override CSS global. Actualmente se usa [data-slot="card"] { border: none } como workaround.

  Evidencia: rapid_dashboards requirio CSS global para eliminar bordes de todas las cards.


id: proposal_1775083192134382057
kind: improve_function
target_id: kpi_card_typescript_ui
title: kpi_card: permitir eliminar border y shadow via className
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:39:52Z
updated_at: 2026-04-15T00:04:14Z
description:
  KPICard tiene border hardcodeado en className ('rounded-lg border bg-card p-4'). En dashboards dark donde las cards no llevan borde, no hay forma limpia de eliminarlo sin className override.

  Fix: usar cn() para merge de clases, permitiendo que className="border-0 shadow-none" sobreescriba los defaults. Ya funciona si el componente usa cn() correctamente, pero documentar el patron.

  Evidencia: en rapid_dashboards los KPIs con borde blanco rompian la estetica dark. Workaround: className="border-0 shadow-none".


id: proposal_1775083183562379637
kind: new_function
title: data_table: tabla con heatmap, sorting y formato condicional
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:39:43Z
updated_at: 2026-04-15T00:04:14Z
description:
  No existe un componente data_table reutilizable en el registry. Las tablas de datos son fundamentales para dashboards y TUIs.

  Specs: tabla con columnas configurables, heatmap por columna (colorea celdas segun intensidad numerica), sticky header, overflow scroll, formato por columna (number, datetime, currency), hover rows. Prop heatmap_columns: string[] para activar heatmap selectivo.

  Referencia: implementado en apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx con heatmap funcional.


id: proposal_1775083177926727090
kind: new_function
title: pie_chart: componente PieChart Recharts para dashboards
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:39:37Z
updated_at: 2026-04-15T00:04:14Z
description:
  No existe un componente pie_chart en el registry. Es necesario para dashboards donde bar_chart no comunica bien proporciones (distribuciones por dominio, lenguaje, purity).

  Specs: PieChart Recharts con Cell por segmento, colores automaticos, labels con nombre+porcentaje, Legend, Tooltip tematico, soporte donut (innerRadius), conversion automatica de valores a number.

  Referencia: implementado en apps/rapid_dashboards/frontend/src/components/widgets/PieChartWidget.tsx. Extraer como componente reutilizable en frontend/functions/ui/.


id: proposal_1775083169379485942
kind: improve_function
target_id: bar_chart_typescript_ui
title: bar_chart: horizontal mode no intercambia ejes XAxis/YAxis
evidence: {}
status: implemented
created_by: reactive_loop
reviewed_by: fn-constructor
created_at: 2026-04-01T22:39:29Z
updated_at: 2026-04-15T00:04:14Z
description:
  Al usar options.horizontal=true, el componente solo pasa layout='vertical' al BarChart de Recharts pero no intercambia XAxis/YAxis. Las barras horizontales no se renderizan.

  Fix: cuando horizontal=true, usar YAxis con dataKey={xKey} type="category" y XAxis type="number". Ajustar radius de barras a [0,4,4,0].

  Evidencia: descubierto al replicar dashboard Metabase en rapid_dashboards. Fix aplicado en apps/rapid_dashboards/frontend/src/components/widgets/BarChartWidget.tsx.


# apps

id: auto_metabase_py_analytics
name: auto_metabase
lang: py
domain: analytics
tags: ["metabase","sync","declarative","yaml","dashboards","gitops"]
uses_types: []
framework: httpx
entry_point: main.py
dir_path: apps/auto_metabase
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
content_hash: 3ed79fd59f572227429225abbb1e3bd8353f04426d9b8fa92de262347f51afeb
description:
  Sincronizacion bidireccional entre archivos YAML locales y una instancia Metabase. Cada dashboard, card, database, collection y document es un archivo editable; pull/push mantiene Metabase y disco en sintonia. Inspirado en rapid_dashboards.

uses_functions:
  ["metabase_auth_py_infra","metabase_list_databases_py_infra","metabase_get_database_py_infra","metabase_add_database_py_infra","metabase_list_dashboards_py_infra","metabase_get_dashboard_py_infra","metabase_create_dashboard_py_infra","metabase_update_dashboard_py_infra","metabase_delete_dashboard_py_infra","metabase_list_cards_py_infra","metabase_get_card_py_infra","metabase_create_card_py_infra","metabase_update_card_py_infra","metabase_delete_card_py_infra","metabase_execute_query_py_infra","metabase_copy_card_py_infra","metabase_move_card_py_infra","metabase_copy_dashboard_py_infra","metabase_move_dashboard_py_infra","metabase_copy_document_py_infra","metabase_move_document_py_infra","metabase_move_collection_py_infra","metabase_list_groups_py_infra","metabase_get_group_py_infra","metabase_create_group_py_infra","metabase_update_group_py_infra","metabase_delete_group_py_infra","metabase_list_memberships_py_infra","metabase_add_membership_py_infra","metabase_delete_membership_py_infra","metabase_get_permission_graph_py_infra","metabase_update_permission_graph_py_infra","metabase_get_collection_graph_py_infra","metabase_update_collection_graph_py_infra","metabase_list_users_py_infra"]

documentation:
  ## Idea

  Cada artefacto de Metabase (dashboard, card, database, collection, document) vive
  como un archivo YAML editable. Los archivos son la fuente de verdad.

  **Multi-proyecto:** cada entorno Metabase (local, prod, staging, ...) es un
  proyecto aislado bajo `projects/{name}/` con su propio `config.yaml`, `.env`,
  `state/` y carpetas de YAMLs. El proyecto activo se elige con `-p NAME`
  o por defecto desde `config.yaml` top-level (`default_project`).

  ## Comandos

  | Comando | Categoria | Que hace |
  |---------|-----------|----------|
  | `projects` | proyectos | Lista proyectos disponibles y marca el default |
  | `init-project NAME --base-url URL` | proyectos | Crea proyecto nuevo (config + carpetas + index vacio) |
  | `login` | sesion | Autentica contra Metabase y guarda token en `state/session.json` |
  | `status` | sesion | Muestra estado del proyecto (sesion, items en index, archivos en disco) |
  | `describe <db>` | exploracion | Lista tablas, columnas y tipos de un database (`--samples` para 3 filas demo) |
  | `sql <db> "QUERY"` | exploracion | Ejecuta SQL ad-hoc, NO crea card. `--limit 100` por defecto |
  | `remote <kind> [--filter S]` | exploracion | Lista items en Metabase sin descargar nada (output human-readable) |
  | `find <kind> <query>` | exploracion | Busca por substring usando `/api/search` indexado. Output TSV (`id\tname\tcol\tupdated`) bash-friendly |
  | `qpull <kind> <query> [--all]` | sync | Quick pull: busca por nombre y pulea el primer match (o todos con `--all`) |
  | `api METHOD PATH [-d JSON]` | escape hatch | Llamada HTTP cruda a la API de Metabase para ops sin comando dedicado |
  | `mbql-check --id N... \| --query S` | validacion | Valida `dataset_query` de cards con `metabase_mbql_validate` (sin tocar Metabase). Detecta uuids duplicados, stage mixing, slot refs rotos, case structure invalida, name collisions |
  | `batch <transformer> [--id N \| --query S] [--apply]` | bulk ops | Aplica transformers del registry sobre cards. Actual: `fix-null-ratio` (SUM(a-b)/SUM(b) -> (SUM(a)-SUM(b))/SUM(b)), `pair-n-n1-columns` (habilita + reubica columna Valor_vendido_1 junto a Valor_vendido en tablas). Dry-run por defecto |
  | `pull <kind> <ref>` | sync | Trae UN item de Metabase a disco (per-item, nunca bulk) |
  | `validate <kind> <slug>` | sync | Valida YAML local sin tocar Metabase. `--check-sql` ejecuta la query |
  | `push <kind> <slug> [--patch]` | sync | Aplica UN item a Metabase. Dry-run por defecto. `--apply` para enviar. `--patch` envia solo keys top-level que difieren (menos bytes, menos side-effects) |
  | `push-all` | sync | Pushea TODOS los YAMLs del proyecto. Solo CREATE/UPDATE, nunca DELETE |
  | `move <kind> <slug> --to <coll>` | move/copy | Mueve card/dashboard/document/collection a otra coleccion (o raiz). Dry-run por defecto |
  | `copy <kind> <slug> [--name] [--to] [--deep]` | move/copy | Clona card/dashboard/document. `--deep` en dashboards clona tambien las cards |
  | `permissions groups-remote` | permisos | Lista grupos en Metabase |
  | `permissions groups-pull <ref>` | permisos | Pull de UN grupo a YAML local |
  | `permissions groups-pull-all` | permisos | Bootstrap: pull de todos los grupos remotos |
  | `permissions groups-push <slug>` | permisos | Push de UN grupo (rename + diff de miembros). Dry-run por defecto |
  | `permissions groups-push-all` | permisos | Push de todos los grupos locales |
  | `permissions groups-delete <slug>` | permisos | Borra grupo en Metabase + YAML local |
  | `permissions data-graph-pull` | permisos | Pull del data permission graph (`permissions/data_graph.yaml`) |
  | `permissions data-graph-push` | permisos | Push del data graph con control de `revision` nativo |
  | `permissions collection-graph-pull [--namespace]` | permisos | Pull del collection graph |
  | `permissions collection-graph-push [--namespace]` | permisos | Push del collection graph |
  | `restore <kind> <slug>` | sync | Restaura YAML local desde backup (no aplica a Metabase) |
  | `diff <kind> <slug>` | sync | Alias temporal de `validate --show-payload` |

  ## Layout

  ```
  apps/auto_metabase/
    config.yaml                       # default_project + projects_dir
    main.py                           # CLI entrypoint
    payload.py                        # builders: YAML -> payload Metabase (resuelve refs, auto-inyecta)
    sync_pull.py                      # pull per-item desde Metabase
    sync_push.py                      # push per-item + push_all (R1-R20)
    sync_validate.py                  # validacion estructural + SQL check
    sync_restore.py                   # restore desde backups locales
    explore.py                        # describe + sql ad-hoc
    scripts/seed_test_data.py         # seed inicial de db+cards+dashboard de prueba
    projects/
      {name}/                         # un directorio por entorno Metabase
        config.yaml                   # base_url + auth + sync rules
        .env                          # credenciales (gitignored)
        state/
          session.json                # token de sesion (gitignored)
          index.json                  # slug local <-> metabase_id por tipo
          push.log                    # JSONL append-only de cada push
          backups/{ts}/{kind}/...     # backup automatico antes de UPDATE
        databases/{slug}.yaml         # connections
        collections/{slug}.yaml       # collections (carpetas)
        cards/{slug}.yaml             # cards reusables
        dashboards/{slug}/              # dashboards (split por tab)
          _dashboard.yaml             #   meta + params + tabs index
          tab_{tab_slug}.yaml         #   dashcards de cada tab
        dashboards/{slug}.yaml        # dashboards legacy (monolitico, backward-compat)
        documents/{slug}.yaml         # Metabase >= v0.51
        permissions/
          groups/{slug}.yaml          # grupos + miembros (emails) por grupo
          data_graph.yaml             # data permission graph (un solo file, revision nativo)
          collection_graph.yaml       # collection permission graph (idem)
  ```

  ## Configuracion

  **Top-level** `config.yaml` (en la raiz del app) selecciona el proyecto por defecto:

  ```yaml
  default_project: test_local
  projects_dir: projects
  ```

  **Por proyecto** `projects/{name}/config.yaml` define la URL y reglas de sync:

  ```yaml
  name: test_local
  description: "Metabase de prueba en Docker local"
  base_url: http://localhost:3000
  auth:
    email_env: METABASE_EMAIL
    password_env: METABASE_PASSWORD
  sync:
    ignore_collections: []   # IDs de colecciones a no sincronizar
    ignore_databases: [1]    # 1 = "Sample Database" interno de Metabase
    prefer_archive: true     # archivar en vez de borrar al hacer push
  ```

  Credenciales en `projects/{name}/.env` (gitignored):

  ```
  METABASE_EMAIL=admin@example.com
  METABASE_PASSWORD=changeme
  ```

  ## Estado

  `state/index.json` mantiene el mapeo slug local <-> id de Metabase para no
  duplicar al hacer push. Estructura:

  ```json
  {
    "databases":   { "registry": 2, "ops_demo": 3 },
    "collections": { "auto_metabase": 5 },
    "cards":       { "totals_by_domain": 12 },
    "dashboards":  { "fn_overview": 4 }
  }
  ```

  ## Pendiente

  - [x] Soporte de Metabase Documents (Metabase >= v0.51).
  - [ ] Soporte de Pulses / Alerts / Subscriptions.
  - [x] Soporte de Permissions (groups + memberships + data graph + collection graph).
  - [x] Move/copy per-item de cards/dashboards/documents/collections.
  - [ ] Diff con render colorizado en TUI.
  - [ ] Recovery automatico cuando un CREATE de dashboard falla a medias
        (POST ok + PUT fail deja un dashboard huerfano sin entrar al index).

  ---

  ## Crear cards y dashboards desde cero (workflow probado)

  ### 1. Referenciar una database que no se sincroniza

  Si quieres apuntar cards a la **Sample Database** de Metabase (db `1`, `ignore_databases: [1]` por defecto), basta con anadir el slug al `state/index.json` — no hace falta YAML en `databases/`:

  ```json
  "databases": {
    "metabase_internal_pg": 2,
    "sample_database": 1
  }
  ```

  Las cards luego usan `_refs.database: sample_database` y `dataset_query.database: sample_database`, y el builder lo resuelve a `1` via index.

  ### 2. Formato del `dataset_query` (MBQL v2 con SQL nativo)

  Metabase moderno usa MBQL stages, no la forma vieja `{type: native, native: {query: ...}}`. La forma correcta para cards SQL:

  ```yaml
  dataset_query:
    lib/type: mbql/query
    database: <slug_o_id>
    stages:
      - lib/type: mbql.stage/native
        native: |-
          SELECT ...
  ```

  `query_type: native` se mantiene en el payload top-level (Metabase lo usa internamente).

  ### 3. SQL del Sample Database = H2

  Tablas en MAYUSCULAS (`PEOPLE`, `ORDERS`, `PRODUCTS`...) y funciones H2:

  - `FORMATDATETIME(col, 'yyyy-MM')` para truncar fechas (no `DATE_TRUNC`).
  - `ROUND(SUM(x), 2)` igual que en Postgres.
  - Joins y agregaciones SQL-92 estandar.

  ### 4. Crear cards nuevas

  YAML minimo de una card nueva:

  ```yaml
  _meta:
    kind: card
    id: null              # null = nueva, push hara CREATE
    slug: clientes_total  # debe coincidir con el filename
  _refs:
    database: sample_database
    collection: null
  payload:
    name: clientes_total
    description: ...
    type: question
    query_type: native
    display: scalar       # o table, bar, line, pie, area, ...
    archived: false
    enable_embedding: false
    collection_preview: true
    visualization_settings: {}
    parameters: []
    parameter_mappings: []
    dataset_query:
      lib/type: mbql/query
      database: sample_database
      stages:
        - lib/type: mbql.stage/native
          native: SELECT COUNT(*) AS total FROM PEOPLE
  ```

  Validar antes de pushear:

  ```bash
  python main.py validate card clientes_total --check-sql
  ```

  `--check-sql` ejecuta la query contra Metabase (solo para cards `native`). Pillar errores de sintaxis aqui es mas barato que en push.

  Push:

  ```bash
  python main.py push card clientes_total --apply
  ```

  Sin `--apply` es dry-run. Tras un CREATE exitoso:

  - Se asigna id real, se actualiza `state/index.json`.
  - Se hace `pull` automatico → el YAML se reescribe con `_meta.id`, `synced_at`, `remote_updated_at`.
  - Hay que pushear las cards UNA POR UNA (no hay batch).

  ### 5. Crear dashboards nuevos

  Las cards referenciadas deben existir antes (ya en el index). Estructura minima del YAML:

  ```yaml
  _meta:
    kind: dashboard
    id: null
    slug: compras_y_clientes
  _refs:
    collection: null
  payload:
    name: Compras y Clientes
    description: ...
    archived: false
    enable_embedding: false
    width: fixed              # o "full"
    auto_apply_filters: true
    parameters: []
    tabs: []
    dashcards:
      - card: clientes_total  # slug, se resuelve a card_id
        row: 0
        col: 0
        size_x: 8             # grid de 24 columnas
        size_y: 3
      - card: compras_total
        row: 0
        col: 8
        size_x: 8
        size_y: 3
  ```

  Push:

  ```bash
  python main.py push dashboard compras_y_clientes --apply
  ```

  **Auto-inyeccion (`build_dashboard_payload`)** — para cada dashcard sin `id`,
  inyecta automaticamente:

  - `id`: negativo unico (-1, -2, ...) en orden de aparicion. Metabase lo necesita
    para distinguir dashcards nuevas (`id < 0`) de las existentes (`id > 0`).
    Si la dashcard ya trae `id` (positivo o negativo), se respeta tal cual.
  - `visualization_settings: {}` si falta.
  - `parameter_mappings: []` si falta.

  Esto resuelve el **escollo historico**: antes, omitir esos campos provocaba que el
  POST creara el dashboard pero el PUT siguiente devolviera 500 — quedando un
  dashboard huerfano en Metabase que no entraba al index local. Ahora los YAMLs
  de dashboards son tan simples como los de cards: solo `card`, `row`, `col`,
  `size_x`, `size_y` por dashcard.

  ### 5b. Dashboard split por tab (directory-per-dashboard)

  Los dashboards grandes se dividen automaticamente en un directorio por dashboard,
  con un archivo `_dashboard.yaml` (meta, parametros, indice de tabs) y un archivo
  `tab_{slug}.yaml` por cada tab con sus dashcards. Esto permite:

  - Edicion quirurgica: abrir solo la tab que necesitas
  - Diffs acotados en git: cambiar 1 dashcard solo toca 1 archivo
  - Navegacion clara: `ls tab_*.yaml` lista las tabs

  **Layout del directorio:**

  ```
  dashboards/bi_ventas_portfolio/
    _dashboard.yaml          # meta + params + tabs index + dashcards globales
    tab_5_min_view.yaml      # 7 dashcards
    tab_foto_centros.yaml    # 8 dashcards
    tab_zona_neumatico.yaml  # 19 dashcards
    ...
  ```

  **`_dashboard.yaml`** contiene `_meta.split_version: 1`, parametros, y un indice
  de tabs con `{id, name, file}` mapeando tab -> archivo. Las dashcards sin
  `dashboard_tab_id` van en `payload.dashcards_globales`.

  **`tab_*.yaml`** contiene `_meta.kind: dashboard_tab`, `_meta.parent_slug`,
  `_meta.tab_id`, `_meta.tab_name`, y `payload.dashcards` ordenadas por `(row, col)`.

  **Backward-compat:** dashboards con el formato legacy `dashboards/{slug}.yaml`
  siguen funcionando. Si existe el directorio `dashboards/{slug}/`, se prefiere
  sobre el archivo monolitico.

  **Round-trip:** `pull` genera el directorio split. `push` lee todos los
  `tab_*.yaml`, re-inyecta `dashboard_tab_id` y compone el payload unificado.
  El API de Metabase no se entera del split.

  ### 6. `push-all` — proyecto entero en un comando

  ```bash
  # Dry-run de todo el proyecto (cards primero, dashboards despues)
  python main.py push-all

  # Aplicar
  python main.py push-all --apply

  # Solo cards (saltar dashboards)
  python main.py push-all --apply --kinds card

  # Forzar saltando R17 (freshness) y R18 (count) en cada item
  python main.py push-all --apply --force-overwrite

  # Permitir warnings estructurales
  python main.py push-all --apply --allow-warnings
  ```

  Garantias:

  - **Solo CREATE o UPDATE — nunca DELETE.** Si un YAML local desaparece, el
    item correspondiente sigue intacto en Metabase. La unica via para borrar
    algo en Metabase es manualmente via UI o API.
  - **Cards primero, dashboards despues.** Asi cualquier card recien creada ya
    esta en el index cuando se construye el payload del dashboard que la
    referencia.
  - **Resiliente a fallos por item.** Si una card falla (validation error, SQL
    invalido, conflicto de freshness), `push-all` captura el `SystemExit`,
    lo registra en el resumen final y continua con el siguiente item.
  - **Reusa toda la logica de `push_one`**: backup obligatorio antes de UPDATE
    (R6), freshness check (R17), count check para dashboards (R18), log JSONL
    en `state/push.log` (R13).

  Resumen final mostrado en stdout:

  ```
  === resumen push all ===
    OK:      11  ['card:clientes_total', 'card:compras_total', ...]
    FAILED:  0   []
  ```

  Exit code: 1 si hubo fallos, 0 si todo OK.

  ### 7. Grid de Metabase

  24 columnas, alturas en filas de ~30px. Layout que funciona bien para 6 cards:

  | Fila | Cards | Filas (size_y) |
  |------|-------|----------------|
  | 0    | 3 KPIs scalar (8 cols c/u) | 3 |
  | 3    | 2 graficos (12 cols c/u)   | 6 |
  | 9    | 1 tabla ancha (24 cols)    | 7 |

  ### 8. Exploracion rapida: `describe` + `sql`

  Dos comandos para no escribir cards a ciegas. **Read-only**, no tocan nada en disco ni en Metabase.

  ```bash
  # Schema del database (tablas + columnas + tipos)
  python main.py describe sample_database
  python main.py describe sample_database --tables-only        # solo nombres + descripcion
  python main.py describe sample_database --filter products    # una sola tabla
  python main.py describe sample_database --samples            # +3 filas de ejemplo por tabla

  # SQL ad-hoc — ideal para iterar antes de guardar como card
  python main.py sql sample_database "SELECT CATEGORY, COUNT(*) FROM PRODUCTS GROUP BY CATEGORY"
  python main.py sql sample_database "SELECT * FROM ORDERS" --limit 5
  ```

  Tres barreras anti-explosion en `sql`:

  1. `--limit N` (default **100**) → se envia como `max-results` constraint a Metabase. Metabase corta server-side, no transferimos filas de mas.
  2. **Hard ceiling 10 000 filas** — incluso `--limit 999999` se cappea.
  3. **5 MB de payload** — si las filas son anchas y exceden, se recorta antes de imprimir.

  Queries que empiezan por `INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER/CREATE` se bloquean. `--allow-write` las deja pasar (Metabase normalmente las bloquea igualmente en `/api/dataset`).

  Errores SQL devuelven el mensaje de Metabase limpio:

  ```
  $ python main.py sql sample_database "SELECT NOPE FROM PEOPLE"
  ERROR (400): Column "NOPE" not found; SQL statement: SELECT NOPE FROM PEOPLE [42122-214]
  ```

  **Workflow recomendado para card nueva:**

  ```bash
  python main.py describe sample_database --filter orders        # 1. ver columnas
  python main.py sql sample_database "SELECT ... FROM ORDERS"    # 2. iterar SQL en stdout
  # 3. cuando la query funciona, copiarla a cards/<slug>.yaml
  python main.py push card <slug> --apply                        # 4. guardarla como card
  ```

  ### 9. Move y copy — cirugia puntual

  `push` empuja el estado completo de un YAML. `move` y `copy` son operaciones
  quirurgicas que solo tocan la ubicacion o clonan:

  ```bash
  # Mover una card a otra coleccion
  python main.py move card clientes_total --to reportes_ventas --apply

  # Mover a la raiz (Our analytics)
  python main.py move dashboard overview --to root --apply

  # Mover una coleccion entera (sub-arbol)
  python main.py move collection reportes --to finanzas --apply

  # Copiar una card
  python main.py copy card clientes_total --name clientes_total_v2 --to sandbox --apply

  # Copiar dashboard con deep-copy (clona tambien las cards)
  python main.py copy dashboard overview --deep --name overview_clone --apply
  ```

  - `--apply` obligatorio para ejecutar (sin el, dry-run).
  - Tras `move` o `copy`, se hace `pull` automatico para refrescar el YAML local
    (`move`) o generar el YAML de la copia (`copy`).
  - `move` NO aplica freshness check (R17) — solo cambia `collection_id`/`parent_id`,
    no sobrescribe contenido.
  - `copy` usa los endpoints nativos de Metabase cuando existen (cards, dashboards);
    para `document` compone `get_document` + `create_document`.

  ### 10. Permisos — grupos, memberships y graphs

  Los permisos se gestionan con el mismo patron YAML-first que cards/dashboards,
  con dos variantes:

  **Grupos (entidades simples, un YAML por grupo):**

  ```bash
  # Bootstrap: trae todos los grupos remotos a disco
  python main.py permissions groups-pull-all

  # Per-grupo
  python main.py permissions groups-pull analistas
  python main.py permissions groups-push analistas --apply
  python main.py permissions groups-push-all --apply
  python main.py permissions groups-delete antiguo --apply
  ```

  Estructura `projects/{name}/permissions/groups/{slug}.yaml`:

  ```yaml
  _meta:
    kind: permission_group
    id: 7
    slug: analistas
    synced_at: "2026-04-13T10:23:12Z"
    remote_hash: "a3f9e4b2c1d8"         # hash del estado remoto (R17)
  payload:
    name: Analistas
    members:
      - ana@example.com                 # emails como identificadores estables
      - bea@example.com
  ```

  El push diff-ea `payload.members` vs el estado remoto: emite `POST /membership`
  por cada add y `DELETE /membership/:id` por cada remove. El rename de `payload.name`
  se aplica si difiere. Los grupos especiales (`All Users` id=1, `Administrators`
  id=2) no se pueden borrar — el CLI lo bloquea.

  **Graphs (una unica pieza JSON grande con `revision` nativo):**

  ```bash
  # Data graph (permisos sobre databases/schemas/tables)
  python main.py permissions data-graph-pull
  # edita projects/{name}/permissions/data_graph.yaml
  python main.py permissions data-graph-push --apply

  # Collection graph (permisos sobre colecciones)
  python main.py permissions collection-graph-pull [--namespace snippets]
  python main.py permissions collection-graph-push --apply [--namespace snippets]
  ```

  Estructura `permissions/data_graph.yaml`:

  ```yaml
  _meta:
    kind: permission_graph
    revision: 47                        # server rechaza PUT si no coincide (409)
    synced_at: "2026-04-13T10:23:12Z"
  payload:
    groups:
      "7":        # group_id (All Users = 1, Admins = 2)
        "2":     # database_id
          view-data: unrestricted
          create-queries: query-builder-and-native
      "8":
        "2":
          view-data: unrestricted
          create-queries: "no"
  ```

  **Control de concurrencia**:
  - Para grupos usamos un **hash** del estado remoto (`sha256(name + sorted(member_ids))`)
    guardado en `_meta.remote_hash`. En push, fetcheamos el grupo, recalculamos el
    hash y si difiere abortamos (R17). `--force-overwrite` salta el check.
  - Para graphs usamos la **`revision`** nativa de Metabase. El server rechaza el
    PUT si no coincide — doble proteccion: checkeamos local vs remoto *antes* de
    enviar, y Metabase comprueba de nuevo al recibir.

  Tanto grupos como graphs hacen **backup R6** antes de aplicar: estado remoto se
  serializa a `state/backups/{ts}/permissions/` como YAML. Los pushes se loguean
  en `state/permissions.log` (JSONL).

  **Excluidos de `push-all`**: los permisos son sensibles, requieren comando explicito.
  `push-all` solo toca cards/dashboards/documents.

  ### 11. Resumen del flujo

  ```
  1. Editar YAMLs de cards en projects/{name}/cards/
  2. Editar YAML del dashboard con dashcards (solo card slug + row/col/size — auto-inject hace el resto)
  3. python main.py push-all --apply
  4. Abrir http://localhost:3000/dashboard/<id>
  ```

  ---

  ## Workflow bash-friendly (para instancias grandes)

  Las mejoras `find` / `qpull` / `api` / `--patch` aceleran el ciclo cuando trabajas
  con instancias de miles de cards (p.ej. aurgi = 6.4k cards) donde NO conviene
  pullear todo.

  ### Busqueda + pull en un paso

  ```bash
  # Descubrir IDs via /api/search (indexado, incluye cards internas de dashboards)
  ./main.py -p aurgi find card "Ventas totales N-1"
  #   5705  Ventas totales N-1   500   2026-04-13T16:04:34Z

  # Encadenable: extrae el primer id con cut -f1
  ID=$(./main.py -p aurgi find card "Ventas totales N-1" -n1 | cut -f1)

  # Pull directo por nombre (trae el primer match)
  ./main.py -p aurgi qpull card "Ventas totales N-1"

  # Pull masivo de matches
  ./main.py -p aurgi qpull dashboard "BI - Ventas" --all
  ```

  El `find` usa `/api/search` con `include_dashboard_questions=true` + `limit=500`.
  Tokeniza por espacios/guiones y elige el token mas largo (>2 chars) para maximizar
  recall, luego post-filtra client-side con substring exacto sobre el query original.

  ### API cruda (escape hatch)

  Para operaciones que no tienen comando dedicado:

  ```bash
  # GET arbitrario
  ./main.py -p aurgi api GET /api/card/5705 | jq '.name, .description'

  # PUT con body JSON
  ./main.py -p aurgi api PUT /api/card/5705 -d '{"description": "nueva"}'

  # Search avanzado
  ./main.py -p aurgi api GET "/api/search?q=venta&models=card&filter_items_in_personal_collection=only"

  # Ejecutar query de una card con parametros
  ./main.py -p aurgi api POST /api/card/5705/query -d '{"parameters": [...]}'
  ```

  Util cuando: Metabase saca endpoints nuevos que no hemos envuelto, o necesitas
  inspeccionar la respuesta cruda de la API sin parsear YAML.

  ### Push en modo `--patch`

  Por defecto `push --apply` envia el payload completo construido desde el YAML.
  `--patch` compara contra el remoto y solo envia los keys top-level que difieren:

  ```bash
  ./main.py -p aurgi push card ventas_totales_n_1 --patch --apply
  #   (patch) enviando 2 keys: ['description', 'visualization_settings']
  # o si nada cambio:
  #   (patch) no hay cambios — salto el PUT
  ```

  Ventajas: menos bytes sobre la red, menos riesgo de efectos laterales en campos
  que no tocaste, y detecta no-op automaticamente (noop = no request).

  ### Auto-registro de dependencias

  `pull_card` / `pull_dashboard` / `pull_document` auto-registran la database y
  collection referenciadas (via `_ensure_tracked()` en `sync_pull.py`). Ya no
  aparece el warning `database_id=X no esta en index`. La entrada se agrega al
  `state/index.json` sin escribir el YAML de la database.

  ### Operaciones masivas y validacion — funciones del registry

  Para acelerar ciclos de trabajo en instancias grandes (aurgi: 6.4k cards) el
  registry expone 4 funciones dedicadas bajo `python/functions/metabase/`. Usables
  desde Python, desde CLI (`./main.py`), o desde agentes.

  #### `metabase_mbql_validate` — pre-flight local (sin red)

  **PURA** — no toca Metabase. Valida el arbol `dataset_query` antes de crear/actualizar
  una card. Detecta 5 clases de bug que costaban round-trips 400/500:

  ```python
  from metabase import metabase_mbql_validate

  errors = metabase_mbql_validate(dataset_query)   # [] si OK, lista de strings si no
  ```

  Checks:

  1. **UUIDs duplicados** en todo el arbol — causa `400 Duplicate :lib/uuid`
  2. **Stage mixing** — aggregations + expressions en la misma stage referenciando slots post-agg → Metabase ignora las expressions silenciosamente
  3. **Slot refs rotas** — `sum_X` donde X > cantidad de sums en la stage
  4. **Case structure invalida** — `["case", meta, cases]` donde `cases` no es lista de `[cond, result]`
  5. **Name collision** — mismo `lib/expression-name` dos veces en una stage

  **CLI:**

  ```bash
  ./main.py -p aurgi mbql-check --id 5705                     # una card
  ./main.py -p aurgi mbql-check --id 5705 5708 7707 -v        # varias, verbose
  ./main.py -p aurgi mbql-check --query "Ventas totales"      # todas las matcheadas
  ```

  #### `metabase_fix_null_ratio` — fix sistematico SUM(a-b)/SUM(b)

  **IMPURA** (hace HTTP). Detecta y reescribe cards vulnerables al patron donde
  una expresion pre-agregacion computa `a - b` y luego se suma. `SUM(a-b) !=
  SUM(a) - SUM(b)` cuando alguna fila tiene NULL en `a` o `b` — el fix reemplaza
  las refs post-agg a ese slot diferencia por `(SUM(a) - SUM(b))` explicito.

  ```python
  from metabase import MetabaseClient, metabase_fix_null_ratio

  client = MetabaseClient(base_url, api_key)
  result = metabase_fix_null_ratio(client, dry_run=False, card_ids=None)
  # {"scanned": 6414, "affected": 335, "fixed": 335, "errors": []}
  ```

  **CLI:**

  ```bash
  ./main.py -p aurgi batch fix-null-ratio                      # dry-run todas
  ./main.py -p aurgi batch fix-null-ratio --id 5705 5708       # selectivo
  ./main.py -p aurgi batch fix-null-ratio --apply              # aplicar a todas
  ```

  #### `metabase_pair_n_n1_columns` — empareja columnas N / N-1 en tablas

  **IMPURA**. Para cards `table`/`pivot` que agregan `SUM(base_field)` y
  `SUM(base_field_1)`, habilita el slot `_1` en `visualization_settings.table.columns`
  y lo reposiciona justo despues de su par (para leer visualmente N vs N-1).

  ```python
  from metabase import metabase_pair_n_n1_columns
  result = metabase_pair_n_n1_columns(client, dry_run=False)
  # {"scanned": 106, "affected": 102, "fixed": 102}
  ```

  **CLI:**

  ```bash
  ./main.py -p aurgi batch pair-n-n1-columns --apply
  ```

  #### `metabase_update_dashboard_safe` — PUT dashboard sin gotchas

  **IMPURA**. Wrapper de `PUT /api/dashboard/:id` que maneja automaticamente los
  3 escollos documentados en "PUT /api/dashboard/:id — gotchas" mas abajo:

  ```python
  from metabase import metabase_update_dashboard_safe

  result = metabase_update_dashboard_safe(
      client,
      dashboard_id=734,
      dashcards_add=[
          {"card_id": 7707, "dashboard_tab_id": 191, "col": 0, "row": 12,
           "size_x": 6, "size_y": 2, "parameter_mappings": [...]},
          ...
      ],
      # Alternativas: dashcards_update=[...] (reemplaza todo),
      #               dashcards_remove=[old_id, ...]
      #               extra_fields={"name": "...", "description": "..."}
  )
  # {"added": [6418, 6419], "updated": 90, "removed": 0, "response": {...}}
  ```

  Soporta 3 modos: `dashcards_update` (full replace), `dashcards_add`/`remove`
  (incremental), o solo `extra_fields` (metadata del dashboard sin tocar cards).

  #### Añadir mas transformers

  El comando `./main.py batch <transformer>` es extensible. Para añadir uno nuevo:

  1. Escribir funcion en `python/functions/metabase/maintenance.py` (o un modulo hermano)
     con signature `def mi_transformer(client, *, dry_run=True, card_ids=None) -> dict`
  2. Exportarla en `python/functions/metabase/__init__.py`
  3. Añadir `.md` con frontmatter, correr `fn index`
  4. Registrar en el dict `TRANSFORMERS` dentro de `cmd_batch()` (`apps/auto_metabase/main.py`)

  ### Estructura MBQL: cosas que se aprenden a puñetazos

  Trabajar con `dataset_query` requiere saber:

  1. **Expresiones post-agregacion viven en una stage SEPARADA** — no se pueden
     mezclar con aggregations en la misma stage. Si lo haces, Metabase las ignora
     (no aparecen en las columnas de salida). Patron correcto:
     ```
     stages: [
       { aggregation: [...], source-card: 4324 },
       { expressions: [...] }   # referencia slots sum, sum_2, ... de stage 0
     ]
     ```

  2. **`lib/uuid` debe ser unico en TODO el arbol** — Metabase valida duplicados
     y rechaza con 400 `Duplicate :lib/uuid`. Si construyes expresiones
     programaticamente y reutilizas subnodes, genera UUIDs frescos cada vez
     (callable/lambda en vez de variable).

  3. **Slot naming en aggregations** — el primer `sum(X)` produce slot `"sum"`,
     el segundo `"sum_2"`, etc. Con otras funciones (`avg`, `count`, ...) cada
     funcion tiene su propio contador. Para referenciar slots desde expresiones
     post-agg: `["field", {"base-type": "type/Float", "lib/uuid": "..."}, "sum_4"]`.

  4. **`case` expression tiene estructura `[[cond, result], ...]`**:
     ```
     ["case", meta, [
       [["=", m, node, 0], 0],
       [["is-null", m, node], 0],
       [["!=", m, node, 0], ["/", m, num, denom]]
     ]]
     ```

  5. **SUM de diferencia pre-agg ≠ diferencia de SUMs con NULLs** — si tienes
     una columna que es `X - Y` como pre-aggregation expression y luego sumas,
     las filas con NULL en X o Y se descartan. Para ratios tipo "% crecimiento"
     siempre computar `(SUM(X) - SUM(Y)) / SUM(Y)` directo en post-agg.

  ### PUT /api/dashboard/:id — gotchas

  Editar un dashboard programaticamente requiere:

  - **Stripear el campo `.card`** de cada dashcard antes de PUT (esta denormalizado
    en GET, infla el payload a MBs y da 413 Payload Too Large).
  - **Incluir `tabs` en el body**, aunque no los modifiques. Si envias solo
    `{dashcards: [...]}` Metabase interpreta "tabs no enviados = borrar tabs" y
    cascadea borrando todos los dashcards referenciados, resultando en 500 FK
    violation.
  - **Nuevos dashcards: usar id negativo temporal** (`id: -1, -2, ...`). Metabase
    los reasigna en el INSERT.
  - **Campos minimos por dashcard**: `{id, card_id, dashboard_tab_id, col, row,
    size_x, size_y, parameter_mappings, visualization_settings, series}`.

  ### Troubleshooting rapido

  | Sintoma | Causa probable | Solucion |
  |---|---|---|
  | `400 Duplicate :lib/uuid` | Reutilizaste un nodo del arbol MBQL con mismo uuid | Regenera uuids con `str(uuid.uuid4())` en cada uso |
  | `400 missing required key 'id'` en PUT dashboard | Nuevos dashcards sin id | Usa id negativo temporal (-1, -2, ...) |
  | `413 Payload Too Large` en PUT dashboard | Dashcards con `.card` embebido | Stripea a `{id, card_id, col, row, size_x, size_y, parameter_mappings, ...}` |
  | `500 FK violation dashboard_tab_id` en PUT | No incluiste `tabs` en el body | Añade `tabs: dashboard['tabs']` al body |
  | Scalar muestra `None` en la columna esperada | Expresiones post-agg en la misma stage que aggregations | Separa en stage 1 |
  | `Read timed out` en `/api/card` | Listado bulk muy grande | Usa `/api/search?q=...` indexado, o sube timeout del cliente a 120s+ |
  | `database_id=X no esta en index` | Version anterior del pull | El auto-tracking actual lo resuelve silenciosamente |
  | `orphan tab file (not in _dashboard.yaml tabs)` | Tab borrada del indice pero archivo queda en disco | Borrar el `tab_*.yaml` huerfano o re-añadirlo a `_dashboard.yaml::payload.tabs` |
  | `tab file referenced but missing` | Entrada en tabs index sin archivo correspondiente | Re-pull el dashboard o crear el archivo manualmente |

  Cualquier cambio futuro: editar el YAML, `push-all --apply` (o `push <kind> <slug> --apply` si quieres aplicar solo uno). Cualquier cambio hecho desde la UI de Metabase: `pull <kind> <slug>`. La logica de freshness (R17) avisa si hubo cambios remotos no traidos antes de pushear local.

  ---

  ## Playbook: acelerar dashboards en produccion

  Receta probada en aurgi (6.4k cards, 734 el dashboard mas grande). Combinando
  los comandos de esta app con las funciones del registry.

  ### A) Investigar y duplicar un dashboard

  ```bash
  # 1. Login + sanity check
  ./main.py -p <proj> login
  ./main.py -p <proj> status

  # 2. Explorar qué hay
  ./main.py -p <proj> find dashboard "nombre parcial"
  ./main.py -p <proj> api GET /api/dashboard/<id> | jq '.name, .collection_id, .tabs[].name'

  # 3. Duplicar con deep copy (clona tambien las cards referenciadas)
  ./main.py -p <proj> copy dashboard <slug> --to <collection_slug> --deep --name "nuevo titulo" --apply
  ```

  ### B) Editar un dashboard grande — edicion quirurgica por tab

  ```bash
  # 1. Pull (genera directorio por dashboard + tab_*.yaml)
  ./main.py -p <proj> qpull dashboard "Nombre del dashboard"
  #   projects/<proj>/dashboards/<slug>/_dashboard.yaml
  #   projects/<proj>/dashboards/<slug>/tab_5_min_view.yaml
  #   projects/<proj>/dashboards/<slug>/tab_foto_centros.yaml
  #   ...

  # 2. Editar SOLO la tab que te interesa
  $EDITOR projects/<proj>/dashboards/<slug>/tab_5_min_view.yaml

  # 3. Diff acotado
  git diff --stat projects/<proj>/dashboards/<slug>/

  # 4. Validate local (rapido, sin red)
  ./main.py -p <proj> validate dashboard <slug>

  # 5. Push con --patch (solo envia keys que cambiaron, noop-detect)
  ./main.py -p <proj> push dashboard <slug> --patch --apply
  ```

  ### C) Crear cards nuevas sin round-trips 400

  ```bash
  # 1. Escribir YAML en projects/<proj>/cards/<slug>.yaml
  # 2. Validar MBQL local ANTES de pushear — evita round-trips
  ./main.py -p <proj> validate card <slug>                    # incluye mbql validation
  ./main.py -p <proj> mbql-check --id <existing_id>           # o sobre una card existente

  # 3. Push dry-run (imprime el payload que se enviaria)
  ./main.py -p <proj> push card <slug>

  # 4. Apply
  ./main.py -p <proj> push card <slug> --apply
  ```

  ### D) Fixes sistematicos a cientos de cards

  ```bash
  # 1. Listar las cards candidatas
  ./main.py -p <proj> mbql-check --query "patron" -v

  # 2. Escanear con un transformer (dry-run)
  ./main.py -p <proj> batch fix-null-ratio

  # 3. Aplicar
  ./main.py -p <proj> batch fix-null-ratio --apply

  # 4. Verificar con pull + diff
  ./main.py -p <proj> qpull card "card especifica"
  git diff projects/<proj>/cards/
  ```

  ### E) Composiciones puntuales via API cruda

  Cuando no hay comando dedicado:

  ```bash
  # Fetch/debug
  ./main.py -p <proj> api GET /api/card/<id>              | jq '.dataset_query.stages[0]'
  ./main.py -p <proj> api GET "/api/search?q=X&models=card&include_dashboard_questions=true" | jq '.data[] | {id, name}'

  # Ejecutar query de una card
  ./main.py -p <proj> api POST /api/card/<id>/query -d '{"parameters": [...]}'

  # SQL ad-hoc (comando dedicado, mas seguro)
  ./main.py -p <proj> sql <database_slug> "SELECT ..." --limit 100
  ```

  ### F) Scripting desde Python (agentes, Jupyter, jobs)

  ```python
  import sys; sys.path.insert(0, '/home/lucas/fn_registry/python/functions')
  from metabase import (
      MetabaseClient,
      metabase_mbql_validate,
      metabase_fix_null_ratio,
      metabase_pair_n_n1_columns,
      metabase_update_dashboard_safe,
      metabase_copy_dashboard,
      metabase_get_card, metabase_list_cards,
      # ... resto de funciones metabase_*
  )

  client = MetabaseClient("https://host", "mb_...")

  # Pre-flight antes de crear
  errors = metabase_mbql_validate(my_dataset_query)
  assert not errors, errors

  # Bulk fix
  result = metabase_fix_null_ratio(client, dry_run=False, card_ids=[5705, 5708])

  # Dashboard update sin los 3 gotchas
  metabase_update_dashboard_safe(
      client, dashboard_id=734,
      dashcards_add=[{"card_id": 7707, "dashboard_tab_id": 191,
                      "col": 0, "row": 12, "size_x": 6, "size_y": 2,
                      "parameter_mappings": [...]}],
  )
  ```

  ### Resumen de atajos

  | Quiero... | Comando |
  |---|---|
  | Saber si mi MBQL es valido antes de subirlo | `mbql-check --id N` o `validate card <slug>` |
  | Empaquetar un fix masivo como transformer reutilizable | `batch <name>`, registrar en el dict `TRANSFORMERS` |
  | Editar una tab de un dashboard grande sin scroll | `qpull dashboard` → abrir `tab_<name>.yaml` → `push --patch --apply` |
  | Mandar solo lo que cambio | `push ... --patch --apply` |
  | Añadir dashcards sin romper el dashboard | `metabase_update_dashboard_safe(dashcards_add=[...])` |
  | Buscar rapido una card por nombre (bash pipes) | `find card "texto" -n1 \| cut -f1` |
  | Ejecutar algo que no tiene comando | `api METHOD /api/... -d '{...}'` |


id: dag_engine_go_infra
name: dag_engine
lang: go
domain: infra
tags: ["service","dag","workflow","scheduler","web","cron"]
framework: net/http + vite + react
entry_point: main.go
dir_path: apps/dag_engine
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 31758115e7d9239e0da2f31aa80ed24f33545e09b8fd6193fede1952b50b06ac
description:
  Motor de ejecucion de DAGs con CLI y interfaz web. Reemplaza Dagu con implementacion propia compatible con el formato YAML existente. Almacena historial de ejecuciones en SQLite.

uses_functions:
  ["dag_parse_go_core","dag_validate_go_core","dag_topo_sort_go_core","dag_resolve_env_go_core","parse_cron_expr_go_core","next_cron_time_go_core","cron_ticker_go_infra","cron_match_go_core","process_spawn_go_infra","process_wait_go_infra","process_kill_go_infra"]

uses_types:
  ["dag_definition_go_core","dag_step_go_core","dag_validation_result_go_core","cron_schedule_go_core","process_handle_go_infra","process_result_go_infra","DagRun_go_infra","DagStepResult_go_infra"]

documentation:
  ## Arquitectura

  CLI + servidor web en un unico binario:

  ```
  dag-engine run <path.yaml>      # ejecuta un DAG desde terminal
  dag-engine list [dir]            # lista DAGs con schedule y estado
  dag-engine status [dag_name]     # historial de ejecuciones
  dag-engine validate <path.yaml>  # valida sin ejecutar
  dag-engine server                # arranca HTTP + frontend web
  ```

  ### Backend (Go)

  - `net/http` con `ServeMux` (Go 1.22+ pattern routing)
  - SQLite via `go-sqlite3` para historial de runs
  - Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
  - Scheduler: cron_ticker por cada DAG con schedule

  ### Frontend (Vite + React + Mantine)

  - DagList: tabla de DAGs con schedule, tags, ultimo status
  - DagDetail: metadata + "Run Now" + historial
  - RunDetail: timeline de steps con stdout/stderr expandible

  ### Storage

  SQLite `dag_engine.db`:
  - `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
  - `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms

  ### Build

  ```bash
  cd frontend && pnpm install && pnpm build
  cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
  ```

  ### Uso

  ```bash
  # CLI
  ./dag-engine run ~/dagu/dags/example.yaml
  ./dag-engine list ~/dagu/dags/

  # Servidor web
  ./dag-engine server --port 8090 --dags-dir ~/dagu/dags/ --scheduler
  # Browser: http://localhost:8090
  ```

notes:
  Compatible con el formato YAML de Dagu. Lee DAGs existentes de `~/dagu/dags/` sin modificaciones.
  Puerto por defecto 8090 (mismo que Dagu).


id: deploy_server_go_infra
name: deploy_server
lang: go
domain: infra
tags: ["service","deploy","ci","cd","webhook","gitea","ssh","vps","docker-compose","systemd"]
uses_types: ["ssh_conn_go_infra","ssh_config_entry_go_infra","DeployConfig_go_infra"]
framework: net/http
entry_point: main.go
dir_path: apps/deploy_server
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
content_hash: 7cb6226fa8d56cbfd685621770ed47ba43ea2a5412a52ef6ba594ae106e52d2c
description:
  Servidor de deploy continuo para apps del registry. Recibe webhooks de Gitea, gestiona targets de deploy en operations.db y orquesta deploys a VPS remotos via SSH. Soporta tres estrategias: systemd, systemd-remote y docker-compose.

uses_functions:
  ["ssh_check_go_infra","ssh_exec_go_infra","ssh_upload_go_infra","rsync_deploy_bash_infra","systemd_generate_unit_go_infra","systemd_install_go_infra","systemd_restart_go_infra","systemd_status_go_infra","vps_setup_app_go_infra","health_check_http_go_infra","ssh_config_read_go_infra","ssh_config_find_go_infra","docker_compose_remote_deploy_bash_infra"]

documentation:
  ## Estrategias de deploy

  Tres estrategias disponibles segun el tipo de app:

  | Estrategia | Flujo | Para que |
  |---|---|---|
  | `systemd` (default) | Build local + rsync + systemctl restart | Apps Go compiladas localmente y subidas al VPS |
  | `systemd-remote` | SSH: git pull + build remoto + systemctl restart | Apps Go cuyo source vive en el VPS (build in-situ) |
  | `docker-compose` | SSH: git pull + docker compose pull + up -d | Stacks Docker Compose en el VPS |

  ### systemd (build local + rsync)

  ```
  SSH check → build local (build_cmd en source_dir) → rsync al VPS
  → chmod +x binario → systemctl restart → health check
  ```

  ### systemd-remote (build remoto)

  ```
  SSH check → git pull origin <branch> en remote_dir
  → build remoto (build_cmd via SSH) → systemctl restart → health check
  ```

  ### docker-compose

  ```
  SSH check → git pull origin <branch> en remote_dir
  → docker compose [-f extra...] pull → docker compose up -d → health check
  ```

  ## Uso

  ```bash
  # Servidor (escucha webhooks y expone API)
  ./deploy_server serve --port 9090

  # CLI: gestionar targets de deploy
  ./deploy_server target add --app my_app --host produccion --port 8080 --health /api/health \
    --build "CGO_ENABLED=0 GOOS=linux go build -o my_app ." --strategy systemd
  ./deploy_server target list
  ./deploy_server target remove my_app

  # Target con strategy systemd-remote (build en el VPS)
  ./deploy_server target add --app agents_and_robots --host localhost \
    --remote-dir /home/ubuntu/CodeProyects/agents_and_robots \
    --binary launcher --build "bash build.sh" \
    --strategy systemd-remote --branch master

  # Target con strategy docker-compose
  ./deploy_server target add --app element_matrix_chat --host localhost \
    --remote-dir /home/ubuntu/CodeProyects/element_matrix_chat \
    --strategy docker-compose --branch master \
    --compose-files "docker-compose.livekit.yml"

  # CLI: deploy manual
  ./deploy_server deploy my_app                  # deploy a todos los hosts del target
  ./deploy_server deploy my_app --host produccion # deploy a un host específico

  # CLI: setup inicial de una app en un VPS
  ./deploy_server setup my_app --host produccion

  # CLI: estado de servicios remotos
  ./deploy_server status my_app       # systemd: systemctl status / docker-compose: docker compose ps
  ./deploy_server status --all
  ```

  ### Flags de target add

  | Flag | Default | Descripcion |
  |---|---|---|
  | `--app` | (requerido) | Nombre de la app (debe coincidir con el nombre del repo en Gitea para webhooks) |
  | `--host` | (requerido) | Alias SSH de ~/.ssh/config (o `localhost` si deploy_server corre en el mismo host) |
  | `--remote-dir` | /opt/apps/<app> | Directorio en el host remoto donde vive la app |
  | `--binary` | <app> | Nombre del binario (solo para strategies systemd/systemd-remote) |
  | `--build` | "" | Comando de build (local para systemd, remoto para systemd-remote) |
  | `--user` | "" | Usuario systemd del servicio |
  | `--port` | 0 | Puerto del servicio (para health checks) |
  | `--health` | "" | Path del health check (ej: /api/health) |
  | `--env` | {} | Variables de entorno como JSON |
  | `--strategy` | systemd | Estrategia: `systemd`, `systemd-remote`, `docker-compose` |
  | `--source-dir` | "" | Directorio local del source relativo al registry root (override de apps/<app>) |
  | `--branch` | main | Branch de git para strategies remotas (git pull origin <branch>) |
  | `--compose-files` | "" | Archivos compose adicionales separados por coma (ej: "docker-compose.livekit.yml") |

  ## API

  ```
  POST /webhook/push         — recibe push de Gitea, detecta app afectada, despliega
  GET  /api/targets          — lista todos los targets de deploy
  GET  /api/targets/:app     — detalle de un target
  POST /api/deploy/:app      — trigger manual de deploy
  GET  /api/status/:app      — estado del servicio remoto (systemctl status o docker compose ps)
  GET  /api/health           — health check del propio deploy_server
  GET  /api/logs             — ultimos 20 deploy logs (todas las apps)
  GET  /api/logs/:app        — ultimos 20 deploy logs de una app
  ```

  ## Webhook de Gitea

  El endpoint `POST /webhook/push` recibe payloads de Gitea en formato JSON. El matching funciona en dos pasos:

  1. **Por paths**: analiza los archivos modificados en los commits. Si algun archivo empieza con `apps/<nombre>/`, extrae `<nombre>` como app afectada.
  2. **Fallback por nombre de repo**: si no hay match por paths, compara `repository.name` del payload contra los targets registrados.

  El fallback es el mecanismo principal para repos externos (como agents_and_robots o element_matrix_chat) que no viven dentro de un monorepo con estructura `apps/`.

  ### Seguridad del webhook

  - Si `DEPLOY_WEBHOOK_SECRET` esta seteada, se valida el header `X-Gitea-Signature` (HMAC-SHA256).
  - Si no esta seteada, se acepta cualquier request (solo para desarrollo).
  - El secret debe coincidir exactamente entre la env var y la configuracion del webhook en Gitea.

  ### Configuracion en Gitea

  Para crear un webhook en Gitea que apunte al deploy_server:

  ```bash
  source bash/functions/infra/gitea_create_webhook.sh
  export GITEA_URL="https://tu-gitea.ejemplo.com"
  export GITEA_TOKEN="<token-api>"
  gitea_create_webhook "<owner>" "<repo>" "http://<ip>:9090/webhook/push" "<webhook_secret>"
  ```

  **Gitea en Docker**: si Gitea corre en un container Docker, `127.0.0.1` apunta al container, no al host. Usar la gateway de la red Docker del container de Gitea:

  ```bash
  # Encontrar la gateway
  docker inspect <gitea_container> --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}'
  # Resultado: 10.0.17.1 (ejemplo)
  # URL del webhook: http://10.0.17.1:9090/webhook/push
  ```

  **Gitea ALLOWED_HOST_LIST**: Gitea restringe por defecto las URLs de webhooks. Configurar en `app.ini`:

  ```ini
  [webhook]
  ALLOWED_HOST_LIST = *
  ```

  O listar las IPs/subredes permitidas. Reiniciar Gitea tras el cambio.

  **Firewall (UFW)**: si el host tiene UFW activo, permitir tráfico desde redes Docker al puerto del deploy_server:

  ```bash
  sudo ufw allow from 10.0.0.0/8 to any port 9090 comment 'deploy_server from Docker'
  ```

  ## Despliegue del propio deploy_server

  deploy_server usa `github.com/mattn/go-sqlite3` (CGO). Para compilar:

  ```bash
  # Local (Linux nativo o WSL)
  CGO_ENABLED=1 go build -o deploy_server .

  # Cross-compile para Linux desde otra plataforma
  CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o deploy_server_linux .
  ```

  ### Instalacion en un VPS

  ```bash
  # 1. Subir binario
  scp deploy_server_linux <host>:/home/ubuntu/CodeProyects/deploy_server/deploy_server

  # 2. Crear systemd unit
  sudo tee /etc/systemd/system/deploy_server.service << 'EOF'
  [Unit]
  Description=deploy_server CI/CD
  After=network.target

  [Service]
  Type=simple
  ExecStart=/home/ubuntu/CodeProyects/deploy_server/deploy_server serve --port 9090
  WorkingDirectory=/home/ubuntu/CodeProyects/deploy_server
  User=ubuntu
  Group=ubuntu
  Environment=DEPLOY_WEBHOOK_SECRET=<secret>
  Environment=PATH=/usr/local/go/bin:/usr/bin:/bin
  Restart=on-failure
  RestartSec=5

  [Install]
  WantedBy=multi-user.target
  EOF

  # 3. Activar
  sudo systemctl daemon-reload && sudo systemctl enable --now deploy_server

  # 4. Verificar
  curl -s http://127.0.0.1:9090/api/health
  ```

  ### SSH a localhost

  Cuando deploy_server y las apps estan en el mismo VPS, el deploy usa SSH a `localhost`. Requisitos:

  ```bash
  # Generar key si no existe
  ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
  # Aceptar host key
  ssh-keyscan localhost >> ~/.ssh/known_hosts
  # Verificar
  ssh -o BatchMode=yes localhost true
  ```

  ## Despliegue actual: organic-machine.com

  deploy_server corre en el VPS organic-machine.com como servicio systemd (`deploy_server.service`), puerto 9090.

  ### Targets registrados

  | App | Estrategia | Host | Remote dir | Branch | Build |
  |---|---|---|---|---|---|
  | agents_and_robots | systemd-remote | localhost | /home/ubuntu/CodeProyects/agents_and_robots | master | `bash build.sh` |
  | element_matrix_chat | docker-compose | localhost | /home/ubuntu/CodeProyects/element_matrix_chat | master | — |
  | registry_api | docker-compose | localhost | /opt/fn-registry-build/apps/registry_api | master | docker compose build+up |

  ### Webhooks activos

  Ambos repos en Gitea (`egutierrez/agents_and_robots`, `egutierrez/element_matrix_chat`) tienen webhooks push apuntando a `http://10.0.17.1:9090/webhook/push` (gateway de la red Docker de Gitea).

  ### Tiempos de deploy medidos

  | App | Trigger | Duracion |
  |---|---|---|
  | agents_and_robots | webhook | ~8.5s (git pull + tests + compile 4 binarios + restart) |
  | element_matrix_chat | webhook | ~2.7s (git pull + docker compose pull + up -d) |

  ### Servicios en el VPS

  | Servicio | Tipo | Estado |
  |---|---|---|
  | deploy_server | systemd (deploy_server.service) | enabled, active, :9090 |
  | agents_and_robots | systemd (agents_and_robots.service) | enabled, active (launcher) |
  | registry_api | Docker (registry-api container) | running, :8420, HTTPS via Traefik en registry.organic-machine.com |

  ### Infraestructura relevante

  - **Gitea**: corre en Docker (container `gitea-dgg044oo04woo4ggcsws4gk0`), red `10.0.17.0/24`, gateway `10.0.17.1`
  - **Coolify**: proxy principal en puertos 80/443/8080, gestiona servicios Docker
  - **UFW**: policy DROP, regla `allow from 10.0.0.0/8 to port 9090` para que Docker alcance deploy_server
  - **Webhook secret**: guardado en `pass agentes/deploy-webhook-secret`
  - **Gitea token**: guardado en `pass agentes/egutierrez-token`
  - **Registry API basicAuth**: guardado en `pass registry/basicauth-user` y `pass registry/basicauth-pass`
  - **Registry API token**: guardado en `pass registry/api-token`

  ## Registro en operations.db

  Dos tablas:

  - **deploy_targets** (PK: app + host): configuracion de cada target con strategy, branch, compose_files, etc.
  - **deploy_logs**: un registro por cada deploy con app, host, status (success/failure), trigger (manual/api/webhook), error, duration_ms, started_at.

notes:
  - Los hosts SSH se resuelven via `~/.ssh/config` — el campo `host` en el target es el alias SSH.
  - Para strategy `docker-compose`: usa `docker compose` (v2, sin guion). Verificar con `docker compose version`.
  - Para strategy `systemd-remote`: el `build_cmd` se ejecuta via SSH en el remote_dir, no localmente.
  - El webhook matching por `repository.name` permite que repos externos (no del monorepo) disparen deploys si el nombre del repo coincide con el nombre del target.
  - deploy_server no se auto-despliega. Actualizaciones: cross-compile local + scp + `sudo systemctl restart deploy_server`.


id: docker_tui_go_infra
name: docker_tui
lang: go
domain: infra
description: TUI interactiva para gestion de contenedores, imagenes, volumenes y redes Docker.
tags: ["docker","tui","bubbletea","containers"]
uses_types: []
framework: bubbletea
entry_point: main.go
dir_path: apps/docker_tui
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: fc380ff9c4f3fa460c6a8894b7a050d9dc3ea999aa17520b7f18f8be00812abf
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/docker_tui
uses_functions:
  ["docker_pull_image_go_infra","docker_list_containers_go_infra","docker_remove_container_go_infra","docker_stop_container_go_infra","docker_start_container_go_infra","docker_list_images_go_infra","docker_remove_image_go_infra","docker_remove_network_go_infra","docker_create_network_go_infra","docker_inspect_container_go_infra","docker_run_container_go_infra","docker_container_logs_go_infra"]

notes:
  Aplicacion TUI con pestanas para contenedores, imagenes, volumenes, redes y compose. Construida con Bubble Tea (Charmbracelet).


id: metabase_registry_py_analytics
name: metabase_registry
lang: py
domain: analytics
tags: ["metabase","dashboard","analytics","visualization","operations"]
uses_types: []
framework: httpx
entry_point: main.py
dir_path: apps/metabase_registry
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 49e8c15fbc68b6514e6b71a5ffff4895a41537686405b8890131f36f4bfa04bf
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/metabase_registry
description:
  Setup y dashboards automaticos de Metabase para visualizar metricas del fn-registry y operations.db de cada app.

uses_functions:
  ["metabase_auth_py_infra","metabase_create_card_py_infra","metabase_create_dashboard_py_infra","metabase_update_dashboard_py_infra","metabase_list_databases_py_infra","metabase_add_database_py_infra","metabase_list_dashboards_py_infra","metabase_delete_dashboard_py_infra","metabase_create_user_py_infra"]

documentation:
  ## Arquitectura

  Metabase corre en Docker (`fn_registry-metabase`) con Postgres como backend interno.
  Las bases de datos SQLite del proyecto se montan como bind mounts RW en `/data/`:

  | Database | Mount en container | Contenido |
  |----------|-------------------|-----------|
  | registry.db | `/data/registry/registry.db` | functions, types, proposals, apps |
  | ops-docker-tui | `/data/ops-docker-tui/operations.db` | entities, relations, executions |
  | ops-metabase-registry | `/data/ops-metabase-registry/operations.db` | entities, relations, executions |
  | ops-pipeline-launcher | `/data/ops-pipeline-launcher/operations.db` | entities, relations, executions |

  ## Dashboards

  | Dashboard | Contenido |
  |-----------|-----------|
  | fn-registry Overview | KPIs, distribucion y analisis del registry |
  | fn-registry Apps | Apps por lenguaje, dominio, dependencias |
  | ops: \<app-name\> | Dashboard operativo por app (entities, relations, executions, assertions) |

  ## Permisos SQLite en Docker

  Metabase corre Java como UID 2000 (usuario `metabase`). SQLite necesita crear journal/WAL
  files en el mismo directorio que la BD. Reglas:

  - **NUNCA** hacer `chown` dentro del container: se propaga al host via bind mount y rompe permisos locales.
  - **Usar `chmod`**: `chmod 777` en directorios, `chmod 666` en archivos `.db`.
  - **Pipeline automatico**: `./fn run metabase_fix_permissions` arregla todos los permisos.
  - **Ejecutar despues de**: recrear container, añadir nueva database, o ver error `SQLITE_READONLY_DIRECTORY`.

  ## Flujo para app nueva

  ```bash
  # 1. Crear operations.db
  ./fn ops init apps/nueva_app

  # 2. Recrear container con el nuevo mount
  docker stop fn_registry-metabase && docker rm fn_registry-metabase
  docker run -d \
    --name fn_registry-metabase \
    --network fn_registry-net \
    -p 3000:3000 \
    -e MB_DB_TYPE=postgres -e MB_DB_DBNAME=metabase \
    -e MB_DB_PORT=5432 -e MB_DB_USER=metabase \
    -e MB_DB_PASS=metabase -e MB_DB_HOST=fn_registry-postgres \
    -v /home/lucas/fn_registry:/registry:ro \
    -v /home/lucas/fn_registry/registry.db:/data/registry/registry.db \
    -v /home/lucas/fn_registry/apps/docker_tui:/data/ops-docker-tui \
    -v /home/lucas/fn_registry/apps/metabase_registry:/data/ops-metabase-registry \
    -v /home/lucas/fn_registry/apps/pipeline_launcher:/data/ops-pipeline-launcher \
    -v /home/lucas/fn_registry/apps/nueva_app:/data/ops-nueva-app \
    metabase/metabase:latest

  # 3. Fix permisos
  ./fn run metabase_fix_permissions

  # 4. Registrar database en Metabase
  ./fn run metabase_add_ops_db nueva_app

  # 5. Crear dashboard operativo
  ./fn run metabase_create_ops_dashboard nueva_app
  ```

  ## Scripts

  | Script | Funcion |
  |--------|---------|
  | `main.py` | Setup inicial: datasource + cards basicas + dashboard Overview |
  | `create_registry_dashboard.py` | Dashboard fn-registry Overview (18 cards) |
  | `create_apps_dashboard.py` | Dashboard fn-registry Apps (10 cards) |

  ## Pipelines relacionados

  | Pipeline | ID | Funcion |
  |----------|-----|---------|
  | `metabase_add_ops_db` | `metabase_add_ops_db_py_pipelines` | Registra operations.db de una app |
  | `metabase_create_ops_dashboard` | `metabase_create_ops_dashboard_py_pipelines` | Crea dashboard operativo para una app |
  | `metabase_fix_permissions` | `metabase_fix_permissions_py_pipelines` | Arregla SQLITE_READONLY_DIRECTORY |

  ## Credenciales

  En `.env` local (NO commitear).


id: pipeline_launcher_go_tools
name: pipeline_launcher
lang: go
domain: tools
description: TUI para lanzar y monitorear pipelines del fn-registry con historial de ejecuciones.
tags: ["pipeline","tui","bubbletea","runner","launcher"]
uses_functions: []
uses_types: []
framework: bubbletea
entry_point: main.go
dir_path: apps/pipeline_launcher
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 83544a964e6fdd49ce628466ffe96492fc5f157bd1d98a08ce4c58d9e08ff92c
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/pipeline_launcher
notes:
  Aplicacion TUI que lista pipelines con tag `launcher` del registry, permite ejecutarlos y muestra historial de ejecuciones desde operations.db.


id: registry_api_go_infra
name: registry_api
lang: go
domain: infra
tags: ["service","api","sync","registry","docker"]
uses_functions: []
uses_types: []
framework: net/http
entry_point: main.go
dir_path: apps/registry_api
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
content_hash: 73314b43b7f84131dff500636874ef997a8a3299e14f25705a6c0e1412f3313c
description:
  API HTTP para sincronizar registry.db entre PCs. Expone sync bidireccional, busqueda FTS5 y mapa de ubicaciones por maquina (pc_locations). Deploy via Docker + Traefik en Coolify.

documentation:
  ## URL produccion

  ```
  https://registry.organic-machine.com
  ```

  ## API

  ```
  POST /api/sync              — sync bidireccional (push+pull en un request)
  GET  /api/search?q=slice    — busqueda FTS5 en functions+types
  GET  /api/functions/:id     — detalle de funcion
  GET  /api/locations         — todas las ubicaciones por PC
  GET  /api/locations/:pc     — ubicaciones de un PC especifico
  POST /api/webhook/push      — trigger rebuild (Gitea webhook)
  GET  /api/status            — health check
  ```

  ### Parametros de busqueda

  ```
  GET /api/search?q=slice&lang=go&domain=core&kind=function&purity=pure
  ```

  ## Seguridad (dos capas)

  ### Capa 1: Traefik basicAuth

  Traefik intercepta TODOS los requests antes de llegar al container. Cualquier acceso requiere credenciales HTTP Basic Auth.

  ```bash
  # Desde curl
  curl -u lucas:mi-password https://registry.organic-machine.com/api/status

  # Desde fn sync (env vars)
  export FN_REGISTRY_API=https://lucas:mi-password@registry.organic-machine.com
  fn sync
  ```

  Config en: `/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml`
  Hash generado con: `htpasswd -nbB lucas <password>` (bcrypt). Para file provider usar `$` simple (NO `$$` — eso es solo para Docker labels).

  ### Capa 2: Token de aplicacion

  Para el endpoint POST /api/sync, un token adicional en el header X-Registry-Token.

  ```bash
  # Header: X-Registry-Token: <token>
  ```

  Si `REGISTRY_API_TOKEN` no esta seteada en el server, la capa de token se desactiva (solo protege basicAuth de Traefik).

  ### Credenciales en pass

  ```bash
  pass registry/basicauth-user   # usuario basicAuth
  pass registry/basicauth-pass   # password basicAuth
  pass registry/api-token         # token para /api/sync
  ```

  ### Configuracion en cada PC

  Añadir a `~/.bashrc` o `~/.zshrc`:

  ```bash
  export FN_REGISTRY_API="https://$(pass registry/basicauth-user):$(pass registry/basicauth-pass)@registry.organic-machine.com"
  export REGISTRY_API_TOKEN="$(pass registry/api-token)"
  ```

  O para cargar una vez por sesion:

  ```bash
  export FN_REGISTRY_API="https://lucas:$(pass registry/basicauth-pass)@registry.organic-machine.com"
  export REGISTRY_API_TOKEN="$(pass registry/api-token)"
  ```

  Y crear la identidad del PC:

  ```bash
  echo "home-wsl" > ~/.fn_pc       # En el PC de casa
  echo "work-laptop" > ~/.fn_pc    # En el del trabajo
  ```

  ## Build

  ### Local (binario)

  ```bash
  CGO_ENABLED=1 go build -tags fts5 -o registry_api .
  ./registry_api --port 8420 --db /path/to/registry.db
  ```

  ### Docker

  ```bash
  # Desde la raiz del repo (contexto necesario para registry/, functions/, cmd/)
  docker build -f apps/registry_api/Dockerfile -t registry-api .
  docker run -p 8420:8420 -v registry_data:/data registry-api
  ```

  El Dockerfile:
  1. Compila `registry_api` y `fn` con CGO+FTS5
  2. Ejecuta `fn index` para generar `registry.db` en build-time
  3. El entrypoint copia la BD al volumen si no existe

  ## Deploy a VPS (organic-machine.com)

  ### Setup inicial (una vez)

  ```bash
  bash bash/functions/infra/setup_registry_api.sh \
    organic-machine.com \
    "mi-token-secreto" \
    lucas \
    "mi-password"
  ```

  El pipeline `setup_registry_api_bash_infra` hace todo:
  1. Verifica SSH
  2. Genera hash bcrypt para Traefik
  3. rsync del repo al VPS
  4. Sube traefik-dynamic.yml a Coolify
  5. Crea .env con el token
  6. docker compose build + up
  7. Health check

  ### Re-deploy (actualizar)

  Ejecutar el mismo script — rsync es incremental y docker compose recrea si la imagen cambio.

  ### Arquitectura en el VPS

  ```
  Internet → Traefik (:443, TLS, basicAuth)
                → registry-api container (:8420)
                    → /data/registry.db (volumen Docker)
  ```

  - Container: `registry-api` en red Docker `coolify`
  - Volumen: `registry_data` montado en `/data`
  - Traefik: file provider, config en `/data/coolify/proxy/dynamic/`
  - TLS: Let's Encrypt via Traefik certResolver

  ## Uso diario

  ```bash
  # Sync desde cualquier PC
  fn sync                    # push+pull completo
  fn sync status             # estado local
  fn sync locations          # mapa de todos los PCs

  # Busqueda remota (util desde scripts o sin clonar el repo)
  curl -u lucas:pass "https://registry.organic-machine.com/api/search?q=metabase"

  # Ver ubicaciones de un PC
  curl -u lucas:pass "https://registry.organic-machine.com/api/locations/home-wsl"
  ```

notes:
  - Puerto 8420 (solo dentro de Docker, no expuesto publicamente — Traefik hace el proxy)
  - La BD se regenera en cada docker build. Para actualizar funciones: rebuild la imagen
  - `fn sync` maneja el merge bidireccional: gana el `updated_at` mas reciente
  - `pc_locations` se generan automaticamente escaneando los dirs locales


id: script_navegador_go_infra
name: script_navegador
lang: go
domain: infra
tags: ["cdp","chrome","browser","automation","yaml"]
uses_types: []
entry_point: main.go
dir_path: apps/script_navegador
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 8194a844f0ffd603c008ae7348913745943e262595c167bd9cc53be7497584e2
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/script_navegador
description:
  Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db.

uses_functions:
  ["chrome_launch_go_browser","cdp_connect_go_browser","cdp_navigate_go_browser","cdp_click_go_browser","cdp_type_text_go_browser","cdp_wait_element_go_browser","cdp_evaluate_go_browser","cdp_get_html_go_browser","cdp_screenshot_go_browser","cdp_close_go_browser"]

documentation:
  ## Descripcion

  CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.

  ## Uso

  ```bash
  # Conectarse a Chrome ya corriendo en puerto 9222
  go run . --script examples/busqueda_google.yaml

  # Lanzar Chrome nuevo (headless)
  go run . --script examples/busqueda_google.yaml --launch --headless

  # Puerto personalizado
  go run . --script examples/busqueda_google.yaml --port 9333
  ```

  ## Formato del script YAML

  ```yaml
  name: "nombre_del_script"
  steps:
    - action: navigate
      url: "https://ejemplo.com"

    - action: wait
      selector: "#elemento"
      timeout_ms: 5000           # opcional, default 10000

    - action: click
      selector: "#boton"
      continue_on_error: true    # opcional, default false

    - action: type
      selector: "input[name=q]"  # hace click primero para enfocar
      text: "texto a escribir"

    - action: screenshot
      path: "/tmp/captura.png"
      full_page: false           # opcional, default false

    - action: evaluate
      expr: "document.title"

    - action: get_html
      # sin parametros adicionales

    - action: sleep
      ms: 500                    # pausa en milisegundos
  ```

  ## Acciones soportadas

  | Accion      | Parametros obligatorios | Parametros opcionales         |
  |-------------|-------------------------|-------------------------------|
  | `navigate`  | `url`                   |                               |
  | `wait`      | `selector`              | `timeout_ms` (default 10000)  |
  | `click`     | `selector`              | `continue_on_error`           |
  | `type`      | `selector`, `text`      | `continue_on_error`           |
  | `screenshot`| `path`                  | `full_page`, `continue_on_error` |
  | `evaluate`  | `expr`                  | `continue_on_error`           |
  | `get_html`  | —                       | `continue_on_error`           |
  | `sleep`     | `ms`                    |                               |

  ## Registro en operations.db

  - **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
  - **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
  - **Logs**: un log por cada paso ejecutado con nivel info/error

notes:
  - Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
  - `continue_on_error: true` por paso permite continuar aunque ese paso falle
  - Flag global `--abort-on-error` (default false) aborta todo el script al primer error
  - Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
  - operations.db se inicializa automaticamente si no existe usando `fn ops init`


id: agents_and_robots_go_agents
name: agents_and_robots
lang: go
domain: agents
tags: ["agents","matrix","bots","llm","element","e2ee","tools","service"]
uses_functions: []
uses_types: []
framework: mautrix
entry_point: cmd/launcher/main.go
dir_path: projects/element_agents/apps/agents_and_robots
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 39e82e8341564fda8c4e07f376242350b7ac42177370be54417e7a2ad8cf2557
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/agents_and_robots.git
project_id: element_agents
description:
  Plataforma Go de bots autonomos Matrix con arquitectura pure core / impure shell. Launcher, agentctl, dashboard TUI y register.

documentation:
  ## Deploy

  - **VPS:** organic-machine.com (SSH alias: organic-machine.com)
  - **Remote path:** /home/ubuntu/CodeProyects/agents_and_robots
  - **Build:** `go build -tags goolm -ldflags="-s -w" -o bin/launcher ./cmd/launcher`
  - **Run:** `./bin/launcher --log-level info`
  - **Binarios:** launcher, agentctl, register, dashboard, verify (en bin/)
  - **Proceso:** launcher corre como proceso directo (no systemd)
  - **Config:** agents/*/config.yaml + .env con tokens Matrix, API keys LLM, pickle keys E2EE

notes:
  4 binarios principales:
  - **launcher** — Inicia agentes como goroutines, descubre configs, sync Matrix
  - **agentctl** — CLI: list, start, stop, remove agentes
  - **register** — Registra usuarios bot en Synapse via admin API
  - **dashboard** — TUI interactiva (bubbletea) para gestion de agentes

  Agentes desplegados: assistant-bot, asistente-2, meteorologo, wikipedia-bot, exchange-bot, reminder-bot, test-bot, father-bot (orchestrator).

  Dependencias clave: mautrix-go, go-openai, mcp-go, cobra, bubbletea, modernc/sqlite, zerolog.
  No requiere CGO (usa pure Go SQLite via modernc).


id: element_matrix_chat_bash_infra
name: element_matrix_chat
lang: bash
domain: infra
tags: ["matrix","element","synapse","docker","selfhosted","chat","livekit","service"]
uses_functions: []
uses_types: []
framework: docker-compose
entry_point: docker-compose.yml
dir_path: projects/element_agents/apps/element_matrix_chat
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: dd799b22b5a743628a1ddb6320938522049afdc127388b0458777042095e5b51
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/element_matrix_chat.git
project_id: element_agents
description:
  Stack completo Matrix autohosteado con Docker Compose: Synapse, Element Web, MAS, LiveKit, PostgreSQL, Synapse Admin.

documentation:
  ## Deploy

  - **VPS:** organic-machine.com (SSH alias: organic-machine.com)
  - **Remote path:** /home/ubuntu/CodeProyects/element_matrix_chat
  - **Deploy:** `docker-compose up -d` (+ `docker-compose -f docker-compose.livekit.yml up -d`)
  - **Setup:** `./scripts/setup.sh`
  - **Puertos:** Element 8081, Synapse Admin 8082, MAS 8083, LiveKit 7880-7882
  - **Config:** .env + configs/homeserver.yaml.template + element-config.json + mas/config.yaml

notes:
  Stack de 10+ containers Docker:
  - **Synapse** — Homeserver Matrix (Python)
  - **Element Web** — Cliente web React
  - **MAS** — Matrix Authentication Service (OIDC/OAuth)
  - **LiveKit** — SFU para videollamadas (Element Call)
  - **PostgreSQL x2** — BDs para Synapse y MAS
  - **Synapse Admin** — Panel de administracion
  - **Nginx** — Reverse proxy para .well-known
  - **LiveKit JWT** — Generador de tokens para RTC

  Red Docker: matrix_net (10.10.10.0/24), gestionada externamente.
  Coolify maneja el proxy principal (puertos 80/443).


id: registry_dashboard_cpp_tui
name: registry_dashboard
lang: cpp
domain: tui
tags: ["dashboard","imgui","visualization","registry","http"]
uses_types: []
framework: imgui
entry_point: main.cpp
dir_path: projects/fn_monitoring/apps/registry_dashboard
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 3dedfc411d841a23336740af0b561b4787df99afe40e10ff5abea2d666428c1d
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/registry_dashboard
project_id: fn_monitoring
description:
  Dashboard ImGui para visualizar el estado del fn_registry. Consume datos via sqlite_api HTTP (fallback a SQLite directo). KPIs, charts, tablas, desglose por lenguaje/dominio/pureza.

uses_functions:
  ["kpi_card_cpp_viz","bar_chart_cpp_viz","pie_chart_cpp_viz","table_view_cpp_viz","sparkline_cpp_viz","dashboard_panel_cpp_core","dashboard_grid_cpp_core","fps_overlay_cpp_core","fullscreen_window_cpp_core"]

documentation:
  ## Arquitectura

  Dashboard C++ con dos modos de acceso a datos:

  1. **HTTP API** (primario): Conecta a `sqlite_api` via HTTP para obtener datos de registry.db. No requiere acceso al filesystem.
  2. **SQLite directo** (fallback): Lee registry.db directamente si la API no esta disponible.

  **Data layers:**
  - `data_http.cpp`: Carga datos via HTTP POST a sqlite_api (cpp-httplib + nlohmann/json)
  - `data.cpp`: Carga directa desde SQLite C API

  **Views** (`views.cpp`): Compone funciones del registry C++ para renderizar:
  - 8 KPI cards: functions, types, apps, analysis, unit tests, proposals, tested%, pure%
  - Bar charts: funciones por lenguaje, por dominio
  - Pie charts: pureza (pure/impure), kind (function/pipeline/component)
  - Tablas: ultimas 20 funciones, apps, analysis, tipos

  ## Build

  ```bash
  # Linux
  cd cpp && cmake -B build/linux -S . && cmake --build build/linux --target registry_dashboard -j$(nproc)

  # Windows (cross-compile)
  cd cpp && cmake -B build/windows -S . -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w64.cmake && cmake --build build/windows --target registry_dashboard -j$(nproc)
  ```

  ## Ejecucion

  ```bash
  # Via API (default, intenta conectar a localhost:8484)
  ./registry_dashboard

  # API explicita
  ./registry_dashboard --api http://192.168.1.10:8484

  # Con SQLite fallback
  ./registry_dashboard --api http://127.0.0.1:8484 /path/to/registry.db

  # Solo SQLite (sin API)
  ./registry_dashboard /path/to/registry.db

  # Windows (PowerShell)
  .\registry_dashboard.ps1
  ```

  ## Dependencias vendored

  | Libreria | Version | Archivo |
  |----------|---------|---------|
  | cpp-httplib | v0.18.3 | vendor/httplib.h |
  | nlohmann/json | v3.11.3 | vendor/nlohmann/json.hpp |

  ## Roadmap

  - [ ] Filtros interactivos por lenguaje/dominio en sidebar
  - [ ] Busqueda FTS5 integrada via API
  - [ ] Detalles de funcion al hacer click en tabla

notes:
  - Por defecto intenta conectar a sqlite_api en `http://127.0.0.1:8484`. Si falla, usa SQLite directo.
  - SQLite compilado estaticamente en Windows via amalgamation vendoreada. En Linux usa libsqlite3 del sistema.
  - cpp-httplib usa sockets nativos (no OpenSSL) — solo HTTP, no HTTPS.


id: sqlite_api_go_infra
name: sqlite_api
lang: go
domain: infra
tags: ["service","api","sqlite","http","registry","fts5"]
uses_functions: []
uses_types: []
framework: net/http
entry_point: main.go
dir_path: projects/fn_monitoring/apps/sqlite_api
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
content_hash: 301fd71f8ef62accb5797fa24be76aa4791fb9c28c333e7211990df0372ec64c
project_id: fn_monitoring
description:
  API REST HTTP read-only sobre registry.db y operations.db de cada app. Permite consultas SQL (solo SELECT/PRAGMA), busqueda FTS5, exploracion de tablas y schema. Bind por defecto a localhost:8484.

documentation:
  ## Uso

  ```bash
  # Arrancar (default: 127.0.0.1:8484)
  cd apps/sqlite_api && go run -tags fts5 .

  # Bind personalizado
  go run -tags fts5 . --bind 0.0.0.0:8484
  ```

  ## Endpoints

  | Metodo | Path | Descripcion |
  |--------|------|-------------|
  | GET | `/health` | Health check |
  | GET | `/api/databases` | Lista DBs disponibles (registry + ops:*) |
  | GET | `/api/databases/:db/tables` | Tablas y vistas de una DB |
  | GET | `/api/databases/:db/schema` | Schema SQL completo |
  | POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
  | GET | `/api/databases/:db/fts?q=...&table=...` | Busqueda FTS5 directa |

  ## Seguridad

  - Solo queries SELECT, PRAGMA, WITH y EXPLAIN
  - SQLite abierto con `?mode=ro` (read-only a nivel driver)
  - Timeout de 5 segundos por query
  - Bind a localhost por defecto
  - CORS habilitado para acceso desde frontends

  ## Bases de datos

  - `registry` — registry.db de la raiz
  - `ops:{app}` — operations.db de apps/{app}/ y projects/*/apps/{app}/

  Auto-descubre operations.db al arrancar escaneando apps/ y projects/*/apps/.

  ## Health check

  ```bash
  curl http://localhost:8484/health
  # {"status":"ok"}
  ```

  ## Puerto

  8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090).


# analysis

id: finanzas_personales_py_finance
name: finanzas_personales
lang: py
domain: finance
description: Exploracion y analisis de finanzas personales con Jupyter notebooks.
tags: ["finanzas","jupyter","pandas","matplotlib"]
uses_functions: []
uses_types: []
framework: jupyterlab
entry_point: notebooks/
repo_url: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/finanzas_personales
dir_path: analysis/finanzas_personales
content_hash: 0c5339b48ec7ec8294308c8e5c100483e15aa16a36580ef1d605818c6775a69a
created_at: 2026-04-15T00:01:11Z
updated_at: 2026-04-15T00:01:11Z
notes:
  Analisis independiente de finanzas personales usando Jupyter Lab con pandas, numpy y matplotlib.
  Incluye kernel startup con acceso al registry y gestion de dependencias con uv.


id: turismo_spain_py_datascience
name: turismo_spain
lang: py
domain: datascience
description: Analisis de turismo extranjero en ciudades de Espana con datos INE FRONTUR/EGATUR y Turespaña.
tags: ["turismo","spain","INE","FRONTUR","EGATUR"]
uses_functions: []
uses_types: []
framework: jupyterlab
entry_point: notebooks/01_turismo_extranjero.ipynb
dir_path: projects/app_turismo/analysis/turismo_spain
content_hash: 8ded68274d8a46d3850f6570b41fd388fc89311dfe7da00d444a2c78e359b8eb
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
project_id: app_turismo
notes:
  Datos fuente:
  - INE tabla 2078: viajeros por puntos turisticos (138 ciudades, 2005-2026)
  - INE tabla 2074: viajeros por provincia
  - Dataset FRONTUR/EGATUR/Turespaña 2025: 16 CSVs con paises emisores, gasto, satisfaccion, motivaciones

  Vault asociado: `turismo_spain` en `/home/lucas/vaults/turismo_spain`


# unit_tests

id: cdp_connect_go_browser_t0
function_id: cdp_connect_go_browser
name: TestFindChrome
file_path: functions/browser/chrome_launch_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFindChrome(t *testing.T) {
  	path, err := findChrome()
  	if err != nil {
  		t.Skipf("Chrome no encontrado (entorno sin Chrome): %v", err)
  	}
  	if path == "" {
  		t.Error("findChrome retorno path vacio")
  	}
  	t.Logf("Chrome encontrado en: %s", path)
  }

  // TestChromeLaunchAndConnect lanza Chrome, conecta CDP, navega a about:blank y cierra.


id: cdp_connect_go_browser_t1
function_id: cdp_connect_go_browser
name: TestChromeLaunchAndConnect
file_path: functions/browser/chrome_launch_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestChromeLaunchAndConnect(t *testing.T) {
  	if testing.Short() {
  		t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
  	}

  	// Verificar que Chrome esta disponible
  	if _, err := findChrome(); err != nil {
  		t.Skipf("Chrome no disponible: %v", err)
  	}

  	opts := ChromeLaunchOpts{
  		Port:        9223, // puerto alternativo para no interferir con sesiones existentes
  		UserDataDir: t.TempDir(),
  		Headless:    true,
  	}

  	pid, err := ChromeLaunch(opts)
  	if err != nil {
  		t.Fatalf("ChromeLaunch: %v", err)
  	}
  	t.Logf("Chrome lanzado con PID %d en puerto %d", pid, opts.Port)

  	defer func() {
  		if err := CdpClose(nil, pid); err != nil {
  			t.Logf("CdpClose (pid only): %v", err)
  		}
  	}()

  	// Conectar CDP
  	conn, err := CdpConnect(opts.Port)
  	if err != nil {
  		t.Fatalf("CdpConnect: %v", err)
  	}
  	defer func() {
  		if err := CdpClose(conn, 0); err != nil {
  			t.Logf("CdpClose (conn): %v", err)
  		}
  	}()

  	// Navegar a about:blank
  	if err := CdpNavigate(conn, "about:blank"); err != nil {
  		t.Fatalf("CdpNavigate about:blank: %v", err)
  	}
  	t.Log("Navegacion a about:blank exitosa")
  }

  // TestCdpEvaluate ejecuta JS simple en Chrome y verifica el resultado.


id: cdp_connect_go_browser_t2
function_id: cdp_connect_go_browser
name: TestCdpEvaluate
file_path: functions/browser/chrome_launch_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCdpEvaluate(t *testing.T) {
  	if testing.Short() {
  		t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
  	}

  	if _, err := findChrome(); err != nil {
  		t.Skipf("Chrome no disponible: %v", err)
  	}

  	opts := ChromeLaunchOpts{
  		Port:        9224,
  		UserDataDir: t.TempDir(),
  		Headless:    true,
  	}

  	pid, err := ChromeLaunch(opts)
  	if err != nil {
  		t.Fatalf("ChromeLaunch: %v", err)
  	}
  	defer CdpClose(nil, pid)

  	conn, err := CdpConnect(opts.Port)
  	if err != nil {
  		t.Fatalf("CdpConnect: %v", err)
  	}
  	defer CdpClose(conn, 0)

  	if err := CdpNavigate(conn, "about:blank"); err != nil {
  		t.Fatalf("CdpNavigate: %v", err)
  	}

  	t.Run("expresion aritmetica simple", func(t *testing.T) {
  		result, err := CdpEvaluate(conn, "1 + 2")
  		if err != nil {
  			t.Fatalf("CdpEvaluate: %v", err)
  		}
  		if result != "3" {
  			t.Errorf("esperado '3', got %q", result)
  		}
  	})

  	t.Run("string literal", func(t *testing.T) {
  		result, err := CdpEvaluate(conn, `"hola mundo"`)
  		if err != nil {
  			t.Fatalf("CdpEvaluate: %v", err)
  		}
  		if result != "hola mundo" {
  			t.Errorf("esperado 'hola mundo', got %q", result)
  		}
  	})

  	t.Run("typeof window", func(t *testing.T) {
  		result, err := CdpEvaluate(conn, "typeof window")
  		if err != nil {
  			t.Fatalf("CdpEvaluate: %v", err)
  		}
  		if result != "object" {
  			t.Errorf("esperado 'object', got %q", result)
  		}
  	})
  }

  // TestCdpGetHTML obtiene el HTML de about:blank y verifica que contiene elementos basicos.


id: cdp_connect_go_browser_t3
function_id: cdp_connect_go_browser
name: TestCdpGetHTML
file_path: functions/browser/chrome_launch_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCdpGetHTML(t *testing.T) {
  	if testing.Short() {
  		t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
  	}

  	if _, err := findChrome(); err != nil {
  		t.Skipf("Chrome no disponible: %v", err)
  	}

  	opts := ChromeLaunchOpts{
  		Port:        9225,
  		UserDataDir: t.TempDir(),
  		Headless:    true,
  	}

  	pid, err := ChromeLaunch(opts)
  	if err != nil {
  		t.Fatalf("ChromeLaunch: %v", err)
  	}
  	defer CdpClose(nil, pid)

  	conn, err := CdpConnect(opts.Port)
  	if err != nil {
  		t.Fatalf("CdpConnect: %v", err)
  	}
  	defer CdpClose(conn, 0)

  	if err := CdpNavigate(conn, "about:blank"); err != nil {
  		t.Fatalf("CdpNavigate: %v", err)
  	}

  	t.Run("html de about blank contiene html y body", func(t *testing.T) {
  		html, err := CdpGetHTML(conn)
  		if err != nil {
  			t.Fatalf("CdpGetHTML: %v", err)
  		}
  		lower := strings.ToLower(html)
  		if !strings.Contains(lower, "<html") {
  			t.Errorf("HTML no contiene tag <html>: %q", html[:min(200, len(html))])
  		}
  		if !strings.Contains(lower, "<body") {
  			t.Errorf("HTML no contiene tag <body>: %q", html[:min(200, len(html))])
  		}
  		t.Logf("HTML (primeros 200 chars): %s", html[:min(200, len(html))])
  	})
  }

  // TestCdpScreenshot toma un screenshot de about:blank y verifica que se crea el archivo PNG.


id: cdp_connect_go_browser_t4
function_id: cdp_connect_go_browser
name: TestCdpScreenshot
file_path: functions/browser/chrome_launch_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCdpScreenshot(t *testing.T) {
  	if testing.Short() {
  		t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
  	}

  	if _, err := findChrome(); err != nil {
  		t.Skipf("Chrome no disponible: %v", err)
  	}

  	opts := ChromeLaunchOpts{
  		Port:        9226,
  		UserDataDir: t.TempDir(),
  		Headless:    true,
  	}

  	pid, err := ChromeLaunch(opts)
  	if err != nil {
  		t.Fatalf("ChromeLaunch: %v", err)
  	}
  	defer CdpClose(nil, pid)

  	conn, err := CdpConnect(opts.Port)
  	if err != nil {
  		t.Fatalf("CdpConnect: %v", err)
  	}
  	defer CdpClose(conn, 0)

  	if err := CdpNavigate(conn, "about:blank"); err != nil {
  		t.Fatalf("CdpNavigate: %v", err)
  	}

  	// Esperar un momento para que la pagina cargue
  	time.Sleep(500 * time.Millisecond)

  	t.Run("screenshot png se crea correctamente", func(t *testing.T) {
  		outputPath := t.TempDir() + "/screenshot.png"
  		err := CdpScreenshot(conn, outputPath, CdpScreenshotOpts{FullPage: false})
  		if err != nil {
  			t.Fatalf("CdpScreenshot: %v", err)
  		}

  		info, err := os.Stat(outputPath)
  		if err != nil {
  			t.Fatalf("screenshot no encontrado: %v", err)
  		}
  		if info.Size() == 0 {
  			t.Error("screenshot vacio (0 bytes)")
  		}
  		t.Logf("Screenshot creado: %s (%d bytes)", outputPath, info.Size())
  	})
  }


id: assert_contains_all_go_core_t0
function_id: assert_contains_all_go_core
name: TestAssertContainsAll
file_path: functions/core/assert_contains_all_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestAssertContainsAll(t *testing.T) {
  	t.Run("todos presentes retorna true y nil", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{"a", "b", "c"}, []string{"a", "b"})
  		if !ok || missing != nil {
  			t.Errorf("got ok=%v missing=%v, want ok=true missing=nil", ok, missing)
  		}
  	})

  	t.Run("un elemento faltante retorna false con el faltante", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{"a", "b"}, []string{"a", "c"})
  		if ok {
  			t.Error("expected ok=false")
  		}
  		if len(missing) != 1 || missing[0] != "c" {
  			t.Errorf("got missing=%v, want [c]", missing)
  		}
  	})

  	t.Run("multiples faltantes retornan todos en missing", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{"a"}, []string{"b", "c", "d"})
  		if ok {
  			t.Error("expected ok=false")
  		}
  		if len(missing) != 3 {
  			t.Errorf("got missing=%v, want [b c d]", missing)
  		}
  	})

  	t.Run("needles vacio retorna true", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{"a", "b"}, []string{})
  		if !ok || missing != nil {
  			t.Errorf("got ok=%v missing=%v, want ok=true missing=nil", ok, missing)
  		}
  	})

  	t.Run("haystack vacio con needles no vacios retorna todos como faltantes", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{}, []string{"x", "y"})
  		if ok {
  			t.Error("expected ok=false")
  		}
  		if len(missing) != 2 {
  			t.Errorf("got missing=%v, want [x y]", missing)
  		}
  	})

  	t.Run("duplicados en haystack no afectan el resultado", func(t *testing.T) {
  		ok, missing := AssertContainsAll([]string{"a", "a", "b", "b"}, []string{"a", "b"})
  		if !ok || missing != nil {
  			t.Errorf("got ok=%v missing=%v, want ok=true missing=nil", ok, missing)
  		}
  	})
  }


id: assert_json_equal_go_core_t0
function_id: assert_json_equal_go_core
name: TestAssertJSONEqual
file_path: functions/core/assert_json_equal_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestAssertJSONEqual(t *testing.T) {
  	t.Run("json identico retorna true y diff vacio", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`{"a":1}`), []byte(`{"a":1}`))
  		if !ok || diff != "" {
  			t.Errorf("got ok=%v diff=%q, want ok=true diff=\"\"", ok, diff)
  		}
  	})

  	t.Run("objetos con claves en distinto orden son iguales", func(t *testing.T) {
  		ok, diff := AssertJSONEqual(
  			[]byte(`{"a":1,"b":2}`),
  			[]byte(`{"b":2,"a":1}`),
  		)
  		if !ok || diff != "" {
  			t.Errorf("got ok=%v diff=%q, want ok=true diff=\"\"", ok, diff)
  		}
  	})

  	t.Run("valores distintos retorna false con diff", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`{"a":1}`), []byte(`{"a":2}`))
  		if ok {
  			t.Error("expected ok=false for different values")
  		}
  		if diff == "" {
  			t.Error("expected non-empty diff")
  		}
  	})

  	t.Run("json invalido en expected retorna false con mensaje de error", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`{not json}`), []byte(`{"a":1}`))
  		if ok {
  			t.Error("expected ok=false for invalid expected JSON")
  		}
  		if diff == "" {
  			t.Error("expected error message in diff")
  		}
  	})

  	t.Run("json invalido en actual retorna false con mensaje de error", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`{"a":1}`), []byte(`{not json}`))
  		if ok {
  			t.Error("expected ok=false for invalid actual JSON")
  		}
  		if diff == "" {
  			t.Error("expected error message in diff")
  		}
  	})

  	t.Run("arrays con mismo contenido son iguales", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`[1,2,3]`), []byte(`[1,2,3]`))
  		if !ok || diff != "" {
  			t.Errorf("got ok=%v diff=%q, want ok=true diff=\"\"", ok, diff)
  		}
  	})

  	t.Run("arrays con distinto orden son distintos", func(t *testing.T) {
  		ok, diff := AssertJSONEqual([]byte(`[1,2,3]`), []byte(`[3,2,1]`))
  		if ok {
  			t.Error("expected ok=false for different array order")
  		}
  		if diff == "" {
  			t.Error("expected non-empty diff for different arrays")
  		}
  	})
  }


id: cron_match_go_core_t0
function_id: cron_match_go_core
name: TestCronMatch
file_path: functions/core/cron_match_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCronMatch(t *testing.T) {
  	t.Run("9:00 AM coincide con 0 9 * * *", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * *")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		ts := time.Date(2026, 4, 11, 9, 0, 0, 0, time.UTC)
  		if !CronMatch(sched, ts) {
  			t.Errorf("expected match for 9:00 AM with '0 9 * * *'")
  		}
  	})

  	t.Run("9:15 AM NO coincide con 0 9 * * *", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * *")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		ts := time.Date(2026, 4, 11, 9, 15, 0, 0, time.UTC)
  		if CronMatch(sched, ts) {
  			t.Errorf("expected no match for 9:15 AM with '0 9 * * *'")
  		}
  	})

  	t.Run("lunes a las 9 coincide con 0 9 * * 1", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * 1")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		// 2026-04-13 is a Monday
  		ts := time.Date(2026, 4, 13, 9, 0, 0, 0, time.UTC)
  		if !CronMatch(sched, ts) {
  			t.Errorf("expected match for Monday 9:00 with '0 9 * * 1'")
  		}
  	})

  	t.Run("domingo a las 9 NO coincide con 0 9 * * 1", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * 1")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		// 2026-04-12 is a Sunday (weekday 0)
  		ts := time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC)
  		if CronMatch(sched, ts) {
  			t.Errorf("expected no match for Sunday 9:00 with '0 9 * * 1'")
  		}
  	})

  	t.Run("wildcard * coincide con cualquier valor", func(t *testing.T) {
  		sched, err := ParseCronExpr("*/15 * * * *")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		// 9:30 should match */15 (0,15,30,45)
  		ts := time.Date(2026, 4, 11, 9, 30, 0, 0, time.UTC)
  		if !CronMatch(sched, ts) {
  			t.Errorf("expected match for 9:30 with '*/15 * * * *'")
  		}
  		// 9:07 should NOT match */15
  		ts2 := time.Date(2026, 4, 11, 9, 7, 0, 0, time.UTC)
  		if CronMatch(sched, ts2) {
  			t.Errorf("expected no match for 9:07 with '*/15 * * * *'")
  		}
  	})

  	t.Run("specific month", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 0 1 4 *")
  		if err != nil {
  			t.Fatalf("ParseCronExpr: %v", err)
  		}
  		// April 1st matches
  		ts := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
  		if !CronMatch(sched, ts) {
  			t.Errorf("expected match for April 1 with '0 0 1 4 *'")
  		}
  		// March 1st does NOT match
  		ts2 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  		if CronMatch(sched, ts2) {
  			t.Errorf("expected no match for March 1 with '0 0 1 4 *'")
  		}
  	})
  }


id: dag_parse_go_core_t0
function_id: dag_parse_go_core
name: TestDagParse
file_path: functions/core/dag_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDagParse(t *testing.T) {
  	t.Run("parsea DAG simple con steps y depends", func(t *testing.T) {
  		data := []byte(`
  name: example
  description: Example workflow
  steps:
    - name: hello
      command: echo "Hello!"
    - name: list_files
      command: ls -la /tmp
      depends:
        - hello
  `)
  		dag, err := DagParse(data)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if dag.Name != "example" {
  			t.Errorf("Name: got %q, want %q", dag.Name, "example")
  		}
  		if len(dag.Steps) != 2 {
  			t.Fatalf("Steps: got %d, want 2", len(dag.Steps))
  		}
  		if dag.Steps[0].Name != "hello" {
  			t.Errorf("Steps[0].Name: got %q, want %q", dag.Steps[0].Name, "hello")
  		}
  		if dag.Steps[1].Name != "list_files" {
  			t.Errorf("Steps[1].Name: got %q, want %q", dag.Steps[1].Name, "list_files")
  		}
  		if len(dag.Steps[1].Depends) != 1 || dag.Steps[1].Depends[0] != "hello" {
  			t.Errorf("Steps[1].Depends: got %v, want [hello]", dag.Steps[1].Depends)
  		}
  	})

  	t.Run("parsea schedule como string y como lista", func(t *testing.T) {
  		// Schedule as string.
  		dataStr := []byte(`
  name: dag-string-schedule
  schedule: "0 9 * * 5"
  steps:
    - name: step1
      command: echo ok
  `)
  		dagStr, err := DagParse(dataStr)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(dagStr.Schedule) != 1 || dagStr.Schedule[0] != "0 9 * * 5" {
  			t.Errorf("Schedule (string): got %v, want [\"0 9 * * 5\"]", dagStr.Schedule)
  		}

  		// Schedule as list.
  		dataList := []byte(`
  name: dag-list-schedule
  schedule:
    - "0 9 * * *"
    - "0 18 * * *"
  steps:
    - name: step1
      command: echo ok
  `)
  		dagList, err := DagParse(dataList)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(dagList.Schedule) != 2 {
  			t.Fatalf("Schedule (list): got %d items, want 2", len(dagList.Schedule))
  		}
  		if dagList.Schedule[0] != "0 9 * * *" {
  			t.Errorf("Schedule[0]: got %q, want %q", dagList.Schedule[0], "0 9 * * *")
  		}
  		if dagList.Schedule[1] != "0 18 * * *" {
  			t.Errorf("Schedule[1]: got %q, want %q", dagList.Schedule[1], "0 18 * * *")
  		}
  	})

  	t.Run("parsea env en formato lista de maps", func(t *testing.T) {
  		data := []byte(`
  name: dag-env
  env:
    - PROJECT_DIR: /home/lucas/analysis
    - PYTHON: /home/lucas/.venv/bin/python
  steps:
    - name: step1
      command: echo ${PROJECT_DIR}
  `)
  		dag, err := DagParse(data)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if dag.Env["PROJECT_DIR"] != "/home/lucas/analysis" {
  			t.Errorf("Env[PROJECT_DIR]: got %q, want %q", dag.Env["PROJECT_DIR"], "/home/lucas/analysis")
  		}
  		if dag.Env["PYTHON"] != "/home/lucas/.venv/bin/python" {
  			t.Errorf("Env[PYTHON]: got %q, want %q", dag.Env["PYTHON"], "/home/lucas/.venv/bin/python")
  		}
  	})

  	t.Run("parsea handler_on y handlers como alias", func(t *testing.T) {
  		// handler_on with single step object.
  		dataHandlerOn := []byte(`
  name: dag-handler-on
  handler_on:
    failure:
      command: echo "FALLO"
  steps:
    - name: step1
      command: echo hello
  `)
  		dagHO, err := DagParse(dataHandlerOn)
  		if err != nil {
  			t.Fatalf("unexpected error (handler_on): %v", err)
  		}
  		if len(dagHO.HandlerOn.Failure) != 1 {
  			t.Fatalf("HandlerOn.Failure: got %d steps, want 1", len(dagHO.HandlerOn.Failure))
  		}
  		if dagHO.HandlerOn.Failure[0].Command != `echo "FALLO"` {
  			t.Errorf("HandlerOn.Failure[0].Command: got %q", dagHO.HandlerOn.Failure[0].Command)
  		}

  		// handlers alias with list of steps.
  		dataHandlers := []byte(`
  name: dag-handlers
  handlers:
    failure:
      - name: mark_as_failed
        command: echo "FAILED"
    success:
      - name: notify
        command: echo "OK"
  steps:
    - name: step1
      command: echo hello
  `)
  		dagH, err := DagParse(dataHandlers)
  		if err != nil {
  			t.Fatalf("unexpected error (handlers): %v", err)
  		}
  		if len(dagH.HandlerOn.Failure) != 1 || dagH.HandlerOn.Failure[0].Name != "mark_as_failed" {
  			t.Errorf("HandlerOn.Failure: got %v", dagH.HandlerOn.Failure)
  		}
  		if len(dagH.HandlerOn.Success) != 1 || dagH.HandlerOn.Success[0].Name != "notify" {
  			t.Errorf("HandlerOn.Success: got %v", dagH.HandlerOn.Success)
  		}
  	})

  	t.Run("parsea continue_on y working_dir a nivel step", func(t *testing.T) {
  		data := []byte(`
  name: dag-step-options
  steps:
    - id: ingest
      description: Procesar archivos
      working_dir: /home/lucas/project
      command: ./bin/ingest
      continue_on:
        failure: true
    - id: informe
      command: python /tmp/script.py
      depends: [ingest]
  `)
  		dag, err := DagParse(data)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(dag.Steps) != 2 {
  			t.Fatalf("Steps: got %d, want 2", len(dag.Steps))
  		}
  		step0 := dag.Steps[0]
  		if step0.ID != "ingest" {
  			t.Errorf("Steps[0].ID: got %q, want %q", step0.ID, "ingest")
  		}
  		if step0.Dir != "/home/lucas/project" {
  			t.Errorf("Steps[0].Dir: got %q, want %q", step0.Dir, "/home/lucas/project")
  		}
  		if !step0.ContinueOn.Failure {
  			t.Errorf("Steps[0].ContinueOn.Failure: got false, want true")
  		}
  		step1 := dag.Steps[1]
  		if len(step1.Depends) != 1 || step1.Depends[0] != "ingest" {
  			t.Errorf("Steps[1].Depends: got %v, want [ingest]", step1.Depends)
  		}
  	})

  	t.Run("parsea type graph", func(t *testing.T) {
  		data := []byte(`
  name: dag-graph
  type: graph
  tags: [finanzas, semanal]
  steps:
    - name: step1
      command: echo a
    - name: step2
      command: echo b
      depends: [step1]
  `)
  		dag, err := DagParse(data)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if dag.Type != "graph" {
  			t.Errorf("Type: got %q, want %q", dag.Type, "graph")
  		}
  		if len(dag.Tags) != 2 {
  			t.Errorf("Tags: got %v, want [finanzas semanal]", dag.Tags)
  		}
  	})
  }


id: dag_topo_sort_go_core_t0
function_id: dag_topo_sort_go_core
name: TestDagTopoSort
file_path: functions/core/dag_topo_sort_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDagTopoSort(t *testing.T) {
  	t.Run("cadena lineal A->B->C produce tres niveles", func(t *testing.T) {
  		steps := []DagStep{
  			{Name: "a"},
  			{Name: "b", Depends: []string{"a"}},
  			{Name: "c", Depends: []string{"b"}},
  		}
  		levels, err := DagTopoSort(steps)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(levels) != 3 {
  			t.Fatalf("levels: got %d, want 3", len(levels))
  		}
  		if len(levels[0]) != 1 || levels[0][0].Name != "a" {
  			t.Errorf("levels[0]: got %v, want [a]", stepNames(levels[0]))
  		}
  		if len(levels[1]) != 1 || levels[1][0].Name != "b" {
  			t.Errorf("levels[1]: got %v, want [b]", stepNames(levels[1]))
  		}
  		if len(levels[2]) != 1 || levels[2][0].Name != "c" {
  			t.Errorf("levels[2]: got %v, want [c]", stepNames(levels[2]))
  		}
  	})

  	t.Run("diamond A->B A->C B->D C->D produce cuatro niveles", func(t *testing.T) {
  		steps := []DagStep{
  			{Name: "a"},
  			{Name: "b", Depends: []string{"a"}},
  			{Name: "c", Depends: []string{"a"}},
  			{Name: "d", Depends: []string{"b", "c"}},
  		}
  		levels, err := DagTopoSort(steps)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		// Expected: level0=[a], level1=[b,c], level2=[d]
  		if len(levels) != 3 {
  			t.Fatalf("levels: got %d, want 3", len(levels))
  		}
  		if len(levels[0]) != 1 || levels[0][0].Name != "a" {
  			t.Errorf("levels[0]: got %v, want [a]", stepNames(levels[0]))
  		}
  		if len(levels[1]) != 2 {
  			t.Errorf("levels[1]: got %v, want 2 steps", stepNames(levels[1]))
  		}
  		if len(levels[2]) != 1 || levels[2][0].Name != "d" {
  			t.Errorf("levels[2]: got %v, want [d]", stepNames(levels[2]))
  		}
  	})

  	t.Run("steps paralelos sin depends produce un solo nivel", func(t *testing.T) {
  		steps := []DagStep{
  			{Name: "x"},
  			{Name: "y"},
  			{Name: "z"},
  		}
  		levels, err := DagTopoSort(steps)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(levels) != 1 {
  			t.Fatalf("levels: got %d, want 1", len(levels))
  		}
  		if len(levels[0]) != 3 {
  			t.Errorf("levels[0]: got %d steps, want 3", len(levels[0]))
  		}
  	})

  	t.Run("ciclo A->B->A retorna error", func(t *testing.T) {
  		steps := []DagStep{
  			{Name: "a", Depends: []string{"b"}},
  			{Name: "b", Depends: []string{"a"}},
  		}
  		_, err := DagTopoSort(steps)
  		if err == nil {
  			t.Fatal("expected error for cycle, got nil")
  		}
  	})
  }

  // stepNames extracts step names for readable test output.
  func stepNames(steps []DagStep) []string {
  	names := make([]string, len(steps))
  	for i, s := range steps {
  		names[i] = stepRef(s)
  	}
  	return names
  }


id: dag_validate_go_core_t0
function_id: dag_validate_go_core
name: TestDagValidate
file_path: functions/core/dag_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDagValidate(t *testing.T) {
  	t.Run("DAG valido retorna Valid true y levels calculados", func(t *testing.T) {
  		dag := DagDefinition{
  			Name: "valid-dag",
  			Steps: []DagStep{
  				{Name: "a", Command: "echo a"},
  				{Name: "b", Command: "echo b", Depends: []string{"a"}},
  				{Name: "c", Command: "echo c", Depends: []string{"a"}},
  				{Name: "d", Command: "echo d", Depends: []string{"b", "c"}},
  			},
  		}
  		res := DagValidate(dag)
  		if !res.Valid {
  			t.Errorf("Valid: got false, want true. Errors: %v", res.Errors)
  		}
  		if len(res.Errors) != 0 {
  			t.Errorf("Errors: got %v, want empty", res.Errors)
  		}
  		// Should have 3 levels: [a], [b,c], [d]
  		if len(res.Levels) != 3 {
  			t.Errorf("Levels: got %d, want 3. Levels: %v", len(res.Levels), res.Levels)
  		}
  		if len(res.Levels[0]) != 1 || res.Levels[0][0] != "a" {
  			t.Errorf("Levels[0]: got %v, want [a]", res.Levels[0])
  		}
  		if len(res.Levels[2]) != 1 || res.Levels[2][0] != "d" {
  			t.Errorf("Levels[2]: got %v, want [d]", res.Levels[2])
  		}
  	})

  	t.Run("step sin nombre retorna error", func(t *testing.T) {
  		dag := DagDefinition{
  			Name: "bad-dag",
  			Steps: []DagStep{
  				{Command: "echo hello"}, // no name, no ID
  			},
  		}
  		res := DagValidate(dag)
  		if res.Valid {
  			t.Error("Valid: got true, want false")
  		}
  		if len(res.Errors) == 0 {
  			t.Error("Errors: got empty, want at least one error")
  		}
  	})

  	t.Run("depends a step inexistente retorna error", func(t *testing.T) {
  		dag := DagDefinition{
  			Name: "bad-deps-dag",
  			Steps: []DagStep{
  				{Name: "step1", Command: "echo ok"},
  				{Name: "step2", Command: "echo ok", Depends: []string{"nonexistent"}},
  			},
  		}
  		res := DagValidate(dag)
  		if res.Valid {
  			t.Error("Valid: got true, want false")
  		}
  		found := false
  		for _, e := range res.Errors {
  			if containsStr(e, "nonexistent") {
  				found = true
  				break
  			}
  		}
  		if !found {
  			t.Errorf("Errors should mention 'nonexistent', got: %v", res.Errors)
  		}
  	})

  	t.Run("ciclo en dependencias retorna error", func(t *testing.T) {
  		dag := DagDefinition{
  			Name: "cyclic-dag",
  			Steps: []DagStep{
  				{Name: "a", Command: "echo a", Depends: []string{"b"}},
  				{Name: "b", Command: "echo b", Depends: []string{"a"}},
  			},
  		}
  		res := DagValidate(dag)
  		if res.Valid {
  			t.Error("Valid: got true, want false")
  		}
  		found := false
  		for _, e := range res.Errors {
  			if containsStr(e, "cycle") {
  				found = true
  				break
  			}
  		}
  		if !found {
  			t.Errorf("Errors should mention 'cycle', got: %v", res.Errors)
  		}
  	})
  }

  // containsStr returns true if s contains substr.
  func containsStr(s, substr string) bool {
  	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
  		func() bool {
  			for i := 0; i <= len(s)-len(substr); i++ {
  				if s[i:i+len(substr)] == substr {
  					return true
  				}
  			}
  			return false
  		}())
  }


id: filter_slice_go_core_t0
function_id: filter_slice_go_core
name: TestFilterSlice
file_path: functions/core/filter_slice_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFilterSlice(t *testing.T) {
  	t.Run("filtra pares", func(t *testing.T) {
  		got := FilterSlice([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
  		if len(got) != 2 || got[0] != 2 || got[1] != 4 {
  			t.Errorf("got %v, want [2 4]", got)
  		}
  	})

  	t.Run("slice vacio retorna vacio", func(t *testing.T) {
  		got := FilterSlice([]int{}, func(n int) bool { return true })
  		if len(got) != 0 {
  			t.Errorf("got %v, want []", got)
  		}
  	})

  	t.Run("ningun match retorna vacio", func(t *testing.T) {
  		got := FilterSlice([]int{1, 3, 5}, func(n int) bool { return n%2 == 0 })
  		if len(got) != 0 {
  			t.Errorf("got %v, want []", got)
  		}
  	})
  }


id: join_by_key_go_core_t0
function_id: join_by_key_go_core
name: TestJoinByKey
file_path: functions/core/join_by_key_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJoinByKey(t *testing.T) {
  	t.Run("Inner join solo matches", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
  		right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 3, "dept": "sales"}}
  		result := JoinByKey(left, right, "id", "inner")
  		if len(result) != 1 {
  			t.Fatalf("got %d rows, want 1", len(result))
  		}
  		if result[0]["id"] != 1 || result[0]["name"] != "Alice" || result[0]["dept"] != "eng" {
  			t.Errorf("unexpected row: %v", result[0])
  		}
  	})

  	t.Run("Left join todos los left con nil para right sin match", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
  		right := []map[string]any{{"id": 1, "dept": "eng"}}
  		result := JoinByKey(left, right, "id", "left")
  		if len(result) != 2 {
  			t.Fatalf("got %d rows, want 2", len(result))
  		}
  		var alice, bob map[string]any
  		for _, r := range result {
  			if r["id"] == 1 {
  				alice = r
  			} else {
  				bob = r
  			}
  		}
  		if alice["dept"] != "eng" {
  			t.Errorf("alice dept = %v, want eng", alice["dept"])
  		}
  		if bob["dept"] != nil {
  			t.Errorf("bob dept = %v, want nil", bob["dept"])
  		}
  	})

  	t.Run("Right join", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice"}}
  		right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 2, "dept": "sales"}}
  		result := JoinByKey(left, right, "id", "right")
  		if len(result) != 2 {
  			t.Fatalf("got %d rows, want 2", len(result))
  		}
  		var eng, sales map[string]any
  		for _, r := range result {
  			if r["id"] == 1 {
  				eng = r
  			} else {
  				sales = r
  			}
  		}
  		if eng["name"] != "Alice" {
  			t.Errorf("eng name = %v, want Alice", eng["name"])
  		}
  		if sales["name"] != nil {
  			t.Errorf("sales name = %v, want nil", sales["name"])
  		}
  	})

  	t.Run("Outer join", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
  		right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 3, "dept": "sales"}}
  		result := JoinByKey(left, right, "id", "outer")
  		ids := map[any]bool{}
  		for _, r := range result {
  			ids[r["id"]] = true
  		}
  		if len(ids) != 3 || !ids[1] || !ids[2] || !ids[3] {
  			t.Errorf("outer join ids = %v, want {1, 2, 3}", ids)
  		}
  	})

  	t.Run("Campos duplicados con sufijo _right", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice", "score": 90}}
  		right := []map[string]any{{"id": 1, "score": 85, "dept": "eng"}}
  		result := JoinByKey(left, right, "id", "inner")
  		if len(result) != 1 {
  			t.Fatalf("got %d rows, want 1", len(result))
  		}
  		if result[0]["score"] != 90 {
  			t.Errorf("score = %v, want 90", result[0]["score"])
  		}
  		if result[0]["score_right"] != 85 {
  			t.Errorf("score_right = %v, want 85", result[0]["score_right"])
  		}
  		if result[0]["dept"] != "eng" {
  			t.Errorf("dept = %v, want eng", result[0]["dept"])
  		}
  	})

  	t.Run("Key ausente en alguna fila", func(t *testing.T) {
  		left := []map[string]any{{"id": 1, "name": "Alice"}, {"name": "Bob"}} // Bob sin id
  		right := []map[string]any{{"id": 1, "dept": "eng"}}
  		result := JoinByKey(left, right, "id", "inner")
  		// Solo Alice matchea (Bob tiene key=nil, right no tiene nil)
  		if len(result) != 1 {
  			t.Fatalf("got %d rows, want 1", len(result))
  		}
  		if result[0]["name"] != "Alice" {
  			t.Errorf("name = %v, want Alice", result[0]["name"])
  		}
  	})
  }


id: map_slice_go_core_t0
function_id: map_slice_go_core
name: TestMapSlice
file_path: functions/core/map_slice_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMapSlice(t *testing.T) {
  	t.Run("transforma enteros a strings", func(t *testing.T) {
  		got := MapSlice([]int{1, 2, 3}, func(n int) string { return fmt.Sprintf("%d", n) })
  		if len(got) != 3 || got[0] != "1" || got[1] != "2" || got[2] != "3" {
  			t.Errorf("got %v", got)
  		}
  	})

  	t.Run("slice vacio retorna vacio", func(t *testing.T) {
  		got := MapSlice([]int{}, func(n int) int { return n * 2 })
  		if len(got) != 0 {
  			t.Errorf("got %v, want []", got)
  		}
  	})

  	t.Run("preserva orden", func(t *testing.T) {
  		got := MapSlice([]int{3, 1, 2}, func(n int) int { return n * 10 })
  		if got[0] != 30 || got[1] != 10 || got[2] != 20 {
  			t.Errorf("got %v, want [30 10 20]", got)
  		}
  	})
  }


id: next_cron_time_go_core_t0
function_id: next_cron_time_go_core
name: TestNextCronTime
file_path: functions/core/next_cron_time_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestNextCronTime(t *testing.T) {
  	utc := time.UTC

  	t.Run("0 * * * * desde :30 retorna la proxima hora en punto", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 * * * *")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		after := time.Date(2024, 1, 15, 14, 30, 0, 0, utc)
  		got := NextCronTime(sched, after)
  		want := time.Date(2024, 1, 15, 15, 0, 0, 0, utc)
  		if !got.Equal(want) {
  			t.Errorf("got %v, want %v", got, want)
  		}
  	})

  	t.Run("@weekly desde viernes retorna proximo domingo a medianoche", func(t *testing.T) {
  		// @weekly = "0 0 * * 0" (Sunday)
  		sched, err := ParseCronExpr("@weekly")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		// 2024-01-19 is a Friday
  		after := time.Date(2024, 1, 19, 10, 0, 0, 0, utc)
  		got := NextCronTime(sched, after)
  		// Next Sunday = 2024-01-21
  		want := time.Date(2024, 1, 21, 0, 0, 0, 0, utc)
  		if !got.Equal(want) {
  			t.Errorf("got %v, want %v", got, want)
  		}
  	})

  	t.Run("0 9 * * 1-5 desde viernes retorna proximo lunes a las 9", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * 1-5")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		// 2024-01-19 is a Friday, after 9am so today is already past.
  		after := time.Date(2024, 1, 19, 10, 0, 0, 0, utc)
  		got := NextCronTime(sched, after)
  		// Next weekday = Monday 2024-01-22
  		want := time.Date(2024, 1, 22, 9, 0, 0, 0, utc)
  		if !got.Equal(want) {
  			t.Errorf("got %v, want %v", got, want)
  		}
  	})

  	t.Run("schedule imposible retorna zero time", func(t *testing.T) {
  		// 30 Feb does not exist — will exhaust 366-day limit quickly for a specific year.
  		// Use a schedule matching only Feb 30, which never occurs.
  		sched := CronSchedule{
  			Minute:     []int{0},
  			Hour:       []int{0},
  			DayOfMonth: []int{30},
  			Month:      []int{2},
  			DayOfWeek:  []int{0, 1, 2, 3, 4, 5, 6},
  			Raw:        "0 0 30 2 *",
  		}
  		after := time.Date(2023, 3, 1, 0, 0, 0, 0, utc)
  		got := NextCronTime(sched, after)
  		if !got.IsZero() {
  			t.Errorf("expected zero time for impossible schedule, got %v", got)
  		}
  	})
  }


id: parse_cron_expr_go_core_t0
function_id: parse_cron_expr_go_core
name: TestParseCronExpr
file_path: functions/core/parse_cron_expr_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestParseCronExpr(t *testing.T) {
  	t.Run("*/15 expande minutos a [0 15 30 45]", func(t *testing.T) {
  		sched, err := ParseCronExpr("*/15 * * * *")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		want := []int{0, 15, 30, 45}
  		if !reflect.DeepEqual(sched.Minute, want) {
  			t.Errorf("Minute = %v, want %v", sched.Minute, want)
  		}
  		// Hour should be all 24 hours
  		if len(sched.Hour) != 24 {
  			t.Errorf("Hour len = %d, want 24", len(sched.Hour))
  		}
  	})

  	t.Run("@daily resuelve a 0 0 en todos los campos restantes", func(t *testing.T) {
  		sched, err := ParseCronExpr("@daily")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if !reflect.DeepEqual(sched.Minute, []int{0}) {
  			t.Errorf("Minute = %v, want [0]", sched.Minute)
  		}
  		if !reflect.DeepEqual(sched.Hour, []int{0}) {
  			t.Errorf("Hour = %v, want [0]", sched.Hour)
  		}
  		// DayOfMonth should be all days
  		if len(sched.DayOfMonth) != 31 {
  			t.Errorf("DayOfMonth len = %d, want 31", len(sched.DayOfMonth))
  		}
  	})

  	t.Run("0 9 1,15 * * expande dias a [1 15]", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 1,15 * *")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if !reflect.DeepEqual(sched.Minute, []int{0}) {
  			t.Errorf("Minute = %v, want [0]", sched.Minute)
  		}
  		if !reflect.DeepEqual(sched.Hour, []int{9}) {
  			t.Errorf("Hour = %v, want [9]", sched.Hour)
  		}
  		if !reflect.DeepEqual(sched.DayOfMonth, []int{1, 15}) {
  			t.Errorf("DayOfMonth = %v, want [1, 15]", sched.DayOfMonth)
  		}
  	})

  	t.Run("0 9 * * 1-5 expande dia de semana a [1 2 3 4 5]", func(t *testing.T) {
  		sched, err := ParseCronExpr("0 9 * * 1-5")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		want := []int{1, 2, 3, 4, 5}
  		if !reflect.DeepEqual(sched.DayOfWeek, want) {
  			t.Errorf("DayOfWeek = %v, want %v", sched.DayOfWeek, want)
  		}
  	})

  	t.Run("expresion con 4 campos retorna error", func(t *testing.T) {
  		_, err := ParseCronExpr("0 9 * *")
  		if err == nil {
  			t.Error("expected error for 4-field expression, got nil")
  		}
  	})

  	t.Run("minuto fuera de rango retorna error", func(t *testing.T) {
  		_, err := ParseCronExpr("60 * * * *")
  		if err == nil {
  			t.Error("expected error for minute=60, got nil")
  		}
  	})
  }


id: test_capture_logs_go_core_t0
function_id: test_capture_logs_go_core
name: TestTestCaptureLogs
file_path: functions/core/test_capture_logs_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestCaptureLogs(t *testing.T) {
  	t.Run("log.Print capturado en el buffer", func(t *testing.T) {
  		buf, _ := TestCaptureLogs(t)

  		log.Print("hello from test")

  		if buf.Len() == 0 {
  			t.Error("expected buffer to have content, got empty")
  		}

  		content := buf.String()
  		if content == "" {
  			t.Error("expected non-empty buffer content")
  		}
  	})

  	t.Run("flush retorna lineas separadas", func(t *testing.T) {
  		_, flush := TestCaptureLogs(t)

  		log.Print("line one")
  		log.Print("line two")
  		log.Print("line three")

  		lines := flush()
  		if len(lines) != 3 {
  			t.Errorf("got %d lines, want 3: %v", len(lines), lines)
  		}
  		if lines[0] != "line one" {
  			t.Errorf("line[0] = %q, want \"line one\"", lines[0])
  		}
  		if lines[1] != "line two" {
  			t.Errorf("line[1] = %q, want \"line two\"", lines[1])
  		}
  		if lines[2] != "line three" {
  			t.Errorf("line[2] = %q, want \"line three\"", lines[2])
  		}
  	})

  	t.Run("sin logs flush retorna nil", func(t *testing.T) {
  		_, flush := TestCaptureLogs(t)
  		lines := flush()
  		if lines != nil {
  			t.Errorf("expected nil for no logs, got %v", lines)
  		}
  	})

  	t.Run("log restaurado al original tras flush", func(t *testing.T) {
  		_, flush := TestCaptureLogs(t)
  		log.Print("captured")
  		flush()

  		// Despues de flush el logger esta restaurado
  		// Verificamos que no panics ni errores al loggear normalmente
  		// (el output va al writer original, no al buffer)
  		log.Print("this goes to original output")
  	})
  }


id: test_db_seed_go_core_t0
function_id: test_db_seed_go_core
name: TestTestDBSeed
file_path: functions/core/test_db_seed_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestDBSeed(t *testing.T) {
  	schema := `CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL);`

  	t.Run("inserta una fila correctamente", func(t *testing.T) {
  		tdb := TestDBSetup(t, schema)

  		err := TestDBSeed(t, tdb.DB, "products", []map[string]any{
  			{"name": "Widget", "price": 9.99},
  		})
  		if err != nil {
  			t.Fatalf("seed: %v", err)
  		}

  		var count int
  		if err := tdb.DB.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&count); err != nil {
  			t.Fatalf("count: %v", err)
  		}
  		if count != 1 {
  			t.Errorf("got %d rows, want 1", count)
  		}
  	})

  	t.Run("inserta multiples filas en una transaccion", func(t *testing.T) {
  		tdb := TestDBSetup(t, schema)

  		rows := []map[string]any{
  			{"name": "A", "price": 1.0},
  			{"name": "B", "price": 2.0},
  			{"name": "C", "price": 3.0},
  		}
  		err := TestDBSeed(t, tdb.DB, "products", rows)
  		if err != nil {
  			t.Fatalf("seed: %v", err)
  		}

  		var count int
  		if err := tdb.DB.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&count); err != nil {
  			t.Fatalf("count: %v", err)
  		}
  		if count != 3 {
  			t.Errorf("got %d rows, want 3", count)
  		}
  	})

  	t.Run("slice vacio no hace nada y retorna nil", func(t *testing.T) {
  		tdb := TestDBSetup(t, schema)

  		err := TestDBSeed(t, tdb.DB, "products", []map[string]any{})
  		if err != nil {
  			t.Errorf("expected nil for empty rows, got: %v", err)
  		}

  		var count int
  		if err := tdb.DB.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&count); err != nil {
  			t.Fatalf("count: %v", err)
  		}
  		if count != 0 {
  			t.Errorf("got %d rows, want 0", count)
  		}
  	})

  	t.Run("error en tabla inexistente retorna error", func(t *testing.T) {
  		tdb := TestDBSetup(t, schema)

  		err := TestDBSeed(t, tdb.DB, "nonexistent_table", []map[string]any{
  			{"name": "X", "price": 1.0},
  		})
  		if err == nil {
  			t.Error("expected error for nonexistent table, got nil")
  		}
  	})
  }


id: test_db_setup_go_core_t0
function_id: test_db_setup_go_core
name: TestTestDBSetup
file_path: functions/core/test_db_setup_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestDBSetup(t *testing.T) {
  	t.Run("DB en memoria creado sin schema esta disponible", func(t *testing.T) {
  		tdb := TestDBSetup(t, "")

  		if tdb.DB == nil {
  			t.Fatal("expected non-nil DB")
  		}
  		if tdb.Path != ":memory:" {
  			t.Errorf("got path %q, want \":memory:\"", tdb.Path)
  		}

  		// Ping para verificar conexion
  		if err := tdb.DB.Ping(); err != nil {
  			t.Errorf("ping failed: %v", err)
  		}
  	})

  	t.Run("schema se ejecuta correctamente al crear el DB", func(t *testing.T) {
  		schema := `CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);`
  		tdb := TestDBSetup(t, schema)

  		// Insertar y recuperar para verificar que la tabla existe
  		_, err := tdb.DB.Exec(`INSERT INTO items (name) VALUES ('test')`)
  		if err != nil {
  			t.Fatalf("insert: %v", err)
  		}

  		var name string
  		if err := tdb.DB.QueryRow(`SELECT name FROM items WHERE id=1`).Scan(&name); err != nil {
  			t.Fatalf("select: %v", err)
  		}
  		if name != "test" {
  			t.Errorf("got %q, want \"test\"", name)
  		}
  	})

  	t.Run("schema invalido llama t.Fatalf", func(t *testing.T) {
  		// Usamos un sub-test con recover para verificar que t.Fatalf es llamado
  		// En tests reales t.Fatalf detiene la goroutine, aqui verificamos indirectamente
  		// que un schema invalido no devuelve un DB utilizable
  		// Este test documenta el comportamiento esperado; en la practica t.Fatalf
  		// termina el test inmediatamente con error si el schema es invalido.
  		// Se verifica que el DB valido funciona y que el schema invalido habria fallado.
  		tdb := TestDBSetup(t, `CREATE TABLE ok (id INTEGER PRIMARY KEY);`)
  		if tdb.DB == nil {
  			t.Fatal("expected valid DB with valid schema")
  		}
  	})
  }


id: test_env_set_go_core_t0
function_id: test_env_set_go_core
name: TestTestEnvSet
file_path: functions/core/test_env_set_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestEnvSet(t *testing.T) {
  	t.Run("variable nueva se setea y se restaura al finalizar", func(t *testing.T) {
  		const key = "TEST_ENV_SET_NEW_VAR_XYZ"
  		os.Unsetenv(key) // asegurarse de que no existe

  		TestEnvSet(t, map[string]string{key: "hello"})

  		if got := os.Getenv(key); got != "hello" {
  			t.Errorf("got %q, want \"hello\"", got)
  		}

  		// Simular que el test termino llamando al restore
  		// (en tests reales t.Cleanup lo hace automaticamente)
  	})

  	t.Run("variable existente se sobreescribe y se restaura al finalizar", func(t *testing.T) {
  		const key = "TEST_ENV_SET_EXISTING_VAR_XYZ"
  		os.Setenv(key, "original")

  		restore := TestEnvSet(t, map[string]string{key: "overridden"})

  		if got := os.Getenv(key); got != "overridden" {
  			t.Errorf("got %q, want \"overridden\"", got)
  		}

  		restore()

  		if got := os.Getenv(key); got != "original" {
  			t.Errorf("after restore got %q, want \"original\"", got)
  		}
  		os.Unsetenv(key)
  	})

  	t.Run("multiples variables se setean y restauran correctamente", func(t *testing.T) {
  		os.Unsetenv("TEST_MULTI_A")
  		os.Unsetenv("TEST_MULTI_B")

  		restore := TestEnvSet(t, map[string]string{
  			"TEST_MULTI_A": "value-a",
  			"TEST_MULTI_B": "value-b",
  		})

  		if got := os.Getenv("TEST_MULTI_A"); got != "value-a" {
  			t.Errorf("A: got %q, want \"value-a\"", got)
  		}
  		if got := os.Getenv("TEST_MULTI_B"); got != "value-b" {
  			t.Errorf("B: got %q, want \"value-b\"", got)
  		}

  		restore()

  		if got, ok := os.LookupEnv("TEST_MULTI_A"); ok {
  			t.Errorf("A should be unset after restore, got %q", got)
  		}
  		if got, ok := os.LookupEnv("TEST_MULTI_B"); ok {
  			t.Errorf("B should be unset after restore, got %q", got)
  		}
  	})

  	t.Run("restore manual funciona antes del cleanup automatico", func(t *testing.T) {
  		const key = "TEST_ENV_MANUAL_RESTORE"
  		os.Setenv(key, "before")

  		restore := TestEnvSet(t, map[string]string{key: "during"})

  		if got := os.Getenv(key); got != "during" {
  			t.Errorf("got %q, want \"during\"", got)
  		}

  		restore() // restaura manualmente

  		if got := os.Getenv(key); got != "before" {
  			t.Errorf("after manual restore got %q, want \"before\"", got)
  		}
  		os.Unsetenv(key)
  	})
  }


id: test_fixture_load_go_core_t0
function_id: test_fixture_load_go_core
name: TestTestFixtureLoad
file_path: functions/core/test_fixture_load_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestFixtureLoad(t *testing.T) {
  	// Crear fixture temporal para los tests
  	dir := t.TempDir()

  	// Fixture JSON valido
  	jsonPath := filepath.Join(dir, "test.json")
  	if err := os.WriteFile(jsonPath, []byte(`{"host":"localhost","port":5432}`), 0o644); err != nil {
  		t.Fatalf("write json fixture: %v", err)
  	}

  	// Fixture JSON invalido
  	badJSONPath := filepath.Join(dir, "bad.json")
  	if err := os.WriteFile(badJSONPath, []byte(`{not valid json`), 0o644); err != nil {
  		t.Fatalf("write bad json fixture: %v", err)
  	}

  	t.Run("carga fixture JSON correctamente", func(t *testing.T) {
  		var dst struct {
  			Host string `json:"host"`
  			Port int    `json:"port"`
  		}
  		err := TestFixtureLoad(t, jsonPath, &dst)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if dst.Host != "localhost" {
  			t.Errorf("got host %q, want \"localhost\"", dst.Host)
  		}
  		if dst.Port != 5432 {
  			t.Errorf("got port %d, want 5432", dst.Port)
  		}
  	})

  	t.Run("archivo inexistente retorna error", func(t *testing.T) {
  		var dst map[string]any
  		err := TestFixtureLoad(t, filepath.Join(dir, "nonexistent.json"), &dst)
  		if err == nil {
  			t.Error("expected error for nonexistent file, got nil")
  		}
  	})

  	t.Run("extension no soportada retorna error", func(t *testing.T) {
  		txtPath := filepath.Join(dir, "file.txt")
  		if err := os.WriteFile(txtPath, []byte("hello"), 0o644); err != nil {
  			t.Fatalf("write txt: %v", err)
  		}
  		var dst any
  		err := TestFixtureLoad(t, txtPath, &dst)
  		if err == nil {
  			t.Error("expected error for unsupported extension, got nil")
  		}
  	})

  	t.Run("JSON invalido retorna error de parsing", func(t *testing.T) {
  		var dst map[string]any
  		err := TestFixtureLoad(t, badJSONPath, &dst)
  		if err == nil {
  			t.Error("expected error for invalid JSON, got nil")
  		}
  	})
  }


id: test_http_server_go_core_t0
function_id: test_http_server_go_core
name: TestTestHTTPServer
file_path: functions/core/test_http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestTestHTTPServer(t *testing.T) {
  	t.Run("ruta registrada responde con el handler correcto", func(t *testing.T) {
  		srv := TestHTTPServer(t, map[string]http.HandlerFunc{
  			"/ping": func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusOK)
  				_, _ = w.Write([]byte("pong"))
  			},
  		})

  		resp, err := srv.Client.Get(srv.URL + "/ping")
  		if err != nil {
  			t.Fatalf("GET /ping: %v", err)
  		}
  		defer resp.Body.Close()

  		if resp.StatusCode != http.StatusOK {
  			t.Errorf("got status %d, want 200", resp.StatusCode)
  		}

  		body, _ := io.ReadAll(resp.Body)
  		if string(body) != "pong" {
  			t.Errorf("got body %q, want \"pong\"", string(body))
  		}
  	})

  	t.Run("ruta no registrada devuelve 404", func(t *testing.T) {
  		srv := TestHTTPServer(t, map[string]http.HandlerFunc{
  			"/exists": func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusOK)
  			},
  		})

  		resp, err := srv.Client.Get(srv.URL + "/notfound")
  		if err != nil {
  			t.Fatalf("GET /notfound: %v", err)
  		}
  		defer resp.Body.Close()

  		if resp.StatusCode != http.StatusNotFound {
  			t.Errorf("got status %d, want 404", resp.StatusCode)
  		}
  	})

  	t.Run("multiples rutas independientes", func(t *testing.T) {
  		srv := TestHTTPServer(t, map[string]http.HandlerFunc{
  			"/a": func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusOK)
  				_, _ = w.Write([]byte("route-a"))
  			},
  			"/b": func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusCreated)
  				_, _ = w.Write([]byte("route-b"))
  			},
  		})

  		respA, err := srv.Client.Get(srv.URL + "/a")
  		if err != nil {
  			t.Fatalf("GET /a: %v", err)
  		}
  		defer respA.Body.Close()
  		if respA.StatusCode != http.StatusOK {
  			t.Errorf("/a: got status %d, want 200", respA.StatusCode)
  		}

  		respB, err := srv.Client.Get(srv.URL + "/b")
  		if err != nil {
  			t.Fatalf("GET /b: %v", err)
  		}
  		defer respB.Body.Close()
  		if respB.StatusCode != http.StatusCreated {
  			t.Errorf("/b: got status %d, want 201", respB.StatusCode)
  		}
  	})
  }


id: validate_struct_fields_go_core_t0
function_id: validate_struct_fields_go_core
name: TestValidateStructFields
file_path: functions/core/validate_struct_fields_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestValidateStructFields(t *testing.T) {
  	t.Run("campo required presente y ausente", func(t *testing.T) {
  		rules := map[string]string{"name": "required"}

  		valid, errs := ValidateStructFields(map[string]any{"name": "Alice"}, rules)
  		if !valid || len(errs) != 0 {
  			t.Errorf("expected valid, got errors: %v", errs)
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{}, rules)
  		if valid2 || len(errs2) == 0 {
  			t.Errorf("expected invalid for missing required field")
  		}
  	})

  	t.Run("type validation string como int falla", func(t *testing.T) {
  		rules := map[string]string{"count": "type=int"}

  		valid, _ := ValidateStructFields(map[string]any{"count": 5}, rules)
  		if !valid {
  			t.Error("expected int 5 to pass type=int")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"count": "five"}, rules)
  		if valid2 || len(errs2) == 0 {
  			t.Error("expected string to fail type=int")
  		}
  	})

  	t.Run("numeric ranges", func(t *testing.T) {
  		rules := map[string]string{"score": "min=0,max=100"}

  		valid, _ := ValidateStructFields(map[string]any{"score": 50}, rules)
  		if !valid {
  			t.Error("expected 50 to pass min=0,max=100")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"score": 150}, rules)
  		if valid2 || !strings.Contains(errs2[0], "max") {
  			t.Errorf("expected max violation, got: %v", errs2)
  		}

  		valid3, errs3 := ValidateStructFields(map[string]any{"score": -1}, rules)
  		if valid3 || !strings.Contains(errs3[0], "min") {
  			t.Errorf("expected min violation, got: %v", errs3)
  		}
  	})

  	t.Run("string lengths", func(t *testing.T) {
  		rules := map[string]string{"tag": "minlen=2,maxlen=10"}

  		valid, _ := ValidateStructFields(map[string]any{"tag": "go"}, rules)
  		if !valid {
  			t.Error("expected 'go' to pass minlen=2,maxlen=10")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"tag": "a"}, rules)
  		if valid2 || !strings.Contains(errs2[0], "minlen") {
  			t.Errorf("expected minlen violation, got: %v", errs2)
  		}

  		valid3, errs3 := ValidateStructFields(map[string]any{"tag": "averylongtag"}, rules)
  		if valid3 || !strings.Contains(errs3[0], "maxlen") {
  			t.Errorf("expected maxlen violation, got: %v", errs3)
  		}
  	})

  	t.Run("oneof validation", func(t *testing.T) {
  		rules := map[string]string{"status": "oneof=active|inactive|pending"}

  		valid, _ := ValidateStructFields(map[string]any{"status": "active"}, rules)
  		if !valid {
  			t.Error("expected 'active' to pass oneof")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"status": "deleted"}, rules)
  		if valid2 || len(errs2) == 0 {
  			t.Errorf("expected oneof violation, got: %v", errs2)
  		}
  	})

  	t.Run("pattern matching", func(t *testing.T) {
  		rules := map[string]string{"email": `pattern=^[^@]+@[^@]+\.[^@]+$`}

  		valid, _ := ValidateStructFields(map[string]any{"email": "user@example.com"}, rules)
  		if !valid {
  			t.Error("expected valid email to pass pattern")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"email": "not-an-email"}, rules)
  		if valid2 || !strings.Contains(errs2[0], "pattern") {
  			t.Errorf("expected pattern violation, got: %v", errs2)
  		}
  	})

  	t.Run("multiples reglas combinadas", func(t *testing.T) {
  		rules := map[string]string{
  			"name":  "required,type=string,minlen=2,maxlen=50",
  			"score": "required,type=float,min=0,max=10",
  		}

  		valid, _ := ValidateStructFields(map[string]any{"name": "Alice", "score": float64(8.5)}, rules)
  		if !valid {
  			t.Error("expected all rules to pass")
  		}

  		valid2, errs2 := ValidateStructFields(map[string]any{"name": "A", "score": float64(11)}, rules)
  		if valid2 || len(errs2) < 2 {
  			t.Errorf("expected at least 2 errors, got: %v", errs2)
  		}
  	})

  	t.Run("map vacio con reglas required", func(t *testing.T) {
  		rules := map[string]string{
  			"id":   "required",
  			"name": "required",
  		}

  		valid, errs := ValidateStructFields(map[string]any{}, rules)
  		if valid || len(errs) < 2 {
  			t.Errorf("expected 2 required errors, got: %v", errs)
  		}
  	})
  }


id: diff_entities_go_datascience_t0
function_id: diff_entities_go_datascience
name: TestDiffEntities
file_path: functions/datascience/diff_entities_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDiffEntities(t *testing.T) {
  	t.Run("entity añadida", func(t *testing.T) {
  		before := []map[string]any{
  			{"id": "1", "name": "Alice"},
  		}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice"},
  			{"id": "2", "name": "Bob"},
  		}
  		result := DiffEntities(before, after, "id", nil)
  		added := result["added"].([]map[string]any)
  		if len(added) != 1 {
  			t.Errorf("expected 1 added, got %d", len(added))
  		}
  		if added[0]["id"] != "2" {
  			t.Errorf("expected added id=2, got %v", added[0]["id"])
  		}
  		if result["unchanged"].(int) != 1 {
  			t.Errorf("expected 1 unchanged, got %v", result["unchanged"])
  		}
  	})

  	t.Run("entity eliminada", func(t *testing.T) {
  		before := []map[string]any{
  			{"id": "1", "name": "Alice"},
  			{"id": "2", "name": "Bob"},
  		}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice"},
  		}
  		result := DiffEntities(before, after, "id", nil)
  		removed := result["removed"].([]map[string]any)
  		if len(removed) != 1 {
  			t.Errorf("expected 1 removed, got %d", len(removed))
  		}
  		if removed[0]["id"] != "2" {
  			t.Errorf("expected removed id=2, got %v", removed[0]["id"])
  		}
  	})

  	t.Run("entity modificada con detalle de campos", func(t *testing.T) {
  		before := []map[string]any{
  			{"id": "1", "name": "Alice", "status": "active"},
  		}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice", "status": "inactive"},
  		}
  		result := DiffEntities(before, after, "id", nil)
  		modified := result["modified"].([]map[string]any)
  		if len(modified) != 1 {
  			t.Errorf("expected 1 modified, got %d", len(modified))
  		}
  		changes := modified[0]["changes"].(map[string]any)
  		statusChange, ok := changes["status"].(map[string]any)
  		if !ok {
  			t.Fatalf("expected status change, got %v", changes)
  		}
  		if statusChange["old"] != "active" {
  			t.Errorf("expected old=active, got %v", statusChange["old"])
  		}
  		if statusChange["new"] != "inactive" {
  			t.Errorf("expected new=inactive, got %v", statusChange["new"])
  		}
  	})

  	t.Run("entities identicas → unchanged", func(t *testing.T) {
  		entities := []map[string]any{
  			{"id": "1", "name": "Alice"},
  			{"id": "2", "name": "Bob"},
  		}
  		result := DiffEntities(entities, entities, "id", nil)
  		if result["unchanged"].(int) != 2 {
  			t.Errorf("expected 2 unchanged, got %v", result["unchanged"])
  		}
  		if len(result["added"].([]map[string]any)) != 0 {
  			t.Errorf("expected 0 added")
  		}
  		if len(result["modified"].([]map[string]any)) != 0 {
  			t.Errorf("expected 0 modified")
  		}
  	})

  	t.Run("ignore_fields funciona", func(t *testing.T) {
  		before := []map[string]any{
  			{"id": "1", "name": "Alice", "updated_at": "2024-01-01"},
  		}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice", "updated_at": "2024-06-01"},
  		}
  		// Default ignores updated_at
  		result := DiffEntities(before, after, "id", nil)
  		if result["unchanged"].(int) != 1 {
  			t.Errorf("expected 1 unchanged (updated_at ignored), got %v", result["unchanged"])
  		}
  		modified := result["modified"].([]map[string]any)
  		if len(modified) != 0 {
  			t.Errorf("expected 0 modified when updated_at is ignored, got %d", len(modified))
  		}
  	})

  	t.Run("lista vacia vs lista con datos", func(t *testing.T) {
  		before := []map[string]any{}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice"},
  		}
  		result := DiffEntities(before, after, "id", nil)
  		added := result["added"].([]map[string]any)
  		if len(added) != 1 {
  			t.Errorf("expected 1 added, got %d", len(added))
  		}
  		if result["unchanged"].(int) != 0 {
  			t.Errorf("expected 0 unchanged")
  		}
  	})

  	t.Run("summary format correcto", func(t *testing.T) {
  		before := []map[string]any{
  			{"id": "1", "name": "Alice"},
  			{"id": "3", "name": "Carol"},
  		}
  		after := []map[string]any{
  			{"id": "1", "name": "Alice Changed"},
  			{"id": "2", "name": "Bob"},
  		}
  		result := DiffEntities(before, after, "id", nil)
  		summary := result["summary"].(string)
  		expected := "1 added, 1 removed, 1 modified, 0 unchanged"
  		if summary != expected {
  			t.Errorf("expected summary %q, got %q", expected, summary)
  		}
  	})
  }


id: pivot_go_datascience_t0
function_id: pivot_go_datascience
name: TestPivot
file_path: functions/datascience/pivot_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPivot(t *testing.T) {
  	t.Run("Pivot basico con sum", func(t *testing.T) {
  		rows := []map[string]any{
  			{"region": "US", "product": "A", "sales": 10},
  			{"region": "US", "product": "B", "sales": 20},
  			{"region": "EU", "product": "A", "sales": 15},
  		}
  		result := Pivot(rows, "region", "product", "sales", "sum")
  		if len(result) != 2 {
  			t.Fatalf("got %d rows, want 2", len(result))
  		}
  		var us, eu map[string]any
  		for _, r := range result {
  			if r["region"] == "US" {
  				us = r
  			} else {
  				eu = r
  			}
  		}
  		if us["A"] != 10.0 {
  			t.Errorf("US.A: got %v, want 10", us["A"])
  		}
  		if us["B"] != 20.0 {
  			t.Errorf("US.B: got %v, want 20", us["B"])
  		}
  		if eu["A"] != 15.0 {
  			t.Errorf("EU.A: got %v, want 15", eu["A"])
  		}
  		if eu["B"] != 0 {
  			t.Errorf("EU.B: got %v, want 0", eu["B"])
  		}
  	})

  	t.Run("Pivot con count y mean", func(t *testing.T) {
  		rows := []map[string]any{
  			{"region": "US", "product": "A", "sales": 10},
  			{"region": "US", "product": "A", "sales": 20},
  			{"region": "EU", "product": "A", "sales": 15},
  		}
  		resultCount := Pivot(rows, "region", "product", "sales", "count")
  		for _, r := range resultCount {
  			if r["region"] == "US" && r["A"] != 2 {
  				t.Errorf("count US.A: got %v, want 2", r["A"])
  			}
  		}

  		resultMean := Pivot(rows, "region", "product", "sales", "mean")
  		for _, r := range resultMean {
  			if r["region"] == "US" {
  				mean, ok := r["A"].(float64)
  				if !ok || mean != 15.0 {
  					t.Errorf("mean US.A: got %v, want 15.0", r["A"])
  				}
  			}
  		}
  	})

  	t.Run("Valores faltantes rellenados con 0", func(t *testing.T) {
  		rows := []map[string]any{
  			{"region": "US", "product": "A", "sales": 5},
  			{"region": "EU", "product": "B", "sales": 8},
  		}
  		result := Pivot(rows, "region", "product", "sales", "sum")
  		for _, r := range result {
  			if r["region"] == "US" && r["B"] != 0 {
  				t.Errorf("US.B: got %v, want 0", r["B"])
  			}
  			if r["region"] == "EU" && r["A"] != 0 {
  				t.Errorf("EU.A: got %v, want 0", r["A"])
  			}
  		}
  	})

  	t.Run("Una sola fila", func(t *testing.T) {
  		rows := []map[string]any{
  			{"region": "US", "product": "A", "sales": 42},
  		}
  		result := Pivot(rows, "region", "product", "sales", "sum")
  		if len(result) != 1 {
  			t.Fatalf("got %d rows, want 1", len(result))
  		}
  		if result[0]["A"] != 42.0 {
  			t.Errorf("got %v, want 42", result[0]["A"])
  		}
  	})

  	t.Run("Multiples valores por celda requieren agregacion", func(t *testing.T) {
  		rows := []map[string]any{
  			{"region": "US", "product": "A", "sales": 10},
  			{"region": "US", "product": "A", "sales": 30},
  		}
  		resultSum := Pivot(rows, "region", "product", "sales", "sum")
  		if resultSum[0]["A"] != 40.0 {
  			t.Errorf("sum: got %v, want 40.0", resultSum[0]["A"])
  		}
  		resultMin := Pivot(rows, "region", "product", "sales", "min")
  		if resultMin[0]["A"] != 10.0 {
  			t.Errorf("min: got %v, want 10.0", resultMin[0]["A"])
  		}
  		resultMax := Pivot(rows, "region", "product", "sales", "max")
  		if resultMax[0]["A"] != 30.0 {
  			t.Errorf("max: got %v, want 30.0", resultMax[0]["A"])
  		}
  	})
  }


id: cache_to_sqlite_go_infra_t0
function_id: cache_to_sqlite_go_infra
name: TestCacheToSQLite_SetGet
file_path: functions/infra/cache_to_sqlite_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCacheToSQLite_SetGet(t *testing.T) {
  	t.Run("Set/Get basico", func(t *testing.T) {
  		c, err := CacheToSQLite(tempDB(t), "default")
  		if err != nil {
  			t.Fatal(err)
  		}
  		defer c.Close()

  		payload, _ := json.Marshal(map[string]int{"x": 1})
  		if err := c.Set("foo", payload, 0); err != nil {
  			t.Fatal(err)
  		}
  		got, ok := c.Get("foo")
  		if !ok {
  			t.Fatal("expected cache hit")
  		}
  		var result map[string]int
  		json.Unmarshal(got, &result)
  		if result["x"] != 1 {
  			t.Errorf("got %v, want x=1", result)
  		}
  	})
  }


id: cache_to_sqlite_go_infra_t1
function_id: cache_to_sqlite_go_infra
name: TestCacheToSQLite_TTLExpirado
file_path: functions/infra/cache_to_sqlite_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCacheToSQLite_TTLExpirado(t *testing.T) {
  	t.Run("TTL expirado", func(t *testing.T) {
  		c, err := CacheToSQLite(tempDB(t), "default")
  		if err != nil {
  			t.Fatal(err)
  		}
  		defer c.Close()

  		payload, _ := json.Marshal("hello")
  		c.Set("temp", payload, 50*time.Millisecond)
  		time.Sleep(100 * time.Millisecond)

  		_, ok := c.Get("temp")
  		if ok {
  			t.Error("expected cache miss after TTL expiry")
  		}
  	})
  }


id: cache_to_sqlite_go_infra_t2
function_id: cache_to_sqlite_go_infra
name: TestCacheToSQLite_GetOrSet
file_path: functions/infra/cache_to_sqlite_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCacheToSQLite_GetOrSet(t *testing.T) {
  	t.Run("GetOrSet con factory", func(t *testing.T) {
  		c, err := CacheToSQLite(tempDB(t), "default")
  		if err != nil {
  			t.Fatal(err)
  		}
  		defer c.Close()

  		calls := 0
  		factory := func() ([]byte, error) {
  			calls++
  			return json.Marshal("computed")
  		}

  		v1, err := c.GetOrSet("k", factory, time.Minute)
  		if err != nil {
  			t.Fatal(err)
  		}
  		v2, err := c.GetOrSet("k", factory, time.Minute)
  		if err != nil {
  			t.Fatal(err)
  		}
  		if string(v1) != string(v2) {
  			t.Errorf("v1=%s v2=%s, want equal", v1, v2)
  		}
  		if calls != 1 {
  			t.Errorf("factory called %d times, want 1", calls)
  		}
  	})
  }


id: cache_to_sqlite_go_infra_t3
function_id: cache_to_sqlite_go_infra
name: TestCacheToSQLite_Concurrencia
file_path: functions/infra/cache_to_sqlite_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCacheToSQLite_Concurrencia(t *testing.T) {
  	t.Run("Concurrencia (goroutines)", func(t *testing.T) {
  		c, err := CacheToSQLite(tempDB(t), "parallel")
  		if err != nil {
  			t.Fatal(err)
  		}
  		defer c.Close()

  		var wg sync.WaitGroup
  		errs := make(chan error, 40)
  		for i := 0; i < 20; i++ {
  			wg.Add(1)
  			go func(n int) {
  				defer wg.Done()
  				key := fmt.Sprintf("key_%d", n)
  				payload, _ := json.Marshal(n)
  				if err := c.Set(key, payload, 0); err != nil {
  					errs <- err
  					return
  				}
  				got, ok := c.Get(key)
  				if !ok {
  					errs <- fmt.Errorf("miss for key %s", key)
  					return
  				}
  				var val int
  				json.Unmarshal(got, &val)
  				if val != n {
  					errs <- fmt.Errorf("key %s: got %d want %d", key, val, n)
  				}
  			}(i)
  		}
  		wg.Wait()
  		close(errs)
  		for err := range errs {
  			t.Error(err)
  		}
  	})
  }


id: config_dump_go_infra_t0
function_id: config_dump_go_infra
name: TestConfigDump
file_path: functions/infra/config_dump_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestConfigDump(t *testing.T) {
  	t.Run("campos normales se incluyen como string", func(t *testing.T) {
  		type Cfg struct {
  			Host string
  			Port int
  		}
  		got := ConfigDump(Cfg{Host: "localhost", Port: 8080})
  		if got["Host"] != "localhost" {
  			t.Errorf("expected Host=localhost, got %q", got["Host"])
  		}
  		if got["Port"] != "8080" {
  			t.Errorf("expected Port=8080, got %q", got["Port"])
  		}
  	})

  	t.Run("campos secret aparecen como tres asteriscos", func(t *testing.T) {
  		type Cfg struct {
  			APIKey string `secret:"true"`
  		}
  		got := ConfigDump(Cfg{APIKey: "super-secret-key"})
  		if got["APIKey"] != "***" {
  			t.Errorf("expected ***, got %q", got["APIKey"])
  		}
  	})

  	t.Run("campos no exportados no aparecen", func(t *testing.T) {
  		type Cfg struct {
  			Public  string
  			private string //nolint:structcheck
  		}
  		_ = Cfg{} // ensure it compiles
  		got := ConfigDump(Cfg{Public: "yes"})
  		if _, ok := got["private"]; ok {
  			t.Error("private field should not be in dump")
  		}
  	})

  	t.Run("puntero a struct funciona", func(t *testing.T) {
  		type Cfg struct {
  			Name string
  		}
  		got := ConfigDump(&Cfg{Name: "test"})
  		if got["Name"] != "test" {
  			t.Errorf("expected Name=test, got %q", got["Name"])
  		}
  	})

  	t.Run("struct con bool y float se formatean correctamente", func(t *testing.T) {
  		type Cfg struct {
  			Debug   bool
  			Timeout float64
  		}
  		got := ConfigDump(Cfg{Debug: true, Timeout: 3.14})
  		if got["Debug"] != "true" {
  			t.Errorf("expected Debug=true, got %q", got["Debug"])
  		}
  		if got["Timeout"] != "3.14" {
  			t.Errorf("expected Timeout=3.14, got %q", got["Timeout"])
  		}
  	})
  }


id: config_from_env_go_infra_t0
function_id: config_from_env_go_infra
name: TestConfigFromEnv
file_path: functions/infra/config_from_env_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestConfigFromEnv(t *testing.T) {
  	t.Run("populate string field desde env", func(t *testing.T) {
  		type Cfg struct {
  			Host string `env:"CFE_HOST"`
  		}
  		t.Setenv("CFE_HOST", "myhost")
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if c.Host != "myhost" {
  			t.Errorf("expected myhost, got %q", c.Host)
  		}
  	})

  	t.Run("populate int field con conversion", func(t *testing.T) {
  		type Cfg struct {
  			Port int `env:"CFE_PORT"`
  		}
  		t.Setenv("CFE_PORT", "9090")
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if c.Port != 9090 {
  			t.Errorf("expected 9090, got %d", c.Port)
  		}
  	})

  	t.Run("populate bool field con conversion", func(t *testing.T) {
  		type Cfg struct {
  			Debug bool `env:"CFE_DEBUG"`
  		}
  		t.Setenv("CFE_DEBUG", "true")
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if !c.Debug {
  			t.Error("expected Debug=true")
  		}
  	})

  	t.Run("populate float64 field", func(t *testing.T) {
  		type Cfg struct {
  			Ratio float64 `env:"CFE_RATIO"`
  		}
  		t.Setenv("CFE_RATIO", "3.14")
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if c.Ratio < 3.13 || c.Ratio > 3.15 {
  			t.Errorf("expected ~3.14, got %f", c.Ratio)
  		}
  	})

  	t.Run("populate slice string comma-separated", func(t *testing.T) {
  		type Cfg struct {
  			Tags []string `env:"CFE_TAGS"`
  		}
  		t.Setenv("CFE_TAGS", "a,b,c")
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(c.Tags) != 3 || c.Tags[0] != "a" || c.Tags[2] != "c" {
  			t.Errorf("unexpected tags: %v", c.Tags)
  		}
  	})

  	t.Run("valor default usado cuando env no seteada", func(t *testing.T) {
  		type Cfg struct {
  			Port int `env:"CFE_PORT_DEFAULT" default:"8080"`
  		}
  		var c Cfg
  		if err := ConfigFromEnv(&c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if c.Port != 8080 {
  			t.Errorf("expected default 8080, got %d", c.Port)
  		}
  	})

  	t.Run("required sin valor retorna error", func(t *testing.T) {
  		type Cfg struct {
  			APIKey string `env:"CFE_API_KEY_REQUIRED" required:"true"`
  		}
  		var c Cfg
  		err := ConfigFromEnv(&c)
  		if err == nil {
  			t.Error("expected error for missing required var")
  		}
  	})

  	t.Run("int invalido retorna error descriptivo", func(t *testing.T) {
  		type Cfg struct {
  			Port int `env:"CFE_PORT_BAD"`
  		}
  		t.Setenv("CFE_PORT_BAD", "not-a-number")
  		var c Cfg
  		err := ConfigFromEnv(&c)
  		if err == nil {
  			t.Error("expected error for invalid int")
  		}
  	})

  	t.Run("puntero nil retorna error", func(t *testing.T) {
  		var p *struct{ X string }
  		err := ConfigFromEnv(p)
  		if err == nil {
  			t.Error("expected error for nil pointer")
  		}
  	})
  }


id: config_from_file_go_infra_t0
function_id: config_from_file_go_infra
name: TestConfigFromFile
file_path: functions/infra/config_from_file_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestConfigFromFile(t *testing.T) {
  	t.Run("carga JSON valido en struct", func(t *testing.T) {
  		type Cfg struct {
  			Host string `json:"host"`
  			Port int    `json:"port"`
  		}
  		content := `{"host":"testhost","port":5432}`
  		dir := t.TempDir()
  		p := filepath.Join(dir, "config.json")
  		if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
  			t.Fatal(err)
  		}

  		var c Cfg
  		if err := ConfigFromFile(p, &c); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if c.Host != "testhost" || c.Port != 5432 {
  			t.Errorf("unexpected: %+v", c)
  		}
  	})

  	t.Run("JSON invalido retorna error", func(t *testing.T) {
  		dir := t.TempDir()
  		p := filepath.Join(dir, "bad.json")
  		os.WriteFile(p, []byte("{not valid json}"), 0o600)

  		var target map[string]any
  		err := ConfigFromFile(p, &target)
  		if err == nil {
  			t.Error("expected error for invalid JSON")
  		}
  	})

  	t.Run("archivo inexistente retorna error", func(t *testing.T) {
  		var target map[string]any
  		err := ConfigFromFile("/nonexistent/config.json", &target)
  		if err == nil {
  			t.Error("expected error for missing file")
  		}
  	})

  	t.Run("extension YAML retorna not implemented", func(t *testing.T) {
  		var target map[string]any
  		err := ConfigFromFile("config.yaml", &target)
  		if err == nil {
  			t.Error("expected not-implemented error for YAML")
  		}
  	})

  	t.Run("extension desconocida retorna error", func(t *testing.T) {
  		var target map[string]any
  		err := ConfigFromFile("config.toml", &target)
  		if err == nil {
  			t.Error("expected error for unsupported extension")
  		}
  	})
  }


id: config_merge_go_infra_t0
function_id: config_merge_go_infra
name: TestConfigMerge
file_path: functions/infra/config_merge_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestConfigMerge(t *testing.T) {
  	t.Run("override gana sobre base en claves comunes", func(t *testing.T) {
  		base := map[string]string{"host": "localhost", "port": "5432"}
  		over := map[string]string{"port": "9999"}
  		got := ConfigMerge(base, over)
  		if got["port"] != "9999" {
  			t.Errorf("expected port=9999, got %q", got["port"])
  		}
  		if got["host"] != "localhost" {
  			t.Errorf("expected host=localhost, got %q", got["host"])
  		}
  	})

  	t.Run("claves solo en base se mantienen", func(t *testing.T) {
  		base := map[string]string{"a": "1", "b": "2"}
  		over := map[string]string{}
  		got := ConfigMerge(base, over)
  		if got["a"] != "1" || got["b"] != "2" {
  			t.Errorf("unexpected result: %v", got)
  		}
  	})

  	t.Run("claves solo en override se agregan", func(t *testing.T) {
  		base := map[string]string{}
  		over := map[string]string{"x": "new"}
  		got := ConfigMerge(base, over)
  		if got["x"] != "new" {
  			t.Errorf("expected x=new, got %q", got["x"])
  		}
  	})

  	t.Run("no muta el map base", func(t *testing.T) {
  		base := map[string]string{"key": "original"}
  		over := map[string]string{"key": "changed"}
  		_ = ConfigMerge(base, over)
  		if base["key"] != "original" {
  			t.Errorf("base was mutated: got %q", base["key"])
  		}
  	})

  	t.Run("merge con maps vacios retorna mapa vacio", func(t *testing.T) {
  		got := ConfigMerge(map[string]string{}, map[string]string{})
  		if len(got) != 0 {
  			t.Errorf("expected empty map, got %v", got)
  		}
  	})
  }


id: config_validate_go_infra_t0
function_id: config_validate_go_infra
name: TestConfigValidate
file_path: functions/infra/config_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestConfigValidate(t *testing.T) {
  	t.Run("struct valido retorna IsValid true", func(t *testing.T) {
  		type Cfg struct {
  			Host string `required:"true"`
  			Port int    `required:"true"`
  		}
  		got := ConfigValidate(Cfg{Host: "localhost", Port: 8080})
  		if !got.IsValid {
  			t.Errorf("expected IsValid=true, errors: %v", got.Errors)
  		}
  		if len(got.Errors) != 0 {
  			t.Errorf("expected no errors, got %v", got.Errors)
  		}
  	})

  	t.Run("campo required vacio acumula error", func(t *testing.T) {
  		type Cfg struct {
  			Host string `required:"true"`
  			Key  string `required:"true"`
  		}
  		got := ConfigValidate(Cfg{})
  		if got.IsValid {
  			t.Error("expected IsValid=false")
  		}
  		if len(got.Errors) != 2 {
  			t.Errorf("expected 2 errors, got %d: %v", len(got.Errors), got.Errors)
  		}
  		for _, e := range got.Errors {
  			if e.Tag != "required" {
  				t.Errorf("expected tag 'required', got %q", e.Tag)
  			}
  		}
  	})

  	t.Run("format email invalido produce error", func(t *testing.T) {
  		type Cfg struct {
  			Email string `format:"email"`
  		}
  		got := ConfigValidate(Cfg{Email: "not-an-email"})
  		if got.IsValid {
  			t.Error("expected IsValid=false")
  		}
  		if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
  			t.Errorf("expected format error, got %v", got.Errors)
  		}
  	})

  	t.Run("format email valido pasa", func(t *testing.T) {
  		type Cfg struct {
  			Email string `format:"email"`
  		}
  		got := ConfigValidate(Cfg{Email: "user@example.com"})
  		if !got.IsValid {
  			t.Errorf("expected IsValid=true, errors: %v", got.Errors)
  		}
  	})

  	t.Run("format url invalido produce error", func(t *testing.T) {
  		type Cfg struct {
  			BaseURL string `format:"url"`
  		}
  		got := ConfigValidate(Cfg{BaseURL: "ftp://bad.url"})
  		if got.IsValid {
  			t.Error("expected IsValid=false")
  		}
  		if len(got.Errors) == 0 || got.Errors[0].Tag != "format" {
  			t.Errorf("expected format error, got %v", got.Errors)
  		}
  	})

  	t.Run("puntero a struct funciona igual", func(t *testing.T) {
  		type Cfg struct {
  			Name string `required:"true"`
  		}
  		got := ConfigValidate(&Cfg{Name: "test"})
  		if !got.IsValid {
  			t.Errorf("expected IsValid=true, errors: %v", got.Errors)
  		}
  	})

  	t.Run("todos los errores acumulados aunque falle el primero", func(t *testing.T) {
  		type Cfg struct {
  			A string `required:"true"`
  			B string `required:"true"`
  			C string `required:"true"`
  		}
  		got := ConfigValidate(Cfg{})
  		if len(got.Errors) != 3 {
  			t.Errorf("expected 3 errors, got %d", len(got.Errors))
  		}
  	})
  }


id: cron_ticker_go_infra_t0
function_id: cron_ticker_go_infra
name: TestCronTicker
file_path: functions/infra/cron_ticker_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCronTicker(t *testing.T) {
  	t.Run("context cancel cierra el channel", func(t *testing.T) {
  		sched := CronTickerSchedule{
  			Minute:     allMinutes(),
  			Hour:       allHours(),
  			DayOfMonth: allDays(),
  			Month:      allMonths(),
  			DayOfWeek:  allDOW(),
  		}
  		ctx, cancel := context.WithCancel(context.Background())
  		ch := CronTicker(sched, ctx)

  		// Cancel immediately.
  		cancel()

  		// Channel should close without blocking.
  		timeout := time.After(2 * time.Second)
  		select {
  		case _, ok := <-ch:
  			if ok {
  				// Might receive one tick before cancel propagates — acceptable.
  			}
  			// Drain remaining.
  			for range ch {
  			}
  		case <-timeout:
  			t.Error("channel did not close within 2s after context cancel")
  		}
  	})

  	t.Run("ticker emite al llegar el momento del schedule", func(t *testing.T) {
  		// Use a schedule that fires every minute (all minutes).
  		// The next tick is at most 60s away. We use a short-lived context
  		// to avoid waiting: instead we verify the channel is not nil and
  		// that cancellation closes it cleanly.
  		sched := CronTickerSchedule{
  			Minute:     allMinutes(),
  			Hour:       allHours(),
  			DayOfMonth: allDays(),
  			Month:      allMonths(),
  			DayOfWeek:  allDOW(),
  		}
  		ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
  		defer cancel()

  		ch := CronTicker(sched, ctx)
  		if ch == nil {
  			t.Fatal("CronTicker returned nil channel")
  		}

  		// Wait for context to expire, then confirm channel closes.
  		<-ctx.Done()
  		timeout := time.After(2 * time.Second)
  		for {
  			select {
  			case _, ok := <-ch:
  				if !ok {
  					return // channel closed, test passes
  				}
  			case <-timeout:
  				t.Error("channel did not close within 2s after context timeout")
  				return
  			}
  		}
  	})
  }


id: crud_create_handler_go_infra_t0
function_id: crud_create_handler_go_infra
name: TestCRUDDefineResource
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDDefineResource(t *testing.T) {
  	t.Run("construye un recurso valido", func(t *testing.T) {
  		res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), false)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if res.Name != "project" || res.Table != "projects" || len(res.Fields) != 4 {
  			t.Errorf("unexpected resource: %+v", res)
  		}
  	})

  	t.Run("rechaza nombre vacio", func(t *testing.T) {
  		_, err := CRUDDefineResource("", "projects", sampleProjectFields(), false)
  		if err == nil {
  			t.Error("expected error for empty name")
  		}
  	})

  	t.Run("rechaza tabla vacia", func(t *testing.T) {
  		_, err := CRUDDefineResource("project", "", sampleProjectFields(), false)
  		if err == nil {
  			t.Error("expected error for empty table")
  		}
  	})

  	t.Run("rechaza lista de campos vacia", func(t *testing.T) {
  		_, err := CRUDDefineResource("project", "projects", nil, false)
  		if err == nil {
  			t.Error("expected error for empty fields")
  		}
  	})

  	t.Run("rechaza tipos invalidos", func(t *testing.T) {
  		_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: "x", Type: "FOO"}}, false)
  		if err == nil {
  			t.Error("expected error for invalid type")
  		}
  	})

  	t.Run("rechaza nombres reservados", func(t *testing.T) {
  		for _, reserved := range []string{"id", "created_at", "updated_at", "deleted_at"} {
  			_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: reserved, Type: "TEXT"}}, false)
  			if err == nil {
  				t.Errorf("expected error for reserved field %q", reserved)
  			}
  		}
  	})

  	t.Run("rechaza duplicados", func(t *testing.T) {
  		_, err := CRUDDefineResource("project", "projects", []CRUDField{
  			{Name: "name", Type: "TEXT"},
  			{Name: "name", Type: "TEXT"},
  		}, false)
  		if err == nil {
  			t.Error("expected error for duplicate field")
  		}
  	})
  }

  // --- CRUDGenerateTableSQL ---


id: crud_create_handler_go_infra_t1
function_id: crud_create_handler_go_infra
name: TestCRUDGenerateTableSQL
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDGenerateTableSQL(t *testing.T) {
  	t.Run("genera tabla basica con timestamps", func(t *testing.T) {
  		res, _ := CRUDDefineResource("project", "projects",
  			[]CRUDField{{Name: "name", Type: "TEXT"}}, false)
  		ddl := CRUDGenerateTableSQL(res)
  		for _, want := range []string{
  			"CREATE TABLE IF NOT EXISTS projects",
  			"id TEXT PRIMARY KEY",
  			"name TEXT",
  			"created_at TEXT NOT NULL",
  			"updated_at TEXT NOT NULL",
  		} {
  			if !strings.Contains(ddl, want) {
  				t.Errorf("DDL missing %q:\n%s", want, ddl)
  			}
  		}
  		if strings.Contains(ddl, "deleted_at") {
  			t.Errorf("DDL should not contain deleted_at:\n%s", ddl)
  		}
  	})

  	t.Run("aplica NOT NULL y UNIQUE", func(t *testing.T) {
  		res, _ := CRUDDefineResource("project", "projects",
  			[]CRUDField{{Name: "name", Type: "TEXT", Required: true, Unique: true}}, false)
  		ddl := CRUDGenerateTableSQL(res)
  		if !strings.Contains(ddl, "name TEXT NOT NULL UNIQUE") {
  			t.Errorf("expected NOT NULL UNIQUE:\n%s", ddl)
  		}
  	})

  	t.Run("aplica DEFAULT", func(t *testing.T) {
  		res, _ := CRUDDefineResource("project", "projects",
  			[]CRUDField{{Name: "priority", Type: "INTEGER", Default: "0"}}, false)
  		ddl := CRUDGenerateTableSQL(res)
  		if !strings.Contains(ddl, "priority INTEGER DEFAULT 0") {
  			t.Errorf("expected DEFAULT clause:\n%s", ddl)
  		}
  	})

  	t.Run("anade deleted_at si soft_delete", func(t *testing.T) {
  		res, _ := CRUDDefineResource("project", "projects",
  			[]CRUDField{{Name: "name", Type: "TEXT"}}, true)
  		ddl := CRUDGenerateTableSQL(res)
  		if !strings.Contains(ddl, "deleted_at TEXT") {
  			t.Errorf("expected deleted_at for soft_delete:\n%s", ddl)
  		}
  	})

  	t.Run("el DDL generado es valido en sqlite", func(t *testing.T) {
  		res, _ := CRUDDefineResource("project", "projects", sampleProjectFields(), true)
  		db := openCRUDTestDB(t)
  		if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
  			t.Fatalf("DDL not valid: %v", err)
  		}
  	})
  }

  // --- CRUDCreateHandler + CRUDGetHandler ---


id: crud_create_handler_go_infra_t2
function_id: crud_create_handler_go_infra
name: TestCRUDCreateAndGet
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDCreateAndGet(t *testing.T) {
  	res, db := buildProjectResource(t, false)
  	mux := http.NewServeMux()
  	CRUDRegisterRoutes(mux, "/api/projects", res, db)

  	t.Run("crea un registro valido y retorna 201", func(t *testing.T) {
  		rec, body := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  			"name": "demo", "description": "hola", "status": "active", "priority": 5,
  		})
  		if rec.Code != http.StatusCreated {
  			t.Fatalf("got status %d, want 201: body=%s", rec.Code, rec.Body.String())
  		}
  		if body["name"] != "demo" {
  			t.Errorf("got name=%v, want demo", body["name"])
  		}
  		if body["id"] == nil || body["id"] == "" {
  			t.Errorf("expected generated id, got %v", body["id"])
  		}
  		if body["created_at"] == nil {
  			t.Errorf("expected created_at, got nil")
  		}
  	})

  	t.Run("retorna 400 si faltan required", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400", rec.Code)
  		}
  	})

  	t.Run("valida min_length", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": ""})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400 for empty name", rec.Code)
  		}
  	})

  	t.Run("valida max_length", func(t *testing.T) {
  		longName := strings.Repeat("a", 60)
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": longName})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400 for too-long name", rec.Code)
  		}
  	})

  	t.Run("valida enum", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  			"name": "x", "status": "pirate",
  		})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400 for invalid enum", rec.Code)
  		}
  	})

  	t.Run("valida min y max numericos", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  			"name": "numeric-min", "priority": -1,
  		})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400 for priority<0", rec.Code)
  		}
  		rec, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  			"name": "numeric-max", "priority": 999,
  		})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400 for priority>10", rec.Code)
  		}
  	})

  	t.Run("retorna 409 si se viola UNIQUE", func(t *testing.T) {
  		_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
  		if rec.Code != http.StatusConflict {
  			t.Errorf("got %d, want 409", rec.Code)
  		}
  	})

  	t.Run("GET recupera el registro creado", func(t *testing.T) {
  		rec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "get-me"})
  		if rec.Code != http.StatusCreated {
  			t.Fatalf("create failed: %d", rec.Code)
  		}
  		id := created["id"].(string)
  		rec, body := doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
  		if rec.Code != http.StatusOK {
  			t.Errorf("got %d, want 200", rec.Code)
  		}
  		if body["name"] != "get-me" {
  			t.Errorf("got name=%v, want get-me", body["name"])
  		}
  	})

  	t.Run("GET no existente retorna 404", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "GET", "/api/projects/nonexistent", nil)
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("got %d, want 404", rec.Code)
  		}
  	})
  }

  // --- CRUDListHandler ---


id: crud_create_handler_go_infra_t3
function_id: crud_create_handler_go_infra
name: TestCRUDListHandler
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDListHandler(t *testing.T) {
  	res, db := buildProjectResource(t, false)
  	mux := http.NewServeMux()
  	CRUDRegisterRoutes(mux, "/api/projects", res, db)

  	// Sembrar datos
  	for i, name := range []string{"a", "b", "c", "d", "e"} {
  		status := "active"
  		if i%2 == 1 {
  			status = "archived"
  		}
  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  			"name": name, "status": status, "priority": i,
  		})
  		if rec.Code != http.StatusCreated {
  			t.Fatalf("seed %s failed: %d", name, rec.Code)
  		}
  	}

  	t.Run("lista todo con paginacion default", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
  		if rec.Code != http.StatusOK {
  			t.Fatalf("got %d, want 200", rec.Code)
  		}
  		var got CRUDListResult
  		if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
  			t.Fatalf("parse body: %v", err)
  		}
  		if got.Total != 5 {
  			t.Errorf("got total=%d, want 5", got.Total)
  		}
  		if len(got.Items) != 5 {
  			t.Errorf("got %d items, want 5", len(got.Items))
  		}
  	})

  	t.Run("respeta page y per_page", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?page=1&per_page=2", nil))
  		var got CRUDListResult
  		_ = json.Unmarshal(rec.Body.Bytes(), &got)
  		if len(got.Items) != 2 {
  			t.Errorf("got %d items, want 2", len(got.Items))
  		}
  		if got.TotalPages != 3 {
  			t.Errorf("got total_pages=%d, want 3", got.TotalPages)
  		}
  	})

  	t.Run("filtra por campo con filter_<field>", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_status=archived", nil))
  		var got CRUDListResult
  		_ = json.Unmarshal(rec.Body.Bytes(), &got)
  		if got.Total != 2 {
  			t.Errorf("got total=%d, want 2 archived", got.Total)
  		}
  	})

  	t.Run("ordena con sort_by y sort_dir", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?sort_by=name&sort_dir=asc", nil))
  		var got CRUDListResult
  		_ = json.Unmarshal(rec.Body.Bytes(), &got)
  		if len(got.Items) < 2 {
  			t.Fatalf("not enough items: %d", len(got.Items))
  		}
  		if got.Items[0]["name"] != "a" || got.Items[1]["name"] != "b" {
  			t.Errorf("sort asc failed: first=%v second=%v", got.Items[0]["name"], got.Items[1]["name"])
  		}
  	})

  	t.Run("ignora filtros con campos desconocidos", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_unknown=xxx", nil))
  		var got CRUDListResult
  		_ = json.Unmarshal(rec.Body.Bytes(), &got)
  		if got.Total != 5 {
  			t.Errorf("got total=%d, want 5 (filter should be ignored)", got.Total)
  		}
  	})
  }

  // --- CRUDUpdateHandler ---


id: crud_create_handler_go_infra_t4
function_id: crud_create_handler_go_infra
name: TestCRUDUpdateHandler
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDUpdateHandler(t *testing.T) {
  	res, db := buildProjectResource(t, false)
  	mux := http.NewServeMux()
  	CRUDRegisterRoutes(mux, "/api/projects", res, db)

  	createRec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
  		"name": "original", "description": "first", "priority": 2,
  	})
  	if createRec.Code != http.StatusCreated {
  		t.Fatalf("create failed: %d", createRec.Code)
  	}
  	id := created["id"].(string)
  	originalUpdatedAt := fmt.Sprintf("%v", created["updated_at"])

  	t.Run("actualiza solo los campos enviados", func(t *testing.T) {
  		rec, body := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{
  			"description": "updated",
  		})
  		if rec.Code != http.StatusOK {
  			t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
  		}
  		if body["description"] != "updated" {
  			t.Errorf("description not updated: %v", body["description"])
  		}
  		if body["name"] != "original" {
  			t.Errorf("name should not change: %v", body["name"])
  		}
  		if fmt.Sprintf("%v", body["updated_at"]) == originalUpdatedAt {
  			t.Errorf("updated_at should change")
  		}
  	})

  	t.Run("retorna 404 si no existe", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/nonexistent", map[string]any{"description": "x"})
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("got %d, want 404", rec.Code)
  		}
  	})

  	t.Run("valida los campos enviados", func(t *testing.T) {
  		rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{"priority": 999})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400", rec.Code)
  		}
  	})
  }

  // --- CRUDDeleteHandler ---


id: crud_create_handler_go_infra_t5
function_id: crud_create_handler_go_infra
name: TestCRUDDeleteHandler
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDDeleteHandler(t *testing.T) {
  	t.Run("hard delete borra fisicamente", func(t *testing.T) {
  		res, db := buildProjectResource(t, false)
  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", res, db)

  		_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "hard"})
  		id := created["id"].(string)

  		rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
  		if rec.Code != http.StatusNoContent {
  			t.Errorf("got %d, want 204", rec.Code)
  		}
  		if rec.Body.Len() != 0 {
  			t.Errorf("expected empty body, got %s", rec.Body.String())
  		}

  		// Verificar que ya no existe
  		rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("after delete, GET got %d, want 404", rec.Code)
  		}

  		// Verificar que la fila ya no esta en la tabla
  		var count int
  		if err := db.QueryRow("SELECT COUNT(*) FROM projects WHERE id = ?", id).Scan(&count); err != nil {
  			t.Fatalf("count: %v", err)
  		}
  		if count != 0 {
  			t.Errorf("hard delete should remove row, got count=%d", count)
  		}
  	})

  	t.Run("soft delete actualiza deleted_at", func(t *testing.T) {
  		res, db := buildProjectResource(t, true)
  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", res, db)

  		_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "soft"})
  		id := created["id"].(string)

  		rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
  		if rec.Code != http.StatusNoContent {
  			t.Errorf("got %d, want 204", rec.Code)
  		}

  		// GET debe dar 404 (la fila esta oculta por soft delete)
  		rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("after soft delete, GET got %d, want 404", rec.Code)
  		}

  		// Pero la fila sigue fisica en la tabla con deleted_at no nulo
  		var deletedAt sql.NullString
  		if err := db.QueryRow("SELECT deleted_at FROM projects WHERE id = ?", id).Scan(&deletedAt); err != nil {
  			t.Fatalf("select: %v", err)
  		}
  		if !deletedAt.Valid || deletedAt.String == "" {
  			t.Errorf("expected deleted_at set, got %+v", deletedAt)
  		}
  	})

  	t.Run("retorna 404 si no existe", func(t *testing.T) {
  		res, db := buildProjectResource(t, false)
  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", res, db)
  		rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/nonexistent", nil)
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("got %d, want 404", rec.Code)
  		}
  	})

  	t.Run("soft delete no lista registros ocultos", func(t *testing.T) {
  		res, db := buildProjectResource(t, true)
  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", res, db)

  		_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "keep"})
  		_, del := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "remove"})
  		delID := del["id"].(string)
  		_, _ = doJSONRequest(t, mux, "DELETE", "/api/projects/"+delID, nil)

  		rec := httptest.NewRecorder()
  		mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
  		var got CRUDListResult
  		_ = json.Unmarshal(rec.Body.Bytes(), &got)
  		if got.Total != 1 {
  			t.Errorf("got total=%d, want 1 (soft deleted should be hidden)", got.Total)
  		}
  	})
  }

  // --- CRUDGenerateHandlers + CRUDRegisterRoutes integration ---


id: crud_create_handler_go_infra_t6
function_id: crud_create_handler_go_infra
name: TestCRUDGenerateHandlers
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDGenerateHandlers(t *testing.T) {
  	t.Run("retorna las 5 keys esperadas", func(t *testing.T) {
  		res, db := buildProjectResource(t, false)
  		handlers := CRUDGenerateHandlers(res, db)
  		for _, key := range []string{"list", "get", "create", "update", "delete"} {
  			if handlers[key] == nil {
  				t.Errorf("handler %q is nil", key)
  			}
  		}
  		if len(handlers) != 5 {
  			t.Errorf("expected 5 handlers, got %d", len(handlers))
  		}
  	})
  }


id: crud_create_handler_go_infra_t7
function_id: crud_create_handler_go_infra
name: TestCRUDRegisterRoutesIntegration
file_path: functions/infra/crud_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestCRUDRegisterRoutesIntegration(t *testing.T) {
  	t.Run("CRUD completo end-to-end via servidor HTTP", func(t *testing.T) {
  		res, db := buildProjectResource(t, false)
  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", res, db)
  		srv := httptest.NewServer(mux)
  		t.Cleanup(srv.Close)

  		// Create
  		createBody, _ := json.Marshal(map[string]any{"name": "e2e", "description": "integration", "priority": 3})
  		resp, err := http.Post(srv.URL+"/api/projects", "application/json", bytes.NewReader(createBody))
  		if err != nil {
  			t.Fatalf("POST: %v", err)
  		}
  		defer resp.Body.Close()
  		if resp.StatusCode != http.StatusCreated {
  			t.Fatalf("create: got %d, want 201", resp.StatusCode)
  		}
  		var created map[string]any
  		_ = json.NewDecoder(resp.Body).Decode(&created)
  		id := created["id"].(string)

  		// Get
  		resp, err = http.Get(srv.URL + "/api/projects/" + id)
  		if err != nil {
  			t.Fatalf("GET: %v", err)
  		}
  		resp.Body.Close()
  		if resp.StatusCode != http.StatusOK {
  			t.Errorf("get: got %d, want 200", resp.StatusCode)
  		}

  		// Update
  		updateBody, _ := json.Marshal(map[string]any{"description": "modified"})
  		req, _ := http.NewRequest("PUT", srv.URL+"/api/projects/"+id, bytes.NewReader(updateBody))
  		req.Header.Set("Content-Type", "application/json")
  		resp, err = http.DefaultClient.Do(req)
  		if err != nil {
  			t.Fatalf("PUT: %v", err)
  		}
  		var updated map[string]any
  		_ = json.NewDecoder(resp.Body).Decode(&updated)
  		resp.Body.Close()
  		if resp.StatusCode != http.StatusOK {
  			t.Errorf("update: got %d, want 200", resp.StatusCode)
  		}
  		if updated["description"] != "modified" {
  			t.Errorf("update did not persist: %v", updated["description"])
  		}

  		// List
  		resp, err = http.Get(srv.URL + "/api/projects")
  		if err != nil {
  			t.Fatalf("LIST: %v", err)
  		}
  		var list CRUDListResult
  		_ = json.NewDecoder(resp.Body).Decode(&list)
  		resp.Body.Close()
  		if list.Total != 1 {
  			t.Errorf("list total: got %d, want 1", list.Total)
  		}

  		// Delete
  		req, _ = http.NewRequest("DELETE", srv.URL+"/api/projects/"+id, nil)
  		resp, err = http.DefaultClient.Do(req)
  		if err != nil {
  			t.Fatalf("DELETE: %v", err)
  		}
  		resp.Body.Close()
  		if resp.StatusCode != http.StatusNoContent {
  			t.Errorf("delete: got %d, want 204", resp.StatusCode)
  		}

  		// Get after delete: 404
  		resp, _ = http.Get(srv.URL + "/api/projects/" + id)
  		resp.Body.Close()
  		if resp.StatusCode != http.StatusNotFound {
  			t.Errorf("after delete: got %d, want 404", resp.StatusCode)
  		}
  	})

  	t.Run("multiples recursos en el mismo mux", func(t *testing.T) {
  		projectRes, db := buildProjectResource(t, false)
  		// Segundo recurso sobre la misma DB
  		userFields := []CRUDField{
  			{Name: "email", Type: "TEXT", Required: true, Unique: true},
  		}
  		userRes, err := CRUDDefineResource("user", "users", userFields, false)
  		if err != nil {
  			t.Fatalf("define user: %v", err)
  		}
  		if _, err := db.Exec(CRUDGenerateTableSQL(userRes)); err != nil {
  			t.Fatalf("create users table: %v", err)
  		}

  		mux := http.NewServeMux()
  		CRUDRegisterRoutes(mux, "/api/projects", projectRes, db)
  		CRUDRegisterRoutes(mux, "/api/users", userRes, db)

  		rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "p1"})
  		if rec.Code != http.StatusCreated {
  			t.Errorf("projects: got %d", rec.Code)
  		}
  		rec, _ = doJSONRequest(t, mux, "POST", "/api/users", map[string]any{"email": "a@b.c"})
  		if rec.Code != http.StatusCreated {
  			t.Errorf("users: got %d", rec.Code)
  		}
  	})
  }


id: docker_build_image_go_infra_t0
function_id: docker_build_image_go_infra
name: TestDockerBuildImage
file_path: functions/infra/docker_build_image_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerBuildImage(t *testing.T) {
  	t.Run("build sin build args retorna image ID", func(t *testing.T) {
  		// Crear un Dockerfile temporal minimo
  		dir := t.TempDir()
  		dockerfile := filepath.Join(dir, "Dockerfile")
  		if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0644); err != nil {
  			t.Skip("no se pudo crear Dockerfile temporal:", err)
  		}

  		id, err := DockerBuildImage(dir, "test-fn-registry-scratch:latest", nil)
  		if err != nil {
  			// Docker puede no estar disponible en CI — skip en vez de fail
  			t.Skipf("docker build no disponible: %v", err)
  		}
  		if id == "" {
  			t.Error("se esperaba un image ID no vacio")
  		}

  		// Limpiar
  		_ = DockerRemoveImage("test-fn-registry-scratch:latest", true)
  	})

  	t.Run("build con build args incluye --build-arg", func(t *testing.T) {
  		dir := t.TempDir()
  		dockerfile := filepath.Join(dir, "Dockerfile")
  		content := "FROM scratch\nARG MY_VERSION\n"
  		if err := os.WriteFile(dockerfile, []byte(content), 0644); err != nil {
  			t.Skip("no se pudo crear Dockerfile:", err)
  		}

  		id, err := DockerBuildImage(dir, "test-fn-registry-args:latest", map[string]string{
  			"MY_VERSION": "42",
  		})
  		if err != nil {
  			t.Skipf("docker build no disponible: %v", err)
  		}
  		if id == "" {
  			t.Error("se esperaba un image ID no vacio")
  		}

  		_ = DockerRemoveImage("test-fn-registry-args:latest", true)
  	})

  	t.Run("error si contextDir no existe", func(t *testing.T) {
  		_, err := DockerBuildImage("/ruta/que/no/existe/nunca", "test:latest", nil)
  		if err == nil {
  			t.Error("se esperaba error para directorio inexistente")
  		}
  	})
  }


id: docker_compose_down_go_infra_t0
function_id: docker_compose_down_go_infra
name: TestDockerComposeDown
file_path: functions/infra/docker_compose_down_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerComposeDown(t *testing.T) {
  	t.Run("removeVolumes true agrega flag -v al comando", func(t *testing.T) {
  		// Verificar que docker compose esta disponible
  		if err := exec.Command("docker", "compose", "version").Run(); err != nil {
  			t.Skip("docker compose no disponible")
  		}

  		// Con archivo inexistente debe fallar pero con el mensaje correcto
  		_, err := DockerComposeDown("/ruta/inexistente/docker-compose.yml", true)
  		if err == nil {
  			t.Error("se esperaba error para archivo inexistente")
  		}
  		errStr := strings.ToLower(err.Error())
  		if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
  			t.Logf("error obtenido: %v", err)
  		}
  	})

  	t.Run("error si composeFile no existe", func(t *testing.T) {
  		_, err := DockerComposeDown("/ruta/que/no/existe/docker-compose.yml", false)
  		if err == nil {
  			t.Error("se esperaba error para archivo inexistente")
  		}
  	})
  }


id: docker_compose_up_go_infra_t0
function_id: docker_compose_up_go_infra
name: TestDockerComposeUp
file_path: functions/infra/docker_compose_up_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerComposeUp(t *testing.T) {
  	t.Run("detach true agrega flag -d al comando", func(t *testing.T) {
  		// Verificar que docker compose esta disponible
  		if err := exec.Command("docker", "compose", "version").Run(); err != nil {
  			t.Skip("docker compose no disponible")
  		}

  		// Con archivo inexistente y detach, debe fallar pero con el mensaje correcto
  		_, err := DockerComposeUp("/ruta/inexistente/docker-compose.yml", true)
  		if err == nil {
  			t.Error("se esperaba error para archivo inexistente")
  		}
  		// El error debe mencionar el archivo o compose
  		errStr := strings.ToLower(err.Error())
  		if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
  			t.Logf("error obtenido: %v", err)
  		}
  	})

  	t.Run("error si composeFile no existe", func(t *testing.T) {
  		_, err := DockerComposeUp("/ruta/que/no/existe/docker-compose.yml", false)
  		if err == nil {
  			t.Error("se esperaba error para archivo inexistente")
  		}
  	})
  }


id: docker_volume_create_go_infra_t0
function_id: docker_volume_create_go_infra
name: TestDockerVolumeCreate
file_path: functions/infra/docker_volume_create_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerVolumeCreate(t *testing.T) {
  	t.Run("crea volume y retorna nombre", func(t *testing.T) {
  		name := "fn-registry-test-vol-create"

  		got, err := DockerVolumeCreate(name)
  		if err != nil {
  			t.Skipf("docker no disponible: %v", err)
  		}

  		if strings.TrimSpace(got) != name {
  			t.Errorf("got %q, want %q", got, name)
  		}

  		// Limpiar
  		_ = DockerVolumeRemove(name, false)
  	})

  	t.Run("idempotente si el volume ya existe", func(t *testing.T) {
  		name := "fn-registry-test-vol-idempotent"

  		first, err := DockerVolumeCreate(name)
  		if err != nil {
  			t.Skipf("docker no disponible: %v", err)
  		}
  		defer DockerVolumeRemove(name, false) //nolint

  		second, err := DockerVolumeCreate(name)
  		if err != nil {
  			t.Errorf("segunda llamada fallo: %v", err)
  		}
  		if first != second {
  			t.Errorf("nombres distintos: %q vs %q", first, second)
  		}
  	})
  }


id: docker_volume_list_go_infra_t0
function_id: docker_volume_list_go_infra
name: TestDockerVolumeList
file_path: functions/infra/docker_volume_list_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerVolumeList(t *testing.T) {
  	t.Run("lista vacia retorna nil sin error", func(t *testing.T) {
  		// Verificar que la funcion no falla incluso si no hay volumes
  		volumes, err := DockerVolumeList()
  		if err != nil {
  			t.Skipf("docker no disponible: %v", err)
  		}
  		// volumes puede ser nil o no segun el sistema — no es un error
  		_ = volumes
  	})

  	t.Run("parsea campos Driver y Name correctamente", func(t *testing.T) {
  		name := "fn-registry-test-vol-list"
  		_, err := DockerVolumeCreate(name)
  		if err != nil {
  			t.Skipf("docker no disponible: %v", err)
  		}
  		defer DockerVolumeRemove(name, false) //nolint

  		volumes, err := DockerVolumeList()
  		if err != nil {
  			t.Fatalf("DockerVolumeList: %v", err)
  		}

  		found := false
  		for _, v := range volumes {
  			if v["Name"] == name {
  				found = true
  				if v["Driver"] == "" {
  					t.Error("Driver vacio para volume existente")
  				}
  				break
  			}
  		}
  		if !found {
  			t.Errorf("volume %q no encontrado en la lista", name)
  		}
  	})
  }


id: docker_volume_remove_go_infra_t0
function_id: docker_volume_remove_go_infra
name: TestDockerVolumeRemove
file_path: functions/infra/docker_volume_remove_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDockerVolumeRemove(t *testing.T) {
  	t.Run("error si volume no existe", func(t *testing.T) {
  		err := DockerVolumeRemove("fn-registry-vol-que-no-existe-xyz", false)
  		if err == nil {
  			t.Error("se esperaba error para volume inexistente")
  		}
  	})

  	t.Run("force flag incluye -f en el comando", func(t *testing.T) {
  		name := "fn-registry-test-vol-remove"
  		_, err := DockerVolumeCreate(name)
  		if err != nil {
  			t.Skipf("docker no disponible: %v", err)
  		}

  		err = DockerVolumeRemove(name, true)
  		if err != nil {
  			t.Errorf("DockerVolumeRemove con force: %v", err)
  		}
  	})
  }


id: dotenv_load_go_infra_t0
function_id: dotenv_load_go_infra
name: TestDotenvLoad
file_path: functions/infra/dotenv_load_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestDotenvLoad(t *testing.T) {
  	// helper to write a temp .env file
  	writeEnv := func(t *testing.T, content string) string {
  		t.Helper()
  		dir := t.TempDir()
  		p := filepath.Join(dir, ".env")
  		if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
  			t.Fatal(err)
  		}
  		return p
  	}

  	t.Run("parsea KEY=VALUE y setea variable", func(t *testing.T) {
  		p := writeEnv(t, "DOTENV_TEST_FOO=hello\n")
  		t.Setenv("DOTENV_TEST_FOO", "") // ensure unset
  		os.Unsetenv("DOTENV_TEST_FOO")

  		if err := DotenvLoad(p); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got := os.Getenv("DOTENV_TEST_FOO"); got != "hello" {
  			t.Errorf("expected DOTENV_TEST_FOO=hello, got %q", got)
  		}
  	})

  	t.Run("ignora lineas comentario y vacias", func(t *testing.T) {
  		content := "# this is a comment\n\nDOTENV_TEST_BAR=world\n"
  		p := writeEnv(t, content)
  		os.Unsetenv("DOTENV_TEST_BAR")

  		if err := DotenvLoad(p); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got := os.Getenv("DOTENV_TEST_BAR"); got != "world" {
  			t.Errorf("expected world, got %q", got)
  		}
  	})

  	t.Run("no sobreescribe variables ya seteadas", func(t *testing.T) {
  		p := writeEnv(t, "DOTENV_TEST_EXISTING=new\n")
  		t.Setenv("DOTENV_TEST_EXISTING", "original")

  		if err := DotenvLoad(p); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got := os.Getenv("DOTENV_TEST_EXISTING"); got != "original" {
  			t.Errorf("expected original (not overwritten), got %q", got)
  		}
  	})

  	t.Run("valores con comillas dobles se stripean", func(t *testing.T) {
  		p := writeEnv(t, `DOTENV_TEST_QUOTED="quoted value"`)
  		os.Unsetenv("DOTENV_TEST_QUOTED")

  		if err := DotenvLoad(p); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got := os.Getenv("DOTENV_TEST_QUOTED"); got != "quoted value" {
  			t.Errorf("expected 'quoted value', got %q", got)
  		}
  	})

  	t.Run("valores con comillas simples se stripean", func(t *testing.T) {
  		p := writeEnv(t, "DOTENV_TEST_SINGLE='single quoted'")
  		os.Unsetenv("DOTENV_TEST_SINGLE")

  		if err := DotenvLoad(p); err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got := os.Getenv("DOTENV_TEST_SINGLE"); got != "single quoted" {
  			t.Errorf("expected 'single quoted', got %q", got)
  		}
  	})

  	t.Run("archivo inexistente retorna error", func(t *testing.T) {
  		err := DotenvLoad("/nonexistent/.env.does.not.exist")
  		if err == nil {
  			t.Error("expected error for missing file")
  		}
  	})

  	t.Run("linea sin signo igual retorna error", func(t *testing.T) {
  		p := writeEnv(t, "INVALID_LINE\n")
  		err := DotenvLoad(p)
  		if err == nil {
  			t.Error("expected error for line without '='")
  		}
  	})
  }


id: email_build_html_go_infra_t0
function_id: email_build_html_go_infra
name: TestEmailBuildHTML
file_path: functions/infra/email_build_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEmailBuildHTML(t *testing.T) {
  	t.Run("construye mensaje html con campos basicos", func(t *testing.T) {
  		msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Hola", "<b>Hola</b>")
  		if msg.From != "alice@example.com" {
  			t.Errorf("From: got %q, want %q", msg.From, "alice@example.com")
  		}
  		if len(msg.To) != 1 || msg.To[0] != "bob@example.com" {
  			t.Errorf("To: got %v, want [bob@example.com]", msg.To)
  		}
  		if msg.Subject != "Hola" {
  			t.Errorf("Subject: got %q, want %q", msg.Subject, "Hola")
  		}
  		if msg.BodyHTML != "<b>Hola</b>" {
  			t.Errorf("BodyHTML: got %q, want %q", msg.BodyHTML, "<b>Hola</b>")
  		}
  	})

  	t.Run("copia el slice to para evitar aliasing", func(t *testing.T) {
  		to := []string{"a@example.com", "b@example.com"}
  		msg := EmailBuildHTML("x@x.com", to, "s", "h")
  		to[0] = "mutated@example.com"
  		if msg.To[0] == "mutated@example.com" {
  			t.Error("To slice was aliased — mutation affected the message")
  		}
  	})

  	t.Run("campos no usados quedan vacios", func(t *testing.T) {
  		msg := EmailBuildHTML("f@example.com", []string{"t@example.com"}, "s", "h")
  		if msg.BodyText != "" {
  			t.Errorf("BodyText should be empty, got %q", msg.BodyText)
  		}
  		if len(msg.CC) != 0 {
  			t.Errorf("CC should be empty, got %v", msg.CC)
  		}
  		if len(msg.BCC) != 0 {
  			t.Errorf("BCC should be empty, got %v", msg.BCC)
  		}
  		if len(msg.Attachments) != 0 {
  			t.Errorf("Attachments should be empty, got %v", msg.Attachments)
  		}
  	})
  }


id: email_build_html_go_infra_t1
function_id: email_build_html_go_infra
name: TestEmailBuildText
file_path: functions/infra/email_build_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEmailBuildText(t *testing.T) {
  	t.Run("construye mensaje text con campos basicos", func(t *testing.T) {
  		msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Alerta", "Servidor caido")
  		if msg.BodyText != "Servidor caido" {
  			t.Errorf("BodyText: got %q, want %q", msg.BodyText, "Servidor caido")
  		}
  		if msg.From != "alice@example.com" {
  			t.Errorf("From: got %q, want %q", msg.From, "alice@example.com")
  		}
  		if msg.Subject != "Alerta" {
  			t.Errorf("Subject: got %q, want %q", msg.Subject, "Alerta")
  		}
  	})

  	t.Run("body html queda vacio", func(t *testing.T) {
  		msg := EmailBuildText("f@x.com", []string{"t@x.com"}, "s", "body")
  		if msg.BodyHTML != "" {
  			t.Errorf("BodyHTML should be empty, got %q", msg.BodyHTML)
  		}
  	})
  }


id: email_build_html_go_infra_t2
function_id: email_build_html_go_infra
name: TestEmailWithAttachment
file_path: functions/infra/email_build_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEmailWithAttachment(t *testing.T) {
  	t.Run("agrega adjunto sin mutar el original", func(t *testing.T) {
  		orig := EmailBuildHTML("a@example.com", []string{"b@example.com"}, "s", "h")
  		att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("data")}
  		msg2 := EmailWithAttachment(orig, att)

  		if len(orig.Attachments) != 0 {
  			t.Errorf("original was mutated: got %d attachments, want 0", len(orig.Attachments))
  		}
  		if len(msg2.Attachments) != 1 {
  			t.Errorf("got %d attachments, want 1", len(msg2.Attachments))
  		}
  		if msg2.Attachments[0].Filename != "f.pdf" {
  			t.Errorf("Filename: got %q, want %q", msg2.Attachments[0].Filename, "f.pdf")
  		}
  	})

  	t.Run("multiples adjuntos se acumulan", func(t *testing.T) {
  		msg := EmailBuildHTML("a@x.com", []string{"b@x.com"}, "s", "h")
  		msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a1.txt", ContentType: "text/plain", Data: []byte("1")})
  		msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a2.txt", ContentType: "text/plain", Data: []byte("2")})
  		if len(msg.Attachments) != 2 {
  			t.Errorf("got %d attachments, want 2", len(msg.Attachments))
  		}
  	})

  	t.Run("copia todos los campos del mensaje", func(t *testing.T) {
  		orig := EmailMessage{
  			From:     "a@x.com",
  			To:       []string{"b@x.com"},
  			CC:       []string{"c@x.com"},
  			BCC:      []string{"d@x.com"},
  			Subject:  "Test",
  			BodyHTML: "<p>Hi</p>",
  			BodyText: "Hi",
  			Headers:  map[string]string{"X-Test": "value"},
  		}
  		att := EmailAttachment{Filename: "f.png", ContentType: "image/png", Data: []byte("img")}
  		msg2 := EmailWithAttachment(orig, att)

  		if msg2.From != orig.From {
  			t.Errorf("From mismatch: %q vs %q", msg2.From, orig.From)
  		}
  		if msg2.Subject != orig.Subject {
  			t.Errorf("Subject mismatch: %q vs %q", msg2.Subject, orig.Subject)
  		}
  		if len(msg2.CC) != 1 || msg2.CC[0] != "c@x.com" {
  			t.Errorf("CC not copied: %v", msg2.CC)
  		}
  		if msg2.Headers["X-Test"] != "value" {
  			t.Errorf("Headers not copied: %v", msg2.Headers)
  		}
  	})
  }


id: email_template_render_go_infra_t0
function_id: email_template_render_go_infra
name: TestEmailTemplateRender
file_path: functions/infra/email_template_render_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEmailTemplateRender(t *testing.T) {
  	t.Run("renderiza template simple con datos", func(t *testing.T) {
  		got, err := EmailTemplateRender("Hola {{.Name}}", map[string]any{"Name": "Alice"})
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got != "Hola Alice" {
  			t.Errorf("got %q, want %q", got, "Hola Alice")
  		}
  	})

  	t.Run("sustituye multiples variables", func(t *testing.T) {
  		tmpl := "Pedido {{.OrderID}} para {{.Customer}} listo."
  		got, err := EmailTemplateRender(tmpl, map[string]any{"OrderID": "ORD-42", "Customer": "Bob"})
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if !strings.Contains(got, "ORD-42") || !strings.Contains(got, "Bob") {
  			t.Errorf("got %q, expected OrderID and Customer", got)
  		}
  	})

  	t.Run("error en template invalido", func(t *testing.T) {
  		_, err := EmailTemplateRender("{{.Unclosed", nil)
  		if err == nil {
  			t.Error("expected error for invalid template, got nil")
  		}
  	})

  	t.Run("template vacio retorna string vacio", func(t *testing.T) {
  		got, err := EmailTemplateRender("", nil)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got != "" {
  			t.Errorf("got %q, want empty string", got)
  		}
  	})
  }


id: env_require_go_infra_t0
function_id: env_require_go_infra
name: TestEnvRequire
file_path: functions/infra/env_require_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEnvRequire(t *testing.T) {
  	t.Run("retorna valor cuando variable esta seteada", func(t *testing.T) {
  		t.Setenv("ENV_REQUIRE_TEST_KEY", "myvalue")
  		got, err := EnvRequire("ENV_REQUIRE_TEST_KEY")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got != "myvalue" {
  			t.Errorf("expected myvalue, got %q", got)
  		}
  	})

  	t.Run("retorna error con mensaje descriptivo si variable no existe", func(t *testing.T) {
  		got, err := EnvRequire("ENV_REQUIRE_DEFINITELY_NOT_SET_XYZ")
  		if err == nil {
  			t.Error("expected error for missing variable")
  		}
  		if got != "" {
  			t.Errorf("expected empty string on error, got %q", got)
  		}
  		if err != nil && len(err.Error()) == 0 {
  			t.Error("error message should not be empty")
  		}
  	})

  	t.Run("variable vacia se trata como no seteada", func(t *testing.T) {
  		t.Setenv("ENV_REQUIRE_EMPTY_KEY", "")
  		_, err := EnvRequire("ENV_REQUIRE_EMPTY_KEY")
  		if err == nil {
  			t.Error("expected error for empty variable")
  		}
  	})
  }


id: env_require_go_infra_t1
function_id: env_require_go_infra
name: TestEnvRequireAll
file_path: functions/infra/env_require_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestEnvRequireAll(t *testing.T) {
  	t.Run("retorna mapa con todos los valores cuando estan seteados", func(t *testing.T) {
  		t.Setenv("REQUIRE_ALL_A", "alpha")
  		t.Setenv("REQUIRE_ALL_B", "beta")
  		got, err := EnvRequireAll([]string{"REQUIRE_ALL_A", "REQUIRE_ALL_B"})
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if got["REQUIRE_ALL_A"] != "alpha" || got["REQUIRE_ALL_B"] != "beta" {
  			t.Errorf("unexpected map: %v", got)
  		}
  	})

  	t.Run("acumula todos los errores de variables faltantes", func(t *testing.T) {
  		_, err := EnvRequireAll([]string{"REQUIRE_ALL_MISSING_1", "REQUIRE_ALL_MISSING_2"})
  		if err == nil {
  			t.Error("expected error")
  		}
  		msg := err.Error()
  		if len(msg) == 0 {
  			t.Error("expected non-empty error message")
  		}
  	})

  	t.Run("lista vacia retorna mapa vacio sin error", func(t *testing.T) {
  		got, err := EnvRequireAll([]string{})
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(got) != 0 {
  			t.Errorf("expected empty map, got %v", got)
  		}
  	})
  }


id: file_delete_go_infra_t0
function_id: file_delete_go_infra
name: TestFileDelete
file_path: functions/infra/file_delete_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFileDelete(t *testing.T) {
  	t.Run("elimina archivo existente", func(t *testing.T) {
  		dir := t.TempDir()
  		path := filepath.Join(dir, "x.txt")
  		if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
  			t.Fatalf("setup: %v", err)
  		}
  		if err := FileDelete(path); err != nil {
  			t.Fatalf("FileDelete err: %v", err)
  		}
  		if _, err := os.Stat(path); !os.IsNotExist(err) {
  			t.Errorf("archivo aun existe: %v", err)
  		}
  	})

  	t.Run("rechaza path con ..", func(t *testing.T) {
  		err := FileDelete("./uploads/../etc/passwd")
  		if err == nil || !strings.Contains(err.Error(), "path traversal") {
  			t.Errorf("got err %v, want path traversal", err)
  		}
  	})

  	t.Run("rechaza path vacio", func(t *testing.T) {
  		err := FileDelete("")
  		if err == nil {
  			t.Error("got nil, want error")
  		}
  	})

  	t.Run("retorna error si no existe", func(t *testing.T) {
  		err := FileDelete(filepath.Join(t.TempDir(), "nope.txt"))
  		if err == nil {
  			t.Error("got nil, want error not found")
  		}
  	})
  }


id: file_save_disk_go_infra_t0
function_id: file_save_disk_go_infra
name: TestFileSaveDisk
file_path: functions/infra/file_save_disk_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFileSaveDisk(t *testing.T) {
  	t.Run("guarda contenido en baseDir con nombre UUID", func(t *testing.T) {
  		dir := t.TempDir()
  		body := strings.NewReader("hello world")
  		got, err := FileSaveDisk(dir, "saludos.txt", body)
  		if err != nil {
  			t.Fatalf("FileSaveDisk err: %v", err)
  		}
  		if got.Filename != "saludos.txt" {
  			t.Errorf("got Filename %q, want saludos.txt", got.Filename)
  		}
  		if !strings.HasSuffix(got.StoredName, ".txt") {
  			t.Errorf("got StoredName %q, want suffix .txt", got.StoredName)
  		}
  		if !strings.HasPrefix(got.Path, dir) {
  			t.Errorf("got Path %q, want prefix %q", got.Path, dir)
  		}
  		if got.Size != int64(len("hello world")) {
  			t.Errorf("got Size %d, want %d", got.Size, len("hello world"))
  		}

  		data, err := os.ReadFile(got.Path)
  		if err != nil {
  			t.Fatalf("ReadFile: %v", err)
  		}
  		if string(data) != "hello world" {
  			t.Errorf("contenido en disco %q, want %q", data, "hello world")
  		}
  	})

  	t.Run("crea baseDir si no existe", func(t *testing.T) {
  		base := filepath.Join(t.TempDir(), "nested", "uploads")
  		body := strings.NewReader("x")
  		got, err := FileSaveDisk(base, "a.png", body)
  		if err != nil {
  			t.Fatalf("FileSaveDisk err: %v", err)
  		}
  		if _, err := os.Stat(got.Path); err != nil {
  			t.Fatalf("archivo no escrito: %v", err)
  		}
  	})

  	t.Run("infiere ContentType desde la extension", func(t *testing.T) {
  		dir := t.TempDir()
  		got, err := FileSaveDisk(dir, "logo.png", strings.NewReader("x"))
  		if err != nil {
  			t.Fatalf("err: %v", err)
  		}
  		if !strings.HasPrefix(got.ContentType, "image/png") {
  			t.Errorf("got ContentType %q, want image/png prefix", got.ContentType)
  		}
  	})
  }


id: file_serve_go_infra_t0
function_id: file_serve_go_infra
name: TestFileServe
file_path: functions/infra/file_serve_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFileServe(t *testing.T) {
  	dir := t.TempDir()
  	if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hola mundo"), 0o644); err != nil {
  		t.Fatalf("setup: %v", err)
  	}

  	handler := FileServe(dir, "/files/", 60)

  	t.Run("sirve archivo existente con headers de cache", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/files/hello.txt", nil)
  		rec := httptest.NewRecorder()
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Fatalf("got %d, want 200", rec.Code)
  		}
  		if rec.Body.String() != "hola mundo" {
  			t.Errorf("got body %q", rec.Body.String())
  		}
  		if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=60" {
  			t.Errorf("got Cache-Control %q", cc)
  		}
  	})

  	t.Run("responde 404 para archivo inexistente", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/files/missing.txt", nil)
  		rec := httptest.NewRecorder()
  		handler.ServeHTTP(rec, req)
  		if rec.Code != http.StatusNotFound {
  			t.Errorf("got %d, want 404", rec.Code)
  		}
  	})

  	t.Run("rechaza path con .. con 400", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/files/../etc/passwd", nil)
  		rec := httptest.NewRecorder()
  		handler.ServeHTTP(rec, req)
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400", rec.Code)
  		}
  	})
  }


id: file_unique_name_go_infra_t0
function_id: file_unique_name_go_infra
name: TestFileUniqueName
file_path: functions/infra/file_unique_name_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFileUniqueName(t *testing.T) {
  	t.Run("preserva extension comun como png", func(t *testing.T) {
  		got := FileUniqueName("foto.png")
  		if !strings.HasSuffix(got, ".png") {
  			t.Fatalf("got %q, want suffix .png", got)
  		}
  		if len(got) < 36+4 { // uuid + ".png"
  			t.Fatalf("got %q, want UUID + .png", got)
  		}
  	})

  	t.Run("convierte extension a minusculas", func(t *testing.T) {
  		got := FileUniqueName("VACACIONES.JPEG")
  		if !strings.HasSuffix(got, ".jpeg") {
  			t.Fatalf("got %q, want suffix .jpeg", got)
  		}
  	})

  	t.Run("remueve caracteres especiales en extension", func(t *testing.T) {
  		got := FileUniqueName("malicious.t!x@t#")
  		if !strings.HasSuffix(got, ".txt") {
  			t.Fatalf("got %q, want suffix .txt", got)
  		}
  	})

  	t.Run("genera UUID sin extension si el archivo no tiene", func(t *testing.T) {
  		got := FileUniqueName("contrato_sin_extension")
  		if strings.Contains(got, ".") {
  			t.Fatalf("got %q, want sin punto", got)
  		}
  		if len(got) != 36 {
  			t.Fatalf("got %q (len %d), want UUID len 36", got, len(got))
  		}
  	})

  	t.Run("trunca extensiones extremadamente largas", func(t *testing.T) {
  		got := FileUniqueName("file." + strings.Repeat("a", 100))
  		// Buscar la ultima parte despues del punto
  		idx := strings.LastIndex(got, ".")
  		if idx < 0 {
  			t.Fatalf("got %q, want al menos un punto", got)
  		}
  		ext := got[idx+1:]
  		if len(ext) > 16 {
  			t.Fatalf("got ext len %d, want <= 16", len(ext))
  		}
  	})

  	t.Run("dos llamadas generan IDs distintos", func(t *testing.T) {
  		a := FileUniqueName("x.png")
  		b := FileUniqueName("x.png")
  		if a == b {
  			t.Fatalf("got %q == %q, want distintos", a, b)
  		}
  	})
  }


id: file_validate_type_go_infra_t0
function_id: file_validate_type_go_infra
name: TestFileValidateType
file_path: functions/infra/file_validate_type_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestFileValidateType(t *testing.T) {
  	allowed := []string{"image/png", "image/jpeg", "application/pdf", "image/webp"}

  	t.Run("detecta JPEG por magic bytes", func(t *testing.T) {
  		header := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "image/jpeg" || !ok {
  			t.Fatalf("got (%q,%v), want (image/jpeg,true)", mime, ok)
  		}
  	})

  	t.Run("detecta PNG por magic bytes", func(t *testing.T) {
  		header := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "image/png" || !ok {
  			t.Fatalf("got (%q,%v), want (image/png,true)", mime, ok)
  		}
  	})

  	t.Run("detecta PDF", func(t *testing.T) {
  		header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "application/pdf" || !ok {
  			t.Fatalf("got (%q,%v), want (application/pdf,true)", mime, ok)
  		}
  	})

  	t.Run("detecta WebP con prefix RIFF y suffix WEBP", func(t *testing.T) {
  		header := []byte{
  			0x52, 0x49, 0x46, 0x46, // RIFF
  			0xAA, 0xBB, 0xCC, 0xDD, // tamano (cualquier valor)
  			0x57, 0x45, 0x42, 0x50, // WEBP
  			0x56, 0x50, 0x38, 0x20,
  		}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "image/webp" || !ok {
  			t.Fatalf("got (%q,%v), want (image/webp,true)", mime, ok)
  		}
  	})

  	t.Run("rechaza tipo no permitido", func(t *testing.T) {
  		// PDF detectado, pero no en allowedTypes
  		header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
  		mime, ok := FileValidateType(header, []string{"image/png"})
  		if mime != "application/pdf" {
  			t.Fatalf("got mime %q, want application/pdf", mime)
  		}
  		if ok {
  			t.Fatalf("got ok=true, want false (PDF no en allowedTypes)")
  		}
  	})

  	t.Run("tipo desconocido retorna vacio", func(t *testing.T) {
  		header := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "" || ok {
  			t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
  		}
  	})

  	t.Run("header demasiado corto retorna vacio", func(t *testing.T) {
  		header := []byte{0xFF}
  		mime, ok := FileValidateType(header, allowed)
  		if mime != "" || ok {
  			t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
  		}
  	})
  }


id: generate_dockerfile_go_infra_t0
function_id: generate_dockerfile_go_infra
name: TestGenerateDockerfile
file_path: functions/infra/generate_dockerfile_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestGenerateDockerfile(t *testing.T) {
  	t.Run("contiene stage builder con golang:1.23-alpine", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 8080, nil)
  		if !strings.Contains(got, "FROM golang:1.23-alpine AS builder") {
  			t.Errorf("expected FROM golang:1.23-alpine AS builder, got:\n%s", got)
  		}
  	})

  	t.Run("contiene stage final con alpine:latest", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 8080, nil)
  		if !strings.Contains(got, "FROM alpine:latest") {
  			t.Errorf("expected FROM alpine:latest, got:\n%s", got)
  		}
  	})

  	t.Run("incluye EXPOSE cuando port mayor a cero", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 8080, nil)
  		if !strings.Contains(got, "EXPOSE 8080") {
  			t.Errorf("expected EXPOSE 8080, got:\n%s", got)
  		}
  	})

  	t.Run("no incluye EXPOSE cuando port es cero", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 0, nil)
  		if strings.Contains(got, "EXPOSE") {
  			t.Errorf("expected no EXPOSE directive, got:\n%s", got)
  		}
  	})

  	t.Run("env vars aparecen ordenadas alfabeticamente", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 8080, map[string]string{
  			"Z_VAR": "z",
  			"A_VAR": "a",
  			"M_VAR": "m",
  		})
  		posA := strings.Index(got, "ENV A_VAR=a")
  		posM := strings.Index(got, "ENV M_VAR=m")
  		posZ := strings.Index(got, "ENV Z_VAR=z")
  		if posA < 0 || posM < 0 || posZ < 0 {
  			t.Fatalf("ENV vars no encontradas en:\n%s", got)
  		}
  		if !(posA < posM && posM < posZ) {
  			t.Errorf("ENV vars no ordenadas: A=%d M=%d Z=%d", posA, posM, posZ)
  		}
  	})

  	t.Run("binaryName aparece en ENTRYPOINT", func(t *testing.T) {
  		got := GenerateDockerfile("mycli", 9090, nil)
  		if !strings.Contains(got, `ENTRYPOINT ["./mycli"]`) {
  			t.Errorf("expected ENTRYPOINT with mycli, got:\n%s", got)
  		}
  	})

  	t.Run("env vars vacias no generan instrucciones ENV", func(t *testing.T) {
  		got := GenerateDockerfile("myapp", 8080, map[string]string{})
  		if strings.Contains(got, "ENV ") {
  			t.Errorf("expected no ENV directives for empty map, got:\n%s", got)
  		}
  	})
  }


id: go_build_binary_go_infra_t0
function_id: go_build_binary_go_infra
name: TestGoBuildBinary
file_path: functions/infra/go_build_binary_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestGoBuildBinary(t *testing.T) {
  	t.Run("compila proyecto valido sin error", func(t *testing.T) {
  		// Crear un proyecto Go mínimo en un directorio temporal
  		tmpDir := t.TempDir()
  		goModContent := "module testapp\n\ngo 1.21\n"
  		mainContent := `package main

  import "fmt"

  func main() {
  	fmt.Println("hello")
  }
  `
  		if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
  			t.Fatal(err)
  		}
  		if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
  			t.Fatal(err)
  		}

  		outputPath := filepath.Join(tmpDir, "bin", "testapp")
  		err := GoBuildBinary(tmpDir, outputPath, "", "")
  		if err != nil {
  			t.Errorf("unexpected error: %v", err)
  		}

  		// Verificar que el binario existe
  		if _, statErr := os.Stat(outputPath); os.IsNotExist(statErr) {
  			t.Errorf("binary not found at %s", outputPath)
  		}
  	})

  	t.Run("outputPath vacio usa build/dirname por defecto", func(t *testing.T) {
  		tmpDir := t.TempDir()
  		goModContent := "module myproject\n\ngo 1.21\n"
  		mainContent := "package main\n\nfunc main() {}\n"
  		if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
  			t.Fatal(err)
  		}
  		if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
  			t.Fatal(err)
  		}

  		err := GoBuildBinary(tmpDir, "", "", "")
  		if err != nil {
  			t.Errorf("unexpected error: %v", err)
  		}

  		expectedPath := filepath.Join(tmpDir, "build", filepath.Base(tmpDir))
  		if _, statErr := os.Stat(expectedPath); os.IsNotExist(statErr) {
  			t.Errorf("expected binary at %s, not found", expectedPath)
  		}
  	})

  	t.Run("ldflags vacio usa -s -w por defecto", func(t *testing.T) {
  		// Este test verifica que la función no falla con ldflags vacío
  		// (los flags por defecto -s -w son válidos para go build)
  		tmpDir := t.TempDir()
  		if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module x\n\ngo 1.21\n"), 0o644); err != nil {
  			t.Fatal(err)
  		}
  		if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
  			t.Fatal(err)
  		}

  		outputPath := filepath.Join(tmpDir, "out")
  		err := GoBuildBinary(tmpDir, outputPath, "", "")
  		if err != nil {
  			t.Errorf("ldflags vacío debería usar -s -w y compilar sin error: %v", err)
  		}
  	})

  	t.Run("error si projectDir no existe", func(t *testing.T) {
  		err := GoBuildBinary("/nonexistent/path/to/project", "/tmp/out", "", "")
  		if err == nil {
  			t.Error("expected error for nonexistent projectDir, got nil")
  		}
  	})
  }


id: health_check_http_go_infra_t0
function_id: health_check_http_go_infra
name: TestHealthCheckHTTP
file_path: functions/infra/health_check_http_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHealthCheckHTTP(t *testing.T) {
  	t.Run("retorna nil cuando el servidor responde 200", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  		}))
  		defer srv.Close()

  		err := HealthCheckHTTP(srv.URL, 5, 100)
  		if err != nil {
  			t.Errorf("se esperaba nil, got: %v", err)
  		}
  	})

  	t.Run("retorna error si el timeout se agota", func(t *testing.T) {
  		// Servidor que nunca responde 200
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusServiceUnavailable)
  		}))
  		defer srv.Close()

  		err := HealthCheckHTTP(srv.URL, 1, 200)
  		if err == nil {
  			t.Error("se esperaba error por timeout")
  		}
  	})

  	t.Run("respeta el intervalo entre intentos", func(t *testing.T) {
  		attempts := 0
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			attempts++
  			if attempts >= 3 {
  				w.WriteHeader(http.StatusOK)
  			} else {
  				w.WriteHeader(http.StatusServiceUnavailable)
  			}
  		}))
  		defer srv.Close()

  		start := time.Now()
  		err := HealthCheckHTTP(srv.URL, 5, 150)
  		elapsed := time.Since(start)

  		if err != nil {
  			t.Errorf("se esperaba nil despues de 3 intentos, got: %v", err)
  		}
  		// Con 2 intervalos de 150ms debe haber pasado al menos 250ms
  		if elapsed < 250*time.Millisecond {
  			t.Errorf("elapsed %v demasiado rapido, se esperaban al menos 250ms", elapsed)
  		}
  		if attempts < 3 {
  			t.Errorf("se esperaban al menos 3 intentos, got %d", attempts)
  		}
  	})
  }


id: http_cors_middleware_go_infra_t0
function_id: http_cors_middleware_go_infra
name: TestHTTPCORSMiddleware
file_path: functions/infra/http_cors_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPCORSMiddleware(t *testing.T) {
  	origins := []string{"https://example.com"}
  	methods := []string{"GET", "POST"}

  	base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.WriteHeader(http.StatusOK)
  	})

  	t.Run("setea headers CORS en respuesta normal", func(t *testing.T) {
  		mw := HTTPCORSMiddleware(origins, methods)
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/api/test", nil)
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Errorf("got status %d, want 200", rec.Code)
  		}
  		if rec.Header().Get("Access-Control-Allow-Origin") == "" {
  			t.Error("Access-Control-Allow-Origin header not set")
  		}
  	})

  	t.Run("responde 204 a peticion OPTIONS preflight", func(t *testing.T) {
  		mw := HTTPCORSMiddleware(origins, methods)
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("OPTIONS", "/api/test", nil)
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusNoContent {
  			t.Errorf("got status %d, want 204", rec.Code)
  		}
  	})

  	t.Run("headers incluyen los origenes configurados", func(t *testing.T) {
  		mw := HTTPCORSMiddleware([]string{"https://app.example.com"}, methods)
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		got := rec.Header().Get("Access-Control-Allow-Origin")
  		if got != "https://app.example.com" {
  			t.Errorf("got Access-Control-Allow-Origin=%q, want %q", got, "https://app.example.com")
  		}
  	})

  	t.Run("headers incluyen los metodos configurados", func(t *testing.T) {
  		mw := HTTPCORSMiddleware(origins, []string{"GET", "DELETE"})
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		got := rec.Header().Get("Access-Control-Allow-Methods")
  		if got != "GET, DELETE" {
  			t.Errorf("got Access-Control-Allow-Methods=%q, want %q", got, "GET, DELETE")
  		}
  	})
  }


id: http_download_file_go_infra_t0
function_id: http_download_file_go_infra
name: TestHttpDownloadFile
file_path: functions/infra/http_download_file_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHttpDownloadFile(t *testing.T) {
  	t.Run("httptest.Server sirve archivo binario", func(t *testing.T) {
  		content := []byte("\x00\x01\x02\x03binary content")
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Header().Set("Content-Type", "application/octet-stream")
  			w.Write(content)
  		}))
  		defer srv.Close()

  		tmp := t.TempDir()
  		dest := filepath.Join(tmp, "out.bin")

  		n, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if n != int64(len(content)) {
  			t.Errorf("got %d bytes, want %d", n, len(content))
  		}
  		got, _ := os.ReadFile(dest)
  		if string(got) != string(content) {
  			t.Errorf("file content mismatch")
  		}
  	})

  	t.Run("Directorio creado automaticamente", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Write([]byte("data"))
  		}))
  		defer srv.Close()

  		tmp := t.TempDir()
  		dest := filepath.Join(tmp, "nested", "deep", "file.bin")

  		_, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if _, err := os.Stat(dest); os.IsNotExist(err) {
  			t.Error("dest file does not exist after download")
  		}
  	})

  	t.Run("Archivo temporal + rename (no deja basura si falla)", func(t *testing.T) {
  		// Server que falla a mitad de la transferencia cortando la conexion
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Write([]byte("partial"))
  			// hijack y cierra bruscamente no disponible facilmente; simulamos con
  			// status 500 antes de escribir
  		}))
  		defer srv.Close()

  		// Verificar que un download exitoso no deja .download-* temporales
  		tmp := t.TempDir()
  		dest := filepath.Join(tmp, "file.bin")

  		HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)

  		entries, _ := os.ReadDir(tmp)
  		for _, e := range entries {
  			if e.Name() != "file.bin" && filepath.Ext(e.Name()) != ".bin" {
  				t.Errorf("unexpected temp file left: %s", e.Name())
  			}
  		}
  	})

  	t.Run("Size retornado coincide", func(t *testing.T) {
  		content := make([]byte, 10000)
  		for i := range content {
  			content[i] = byte(i % 256)
  		}
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Write(content)
  		}))
  		defer srv.Close()

  		tmp := t.TempDir()
  		dest := filepath.Join(tmp, "big.bin")

  		n, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if n != int64(len(content)) {
  			t.Errorf("got %d bytes, want %d", n, len(content))
  		}
  	})
  }


id: http_error_response_go_infra_t0
function_id: http_error_response_go_infra
name: TestHTTPJSONResponse
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPJSONResponse(t *testing.T) {
  	t.Run("escribe status code correcto", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		HTTPJSONResponse(rec, http.StatusCreated, map[string]string{"ok": "true"})
  		if rec.Code != http.StatusCreated {
  			t.Errorf("got status %d, want 201", rec.Code)
  		}
  	})

  	t.Run("setea Content-Type application/json", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		HTTPJSONResponse(rec, http.StatusOK, "test")
  		ct := rec.Header().Get("Content-Type")
  		if ct != "application/json" {
  			t.Errorf("got Content-Type=%q, want application/json", ct)
  		}
  	})

  	t.Run("serializa datos correctamente a JSON", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		data := map[string]int{"count": 42}
  		HTTPJSONResponse(rec, http.StatusOK, data)

  		var got map[string]int
  		if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
  			t.Fatalf("failed to parse response JSON: %v", err)
  		}
  		if got["count"] != 42 {
  			t.Errorf("got count=%d, want 42", got["count"])
  		}
  	})
  }

  // --- HTTPErrorResponse ---


id: http_error_response_go_infra_t1
function_id: http_error_response_go_infra
name: TestHTTPErrorResponse
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPErrorResponse(t *testing.T) {
  	t.Run("escribe el status code del HTTPError", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		HTTPErrorResponse(rec, HTTPError{Status: http.StatusBadRequest, Code: "bad", Message: "err"})
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got status %d, want 400", rec.Code)
  		}
  	})

  	t.Run("setea Content-Type application/json", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		HTTPErrorResponse(rec, HTTPError{Status: 500, Code: "err", Message: "fail"})
  		ct := rec.Header().Get("Content-Type")
  		if ct != "application/json" {
  			t.Errorf("got Content-Type=%q, want application/json", ct)
  		}
  	})

  	t.Run("serializa Code y Message en el body JSON", func(t *testing.T) {
  		rec := httptest.NewRecorder()
  		HTTPErrorResponse(rec, HTTPError{Status: 422, Code: "validation_error", Message: "campo requerido"})

  		var got HTTPError
  		if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
  			t.Fatalf("failed to parse response JSON: %v", err)
  		}
  		if got.Code != "validation_error" {
  			t.Errorf("got Code=%q, want validation_error", got.Code)
  		}
  		if got.Message != "campo requerido" {
  			t.Errorf("got Message=%q, want 'campo requerido'", got.Message)
  		}
  	})
  }

  // --- HTTPParseBody ---


id: http_error_response_go_infra_t2
function_id: http_error_response_go_infra
name: TestHTTPParseBody
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPParseBody(t *testing.T) {
  	t.Run("decodifica JSON valido correctamente", func(t *testing.T) {
  		body := `{"name":"Lucas","age":30}`
  		req := httptest.NewRequest("POST", "/", strings.NewReader(body))
  		req.Header.Set("Content-Type", "application/json")

  		var dst struct {
  			Name string `json:"name"`
  			Age  int    `json:"age"`
  		}
  		err := HTTPParseBody(req, &dst, 1<<20)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if dst.Name != "Lucas" || dst.Age != 30 {
  			t.Errorf("got {%q, %d}, want {Lucas, 30}", dst.Name, dst.Age)
  		}
  	})

  	t.Run("retorna error si el body supera maxBytes", func(t *testing.T) {
  		body := strings.Repeat("x", 100)
  		payload := `{"data":"` + body + `"}`
  		req := httptest.NewRequest("POST", "/", strings.NewReader(payload))

  		var dst struct {
  			Data string `json:"data"`
  		}
  		err := HTTPParseBody(req, &dst, 10) // limite de 10 bytes
  		if err == nil {
  			t.Error("expected error for oversized body, got nil")
  		}
  	})

  	t.Run("retorna error si el JSON es invalido", func(t *testing.T) {
  		req := httptest.NewRequest("POST", "/", strings.NewReader(`{invalid json`))
  		var dst map[string]any
  		err := HTTPParseBody(req, &dst, 1<<20)
  		if err == nil {
  			t.Error("expected error for invalid JSON, got nil")
  		}
  	})

  	t.Run("retorna error si hay campos desconocidos", func(t *testing.T) {
  		req := httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","unknown":"field"}`))
  		var dst struct {
  			Name string `json:"name"`
  		}
  		err := HTTPParseBody(req, &dst, 1<<20)
  		if err == nil {
  			t.Error("expected error for unknown fields, got nil")
  		}
  	})
  }

  // --- HTTPLoggerMiddleware ---


id: http_error_response_go_infra_t3
function_id: http_error_response_go_infra
name: TestHTTPLoggerMiddleware
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPLoggerMiddleware(t *testing.T) {
  	t.Run("loguea metodo y path del request", func(t *testing.T) {
  		var buf bytes.Buffer
  		mw := HTTPLoggerMiddleware(&buf)
  		handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/api/test", nil)
  		handler.ServeHTTP(rec, req)

  		log := buf.String()
  		if !strings.Contains(log, "GET") {
  			t.Errorf("log does not contain method GET: %q", log)
  		}
  		if !strings.Contains(log, "/api/test") {
  			t.Errorf("log does not contain path /api/test: %q", log)
  		}
  	})

  	t.Run("loguea el status code de la respuesta", func(t *testing.T) {
  		var buf bytes.Buffer
  		mw := HTTPLoggerMiddleware(&buf)
  		handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusNotFound)
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/missing", nil)
  		handler.ServeHTTP(rec, req)

  		log := buf.String()
  		if !strings.Contains(log, "404") {
  			t.Errorf("log does not contain status 404: %q", log)
  		}
  	})

  	t.Run("loguea la duracion en milisegundos", func(t *testing.T) {
  		var buf bytes.Buffer
  		mw := HTTPLoggerMiddleware(&buf)
  		handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		log := buf.String()
  		if !strings.Contains(log, "ms") {
  			t.Errorf("log does not contain duration in ms: %q", log)
  		}
  	})
  }

  // --- HTTPRouter ---


id: http_error_response_go_infra_t4
function_id: http_error_response_go_infra
name: TestHTTPRouter
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPRouter(t *testing.T) {
  	t.Run("registra una ruta y responde correctamente", func(t *testing.T) {
  		mux := HTTPRouter([]Route{
  			{
  				Method: "GET",
  				Path:   "/api/ping",
  				Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  					w.WriteHeader(http.StatusOK)
  					w.Write([]byte("pong"))
  				}),
  			},
  		})

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/api/ping", nil)
  		mux.ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Errorf("got status %d, want 200", rec.Code)
  		}
  		if rec.Body.String() != "pong" {
  			t.Errorf("got body %q, want pong", rec.Body.String())
  		}
  	})

  	t.Run("registra multiples rutas en el mismo mux", func(t *testing.T) {
  		mux := HTTPRouter([]Route{
  			{Method: "GET", Path: "/a", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				w.Write([]byte("a"))
  			})},
  			{Method: "GET", Path: "/b", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				w.Write([]byte("b"))
  			})},
  		})

  		recA := httptest.NewRecorder()
  		mux.ServeHTTP(recA, httptest.NewRequest("GET", "/a", nil))
  		if recA.Body.String() != "a" {
  			t.Errorf("route /a: got %q, want a", recA.Body.String())
  		}

  		recB := httptest.NewRecorder()
  		mux.ServeHTTP(recB, httptest.NewRequest("GET", "/b", nil))
  		if recB.Body.String() != "b" {
  			t.Errorf("route /b: got %q, want b", recB.Body.String())
  		}
  	})

  	t.Run("ruta no registrada retorna 404", func(t *testing.T) {
  		mux := HTTPRouter([]Route{
  			{Method: "GET", Path: "/known", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusOK)
  			})},
  		})

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/unknown", nil)
  		mux.ServeHTTP(rec, req)

  		if rec.Code != http.StatusNotFound {
  			t.Errorf("got status %d, want 404", rec.Code)
  		}
  	})
  }

  // --- HTTPServe ---


id: http_error_response_go_infra_t5
function_id: http_error_response_go_infra
name: TestHTTPServe
file_path: functions/infra/http_server_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPServe(t *testing.T) {
  	t.Run("servidor arranca y responde peticiones", func(t *testing.T) {
  		ctx, cancel := context.WithCancel(context.Background())
  		defer cancel()

  		mux := HTTPRouter([]Route{
  			{Method: "GET", Path: "/ping", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				w.WriteHeader(http.StatusOK)
  				w.Write([]byte("pong"))
  			})},
  		})

  		errCh := make(chan error, 1)
  		go func() {
  			errCh <- HTTPServe("127.0.0.1:0", mux, ctx)
  		}()

  		// Cancelar el contexto rapidamente — el servidor deberia cerrarse limpiamente
  		time.Sleep(10 * time.Millisecond)
  		cancel()

  		select {
  		case err := <-errCh:
  			if err != nil {
  				t.Errorf("unexpected error from HTTPServe: %v", err)
  			}
  		case <-time.After(5 * time.Second):
  			t.Error("HTTPServe did not stop after context cancel")
  		}
  	})

  	t.Run("graceful shutdown al cancelar contexto", func(t *testing.T) {
  		ctx, cancel := context.WithCancel(context.Background())

  		handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  		})

  		done := make(chan error, 1)
  		go func() {
  			done <- HTTPServe("127.0.0.1:0", handler, ctx)
  		}()

  		time.Sleep(10 * time.Millisecond)
  		cancel()

  		select {
  		case err := <-done:
  			if err != nil {
  				t.Errorf("shutdown error: %v", err)
  			}
  		case <-time.After(5 * time.Second):
  			t.Error("server did not shutdown within 5 seconds")
  		}
  	})
  }


id: http_get_json_go_infra_t0
function_id: http_get_json_go_infra
name: TestHttpGetJSON
file_path: functions/infra/http_get_json_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHttpGetJSON(t *testing.T) {
  	t.Run("httptest.Server con respuesta JSON", func(t *testing.T) {
  		payload := map[string]any{"ok": true, "value": float64(42)}
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Header().Set("Content-Type", "application/json")
  			json.NewEncoder(w).Encode(payload)
  		}))
  		defer srv.Close()

  		result, err := HttpGetJSON(srv.URL, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if result["ok"] != true {
  			t.Errorf("got ok=%v, want true", result["ok"])
  		}
  	})

  	t.Run("Status 404 → error", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			http.Error(w, "not found", http.StatusNotFound)
  		}))
  		defer srv.Close()

  		_, err := HttpGetJSON(srv.URL, nil, 5*time.Second)
  		if err == nil {
  			t.Fatal("expected error, got nil")
  		}
  		if !strings.Contains(err.Error(), "404") {
  			t.Errorf("error should contain 404, got: %v", err)
  		}
  	})

  	t.Run("Timeout → error", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			// No responde — bloquea hasta que el cliente cancela
  			<-r.Context().Done()
  		}))
  		defer srv.Close()

  		_, err := HttpGetJSON(srv.URL, nil, 50*time.Millisecond)
  		if err == nil {
  			t.Fatal("expected timeout error, got nil")
  		}
  	})

  	t.Run("Headers custom", func(t *testing.T) {
  		receivedHeaders := make(chan http.Header, 1)
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			receivedHeaders <- r.Header.Clone()
  			w.Header().Set("Content-Type", "application/json")
  			w.Write([]byte(`{}`))
  		}))
  		defer srv.Close()

  		headers := map[string]string{"X-Api-Key": "mytoken"}
  		_, err := HttpGetJSON(srv.URL, headers, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		h := <-receivedHeaders
  		if h.Get("X-Api-Key") != "mytoken" {
  			t.Errorf("X-Api-Key not sent, got: %v", h.Get("X-Api-Key"))
  		}
  		if h.Get("Accept") != "application/json" {
  			t.Errorf("Accept header missing, got: %v", h.Get("Accept"))
  		}
  	})
  }


id: http_middleware_chain_go_infra_t0
function_id: http_middleware_chain_go_infra
name: TestHTTPMiddlewareChain
file_path: functions/infra/http_middleware_chain_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHTTPMiddlewareChain(t *testing.T) {
  	t.Run("sin middlewares pasa al handler final", func(t *testing.T) {
  		chain := HTTPMiddlewareChain()
  		handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  			w.Write([]byte("final"))
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
  		}
  		if rec.Body.String() != "final" {
  			t.Errorf("got body %q, want %q", rec.Body.String(), "final")
  		}
  	})

  	t.Run("un middleware se aplica correctamente", func(t *testing.T) {
  		addHeader := func(next http.Handler) http.Handler {
  			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				w.Header().Set("X-Test", "applied")
  				next.ServeHTTP(w, r)
  			})
  		}

  		chain := HTTPMiddlewareChain(Middleware(addHeader))
  		handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.WriteHeader(http.StatusOK)
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		if rec.Header().Get("X-Test") != "applied" {
  			t.Errorf("got X-Test=%q, want %q", rec.Header().Get("X-Test"), "applied")
  		}
  	})

  	t.Run("dos middlewares se aplican en orden izquierda-derecha", func(t *testing.T) {
  		order := []string{}

  		first := Middleware(func(next http.Handler) http.Handler {
  			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				order = append(order, "first")
  				next.ServeHTTP(w, r)
  			})
  		})
  		second := Middleware(func(next http.Handler) http.Handler {
  			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  				order = append(order, "second")
  				next.ServeHTTP(w, r)
  			})
  		})

  		chain := HTTPMiddlewareChain(first, second)
  		handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			order = append(order, "handler")
  			w.WriteHeader(http.StatusOK)
  		}))

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		handler.ServeHTTP(rec, req)

  		if len(order) != 3 || order[0] != "first" || order[1] != "second" || order[2] != "handler" {
  			t.Errorf("got order %v, want [first second handler]", order)
  		}
  	})
  }


id: http_post_json_go_infra_t0
function_id: http_post_json_go_infra
name: TestHttpPostJSON
file_path: functions/infra/http_post_json_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestHttpPostJSON(t *testing.T) {
  	t.Run("httptest.Server recibe body correcto", func(t *testing.T) {
  		received := make(chan map[string]any, 1)
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			var body map[string]any
  			data, _ := io.ReadAll(r.Body)
  			json.Unmarshal(data, &body)
  			received <- body
  			w.Header().Set("Content-Type", "application/json")
  			w.Write([]byte(`{"ok": true}`))
  		}))
  		defer srv.Close()

  		_, err := HttpPostJSON(srv.URL, map[string]any{"name": "Alice", "score": 100}, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		body := <-received
  		if body["name"] != "Alice" {
  			t.Errorf("name not received correctly, got: %v", body["name"])
  		}
  	})

  	t.Run("Status 201 → exito", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			w.Header().Set("Content-Type", "application/json")
  			w.WriteHeader(http.StatusCreated)
  			w.Write([]byte(`{"id": 42}`))
  		}))
  		defer srv.Close()

  		result, err := HttpPostJSON(srv.URL, map[string]any{"x": 1}, nil, 5*time.Second)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if result["id"] != float64(42) {
  			t.Errorf("got id=%v, want 42", result["id"])
  		}
  	})

  	t.Run("Status 500 → error con body parcial", func(t *testing.T) {
  		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			http.Error(w, "internal server error details", http.StatusInternalServerError)
  		}))
  		defer srv.Close()

  		_, err := HttpPostJSON(srv.URL, map[string]any{}, nil, 5*time.Second)
  		if err == nil {
  			t.Fatal("expected error, got nil")
  		}
  		if !strings.Contains(err.Error(), "500") {
  			t.Errorf("error should contain 500, got: %v", err)
  		}
  	})
  }


id: job_cleanup_go_infra_t0
function_id: job_cleanup_go_infra
name: TestJobQueueCreate
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobQueueCreate(t *testing.T) {
  	t.Run("test_queue_create", func(t *testing.T) {
  		db := openTestDB(t)
  		q, err := JobQueueCreate(db, "jobs")
  		if err != nil {
  			t.Fatalf("JobQueueCreate: %v", err)
  		}
  		if q == nil {
  			t.Fatal("expected non-nil JobQueue")
  		}
  		if q.TableName != "jobs" {
  			t.Errorf("TableName = %q, want %q", q.TableName, "jobs")
  		}

  		// Verify table exists by inserting a row directly.
  		_, err = db.Exec(`INSERT INTO jobs (id, type, scheduled_at) VALUES ('x', 'test', ?)`,
  			time.Now().UTC().Format(time.RFC3339))
  		if err != nil {
  			t.Fatalf("table not created: %v", err)
  		}
  	})

  	t.Run("test_queue_create_default_table_name", func(t *testing.T) {
  		db := openTestDB(t)
  		q, err := JobQueueCreate(db, "")
  		if err != nil {
  			t.Fatalf("JobQueueCreate with empty name: %v", err)
  		}
  		if q.TableName != "jobs" {
  			t.Errorf("TableName = %q, want %q", q.TableName, "jobs")
  		}
  	})

  	t.Run("test_queue_create_nil_db", func(t *testing.T) {
  		_, err := JobQueueCreate(nil, "jobs")
  		if err == nil {
  			t.Fatal("expected error for nil db")
  		}
  	})
  }


id: job_cleanup_go_infra_t1
function_id: job_cleanup_go_infra
name: TestJobEnqueueDequeue
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobEnqueueDequeue(t *testing.T) {
  	t.Run("enqueue_dequeue_atomicidad", func(t *testing.T) {
  		db := openTestDB(t)
  		q, err := JobQueueCreate(db, "jobs")
  		if err != nil {
  			t.Fatalf("create: %v", err)
  		}

  		id, err := JobEnqueue(q, "send_email", `{"to":"a@b.com"}`)
  		if err != nil {
  			t.Fatalf("enqueue: %v", err)
  		}
  		if id == "" {
  			t.Fatal("expected non-empty job ID")
  		}

  		job, err := JobDequeue(q, nil)
  		if err != nil {
  			t.Fatalf("dequeue: %v", err)
  		}
  		if job == nil {
  			t.Fatal("expected a job, got nil")
  		}
  		if job.ID != id {
  			t.Errorf("job ID = %q, want %q", job.ID, id)
  		}
  		if job.Type != "send_email" {
  			t.Errorf("job Type = %q, want %q", job.Type, "send_email")
  		}
  		if job.Status != JobStatusRunning {
  			t.Errorf("job Status = %q, want %q", job.Status, JobStatusRunning)
  		}

  		// Second dequeue should return nil (job is now running, not pending).
  		job2, err := JobDequeue(q, nil)
  		if err != nil {
  			t.Fatalf("second dequeue: %v", err)
  		}
  		if job2 != nil {
  			t.Errorf("expected nil second dequeue, got job %q", job2.ID)
  		}
  	})

  	t.Run("dequeue_empty_queue_returns_nil", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		job, err := JobDequeue(q, nil)
  		if err != nil {
  			t.Fatalf("dequeue empty: %v", err)
  		}
  		if job != nil {
  			t.Errorf("expected nil, got job %q", job.ID)
  		}
  	})

  	t.Run("dequeue_priority_order", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		_, _ = JobEnqueue(q, "low", "{}", WithPriority(0))
  		_, _ = JobEnqueue(q, "high", "{}", WithPriority(10))
  		_, _ = JobEnqueue(q, "mid", "{}", WithPriority(5))

  		job, _ := JobDequeue(q, nil)
  		if job == nil || job.Type != "high" {
  			t.Errorf("expected high-priority job first, got %v", job)
  		}
  	})

  	t.Run("dequeue_jobtype_filter", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		_, _ = JobEnqueue(q, "email", "{}")
  		_, _ = JobEnqueue(q, "sms", "{}")

  		// Only dequeue "sms" jobs.
  		job, err := JobDequeue(q, []string{"sms"})
  		if err != nil {
  			t.Fatalf("dequeue with filter: %v", err)
  		}
  		if job == nil || job.Type != "sms" {
  			t.Errorf("expected sms job, got %v", job)
  		}
  	})

  	t.Run("dequeue_scheduled_in_future_waits", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		future := time.Now().Add(1 * time.Hour)
  		_, _ = JobEnqueue(q, "future", "{}", WithScheduledAt(future))

  		job, err := JobDequeue(q, nil)
  		if err != nil {
  			t.Fatalf("dequeue future: %v", err)
  		}
  		if job != nil {
  			t.Errorf("expected nil (future job not yet due), got job %q", job.ID)
  		}
  	})
  }


id: job_cleanup_go_infra_t2
function_id: job_cleanup_go_infra
name: TestJobCompleteAndFail
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobCompleteAndFail(t *testing.T) {
  	t.Run("complete_fail_transitions", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(3))
  		job, _ := JobDequeue(q, nil)
  		if job == nil {
  			t.Fatal("expected job")
  		}

  		// Complete it.
  		if err := JobComplete(q, id, `{"ok":true}`); err != nil {
  			t.Fatalf("complete: %v", err)
  		}

  		// Verify status in DB.
  		var status string
  		_ = db.QueryRow(`SELECT status FROM jobs WHERE id = ?`, id).Scan(&status)
  		if status != "completed" {
  			t.Errorf("status = %q, want completed", status)
  		}
  	})

  	t.Run("fail_increments_attempts", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(3))
  		_, _ = JobDequeue(q, nil)

  		// First failure → status = failed (attempts=1 < max=3).
  		if err := JobFail(q, id, "oops"); err != nil {
  			t.Fatalf("fail: %v", err)
  		}
  		var status string
  		var attempts int
  		_ = db.QueryRow(`SELECT status, attempts FROM jobs WHERE id = ?`, id).Scan(&status, &attempts)
  		if status != "failed" {
  			t.Errorf("status = %q, want failed", status)
  		}
  		if attempts != 1 {
  			t.Errorf("attempts = %d, want 1", attempts)
  		}
  	})

  	t.Run("fail_transitions_to_dead", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		id, _ := JobEnqueue(q, "work", "{}", WithMaxAttempts(2))

  		// Fail twice → dead.
  		for i := 0; i < 2; i++ {
  			// Re-set to pending so we can dequeue again (simulate retry logic).
  			if i > 0 {
  				_, _ = db.Exec(`UPDATE jobs SET status = 'pending' WHERE id = ?`, id)
  			}
  			_, _ = JobDequeue(q, nil)
  			_ = JobFail(q, id, fmt.Sprintf("error %d", i+1))
  		}

  		var status string
  		_ = db.QueryRow(`SELECT status FROM jobs WHERE id = ?`, id).Scan(&status)
  		if status != "dead" {
  			t.Errorf("status = %q, want dead after max_attempts reached", status)
  		}
  	})
  }


id: job_cleanup_go_infra_t3
function_id: job_cleanup_go_infra
name: TestJobStatusSummaryFn
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobStatusSummaryFn(t *testing.T) {
  	t.Run("job_status_summary_format", func(t *testing.T) {
  		counts := map[string]int{
  			"pending":   5,
  			"running":   2,
  			"completed": 10,
  			"failed":    1,
  			"dead":      0,
  		}
  		got := JobStatusSummary(counts)
  		want := "pending: 5, running: 2, completed: 10, failed: 1, dead: 0"
  		if got != want {
  			t.Errorf("got %q, want %q", got, want)
  		}
  	})

  	t.Run("job_status_summary_empty_map", func(t *testing.T) {
  		got := JobStatusSummary(map[string]int{})
  		want := "pending: 0, running: 0, completed: 0, failed: 0, dead: 0"
  		if got != want {
  			t.Errorf("got %q, want %q", got, want)
  		}
  	})
  }


id: job_cleanup_go_infra_t4
function_id: job_cleanup_go_infra
name: TestJobWorkerGracefulShutdown
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobWorkerGracefulShutdown(t *testing.T) {
  	t.Run("worker_graceful_shutdown", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		ctx, cancel := context.WithCancel(context.Background())

  		done := make(chan struct{})
  		go func() {
  			defer close(done)
  			_ = JobWorker(ctx, q, func(j Job) error {
  				return nil
  			}, WithPollInterval(10*time.Millisecond))
  		}()

  		// Let the worker poll a couple of times.
  		time.Sleep(50 * time.Millisecond)
  		cancel()

  		select {
  		case <-done:
  			// OK — worker exited cleanly.
  		case <-time.After(2 * time.Second):
  			t.Fatal("worker did not stop within timeout after context cancellation")
  		}
  	})

  	t.Run("worker_processes_jobs", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		var processed atomic.Int32
  		for i := 0; i < 3; i++ {
  			_, _ = JobEnqueue(q, "task", `{}`)
  		}

  		ctx, cancel := context.WithCancel(context.Background())
  		go func() {
  			_ = JobWorker(ctx, q, func(j Job) error {
  				processed.Add(1)
  				return nil
  			}, WithPollInterval(10*time.Millisecond))
  		}()

  		// Wait for all 3 to be processed (with timeout).
  		deadline := time.Now().Add(2 * time.Second)
  		for time.Now().Before(deadline) {
  			if processed.Load() >= 3 {
  				break
  			}
  			time.Sleep(10 * time.Millisecond)
  		}
  		cancel()

  		if n := processed.Load(); n < 3 {
  			t.Errorf("processed %d jobs, want 3", n)
  		}
  	})
  }


id: job_cleanup_go_infra_t5
function_id: job_cleanup_go_infra
name: TestJobConcurrency
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobConcurrency(t *testing.T) {
  	t.Run("concurrency_multiples_goroutines_dequeue", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		const numJobs = 50
  		for i := 0; i < numJobs; i++ {
  			_, _ = JobEnqueue(q, "task", fmt.Sprintf(`{"i":%d}`, i))
  		}

  		var processed atomic.Int32
  		var wg sync.WaitGroup

  		ctx, cancel := context.WithCancel(context.Background())
  		const numWorkers = 5
  		for i := 0; i < numWorkers; i++ {
  			wg.Add(1)
  			go func() {
  				defer wg.Done()
  				_ = JobWorker(ctx, q, func(j Job) error {
  					processed.Add(1)
  					return nil
  				}, WithPollInterval(5*time.Millisecond))
  			}()
  		}

  		// Wait until all jobs are processed.
  		deadline := time.Now().Add(5 * time.Second)
  		for time.Now().Before(deadline) {
  			if processed.Load() >= numJobs {
  				break
  			}
  			time.Sleep(10 * time.Millisecond)
  		}
  		cancel()
  		wg.Wait()

  		if n := processed.Load(); n != numJobs {
  			t.Errorf("processed %d, want %d (no double-processing with concurrent workers)", n, numJobs)
  		}
  	})
  }


id: job_cleanup_go_infra_t6
function_id: job_cleanup_go_infra
name: TestJobCleanupFn
file_path: functions/infra/job_queue_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJobCleanupFn(t *testing.T) {
  	t.Run("job_cleanup_removes_old_terminal_jobs", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		id, _ := JobEnqueue(q, "work", "{}")
  		_, _ = JobDequeue(q, nil)
  		_ = JobComplete(q, id, "")

  		// Make the job look old (set created_at in the past, always UTC to match JobCleanup).
  		past := time.Now().UTC().Add(-25 * time.Hour).Format(time.RFC3339)
  		_, _ = db.Exec(`UPDATE jobs SET created_at = ? WHERE id = ?`, past, id)

  		n, err := JobCleanup(q, 24*time.Hour)
  		if err != nil {
  			t.Fatalf("cleanup: %v", err)
  		}
  		if n != 1 {
  			t.Errorf("deleted %d rows, want 1", n)
  		}

  		// Job should be gone.
  		var count int
  		_ = db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE id = ?`, id).Scan(&count)
  		if count != 0 {
  			t.Errorf("job still in DB after cleanup")
  		}
  	})

  	t.Run("job_cleanup_keeps_recent_jobs", func(t *testing.T) {
  		db := openTestDB(t)
  		q, _ := JobQueueCreate(db, "jobs")

  		id, _ := JobEnqueue(q, "work", "{}")
  		_, _ = JobDequeue(q, nil)
  		_ = JobComplete(q, id, "")

  		// Job was just created — should not be cleaned up.
  		n, err := JobCleanup(q, 24*time.Hour)
  		if err != nil {
  			t.Fatalf("cleanup: %v", err)
  		}
  		if n != 0 {
  			t.Errorf("deleted %d rows, want 0 (job is recent)", n)
  		}
  	})
  }


id: jwt_generate_go_infra_t0
function_id: jwt_generate_go_infra
name: TestJWTGenerate_ReturnsThreeSegments
file_path: functions/infra/jwt_generate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTGenerate_ReturnsThreeSegments(t *testing.T) {
  	claims := JWTClaims{
  		Subject:   "user-1",
  		ExpiresAt: time.Now().Add(time.Hour).Unix(),
  	}
  	token, err := JWTGenerate(claims, "test-secret-0123456789abcdef")
  	if err != nil {
  		t.Fatalf("JWTGenerate error: %v", err)
  	}
  	parts := strings.Split(token, ".")
  	if len(parts) != 3 {
  		t.Fatalf("esperados 3 segmentos, got %d en %q", len(parts), token)
  	}
  	for i, p := range parts {
  		if p == "" {
  			t.Fatalf("segmento %d vacio", i)
  		}
  	}
  }


id: jwt_generate_go_infra_t1
function_id: jwt_generate_go_infra
name: TestJWTGenerate_FillsIssuedAtWhenZero
file_path: functions/infra/jwt_generate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTGenerate_FillsIssuedAtWhenZero(t *testing.T) {
  	claims := JWTClaims{
  		Subject:   "user-1",
  		ExpiresAt: time.Now().Add(time.Hour).Unix(),
  	}
  	before := time.Now().Unix()
  	token, err := JWTGenerate(claims, "s")
  	if err != nil {
  		t.Fatalf("JWTGenerate error: %v", err)
  	}
  	after := time.Now().Unix()
  	parsed, err := JWTValidate(token, "s")
  	if err != nil {
  		t.Fatalf("JWTValidate error: %v", err)
  	}
  	if parsed.IssuedAt < before || parsed.IssuedAt > after {
  		t.Fatalf("IssuedAt %d fuera del rango [%d,%d]", parsed.IssuedAt, before, after)
  	}
  }


id: jwt_generate_go_infra_t2
function_id: jwt_generate_go_infra
name: TestJWTGenerate_ErrorsOnEmptySecret
file_path: functions/infra/jwt_generate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTGenerate_ErrorsOnEmptySecret(t *testing.T) {
  	_, err := JWTGenerate(JWTClaims{Subject: "x"}, "")
  	if err == nil {
  		t.Fatal("esperaba error con secret vacio")
  	}
  }


id: jwt_middleware_go_infra_t0
function_id: jwt_middleware_go_infra
name: TestJWTMiddleware_ValidToken
file_path: functions/infra/jwt_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTMiddleware_ValidToken(t *testing.T) {
  	secret := "test-sec"
  	token := makeTokenFor(t, "alice", secret)

  	var gotSubject string
  	handler := JWTMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		claims, ok := JWTClaimsFromContext(r.Context())
  		if !ok {
  			t.Error("JWTClaimsFromContext no encontro claims")
  		}
  		gotSubject = claims.Subject
  		w.WriteHeader(200)
  	}))

  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	req.Header.Set("Authorization", "Bearer "+token)
  	rec := httptest.NewRecorder()

  	handler.ServeHTTP(rec, req)
  	if rec.Code != 200 {
  		t.Errorf("status = %d", rec.Code)
  	}
  	if gotSubject != "alice" {
  		t.Errorf("subject = %q", gotSubject)
  	}
  }


id: jwt_middleware_go_infra_t1
function_id: jwt_middleware_go_infra
name: TestJWTMiddleware_MissingAuthHeader
file_path: functions/infra/jwt_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTMiddleware_MissingAuthHeader(t *testing.T) {
  	handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("no deberia ejecutarse")
  	}))
  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, req)
  	if rec.Code != 401 {
  		t.Errorf("status = %d", rec.Code)
  	}
  }


id: jwt_middleware_go_infra_t2
function_id: jwt_middleware_go_infra
name: TestJWTMiddleware_WrongFormat
file_path: functions/infra/jwt_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTMiddleware_WrongFormat(t *testing.T) {
  	handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("no deberia ejecutarse")
  	}))
  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	req.Header.Set("Authorization", "Basic abcdef")
  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, req)
  	if rec.Code != 401 {
  		t.Errorf("status = %d", rec.Code)
  	}
  }


id: jwt_middleware_go_infra_t3
function_id: jwt_middleware_go_infra
name: TestJWTMiddleware_InvalidToken
file_path: functions/infra/jwt_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTMiddleware_InvalidToken(t *testing.T) {
  	handler := JWTMiddleware("secret-a")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("no deberia ejecutarse")
  	}))
  	tok := makeTokenFor(t, "x", "secret-b") // firmado con otro secret
  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	req.Header.Set("Authorization", "Bearer "+tok)
  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, req)
  	if rec.Code != 401 {
  		t.Errorf("status = %d", rec.Code)
  	}
  }


id: jwt_middleware_go_infra_t4
function_id: jwt_middleware_go_infra
name: TestJWTClaimsFromContext_NotPresent
file_path: functions/infra/jwt_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTClaimsFromContext_NotPresent(t *testing.T) {
  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	_, ok := JWTClaimsFromContext(req.Context())
  	if ok {
  		t.Fatal("no deberia haber claims en un context nuevo")
  	}
  }


id: jwt_validate_go_infra_t0
function_id: jwt_validate_go_infra
name: TestJWTValidate_ValidatesGeneratedToken
file_path: functions/infra/jwt_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTValidate_ValidatesGeneratedToken(t *testing.T) {
  	claims := JWTClaims{
  		Subject:   "user-42",
  		Issuer:    "tester",
  		ExpiresAt: time.Now().Add(time.Hour).Unix(),
  		Custom:    map[string]any{"role": "admin"},
  	}
  	token, err := JWTGenerate(claims, "super-secret")
  	if err != nil {
  		t.Fatalf("JWTGenerate error: %v", err)
  	}
  	got, err := JWTValidate(token, "super-secret")
  	if err != nil {
  		t.Fatalf("JWTValidate error: %v", err)
  	}
  	if got.Subject != "user-42" {
  		t.Errorf("Subject = %q, esperado user-42", got.Subject)
  	}
  	if got.Issuer != "tester" {
  		t.Errorf("Issuer = %q, esperado tester", got.Issuer)
  	}
  	if got.Custom["role"] != "admin" {
  		t.Errorf("Custom[role] = %v, esperado admin", got.Custom["role"])
  	}
  }


id: jwt_validate_go_infra_t1
function_id: jwt_validate_go_infra
name: TestJWTValidate_RejectsInvalidSignature
file_path: functions/infra/jwt_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTValidate_RejectsInvalidSignature(t *testing.T) {
  	token, err := JWTGenerate(JWTClaims{Subject: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret-a")
  	if err != nil {
  		t.Fatalf("JWTGenerate error: %v", err)
  	}
  	if _, err := JWTValidate(token, "secret-b"); err == nil {
  		t.Fatal("esperaba error con secret distinto")
  	}
  }


id: jwt_validate_go_infra_t2
function_id: jwt_validate_go_infra
name: TestJWTValidate_RejectsExpiredToken
file_path: functions/infra/jwt_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTValidate_RejectsExpiredToken(t *testing.T) {
  	token, err := JWTGenerate(JWTClaims{
  		Subject:   "x",
  		ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
  	}, "s")
  	if err != nil {
  		t.Fatalf("JWTGenerate error: %v", err)
  	}
  	if _, err := JWTValidate(token, "s"); err == nil {
  		t.Fatal("esperaba error con token expirado")
  	}
  }


id: jwt_validate_go_infra_t3
function_id: jwt_validate_go_infra
name: TestJWTValidate_RejectsMalformedToken
file_path: functions/infra/jwt_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTValidate_RejectsMalformedToken(t *testing.T) {
  	cases := []string{
  		"",
  		"not-a-token",
  		"one.two",
  		"one.two.three.four",
  	}
  	for _, tok := range cases {
  		if _, err := JWTValidate(tok, "s"); err == nil {
  			t.Errorf("esperaba error con token %q", tok)
  		}
  	}
  }


id: jwt_validate_go_infra_t4
function_id: jwt_validate_go_infra
name: TestJWTValidate_RejectsOtherAlgorithms
file_path: functions/infra/jwt_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestJWTValidate_RejectsOtherAlgorithms(t *testing.T) {
  	// Token con alg=none no es aceptado aunque la firma sea vacia
  	// Construimos manualmente: header con alg=none
  	// "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" = {"alg":"none","typ":"JWT"}
  	token := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ4In0."
  	if _, err := JWTValidate(token, "s"); err == nil {
  		t.Fatal("esperaba error con alg=none")
  	}
  	if !strings.Contains(token, ".") {
  		t.Fatal("token debe tener puntos")
  	}
  }


id: log_debug_go_infra_t0
function_id: log_debug_go_infra
name: TestLoggerNew
file_path: functions/infra/logger_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestLoggerNew(t *testing.T) {
  	t.Run("crea logger JSON valido", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, err := LoggerNew(LogLevelInfo, buf, "json")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if logger == nil {
  			t.Fatal("expected non-nil logger")
  		}
  		if logger.Format != "json" {
  			t.Errorf("got format=%q, want json", logger.Format)
  		}
  		if logger.Level != LogLevelInfo {
  			t.Errorf("got level=%d, want LogLevelInfo", logger.Level)
  		}
  	})

  	t.Run("crea logger text valido", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, err := LoggerNew(LogLevelDebug, buf, "text")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if logger.Format != "text" {
  			t.Errorf("got format=%q, want text", logger.Format)
  		}
  	})

  	t.Run("rechaza formato invalido", func(t *testing.T) {
  		_, err := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "xml")
  		if err == nil {
  			t.Fatal("expected error for invalid format")
  		}
  	})

  	t.Run("output nil cae en os.Stderr sin panic", func(t *testing.T) {
  		logger, err := LoggerNew(LogLevelError, nil, "json")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if logger.Output == nil {
  			t.Error("expected Output to default to os.Stderr, got nil")
  		}
  	})

  	t.Run("emite JSON valido al escribir", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelInfo, buf, "json")
  		LogInfo(logger, "hello")

  		var parsed map[string]any
  		if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
  			t.Fatalf("output no es JSON valido: %v\noutput: %s", err, buf.String())
  		}
  		if parsed["msg"] != "hello" {
  			t.Errorf("got msg=%v, want hello", parsed["msg"])
  		}
  		if parsed["level"] != "INFO" {
  			t.Errorf("got level=%v, want INFO", parsed["level"])
  		}
  	})

  	t.Run("filtra mensajes debajo del nivel configurado", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelWarn, buf, "json")
  		LogDebug(logger, "debug msg")
  		LogInfo(logger, "info msg")
  		LogWarn(logger, "warn msg")
  		LogError(logger, "error msg")

  		output := buf.String()
  		if strings.Contains(output, "debug msg") {
  			t.Error("debug msg no deberia aparecer con LogLevelWarn")
  		}
  		if strings.Contains(output, "info msg") {
  			t.Error("info msg no deberia aparecer con LogLevelWarn")
  		}
  		if !strings.Contains(output, "warn msg") {
  			t.Error("warn msg deberia aparecer con LogLevelWarn")
  		}
  		if !strings.Contains(output, "error msg") {
  			t.Error("error msg deberia aparecer con LogLevelWarn")
  		}
  	})
  }

  // --- LoggerWith ---


id: log_debug_go_infra_t1
function_id: log_debug_go_infra
name: TestLoggerWith
file_path: functions/infra/logger_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestLoggerWith(t *testing.T) {
  	t.Run("anade campos al logger", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		base, _ := LoggerNew(LogLevelInfo, buf, "json")
  		appLog := LoggerWith(base, map[string]any{"app": "test", "version": "1.0"})

  		LogInfo(appLog, "evento")

  		var parsed map[string]any
  		if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
  			t.Fatalf("JSON invalido: %v", err)
  		}
  		if parsed["app"] != "test" {
  			t.Errorf("got app=%v, want test", parsed["app"])
  		}
  		if parsed["version"] != "1.0" {
  			t.Errorf("got version=%v, want 1.0", parsed["version"])
  		}
  	})

  	t.Run("no muta el logger original", func(t *testing.T) {
  		base, _ := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "json")
  		if len(base.Fields) != 0 {
  			t.Fatalf("base logger deberia tener 0 fields iniciales, got %d", len(base.Fields))
  		}
  		_ = LoggerWith(base, map[string]any{"a": 1})
  		if len(base.Fields) != 0 {
  			t.Errorf("base logger no deberia haber mutado, got %d fields", len(base.Fields))
  		}
  	})

  	t.Run("apila fields sobre un logger ya contextualizado", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		base, _ := LoggerNew(LogLevelInfo, buf, "json")
  		appLog := LoggerWith(base, map[string]any{"app": "api"})
  		reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc"})

  		LogInfo(reqLog, "inicio")

  		var parsed map[string]any
  		if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
  			t.Fatalf("JSON invalido: %v", err)
  		}
  		if parsed["app"] != "api" {
  			t.Errorf("falta campo app heredado del padre, got %v", parsed["app"])
  		}
  		if parsed["request_id"] != "abc" {
  			t.Errorf("falta campo request_id nuevo, got %v", parsed["request_id"])
  		}
  	})

  	t.Run("retorna nil si recibe nil", func(t *testing.T) {
  		got := LoggerWith(nil, map[string]any{"k": "v"})
  		if got != nil {
  			t.Errorf("expected nil, got %v", got)
  		}
  	})
  }

  // --- Log niveles ---


id: log_debug_go_infra_t2
function_id: log_debug_go_infra
name: TestLogLevels
file_path: functions/infra/logger_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestLogLevels(t *testing.T) {
  	t.Run("LogInfo emite nivel INFO", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelDebug, buf, "json")
  		LogInfo(logger, "m")

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["level"] != "INFO" {
  			t.Errorf("got level=%v, want INFO", parsed["level"])
  		}
  	})

  	t.Run("LogWarn emite nivel WARN", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelDebug, buf, "json")
  		LogWarn(logger, "m")

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["level"] != "WARN" {
  			t.Errorf("got level=%v, want WARN", parsed["level"])
  		}
  	})

  	t.Run("LogError emite nivel ERROR", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelDebug, buf, "json")
  		LogError(logger, "m")

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["level"] != "ERROR" {
  			t.Errorf("got level=%v, want ERROR", parsed["level"])
  		}
  	})

  	t.Run("LogDebug emite nivel DEBUG", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelDebug, buf, "json")
  		LogDebug(logger, "m")

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["level"] != "DEBUG" {
  			t.Errorf("got level=%v, want DEBUG", parsed["level"])
  		}
  	})

  	t.Run("campos inline en la llamada aparecen en el JSON", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelInfo, buf, "json")
  		LogInfo(logger, "evento", "port", 8080, "user", "lucas")

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["port"] != float64(8080) {
  			t.Errorf("got port=%v, want 8080", parsed["port"])
  		}
  		if parsed["user"] != "lucas" {
  			t.Errorf("got user=%v, want lucas", parsed["user"])
  		}
  	})

  	t.Run("logger nil no hace panic en las funciones de log", func(t *testing.T) {
  		defer func() {
  			if r := recover(); r != nil {
  				t.Errorf("panic inesperado con logger nil: %v", r)
  			}
  		}()
  		LogDebug(nil, "msg")
  		LogInfo(nil, "msg")
  		LogWarn(nil, "msg")
  		LogError(nil, "msg")
  	})
  }

  // --- LoggerMiddleware ---


id: log_debug_go_infra_t3
function_id: log_debug_go_infra
name: TestLoggerMiddleware
file_path: functions/infra/logger_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestLoggerMiddleware(t *testing.T) {
  	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.WriteHeader(http.StatusCreated)
  		_, _ = w.Write([]byte("ok"))
  	})

  	t.Run("loguea method, path, status y duration_ms", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelInfo, buf, "json")
  		mw := LoggerMiddleware(logger)

  		req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
  		rec := httptest.NewRecorder()
  		mw(handler).ServeHTTP(rec, req)

  		var parsed map[string]any
  		if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
  			t.Fatalf("JSON invalido: %v\noutput: %s", err, buf.String())
  		}
  		if parsed["method"] != "POST" {
  			t.Errorf("got method=%v, want POST", parsed["method"])
  		}
  		if parsed["path"] != "/api/users" {
  			t.Errorf("got path=%v, want /api/users", parsed["path"])
  		}
  		if parsed["status"] != float64(http.StatusCreated) {
  			t.Errorf("got status=%v, want 201", parsed["status"])
  		}
  		if _, ok := parsed["duration_ms"]; !ok {
  			t.Error("falta campo duration_ms en el log")
  		}
  	})

  	t.Run("usa status 200 si el handler no llama WriteHeader", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		logger, _ := LoggerNew(LogLevelInfo, buf, "json")
  		mw := LoggerMiddleware(logger)

  		silentHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			_, _ = w.Write([]byte("hi"))
  		})

  		req := httptest.NewRequest(http.MethodGet, "/health", nil)
  		rec := httptest.NewRecorder()
  		mw(silentHandler).ServeHTTP(rec, req)

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["status"] != float64(http.StatusOK) {
  			t.Errorf("got status=%v, want 200", parsed["status"])
  		}
  	})

  	t.Run("preserva los campos contextuales del logger", func(t *testing.T) {
  		buf := &bytes.Buffer{}
  		base, _ := LoggerNew(LogLevelInfo, buf, "json")
  		appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"})
  		mw := LoggerMiddleware(appLog)

  		req := httptest.NewRequest(http.MethodGet, "/", nil)
  		rec := httptest.NewRecorder()
  		mw(handler).ServeHTTP(rec, req)

  		var parsed map[string]any
  		_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
  		if parsed["app"] != "sqlite_api" {
  			t.Errorf("falta campo contextual app=sqlite_api, got %v", parsed["app"])
  		}
  	})
  }


id: migration_create_go_infra_t0
function_id: migration_create_go_infra
name: TestMigrationCreate
file_path: functions/infra/migration_create_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationCreate(t *testing.T) {
  	t.Run("directorio vacio crea archivo con version 001", func(t *testing.T) {
  		dir := t.TempDir()
  		path, err := MigrationCreate(dir, "create_users")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}

  		base := filepath.Base(path)
  		if base != "001_create_users.sql" {
  			t.Errorf("filename: got %q, want %q", base, "001_create_users.sql")
  		}

  		content, err := os.ReadFile(path)
  		if err != nil {
  			t.Fatalf("cannot read created file: %v", err)
  		}
  		if !strings.Contains(string(content), "-- +up") {
  			t.Errorf("file missing -- +up marker")
  		}
  		if !strings.Contains(string(content), "-- +down") {
  			t.Errorf("file missing -- +down marker")
  		}
  	})

  	t.Run("directorio con migraciones existentes calcula siguiente version", func(t *testing.T) {
  		dir := t.TempDir()

  		// Create existing files
  		os.WriteFile(filepath.Join(dir, "001_create_users.sql"), []byte("-- +up\nCREATE TABLE users (id TEXT);\n-- +down\nDROP TABLE users;\n"), 0o644)
  		os.WriteFile(filepath.Join(dir, "002_add_email.sql"), []byte("-- +up\nALTER TABLE users ADD COLUMN email TEXT;\n-- +down\n\n"), 0o644)

  		path, err := MigrationCreate(dir, "add_index")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}

  		base := filepath.Base(path)
  		if base != "003_add_index.sql" {
  			t.Errorf("filename: got %q, want %q", base, "003_add_index.sql")
  		}
  	})

  	t.Run("nombre invalido retorna error", func(t *testing.T) {
  		dir := t.TempDir()
  		_, err := MigrationCreate(dir, "123invalid")
  		if err == nil {
  			t.Fatal("expected error for invalid name, got nil")
  		}
  	})

  	t.Run("directorio inexistente se crea automaticamente", func(t *testing.T) {
  		base := t.TempDir()
  		dir := filepath.Join(base, "nested", "migrations")

  		path, err := MigrationCreate(dir, "init")
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}

  		if _, err := os.Stat(dir); os.IsNotExist(err) {
  			t.Errorf("directory was not created: %s", dir)
  		}

  		if filepath.Dir(path) != dir {
  			t.Errorf("file not in expected dir: %s", path)
  		}
  	})
  }


id: migration_down_go_infra_t0
function_id: migration_down_go_infra
name: TestMigrationDown
file_path: functions/infra/migration_down_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationDown(t *testing.T) {
  	t.Run("revertir ultima migracion elimina registro y ejecuta down_sql", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")

  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		reverted, err := MigrationDown(db, 1)
  		if err != nil {
  			t.Fatalf("down failed: %v", err)
  		}
  		if len(reverted) != 1 {
  			t.Errorf("expected 1 reverted, got %d", len(reverted))
  		}
  		if reverted[0].Version != 2 {
  			t.Errorf("expected version 2 reverted, got %d", reverted[0].Version)
  		}

  		// roles table should be gone
  		if err := db.QueryRow("SELECT COUNT(*) FROM roles").Err(); err == nil {
  			t.Error("roles table should not exist after down")
  		}

  		// users table should still exist
  		var count int
  		if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
  			t.Errorf("users table should still exist: %v", err)
  		}
  	})

  	t.Run("revertir n migraciones revierte en orden descendente", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
  		writeMigrationFile(t, dir, "003_create_logs.sql",
  			"-- +up\nCREATE TABLE logs (id INTEGER PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS logs;\n")

  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		reverted, err := MigrationDown(db, 2)
  		if err != nil {
  			t.Fatalf("down 2 failed: %v", err)
  		}
  		if len(reverted) != 2 {
  			t.Errorf("expected 2 reverted, got %d", len(reverted))
  		}
  		// Should be reverted in descending order: 3, 2
  		if reverted[0].Version != 3 || reverted[1].Version != 2 {
  			t.Errorf("reverted order wrong: got %d, %d", reverted[0].Version, reverted[1].Version)
  		}

  		// users table should still exist (migration 1 not reverted)
  		var count int
  		if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
  			t.Errorf("users table should still exist: %v", err)
  		}
  	})

  	t.Run("n cero no revierte nada", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")

  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		reverted, err := MigrationDown(db, 0)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(reverted) != 0 {
  			t.Errorf("expected 0 reverted for n=0, got %d", len(reverted))
  		}
  	})

  	t.Run("base de datos sin migraciones retorna slice vacio", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		// Apply to create _migrations table
  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		reverted, err := MigrationDown(db, 5)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(reverted) != 0 {
  			t.Errorf("expected 0 reverted from empty DB, got %d", len(reverted))
  		}
  	})
  }


id: migration_parse_go_infra_t0
function_id: migration_parse_go_infra
name: TestMigrationParse
file_path: functions/infra/migration_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationParse(t *testing.T) {
  	t.Run("archivo valido con up y down retorna Migration correcta", func(t *testing.T) {
  		content := "\n-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n\n-- +down\nDROP TABLE IF EXISTS users;\n"
  		m, err := MigrationParse("001_create_users.sql", content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if m.Version != 1 {
  			t.Errorf("Version: got %d, want 1", m.Version)
  		}
  		if m.Name != "create_users" {
  			t.Errorf("Name: got %q, want %q", m.Name, "create_users")
  		}
  		if !strings.Contains(m.UpSQL, "CREATE TABLE users") {
  			t.Errorf("UpSQL missing CREATE TABLE: %q", m.UpSQL)
  		}
  		if !strings.Contains(m.DownSQL, "DROP TABLE") {
  			t.Errorf("DownSQL missing DROP TABLE: %q", m.DownSQL)
  		}
  	})

  	t.Run("archivo sin bloque down retorna DownSQL vacio sin error", func(t *testing.T) {
  		content := "-- +up\nCREATE TABLE logs (id INTEGER PRIMARY KEY);\n"
  		m, err := MigrationParse("002_create_logs.sql", content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if m.Version != 2 {
  			t.Errorf("Version: got %d, want 2", m.Version)
  		}
  		if m.Name != "create_logs" {
  			t.Errorf("Name: got %q, want %q", m.Name, "create_logs")
  		}
  		if m.DownSQL != "" {
  			t.Errorf("DownSQL: got %q, want empty", m.DownSQL)
  		}
  	})

  	t.Run("filename sin separador underscore retorna error", func(t *testing.T) {
  		_, err := MigrationParse("001.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
  		if err == nil {
  			t.Fatal("expected error, got nil")
  		}
  	})

  	t.Run("version no numerica retorna error", func(t *testing.T) {
  		_, err := MigrationParse("abc_create_users.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
  		if err == nil {
  			t.Fatal("expected error, got nil")
  		}
  	})

  	t.Run("bloque up vacio retorna error", func(t *testing.T) {
  		content := "-- +up\n\n-- +down\nDROP TABLE users;\n"
  		_, err := MigrationParse("001_create_users.sql", content)
  		if err == nil {
  			t.Fatal("expected error for empty up block, got nil")
  		}
  	})

  	t.Run("version cero retorna error", func(t *testing.T) {
  		_, err := MigrationParse("000_something.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
  		if err == nil {
  			t.Fatal("expected error for version 0, got nil")
  		}
  	})

  	t.Run("nombre descriptivo vacio retorna error", func(t *testing.T) {
  		_, err := MigrationParse("001_.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
  		if err == nil {
  			t.Fatal("expected error for empty descriptive name, got nil")
  		}
  	})

  	t.Run("filename con path completo extrae nombre base", func(t *testing.T) {
  		content := "-- +up\nCREATE TABLE x (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE x;\n"
  		m, err := MigrationParse("apps/my_app/migrations/003_add_index.sql", content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if m.Version != 3 {
  			t.Errorf("Version: got %d, want 3", m.Version)
  		}
  		if m.Name != "add_index" {
  			t.Errorf("Name: got %q, want %q", m.Name, "add_index")
  		}
  	})
  }


id: migration_status_go_infra_t0
function_id: migration_status_go_infra
name: TestMigrationStatus
file_path: functions/infra/migration_status_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationStatus(t *testing.T) {
  	t.Run("migraciones en disco pero no en BD aparecen como pending", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")

  		statuses, err := MigrationGetStatus(db, dir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(statuses) != 2 {
  			t.Fatalf("expected 2 statuses, got %d", len(statuses))
  		}
  		for _, s := range statuses {
  			if s.Applied {
  				t.Errorf("version %d should be pending, got applied", s.Version)
  			}
  		}
  	})

  	t.Run("migraciones aplicadas aparecen con Applied=true y AppliedAt", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")

  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		statuses, err := MigrationGetStatus(db, dir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(statuses) != 2 {
  			t.Fatalf("expected 2 statuses, got %d", len(statuses))
  		}
  		for _, s := range statuses {
  			if !s.Applied {
  				t.Errorf("version %d should be applied, got pending", s.Version)
  			}
  			if s.AppliedAt.IsZero() {
  				t.Errorf("version %d AppliedAt should not be zero", s.Version)
  			}
  		}
  	})

  	t.Run("migraciones aplicadas sin archivo en disco aparecen como orphaned", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")

  		if _, err := MigrationUp(db, dir); err != nil {
  			t.Fatalf("up failed: %v", err)
  		}

  		// Remove file from disk but migration is applied in DB
  		// Now check status with an empty dir
  		emptyDir := t.TempDir()
  		statuses, err := MigrationGetStatus(db, emptyDir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(statuses) != 1 {
  			t.Fatalf("expected 1 status (orphaned), got %d", len(statuses))
  		}
  		s := statuses[0]
  		if !s.Applied {
  			t.Errorf("orphaned migration should have Applied=true")
  		}
  		if !containsStr(s.Name, "orphaned") {
  			t.Errorf("orphaned migration name should contain 'orphaned', got %q", s.Name)
  		}
  	})

  	t.Run("base de datos sin tabla _migrations retorna todas como pending", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")

  		// Do NOT call MigrationUp — _migrations table doesn't exist
  		statuses, err := MigrationGetStatus(db, dir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(statuses) != 1 {
  			t.Fatalf("expected 1 status, got %d", len(statuses))
  		}
  		if statuses[0].Applied {
  			t.Errorf("migration should be pending when _migrations does not exist")
  		}
  	})
  }


id: migration_up_go_infra_t0
function_id: migration_up_go_infra
name: TestMigrationUp
file_path: functions/infra/migration_up_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationUp(t *testing.T) {
  	t.Run("base de datos vacia aplica todas las migraciones", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")

  		applied, err := MigrationUp(db, dir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(applied) != 2 {
  			t.Errorf("applied count: got %d, want 2", len(applied))
  		}
  		if applied[0].Version != 1 || applied[1].Version != 2 {
  			t.Errorf("applied versions: got %v", []int{applied[0].Version, applied[1].Version})
  		}

  		// Verify tables were created
  		var count int
  		if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
  			t.Errorf("users table not created: %v", err)
  		}
  		if err := db.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil {
  			t.Errorf("roles table not created: %v", err)
  		}
  	})

  	t.Run("migraciones ya aplicadas se omiten", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_create_roles.sql",
  			"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")

  		// Apply all
  		_, err := MigrationUp(db, dir)
  		if err != nil {
  			t.Fatalf("first up failed: %v", err)
  		}

  		// Apply again — should apply nothing
  		applied, err := MigrationUp(db, dir)
  		if err != nil {
  			t.Fatalf("second up failed: %v", err)
  		}
  		if len(applied) != 0 {
  			t.Errorf("expected 0 applied on second run, got %d", len(applied))
  		}
  	})

  	t.Run("migracion con SQL invalido retorna error y deja las anteriores aplicadas", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		writeMigrationFile(t, dir, "001_create_users.sql",
  			"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
  		writeMigrationFile(t, dir, "002_bad_sql.sql",
  			"-- +up\nTHIS IS NOT VALID SQL!!!;\n-- +down\n\n")

  		applied, err := MigrationUp(db, dir)
  		if err == nil {
  			t.Fatal("expected error for invalid SQL, got nil")
  		}
  		// Version 1 should be applied
  		if len(applied) != 1 || applied[0].Version != 1 {
  			t.Errorf("expected [1] applied before failure, got versions: %v", applied)
  		}

  		// users table should still exist (migration 1 committed)
  		var count int
  		if err2 := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err2 != nil {
  			t.Errorf("users table not present after partial apply: %v", err2)
  		}
  	})

  	t.Run("directorio sin archivos sql no aplica nada", func(t *testing.T) {
  		db := openMigrationTestDB(t)
  		dir := t.TempDir()

  		applied, err := MigrationUp(db, dir)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if len(applied) != 0 {
  			t.Errorf("expected 0 applied for empty dir, got %d", len(applied))
  		}
  	})
  }


id: migration_validate_go_infra_t0
function_id: migration_validate_go_infra
name: TestMigrationValidate
file_path: functions/infra/migration_validate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestMigrationValidate(t *testing.T) {
  	t.Run("secuencia valida retorna sin errores", func(t *testing.T) {
  		migrations := []Migration{
  			makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
  			makeMig(2, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
  			makeMig(3, "create_roles", "CREATE TABLE roles (id TEXT PRIMARY KEY);"),
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) != 0 {
  			t.Errorf("expected no errors, got: %v", errs)
  		}
  	})

  	t.Run("secuencia vacia retorna sin errores", func(t *testing.T) {
  		errs := MigrationValidate([]Migration{})
  		if len(errs) != 0 {
  			t.Errorf("expected no errors for empty slice, got: %v", errs)
  		}
  	})

  	t.Run("version duplicada reporta error", func(t *testing.T) {
  		migrations := []Migration{
  			makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
  			makeMig(1, "create_users_dup", "CREATE TABLE users2 (id TEXT PRIMARY KEY);"),
  			makeMig(2, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) == 0 {
  			t.Fatal("expected error for duplicate version, got none")
  		}
  		found := false
  		for _, e := range errs {
  			if containsStr(e, "duplicate") {
  				found = true
  			}
  		}
  		if !found {
  			t.Errorf("expected 'duplicate' in errors, got: %v", errs)
  		}
  	})

  	t.Run("hueco en versiones reporta version faltante", func(t *testing.T) {
  		migrations := []Migration{
  			makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
  			makeMig(3, "create_roles", "CREATE TABLE roles (id TEXT PRIMARY KEY);"),
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) == 0 {
  			t.Fatal("expected error for gap in versions, got none")
  		}
  		found := false
  		for _, e := range errs {
  			if containsStr(e, "gap") || containsStr(e, "missing 2") {
  				found = true
  			}
  		}
  		if !found {
  			t.Errorf("expected gap error in errors, got: %v", errs)
  		}
  	})

  	t.Run("up_sql vacio reporta error", func(t *testing.T) {
  		migrations := []Migration{
  			{Version: 1, Name: "create_users", UpSQL: "", DownSQL: "DROP TABLE users;"},
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) == 0 {
  			t.Fatal("expected error for empty up_sql, got none")
  		}
  		found := false
  		for _, e := range errs {
  			if containsStr(e, "empty up_sql") {
  				found = true
  			}
  		}
  		if !found {
  			t.Errorf("expected 'empty up_sql' in errors, got: %v", errs)
  		}
  	})

  	t.Run("nombre vacio reporta error", func(t *testing.T) {
  		migrations := []Migration{
  			{Version: 1, Name: "", UpSQL: "CREATE TABLE users (id TEXT PRIMARY KEY);"},
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) == 0 {
  			t.Fatal("expected error for empty name, got none")
  		}
  		found := false
  		for _, e := range errs {
  			if containsStr(e, "empty name") {
  				found = true
  			}
  		}
  		if !found {
  			t.Errorf("expected 'empty name' in errors, got: %v", errs)
  		}
  	})

  	t.Run("versiones que no empiezan en 1 reportan error", func(t *testing.T) {
  		migrations := []Migration{
  			makeMig(2, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
  			makeMig(3, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
  		}
  		errs := MigrationValidate(migrations)
  		if len(errs) == 0 {
  			t.Fatal("expected error for versions not starting at 1, got none")
  		}
  		found := false
  		for _, e := range errs {
  			if containsStr(e, "start at 1") {
  				found = true
  			}
  		}
  		if !found {
  			t.Errorf("expected 'start at 1' error, got: %v", errs)
  		}
  	})

  	t.Run("multiple errores se reportan todos", func(t *testing.T) {
  		migrations := []Migration{
  			{Version: 2, Name: "", UpSQL: ""},
  			{Version: 4, Name: "something", UpSQL: "CREATE TABLE x (id TEXT);"},
  		}
  		errs := MigrationValidate(migrations)
  		// Expect: not starting at 1, gap between 2 and 4, empty name for v2, empty up_sql for v2
  		if len(errs) < 3 {
  			t.Errorf("expected at least 3 errors, got %d: %v", len(errs), errs)
  		}
  	})
  }

  func containsStr(s, sub string) bool {
  	return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
  		func() bool {
  			for i := 0; i <= len(s)-len(sub); i++ {
  				if s[i:i+len(sub)] == sub {
  					return true
  				}
  			}
  			return false
  		}())
  }


id: oauth2_auth_url_go_infra_t0
function_id: oauth2_auth_url_go_infra
name: TestOauth2AuthURL_BuildsBasicParams
file_path: functions/infra/oauth2_auth_url_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2AuthURL_BuildsBasicParams(t *testing.T) {
  	cfg := OAuthConfig{
  		ClientID:    "abc-client",
  		AuthURL:     "https://example.com/authorize",
  		RedirectURL: "http://localhost/callback",
  		Scopes:      []string{"openid", "email"},
  	}
  	got := Oauth2AuthURL(cfg, "state-xyz")
  	u, err := url.Parse(got)
  	if err != nil {
  		t.Fatalf("parse: %v", err)
  	}
  	if u.Scheme != "https" || u.Host != "example.com" || u.Path != "/authorize" {
  		t.Fatalf("base URL incorrecta: %s", got)
  	}
  	q := u.Query()
  	if q.Get("client_id") != "abc-client" {
  		t.Errorf("client_id = %q", q.Get("client_id"))
  	}
  	if q.Get("redirect_uri") != "http://localhost/callback" {
  		t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
  	}
  	if q.Get("response_type") != "code" {
  		t.Errorf("response_type = %q", q.Get("response_type"))
  	}
  	if q.Get("scope") != "openid email" {
  		t.Errorf("scope = %q", q.Get("scope"))
  	}
  	if q.Get("state") != "state-xyz" {
  		t.Errorf("state = %q", q.Get("state"))
  	}
  }


id: oauth2_auth_url_go_infra_t1
function_id: oauth2_auth_url_go_infra
name: TestOauth2AuthURL_OmitsEmptyState
file_path: functions/infra/oauth2_auth_url_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2AuthURL_OmitsEmptyState(t *testing.T) {
  	cfg := OAuthConfig{ClientID: "c", AuthURL: "https://x.test/a", RedirectURL: "http://r"}
  	got := Oauth2AuthURL(cfg, "")
  	if strings.Contains(got, "state=") {
  		t.Errorf("state deberia estar ausente: %s", got)
  	}
  }


id: oauth2_auth_url_go_infra_t2
function_id: oauth2_auth_url_go_infra
name: TestOauth2AuthURL_HandlesExistingQueryString
file_path: functions/infra/oauth2_auth_url_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2AuthURL_HandlesExistingQueryString(t *testing.T) {
  	cfg := OAuthConfig{
  		ClientID:    "c",
  		AuthURL:     "https://example.com/authorize?hd=domain.com",
  		RedirectURL: "http://r",
  	}
  	got := Oauth2AuthURL(cfg, "s")
  	u, err := url.Parse(got)
  	if err != nil {
  		t.Fatalf("parse: %v", err)
  	}
  	q := u.Query()
  	if q.Get("hd") != "domain.com" {
  		t.Errorf("param pre-existente se perdio")
  	}
  	if q.Get("client_id") != "c" {
  		t.Errorf("client_id no agregado")
  	}
  }


id: oauth2_exchange_go_infra_t0
function_id: oauth2_exchange_go_infra
name: TestOauth2Exchange_Success
file_path: functions/infra/oauth2_exchange_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Exchange_Success(t *testing.T) {
  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		if r.Method != http.MethodPost {
  			t.Errorf("metodo = %s", r.Method)
  		}
  		if err := r.ParseForm(); err != nil {
  			t.Fatalf("ParseForm: %v", err)
  		}
  		if got := r.PostForm.Get("grant_type"); got != "authorization_code" {
  			t.Errorf("grant_type = %q", got)
  		}
  		if got := r.PostForm.Get("code"); got != "abc-code" {
  			t.Errorf("code = %q", got)
  		}
  		w.Header().Set("Content-Type", "application/json")
  		json.NewEncoder(w).Encode(map[string]any{
  			"access_token":  "at-123",
  			"refresh_token": "rt-456",
  			"token_type":    "Bearer",
  			"expires_in":    3600,
  		})
  	}))
  	defer srv.Close()

  	cfg := OAuthConfig{
  		ClientID:     "c",
  		ClientSecret: "s",
  		TokenURL:     srv.URL,
  		RedirectURL:  "http://r",
  	}
  	tokens, err := Oauth2Exchange(cfg, "abc-code")
  	if err != nil {
  		t.Fatalf("Oauth2Exchange: %v", err)
  	}
  	if tokens.AccessToken != "at-123" || tokens.RefreshToken != "rt-456" || tokens.TokenType != "Bearer" {
  		t.Errorf("tokens = %+v", tokens)
  	}
  	if tokens.ExpiresAt == 0 {
  		t.Error("ExpiresAt no deberia ser 0")
  	}
  }


id: oauth2_exchange_go_infra_t1
function_id: oauth2_exchange_go_infra
name: TestOauth2Exchange_EmptyCode
file_path: functions/infra/oauth2_exchange_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Exchange_EmptyCode(t *testing.T) {
  	cfg := OAuthConfig{TokenURL: "http://x", ClientID: "c"}
  	if _, err := Oauth2Exchange(cfg, ""); err == nil {
  		t.Fatal("esperaba error con code vacio")
  	}
  }


id: oauth2_exchange_go_infra_t2
function_id: oauth2_exchange_go_infra
name: TestOauth2Exchange_ProviderError
file_path: functions/infra/oauth2_exchange_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Exchange_ProviderError(t *testing.T) {
  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.WriteHeader(400)
  		w.Header().Set("Content-Type", "application/json")
  		json.NewEncoder(w).Encode(map[string]string{
  			"error":             "invalid_grant",
  			"error_description": "code expired",
  		})
  	}))
  	defer srv.Close()

  	cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c"}
  	if _, err := Oauth2Exchange(cfg, "code"); err == nil {
  		t.Fatal("esperaba error del proveedor")
  	}
  }


id: oauth2_refresh_go_infra_t0
function_id: oauth2_refresh_go_infra
name: TestOauth2Refresh_Success
file_path: functions/infra/oauth2_refresh_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Refresh_Success(t *testing.T) {
  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		_ = r.ParseForm()
  		if got := r.PostForm.Get("grant_type"); got != "refresh_token" {
  			t.Errorf("grant_type = %q", got)
  		}
  		if got := r.PostForm.Get("refresh_token"); got != "rt-old" {
  			t.Errorf("refresh_token = %q", got)
  		}
  		w.Header().Set("Content-Type", "application/json")
  		json.NewEncoder(w).Encode(map[string]any{
  			"access_token":  "at-new",
  			"refresh_token": "rt-new",
  			"token_type":    "Bearer",
  			"expires_in":    1800,
  		})
  	}))
  	defer srv.Close()

  	cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c", ClientSecret: "s"}
  	tokens, err := Oauth2Refresh(cfg, "rt-old")
  	if err != nil {
  		t.Fatalf("Oauth2Refresh: %v", err)
  	}
  	if tokens.AccessToken != "at-new" {
  		t.Errorf("AccessToken = %q", tokens.AccessToken)
  	}
  	if tokens.RefreshToken != "rt-new" {
  		t.Errorf("RefreshToken = %q", tokens.RefreshToken)
  	}
  }


id: oauth2_refresh_go_infra_t1
function_id: oauth2_refresh_go_infra
name: TestOauth2Refresh_PreservesRefreshTokenIfProviderOmits
file_path: functions/infra/oauth2_refresh_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Refresh_PreservesRefreshTokenIfProviderOmits(t *testing.T) {
  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.Header().Set("Content-Type", "application/json")
  		json.NewEncoder(w).Encode(map[string]any{
  			"access_token": "at-new",
  			"token_type":   "Bearer",
  			"expires_in":   1800,
  			// no refresh_token
  		})
  	}))
  	defer srv.Close()

  	cfg := OAuthConfig{TokenURL: srv.URL}
  	tokens, err := Oauth2Refresh(cfg, "rt-keep")
  	if err != nil {
  		t.Fatalf("Oauth2Refresh: %v", err)
  	}
  	if tokens.RefreshToken != "rt-keep" {
  		t.Errorf("esperaba conservar rt-keep, got %q", tokens.RefreshToken)
  	}
  }


id: oauth2_refresh_go_infra_t2
function_id: oauth2_refresh_go_infra
name: TestOauth2Refresh_EmptyToken
file_path: functions/infra/oauth2_refresh_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestOauth2Refresh_EmptyToken(t *testing.T) {
  	cfg := OAuthConfig{TokenURL: "http://x"}
  	if _, err := Oauth2Refresh(cfg, ""); err == nil {
  		t.Fatal("esperaba error con refresh vacio")
  	}
  }


id: parse_nordvpn_status_go_infra_t0
function_id: parse_nordvpn_status_go_infra
name: TestParseNordVPNStatus_Connected
file_path: functions/infra/parse_nordvpn_status_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestParseNordVPNStatus_Connected(t *testing.T) {
  	input := `Status: Connected
  Hostname: es42.nordvpn.com
  IP: 185.230.124.42
  Country: Spain
  City: Madrid
  Current Technology: NordLynx
  Current Protocol: nordlynx
  Transfer: 1.2 MiB received, 500 KiB sent
  Uptime: 5 minutes 32 seconds`

  	got := ParseNordVPNStatus(input)

  	if !got.Connected {
  		t.Error("expected Connected=true")
  	}
  	if got.Hostname != "es42.nordvpn.com" {
  		t.Errorf("Hostname = %q, want es42.nordvpn.com", got.Hostname)
  	}
  	if got.IP != "185.230.124.42" {
  		t.Errorf("IP = %q, want 185.230.124.42", got.IP)
  	}
  	if got.Country != "Spain" {
  		t.Errorf("Country = %q, want Spain", got.Country)
  	}
  	if got.City != "Madrid" {
  		t.Errorf("City = %q, want Madrid", got.City)
  	}
  	if got.Technology != "NordLynx" {
  		t.Errorf("Technology = %q, want NordLynx", got.Technology)
  	}
  }


id: parse_nordvpn_status_go_infra_t1
function_id: parse_nordvpn_status_go_infra
name: TestParseNordVPNStatus_Disconnected
file_path: functions/infra/parse_nordvpn_status_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestParseNordVPNStatus_Disconnected(t *testing.T) {
  	input := `Status: Disconnected`

  	got := ParseNordVPNStatus(input)

  	if got.Connected {
  		t.Error("expected Connected=false")
  	}
  	if got.Status != "Disconnected" {
  		t.Errorf("Status = %q, want Disconnected", got.Status)
  	}
  }


id: parse_nordvpn_status_go_infra_t2
function_id: parse_nordvpn_status_go_infra
name: TestParseNordVPNStatus_WithANSI
file_path: functions/infra/parse_nordvpn_status_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestParseNordVPNStatus_WithANSI(t *testing.T) {
  	input := "\x1b[32mStatus: Connected\x1b[0m\n\x1b[32m- Hostname: us1234.nordvpn.com\x1b[0m"

  	got := ParseNordVPNStatus(input)

  	if !got.Connected {
  		t.Error("expected Connected=true with ANSI codes")
  	}
  	if got.Hostname != "us1234.nordvpn.com" {
  		t.Errorf("Hostname = %q, want us1234.nordvpn.com", got.Hostname)
  	}
  }


id: password_hash_go_infra_t0
function_id: password_hash_go_infra
name: TestPasswordHash_DefaultCost
file_path: functions/infra/password_hash_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordHash_DefaultCost(t *testing.T) {
  	hash, err := PasswordHash("hunter2", 0)
  	if err != nil {
  		t.Fatalf("PasswordHash error: %v", err)
  	}
  	if !strings.HasPrefix(hash, "$2") {
  		t.Errorf("hash no tiene prefijo bcrypt: %q", hash)
  	}
  }


id: password_hash_go_infra_t1
function_id: password_hash_go_infra
name: TestPasswordHash_CustomCost
file_path: functions/infra/password_hash_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordHash_CustomCost(t *testing.T) {
  	hash, err := PasswordHash("password", 4) // 4 = minimum, rapido para tests
  	if err != nil {
  		t.Fatalf("PasswordHash error: %v", err)
  	}
  	if hash == "" {
  		t.Fatal("hash vacio")
  	}
  }


id: password_hash_go_infra_t2
function_id: password_hash_go_infra
name: TestPasswordHash_DifferentSalts
file_path: functions/infra/password_hash_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordHash_DifferentSalts(t *testing.T) {
  	h1, _ := PasswordHash("same-password", 4)
  	h2, _ := PasswordHash("same-password", 4)
  	if h1 == h2 {
  		t.Fatal("los hashes deben diferir por salt distinto")
  	}
  }


id: password_verify_go_infra_t0
function_id: password_verify_go_infra
name: TestPasswordVerify_CorrectPassword
file_path: functions/infra/password_verify_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordVerify_CorrectPassword(t *testing.T) {
  	hash, _ := PasswordHash("correct-horse-battery-staple", 4)
  	if err := PasswordVerify("correct-horse-battery-staple", hash); err != nil {
  		t.Fatalf("esperaba nil, got %v", err)
  	}
  }


id: password_verify_go_infra_t1
function_id: password_verify_go_infra
name: TestPasswordVerify_WrongPassword
file_path: functions/infra/password_verify_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordVerify_WrongPassword(t *testing.T) {
  	hash, _ := PasswordHash("secret", 4)
  	if err := PasswordVerify("wrong", hash); err == nil {
  		t.Fatal("esperaba error con password incorrecto")
  	}
  }


id: password_verify_go_infra_t2
function_id: password_verify_go_infra
name: TestPasswordVerify_InvalidHash
file_path: functions/infra/password_verify_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestPasswordVerify_InvalidHash(t *testing.T) {
  	if err := PasswordVerify("x", "not-a-bcrypt-hash"); err == nil {
  		t.Fatal("esperaba error con hash invalido")
  	}
  }


id: process_kill_go_infra_t0
function_id: process_kill_go_infra
name: TestProcessSpawn
file_path: functions/infra/process_spawn_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestProcessSpawn(t *testing.T) {
  	t.Run("spawn and wait echo", func(t *testing.T) {
  		h, err := ProcessSpawn("echo hello", "", nil, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		res, err := ProcessWait(h, 10)
  		if err != nil {
  			t.Fatalf("wait: %v", err)
  		}
  		if res.ExitCode != 0 {
  			t.Errorf("exit code: got %d, want 0", res.ExitCode)
  		}
  		if !strings.Contains(res.Stdout, "hello") {
  			t.Errorf("stdout: got %q, want it to contain 'hello'", res.Stdout)
  		}
  	})

  	t.Run("spawn with timeout kills", func(t *testing.T) {
  		h, err := ProcessSpawn("sleep 60", "", nil, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		res, err := ProcessWait(h, 2)
  		if err != nil {
  			t.Fatalf("wait: %v", err)
  		}
  		if !res.Killed {
  			t.Errorf("killed: got false, want true")
  		}
  		if res.ExitCode == 0 {
  			t.Errorf("exit code: got 0, want != 0 after kill")
  		}
  	})

  	t.Run("spawn with env", func(t *testing.T) {
  		h, err := ProcessSpawn("echo $TEST_VAR", "", []string{"PATH=/usr/bin:/bin", "TEST_VAR=hello123"}, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		res, err := ProcessWait(h, 10)
  		if err != nil {
  			t.Fatalf("wait: %v", err)
  		}
  		if !strings.Contains(res.Stdout, "hello123") {
  			t.Errorf("stdout: got %q, want it to contain 'hello123'", res.Stdout)
  		}
  	})

  	t.Run("spawn script", func(t *testing.T) {
  		script := "#!/bin/sh\necho line1\necho line2"
  		h, err := ProcessSpawn(script, "", nil, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		res, err := ProcessWait(h, 10)
  		if err != nil {
  			t.Fatalf("wait: %v", err)
  		}
  		if !strings.Contains(res.Stdout, "line1") {
  			t.Errorf("stdout: got %q, want it to contain 'line1'", res.Stdout)
  		}
  		if !strings.Contains(res.Stdout, "line2") {
  			t.Errorf("stdout: got %q, want it to contain 'line2'", res.Stdout)
  		}
  	})

  	t.Run("spawn with working dir", func(t *testing.T) {
  		h, err := ProcessSpawn("pwd", "/tmp", nil, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		res, err := ProcessWait(h, 10)
  		if err != nil {
  			t.Fatalf("wait: %v", err)
  		}
  		if !strings.Contains(res.Stdout, "/tmp") {
  			t.Errorf("stdout: got %q, want it to contain '/tmp'", res.Stdout)
  		}
  	})

  	t.Run("kill process", func(t *testing.T) {
  		h, err := ProcessSpawn("sleep 60", "", nil, "")
  		if err != nil {
  			t.Fatalf("spawn: %v", err)
  		}
  		if err := ProcessKill(h, 1); err != nil {
  			t.Fatalf("kill: %v", err)
  		}
  		// After kill, Wait should unblock quickly.
  		_ = h.Cmd.Wait()
  		state := h.Cmd.ProcessState
  		if state == nil {
  			t.Fatal("process state is nil after kill+wait")
  		}
  		if state.ExitCode() == 0 {
  			t.Errorf("exit code: got 0 after kill, want non-zero")
  		}
  	})
  }


id: rate_limit_headers_go_infra_t0
function_id: rate_limit_headers_go_infra
name: TestRateLimitHeaders
file_path: functions/infra/rate_limit_headers_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimitHeaders(t *testing.T) {
  	now := time.Now()

  	t.Run("setea X-RateLimit-Limit con el burst", func(t *testing.T) {
  		result := RateLimitResult{Allowed: true, Remaining: 5, ResetAt: now}
  		h := RateLimitHeaders(result, 20)
  		if h.Get("X-RateLimit-Limit") != "20" {
  			t.Errorf("X-RateLimit-Limit=%q, want 20", h.Get("X-RateLimit-Limit"))
  		}
  	})

  	t.Run("setea X-RateLimit-Remaining con result.Remaining", func(t *testing.T) {
  		result := RateLimitResult{Allowed: true, Remaining: 7, ResetAt: now}
  		h := RateLimitHeaders(result, 10)
  		if h.Get("X-RateLimit-Remaining") != "7" {
  			t.Errorf("X-RateLimit-Remaining=%q, want 7", h.Get("X-RateLimit-Remaining"))
  		}
  	})

  	t.Run("setea X-RateLimit-Reset como timestamp unix", func(t *testing.T) {
  		future := now.Add(30 * time.Second)
  		result := RateLimitResult{Allowed: true, Remaining: 1, ResetAt: future}
  		h := RateLimitHeaders(result, 10)
  		got := h.Get("X-RateLimit-Reset")
  		want := strconv.FormatInt(future.Unix(), 10)
  		if got != want {
  			t.Errorf("X-RateLimit-Reset=%q, want %q", got, want)
  		}
  	})

  	t.Run("incluye Retry-After cuando no esta permitido", func(t *testing.T) {
  		result := RateLimitResult{Allowed: false, Remaining: 0, ResetAt: now, RetryAfter: 2.3}
  		h := RateLimitHeaders(result, 10)
  		if h.Get("Retry-After") == "" {
  			t.Fatal("Retry-After deberia estar seteado")
  		}
  		// Debe redondear hacia arriba
  		ra, _ := strconv.Atoi(h.Get("Retry-After"))
  		if ra != 3 {
  			t.Errorf("Retry-After=%d, want 3 (ceil de 2.3)", ra)
  		}
  	})

  	t.Run("Retry-After minimo es 1", func(t *testing.T) {
  		result := RateLimitResult{Allowed: false, Remaining: 0, ResetAt: now, RetryAfter: 0.1}
  		h := RateLimitHeaders(result, 10)
  		if h.Get("Retry-After") != "1" {
  			t.Errorf("Retry-After=%q, want 1 (minimo)", h.Get("Retry-After"))
  		}
  	})

  	t.Run("no incluye Retry-After cuando esta permitido", func(t *testing.T) {
  		result := RateLimitResult{Allowed: true, Remaining: 5, ResetAt: now}
  		h := RateLimitHeaders(result, 10)
  		if h.Get("Retry-After") != "" {
  			t.Errorf("Retry-After=%q, want vacio", h.Get("Retry-After"))
  		}
  	})
  }


id: rate_limit_middleware_go_infra_t0
function_id: rate_limit_middleware_go_infra
name: TestRateLimitMiddleware
file_path: functions/infra/rate_limit_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimitMiddleware(t *testing.T) {
  	base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.WriteHeader(http.StatusOK)
  		w.Write([]byte("ok"))
  	})

  	t.Run("permite request bajo el limite", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		mw := RateLimitMiddleware(rl)
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/api", nil)
  		req.RemoteAddr = "192.0.2.1:1234"
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Errorf("status=%d, want 200", rec.Code)
  		}
  		if rec.Header().Get("X-RateLimit-Limit") != "20" {
  			t.Errorf("X-RateLimit-Limit=%q, want 20", rec.Header().Get("X-RateLimit-Limit"))
  		}
  	})

  	t.Run("responde 429 cuando se excede el limite", func(t *testing.T) {
  		rl := RateLimiterNew(1, 2)
  		mw := RateLimitMiddleware(rl)
  		handler := mw(base)

  		// Consumir burst
  		for i := 0; i < 2; i++ {
  			rec := httptest.NewRecorder()
  			req := httptest.NewRequest("GET", "/api", nil)
  			req.RemoteAddr = "192.0.2.2:1234"
  			handler.ServeHTTP(rec, req)
  		}
  		// Tercer request rechazado
  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/api", nil)
  		req.RemoteAddr = "192.0.2.2:1234"
  		handler.ServeHTTP(rec, req)

  		if rec.Code != http.StatusTooManyRequests {
  			t.Errorf("status=%d, want 429", rec.Code)
  		}
  		if rec.Header().Get("Retry-After") == "" {
  			t.Error("Retry-After header no seteado en 429")
  		}
  	})

  	t.Run("IPs distintas tienen buckets independientes", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1)
  		mw := RateLimitMiddleware(rl)
  		handler := mw(base)

  		// Agotar IP A
  		recA := httptest.NewRecorder()
  		reqA := httptest.NewRequest("GET", "/api", nil)
  		reqA.RemoteAddr = "10.0.0.1:1000"
  		handler.ServeHTTP(recA, reqA)

  		recA2 := httptest.NewRecorder()
  		reqA2 := httptest.NewRequest("GET", "/api", nil)
  		reqA2.RemoteAddr = "10.0.0.1:1000"
  		handler.ServeHTTP(recA2, reqA2)
  		if recA2.Code != http.StatusTooManyRequests {
  			t.Fatal("IP A deberia estar limitada")
  		}

  		// IP B intacta
  		recB := httptest.NewRecorder()
  		reqB := httptest.NewRequest("GET", "/api", nil)
  		reqB.RemoteAddr = "10.0.0.2:1000"
  		handler.ServeHTTP(recB, reqB)
  		if recB.Code != http.StatusOK {
  			t.Errorf("IP B status=%d, want 200", recB.Code)
  		}
  	})

  	t.Run("X-Forwarded-For tiene prioridad sobre RemoteAddr", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1)
  		mw := RateLimitMiddleware(rl)
  		handler := mw(base)

  		// Request 1 con XFF=A, RemoteAddr=R1
  		rec1 := httptest.NewRecorder()
  		req1 := httptest.NewRequest("GET", "/api", nil)
  		req1.Header.Set("X-Forwarded-For", "203.0.113.1")
  		req1.RemoteAddr = "192.0.2.99:5000"
  		handler.ServeHTTP(rec1, req1)
  		if rec1.Code != http.StatusOK {
  			t.Fatal("primer request deberia pasar")
  		}

  		// Request 2 con XFF=A pero RemoteAddr distinto -> mismo bucket A -> rechazado
  		rec2 := httptest.NewRecorder()
  		req2 := httptest.NewRequest("GET", "/api", nil)
  		req2.Header.Set("X-Forwarded-For", "203.0.113.1")
  		req2.RemoteAddr = "192.0.2.100:5000"
  		handler.ServeHTTP(rec2, req2)
  		if rec2.Code != http.StatusTooManyRequests {
  			t.Errorf("XFF deberia identificar misma IP, status=%d", rec2.Code)
  		}
  	})

  	t.Run("responde con Retry-After header en 429", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1)
  		mw := RateLimitMiddleware(rl)
  		handler := mw(base)

  		// Agotar
  		req := httptest.NewRequest("GET", "/api", nil)
  		req.RemoteAddr = "172.16.0.1:1000"
  		handler.ServeHTTP(httptest.NewRecorder(), req)

  		// Rechazado
  		rec := httptest.NewRecorder()
  		handler.ServeHTTP(rec, req)
  		if rec.Code != http.StatusTooManyRequests {
  			t.Fatalf("status=%d, want 429", rec.Code)
  		}
  		if rec.Header().Get("Retry-After") == "" {
  			t.Error("Retry-After vacio en 429")
  		}
  		if rec.Header().Get("Content-Type") != "application/json" {
  			t.Errorf("Content-Type=%q, want application/json", rec.Header().Get("Content-Type"))
  		}
  	})
  }


id: rate_limit_middleware_go_infra_t1
function_id: rate_limit_middleware_go_infra
name: TestRateLimitClientIP
file_path: functions/infra/rate_limit_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimitClientIP(t *testing.T) {
  	t.Run("X-Forwarded-For first hop", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/", nil)
  		req.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.1, 10.0.0.1")
  		req.RemoteAddr = "192.0.2.1:1234"
  		got := rateLimitClientIP(req)
  		if got != "203.0.113.1" {
  			t.Errorf("got %q, want 203.0.113.1", got)
  		}
  	})

  	t.Run("X-Real-IP fallback", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/", nil)
  		req.Header.Set("X-Real-IP", "203.0.113.5")
  		req.RemoteAddr = "192.0.2.1:1234"
  		got := rateLimitClientIP(req)
  		if got != "203.0.113.5" {
  			t.Errorf("got %q, want 203.0.113.5", got)
  		}
  	})

  	t.Run("RemoteAddr cuando no hay headers", func(t *testing.T) {
  		req := httptest.NewRequest("GET", "/", nil)
  		req.RemoteAddr = "192.0.2.1:1234"
  		got := rateLimitClientIP(req)
  		if got != "192.0.2.1" {
  			t.Errorf("got %q, want 192.0.2.1", got)
  		}
  	})
  }


id: rate_limiter_by_key_go_infra_t0
function_id: rate_limiter_by_key_go_infra
name: TestRateLimiterByKey
file_path: functions/infra/rate_limiter_by_key_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimiterByKey(t *testing.T) {
  	base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		w.WriteHeader(http.StatusOK)
  	})
  	keyFunc := func(r *http.Request) string {
  		return r.Header.Get("X-API-Key")
  	}

  	t.Run("aplica limit por la clave devuelta por keyFunc", func(t *testing.T) {
  		rl := RateLimiterNew(1, 2)
  		mw := RateLimiterByKey(rl, keyFunc)
  		handler := mw(base)

  		// Agotar tokens de api-key-A
  		for i := 0; i < 2; i++ {
  			req := httptest.NewRequest("GET", "/", nil)
  			req.Header.Set("X-API-Key", "key-A")
  			handler.ServeHTTP(httptest.NewRecorder(), req)
  		}
  		// Tercer rechazado
  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		req.Header.Set("X-API-Key", "key-A")
  		handler.ServeHTTP(rec, req)
  		if rec.Code != http.StatusTooManyRequests {
  			t.Errorf("status=%d, want 429", rec.Code)
  		}

  		// key-B intacta
  		recB := httptest.NewRecorder()
  		reqB := httptest.NewRequest("GET", "/", nil)
  		reqB.Header.Set("X-API-Key", "key-B")
  		handler.ServeHTTP(recB, reqB)
  		if recB.Code != http.StatusOK {
  			t.Errorf("key-B status=%d, want 200", recB.Code)
  		}
  	})

  	t.Run("key vacia salta el limit", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1)
  		mw := RateLimiterByKey(rl, keyFunc)
  		handler := mw(base)

  		// Sin X-API-Key, hagamos muchos requests
  		for i := 0; i < 10; i++ {
  			rec := httptest.NewRecorder()
  			req := httptest.NewRequest("GET", "/", nil)
  			handler.ServeHTTP(rec, req)
  			if rec.Code != http.StatusOK {
  				t.Errorf("request %d status=%d, want 200 (sin key, sin limit)", i, rec.Code)
  			}
  		}
  	})

  	t.Run("responde 429 con body JSON al exceder", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1)
  		mw := RateLimiterByKey(rl, keyFunc)
  		handler := mw(base)

  		// Agotar
  		req := httptest.NewRequest("GET", "/", nil)
  		req.Header.Set("X-API-Key", "key-X")
  		handler.ServeHTTP(httptest.NewRecorder(), req)

  		// Rechazado
  		rec := httptest.NewRecorder()
  		handler.ServeHTTP(rec, req)
  		if rec.Code != http.StatusTooManyRequests {
  			t.Fatalf("status=%d, want 429", rec.Code)
  		}
  		var body map[string]any
  		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  			t.Fatalf("body no es JSON: %v", err)
  		}
  		// HTTPError se serializa con campos PascalCase (sin tags JSON)
  		if body["Code"] != "rate_limited" {
  			t.Errorf("Code=%v, want rate_limited", body["Code"])
  		}
  	})

  	t.Run("headers X-RateLimit-* siempre presentes en respuesta", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		mw := RateLimiterByKey(rl, keyFunc)
  		handler := mw(base)

  		rec := httptest.NewRecorder()
  		req := httptest.NewRequest("GET", "/", nil)
  		req.Header.Set("X-API-Key", "key-Y")
  		handler.ServeHTTP(rec, req)

  		if rec.Header().Get("X-RateLimit-Limit") == "" {
  			t.Error("X-RateLimit-Limit ausente")
  		}
  		if rec.Header().Get("X-RateLimit-Remaining") == "" {
  			t.Error("X-RateLimit-Remaining ausente")
  		}
  		if rec.Header().Get("X-RateLimit-Reset") == "" {
  			t.Error("X-RateLimit-Reset ausente")
  		}
  	})
  }


id: rate_limiter_check_go_infra_t0
function_id: rate_limiter_check_go_infra
name: TestRateLimiterCheck
file_path: functions/infra/rate_limiter_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimiterCheck(t *testing.T) {
  	t.Run("primer request siempre allowed con burst-1 remaining", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		result := RateLimiterCheck(rl, "client-1")
  		if !result.Allowed {
  			t.Error("primer request rechazado")
  		}
  		if result.Remaining != 19 {
  			t.Errorf("Remaining=%d, want 19", result.Remaining)
  		}
  		if result.RetryAfter != 0 {
  			t.Errorf("RetryAfter=%v, want 0", result.RetryAfter)
  		}
  	})

  	t.Run("consumir todos los tokens bloquea siguiente request", func(t *testing.T) {
  		rl := RateLimiterNew(1, 5)
  		// Consumir los 5 tokens del burst
  		for i := 0; i < 5; i++ {
  			r := RateLimiterCheck(rl, "client-2")
  			if !r.Allowed {
  				t.Fatalf("request %d rechazado, esperaba allowed", i)
  			}
  		}
  		// El sexto deberia ser rechazado
  		r := RateLimiterCheck(rl, "client-2")
  		if r.Allowed {
  			t.Error("request post-burst permitido, esperaba rechazo")
  		}
  		if r.Remaining != 0 {
  			t.Errorf("Remaining=%d, want 0", r.Remaining)
  		}
  	})

  	t.Run("los tokens se recargan con el paso del tiempo", func(t *testing.T) {
  		rl := RateLimiterNew(100, 2) // 100 tokens/seg = 1 token cada 10ms
  		// Consumir burst
  		RateLimiterCheck(rl, "client-3")
  		RateLimiterCheck(rl, "client-3")
  		// Tercer request rechazado
  		r := RateLimiterCheck(rl, "client-3")
  		if r.Allowed {
  			t.Fatal("tercer request inmediato deberia estar rechazado")
  		}
  		// Esperar suficiente para recargar al menos 1 token
  		time.Sleep(50 * time.Millisecond)
  		r2 := RateLimiterCheck(rl, "client-3")
  		if !r2.Allowed {
  			t.Error("request tras recarga deberia estar permitido")
  		}
  	})

  	t.Run("retryAfter es positivo cuando se rechaza", func(t *testing.T) {
  		rl := RateLimiterNew(1, 1) // 1 token/seg, burst 1
  		RateLimiterCheck(rl, "client-4")
  		r := RateLimiterCheck(rl, "client-4")
  		if r.Allowed {
  			t.Fatal("segundo request inmediato deberia rechazarse")
  		}
  		if r.RetryAfter <= 0 {
  			t.Errorf("RetryAfter=%v, want > 0", r.RetryAfter)
  		}
  		if r.RetryAfter > 1.5 {
  			t.Errorf("RetryAfter=%v, esperado <= 1s aprox", r.RetryAfter)
  		}
  	})

  	t.Run("keys distintas tienen buckets independientes", func(t *testing.T) {
  		rl := RateLimiterNew(1, 2)
  		// Consumir todo de A
  		RateLimiterCheck(rl, "A")
  		RateLimiterCheck(rl, "A")
  		ra := RateLimiterCheck(rl, "A")
  		if ra.Allowed {
  			t.Fatal("A deberia estar agotado")
  		}
  		// B sigue intacto
  		rb := RateLimiterCheck(rl, "B")
  		if !rb.Allowed {
  			t.Error("B deberia tener tokens propios")
  		}
  	})
  }


id: rate_limiter_cleanup_go_infra_t0
function_id: rate_limiter_cleanup_go_infra
name: TestRateLimiterCleanup
file_path: functions/infra/rate_limiter_cleanup_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimiterCleanup(t *testing.T) {
  	t.Run("purga entries con lastSeen mas antiguo que maxAge", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		// Inyectar manualmente una entry vieja
  		rl.mu.Lock()
  		rl.clients["old"] = &rateLimiterClient{tokens: 5, lastSeen: time.Now().Add(-time.Hour)}
  		rl.clients["new"] = &rateLimiterClient{tokens: 5, lastSeen: time.Now()}
  		rl.mu.Unlock()

  		stop := RateLimiterCleanup(rl, 10*time.Minute, 30*time.Millisecond)
  		defer stop()

  		// Esperar al menos un tick
  		time.Sleep(100 * time.Millisecond)

  		rl.mu.Lock()
  		_, oldExists := rl.clients["old"]
  		_, newExists := rl.clients["new"]
  		rl.mu.Unlock()

  		if oldExists {
  			t.Error("entry 'old' no fue purgada")
  		}
  		if !newExists {
  			t.Error("entry 'new' fue purgada incorrectamente")
  		}
  	})

  	t.Run("no purga entries recientes", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		RateLimiterCheck(rl, "fresh")

  		stop := RateLimiterCleanup(rl, 10*time.Minute, 30*time.Millisecond)
  		defer stop()

  		time.Sleep(100 * time.Millisecond)

  		rl.mu.Lock()
  		_, exists := rl.clients["fresh"]
  		rl.mu.Unlock()
  		if !exists {
  			t.Error("entry reciente fue purgada incorrectamente")
  		}
  	})

  	t.Run("stop detiene la goroutine sin panic", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		stop := RateLimiterCleanup(rl, time.Second, 10*time.Millisecond)
  		stop()
  		// Si esto no panica, pasa
  		time.Sleep(50 * time.Millisecond)
  	})

  	t.Run("stop es idempotente", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		stop := RateLimiterCleanup(rl, time.Second, 10*time.Millisecond)
  		stop()
  		stop() // segunda llamada no debe panic
  		stop() // tercera tampoco
  	})
  }


id: rate_limiter_new_go_infra_t0
function_id: rate_limiter_new_go_infra
name: TestRateLimiterNew
file_path: functions/infra/rate_limiter_new_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRateLimiterNew(t *testing.T) {
  	t.Run("crea limiter con rate y burst configurados", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		if rl == nil {
  			t.Fatal("RateLimiterNew retorno nil")
  		}
  		if rl.rate != 10 {
  			t.Errorf("rate=%v, want 10", rl.rate)
  		}
  		if rl.burst != 20 {
  			t.Errorf("burst=%d, want 20", rl.burst)
  		}
  	})

  	t.Run("valores cero se sustituyen por 1", func(t *testing.T) {
  		rl := RateLimiterNew(0, 0)
  		if rl.rate != 1 {
  			t.Errorf("rate=%v, want 1 (default)", rl.rate)
  		}
  		if rl.burst != 1 {
  			t.Errorf("burst=%d, want 1 (default)", rl.burst)
  		}

  		rl2 := RateLimiterNew(-5, -10)
  		if rl2.rate != 1 || rl2.burst != 1 {
  			t.Errorf("valores negativos no se normalizaron a 1: rate=%v burst=%d", rl2.rate, rl2.burst)
  		}
  	})

  	t.Run("el mapa de clientes empieza vacio", func(t *testing.T) {
  		rl := RateLimiterNew(10, 20)
  		if rl.clients == nil {
  			t.Error("clients map es nil, deberia ser inicializado")
  		}
  		if len(rl.clients) != 0 {
  			t.Errorf("clients len=%d, want 0", len(rl.clients))
  		}
  	})
  }


id: rbac_check_go_infra_t0
function_id: rbac_check_go_infra
name: TestRBACCheck_Granted
file_path: functions/infra/rbac_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACCheck_Granted(t *testing.T) {
  	roles := makeRoles()
  	if !RBACCheck(roles, "admin", Permission{Resource: "users", Action: "delete"}) {
  		t.Fatal("admin deberia tener users/delete")
  	}
  	if !RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "read"}) {
  		t.Fatal("viewer deberia tener users/read")
  	}
  }


id: rbac_check_go_infra_t1
function_id: rbac_check_go_infra
name: TestRBACCheck_Denied
file_path: functions/infra/rbac_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACCheck_Denied(t *testing.T) {
  	roles := makeRoles()
  	if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "delete"}) {
  		t.Fatal("viewer NO deberia tener users/delete")
  	}
  }


id: rbac_check_go_infra_t2
function_id: rbac_check_go_infra
name: TestRBACCheck_UnknownRole
file_path: functions/infra/rbac_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACCheck_UnknownRole(t *testing.T) {
  	roles := makeRoles()
  	if RBACCheck(roles, "ghost", Permission{Resource: "users", Action: "read"}) {
  		t.Fatal("rol inexistente no deberia tener permisos")
  	}
  }


id: rbac_check_go_infra_t3
function_id: rbac_check_go_infra
name: TestRBACCheck_ExactMatch
file_path: functions/infra/rbac_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACCheck_ExactMatch(t *testing.T) {
  	roles := makeRoles()
  	// Resource distinto
  	if RBACCheck(roles, "admin", Permission{Resource: "billing", Action: "read"}) {
  		t.Fatal("admin no tiene billing/read")
  	}
  	// Action distinta
  	if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "write"}) {
  		t.Fatal("viewer no tiene users/write")
  	}
  }


id: rbac_middleware_go_infra_t0
function_id: rbac_middleware_go_infra
name: TestRBACMiddleware_PermissionGranted
file_path: functions/infra/rbac_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACMiddleware_PermissionGranted(t *testing.T) {
  	secret := "s"
  	chain := HTTPMiddlewareChain(
  		JWTMiddleware(secret),
  		RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}),
  	)
  	var called bool
  	handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		called = true
  		w.WriteHeader(200)
  	}))

  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, buildAuthedRequest(t, "admin", secret))
  	if rec.Code != 200 || !called {
  		t.Errorf("admin deberia pasar: status=%d called=%v", rec.Code, called)
  	}
  }


id: rbac_middleware_go_infra_t1
function_id: rbac_middleware_go_infra
name: TestRBACMiddleware_PermissionDenied
file_path: functions/infra/rbac_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACMiddleware_PermissionDenied(t *testing.T) {
  	secret := "s"
  	chain := HTTPMiddlewareChain(
  		JWTMiddleware(secret),
  		RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}),
  	)
  	handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("viewer no deberia pasar")
  	}))

  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, buildAuthedRequest(t, "viewer", secret))
  	if rec.Code != 403 {
  		t.Errorf("status = %d, esperaba 403", rec.Code)
  	}
  }


id: rbac_middleware_go_infra_t2
function_id: rbac_middleware_go_infra
name: TestRBACMiddleware_UnknownRole
file_path: functions/infra/rbac_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACMiddleware_UnknownRole(t *testing.T) {
  	secret := "s"
  	chain := HTTPMiddlewareChain(
  		JWTMiddleware(secret),
  		RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"}),
  	)
  	handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("rol ghost no deberia pasar")
  	}))

  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, buildAuthedRequest(t, "ghost", secret))
  	if rec.Code != 403 {
  		t.Errorf("status = %d, esperaba 403", rec.Code)
  	}
  }


id: rbac_middleware_go_infra_t3
function_id: rbac_middleware_go_infra
name: TestRBACMiddleware_NoClaims
file_path: functions/infra/rbac_middleware_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestRBACMiddleware_NoClaims(t *testing.T) {
  	// Sin JWTMiddleware delante → no hay claims en context
  	mw := RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"})
  	handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  		t.Error("no deberia ejecutarse")
  	}))
  	req := httptest.NewRequest(http.MethodGet, "/", nil)
  	rec := httptest.NewRecorder()
  	handler.ServeHTTP(rec, req)
  	if rec.Code != 401 {
  		t.Errorf("status = %d, esperaba 401", rec.Code)
  	}
  }


id: session_cleanup_go_infra_t0
function_id: session_cleanup_go_infra
name: TestSessionCreate_PersistsSession
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionCreate_PersistsSession(t *testing.T) {
  	db := openSessionTestDB(t)
  	s, err := SessionCreate(db, "user-1", time.Hour, map[string]any{"role": "admin"})
  	if err != nil {
  		t.Fatalf("SessionCreate: %v", err)
  	}
  	if s.Token == "" || len(s.Token) != 64 {
  		t.Errorf("token de largo incorrecto: len=%d", len(s.Token))
  	}
  	if s.UserID != "user-1" {
  		t.Errorf("UserID = %q", s.UserID)
  	}
  	if s.ExpiresAt <= s.CreatedAt {
  		t.Errorf("ExpiresAt %d <= CreatedAt %d", s.ExpiresAt, s.CreatedAt)
  	}

  	// Verificar persistencia
  	var count int
  	if err := db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&count); err != nil {
  		t.Fatalf("query: %v", err)
  	}
  	if count != 1 {
  		t.Errorf("esperaba 1 fila, got %d", count)
  	}
  }


id: session_cleanup_go_infra_t1
function_id: session_cleanup_go_infra
name: TestSessionCreate_GeneratesDistinctTokens
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionCreate_GeneratesDistinctTokens(t *testing.T) {
  	db := openSessionTestDB(t)
  	s1, _ := SessionCreate(db, "u", time.Hour, nil)
  	s2, _ := SessionCreate(db, "u", time.Hour, nil)
  	if s1.Token == s2.Token {
  		t.Fatal("dos sesiones no pueden tener el mismo token")
  	}
  }


id: session_cleanup_go_infra_t2
function_id: session_cleanup_go_infra
name: TestSessionCreate_RejectsEmptyUserID
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionCreate_RejectsEmptyUserID(t *testing.T) {
  	db := openSessionTestDB(t)
  	if _, err := SessionCreate(db, "", time.Hour, nil); err == nil {
  		t.Fatal("esperaba error con user_id vacio")
  	}
  }


id: session_cleanup_go_infra_t3
function_id: session_cleanup_go_infra
name: TestSessionValidate_ValidSession
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionValidate_ValidSession(t *testing.T) {
  	db := openSessionTestDB(t)
  	s, _ := SessionCreate(db, "user-7", time.Hour, map[string]any{"k": "v"})

  	got, err := SessionValidate(db, s.Token)
  	if err != nil {
  		t.Fatalf("SessionValidate: %v", err)
  	}
  	if got.UserID != "user-7" {
  		t.Errorf("UserID = %q", got.UserID)
  	}
  	if got.Metadata["k"] != "v" {
  		t.Errorf("metadata[k] = %v", got.Metadata["k"])
  	}
  }


id: session_cleanup_go_infra_t4
function_id: session_cleanup_go_infra
name: TestSessionValidate_MissingToken
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionValidate_MissingToken(t *testing.T) {
  	db := openSessionTestDB(t)
  	_ = sessionEnsureTable(db)
  	if _, err := SessionValidate(db, "nope"); err == nil {
  		t.Fatal("esperaba error con token inexistente")
  	}
  }


id: session_cleanup_go_infra_t5
function_id: session_cleanup_go_infra
name: TestSessionValidate_ExpiredSession
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionValidate_ExpiredSession(t *testing.T) {
  	db := openSessionTestDB(t)
  	_ = sessionEnsureTable(db)
  	// Insertar manualmente una sesion ya expirada
  	past := time.Now().Unix() - 60
  	_, err := db.Exec(
  		"INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
  		"expired-tok", "u", past, past-100, "{}",
  	)
  	if err != nil {
  		t.Fatalf("insert: %v", err)
  	}
  	if _, err := SessionValidate(db, "expired-tok"); err == nil {
  		t.Fatal("esperaba error con sesion expirada")
  	}
  }


id: session_cleanup_go_infra_t6
function_id: session_cleanup_go_infra
name: TestSessionCleanup_RemovesOnlyExpired
file_path: functions/infra/session_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSessionCleanup_RemovesOnlyExpired(t *testing.T) {
  	db := openSessionTestDB(t)
  	active, _ := SessionCreate(db, "active-user", time.Hour, nil)
  	_ = sessionEnsureTable(db)
  	past := time.Now().Unix() - 60
  	_, _ = db.Exec(
  		"INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
  		"expired", "u", past, past, "{}",
  	)

  	n, err := SessionCleanup(db)
  	if err != nil {
  		t.Fatalf("SessionCleanup: %v", err)
  	}
  	if n != 1 {
  		t.Errorf("esperaba 1 eliminada, got %d", n)
  	}

  	// La activa sigue ahi
  	if _, err := SessionValidate(db, active.Token); err != nil {
  		t.Errorf("sesion activa no deberia haberse borrado: %v", err)
  	}
  }


id: smtp_connect_go_infra_t0
function_id: smtp_connect_go_infra
name: TestSMTPConnect
file_path: functions/infra/smtp_connect_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSMTPConnect(t *testing.T) {
  	t.Run("conecta sin cifrado a servidor mock", func(t *testing.T) {
  		addr, stop := mockSMTPServer(t)
  		defer stop()

  		host, portStr, _ := net.SplitHostPort(addr)
  		port := 0
  		fmt.Sscanf(portStr, "%d", &port)

  		cfg := SMTPConfig{
  			Host:    host,
  			Port:    port,
  			TLSMode: "", // no encryption
  		}
  		client, err := SMTPConnect(cfg)
  		if err != nil {
  			t.Fatalf("SMTPConnect: %v", err)
  		}
  		client.Quit()
  	})

  	t.Run("error si el servidor no existe", func(t *testing.T) {
  		// Use a port that is listening but immediately closes (RST) — we start
  		// and immediately close a listener so the port is known-closed.
  		ln2, err := net.Listen("tcp", "127.0.0.1:0")
  		if err != nil {
  			t.Fatalf("setup: %v", err)
  		}
  		h2, p2str, _ := net.SplitHostPort(ln2.Addr().String())
  		ln2.Close() // now nothing listens — connections get refused
  		p2 := 0
  		fmt.Sscanf(p2str, "%d", &p2)

  		cfg := SMTPConfig{
  			Host:    h2,
  			Port:    p2,
  			TLSMode: "",
  		}
  		_, connErr := SMTPConnect(cfg)
  		if connErr == nil {
  			t.Error("expected error for refused connection, got nil")
  		}
  	})
  }


id: smtp_send_go_infra_t0
function_id: smtp_send_go_infra
name: TestSMTPSend
file_path: functions/infra/smtp_send_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSMTPSend(t *testing.T) {
  	t.Run("envia mensaje texto plano via mock smtp", func(t *testing.T) {
  		host, port, stop := startMockSMTPForSend(t)
  		defer stop()

  		cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""}
  		client, err := SMTPConnect(cfg)
  		if err != nil {
  			t.Fatalf("SMTPConnect: %v", err)
  		}
  		defer client.Quit()

  		msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Test", "Hello Bob")
  		if err := SMTPSend(client, msg); err != nil {
  			t.Fatalf("SMTPSend: %v", err)
  		}
  	})

  	t.Run("envia mensaje html via mock smtp", func(t *testing.T) {
  		host, port, stop := startMockSMTPForSend(t)
  		defer stop()

  		cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""}
  		client, err := SMTPConnect(cfg)
  		if err != nil {
  			t.Fatalf("SMTPConnect: %v", err)
  		}
  		defer client.Quit()

  		msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "HTML Test", "<b>Hello</b>")
  		if err := SMTPSend(client, msg); err != nil {
  			t.Fatalf("SMTPSend: %v", err)
  		}
  	})

  	t.Run("envia con adjunto via mock smtp", func(t *testing.T) {
  		host, port, stop := startMockSMTPForSend(t)
  		defer stop()

  		cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""}
  		client, err := SMTPConnect(cfg)
  		if err != nil {
  			t.Fatalf("SMTPConnect: %v", err)
  		}
  		defer client.Quit()

  		msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Att Test", "See attachment")
  		att := EmailAttachment{Filename: "data.txt", ContentType: "text/plain", Data: []byte("file content")}
  		msg = EmailWithAttachment(msg, att)

  		if err := SMTPSend(client, msg); err != nil {
  			t.Fatalf("SMTPSend with attachment: %v", err)
  		}
  	})
  }

  // TestExtractAddr tests the internal extractAddr helper.


id: smtp_send_go_infra_t1
function_id: smtp_send_go_infra
name: TestExtractAddr
file_path: functions/infra/smtp_send_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestExtractAddr(t *testing.T) {
  	cases := []struct {
  		input string
  		want  string
  	}{
  		{"Alice <alice@example.com>", "alice@example.com"},
  		{"bob@example.com", "bob@example.com"},
  		{"  carol@example.com  ", "carol@example.com"},
  	}
  	for _, c := range cases {
  		got := extractAddr(c.input)
  		if got != c.want {
  			t.Errorf("extractAddr(%q) = %q, want %q", c.input, got, c.want)
  		}
  	}
  }

  // TestBuildMIME tests the MIME builder with different body combinations.


id: smtp_send_go_infra_t2
function_id: smtp_send_go_infra
name: TestBuildMIME
file_path: functions/infra/smtp_send_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestBuildMIME(t *testing.T) {
  	t.Run("solo texto plano", func(t *testing.T) {
  		msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "Hello plain")
  		body, err := buildMIME(msg)
  		if err != nil {
  			t.Fatalf("buildMIME: %v", err)
  		}
  		s := string(body)
  		if !strings.Contains(s, "text/plain") {
  			t.Errorf("expected text/plain in MIME, got:\n%s", s)
  		}
  		if !strings.Contains(s, "Hello plain") {
  			t.Errorf("expected body text in MIME, got:\n%s", s)
  		}
  	})

  	t.Run("solo html", func(t *testing.T) {
  		msg := EmailBuildHTML("a@a.com", []string{"b@b.com"}, "Subj", "<b>Bold</b>")
  		body, err := buildMIME(msg)
  		if err != nil {
  			t.Fatalf("buildMIME: %v", err)
  		}
  		s := string(body)
  		if !strings.Contains(s, "text/html") {
  			t.Errorf("expected text/html in MIME, got:\n%s", s)
  		}
  	})

  	t.Run("multipart con adjunto", func(t *testing.T) {
  		msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "body")
  		att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("pdf")}
  		msg = EmailWithAttachment(msg, att)
  		body, err := buildMIME(msg)
  		if err != nil {
  			t.Fatalf("buildMIME: %v", err)
  		}
  		s := string(body)
  		if !strings.Contains(s, "multipart/mixed") {
  			t.Errorf("expected multipart/mixed in MIME, got:\n%s", s)
  		}
  		if !strings.Contains(s, "f.pdf") {
  			t.Errorf("expected attachment filename in MIME, got:\n%s", s)
  		}
  	})
  }


id: sse_handler_go_infra_t0
function_id: sse_handler_go_infra
name: TestSSESend
file_path: functions/infra/sse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSESend(t *testing.T) {
  	t.Run("serializa data simple sin event ni id", func(t *testing.T) {
  		rec := newFlushRecorder()
  		err := SSESend(rec, SSEEvent{Data: "hola"})
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		body := rec.Body.String()
  		want := "data: hola\n\n"
  		if body != want {
  			t.Errorf("got body %q, want %q", body, want)
  		}
  	})

  	t.Run("incluye event cuando esta presente", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Event: "tick", Data: "1"})
  		body := rec.Body.String()
  		if !strings.HasPrefix(body, "event: tick\n") {
  			t.Errorf("body should start with 'event: tick\\n', got %q", body)
  		}
  	})

  	t.Run("incluye id cuando esta presente", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{ID: "42", Data: "x"})
  		if !strings.Contains(rec.Body.String(), "id: 42\n") {
  			t.Errorf("body should contain 'id: 42\\n', got %q", rec.Body.String())
  		}
  	})

  	t.Run("incluye retry cuando es positivo", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Retry: 5000, Data: "x"})
  		if !strings.Contains(rec.Body.String(), "retry: 5000\n") {
  			t.Errorf("body should contain 'retry: 5000\\n', got %q", rec.Body.String())
  		}
  	})

  	t.Run("omite retry cuando es 0", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Retry: 0, Data: "x"})
  		if strings.Contains(rec.Body.String(), "retry:") {
  			t.Errorf("body should NOT contain 'retry:', got %q", rec.Body.String())
  		}
  	})

  	t.Run("data multilinea genera multiples lineas data:", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Data: "linea1\nlinea2\nlinea3"})
  		body := rec.Body.String()
  		for _, want := range []string{"data: linea1\n", "data: linea2\n", "data: linea3\n"} {
  			if !strings.Contains(body, want) {
  				t.Errorf("body should contain %q, got %q", want, body)
  			}
  		}
  	})

  	t.Run("hace flush si el writer es Flusher", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Data: "x"})
  		if rec.FlushCount() != 1 {
  			t.Errorf("expected 1 flush, got %d", rec.FlushCount())
  		}
  	})

  	t.Run("termina cada evento con doble salto de linea", func(t *testing.T) {
  		rec := newFlushRecorder()
  		_ = SSESend(rec, SSEEvent{Event: "x", Data: "y"})
  		body := rec.Body.String()
  		if !strings.HasSuffix(body, "\n\n") {
  			t.Errorf("body should end with '\\n\\n', got %q", body)
  		}
  	})
  }

  // --- SSEKeepalive ---


id: sse_handler_go_infra_t1
function_id: sse_handler_go_infra
name: TestSSEKeepalive
file_path: functions/infra/sse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSEKeepalive(t *testing.T) {
  	t.Run("escribe comentario keepalive periodicamente", func(t *testing.T) {
  		rec := newFlushRecorder()
  		done := make(chan struct{})

  		go SSEKeepalive(rec, 10*time.Millisecond, done)
  		time.Sleep(50 * time.Millisecond)
  		close(done)
  		time.Sleep(20 * time.Millisecond)

  		body := rec.Body.String()
  		if !strings.Contains(body, ": keepalive\n\n") {
  			t.Errorf("body should contain ': keepalive\\n\\n', got %q", body)
  		}
  		count := strings.Count(body, ": keepalive\n\n")
  		if count < 2 {
  			t.Errorf("expected at least 2 keepalives, got %d", count)
  		}
  	})

  	t.Run("termina cuando done se cierra", func(t *testing.T) {
  		rec := newFlushRecorder()
  		done := make(chan struct{})
  		finished := make(chan struct{})

  		go func() {
  			SSEKeepalive(rec, 5*time.Millisecond, done)
  			close(finished)
  		}()

  		close(done)
  		select {
  		case <-finished:
  			// ok
  		case <-time.After(100 * time.Millisecond):
  			t.Error("SSEKeepalive did not return after done was closed")
  		}
  	})
  }

  // --- SSEHandler ---


id: sse_handler_go_infra_t2
function_id: sse_handler_go_infra
name: TestSSEHandler
file_path: functions/infra/sse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSEHandler(t *testing.T) {
  	t.Run("setea headers SSE correctos", func(t *testing.T) {
  		events := make(chan SSEEvent)
  		handler := SSEHandler(events)

  		// Lanzar handler en goroutine y cerrar canal para que retorne
  		go func() {
  			time.Sleep(20 * time.Millisecond)
  			close(events)
  		}()

  		ts := httptest.NewServer(handler)
  		defer ts.Close()

  		resp, err := http.Get(ts.URL)
  		if err != nil {
  			t.Fatalf("get failed: %v", err)
  		}
  		defer resp.Body.Close()

  		if got := resp.Header.Get("Content-Type"); got != "text/event-stream" {
  			t.Errorf("Content-Type = %q, want text/event-stream", got)
  		}
  		if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
  			t.Errorf("Cache-Control = %q, want no-cache", got)
  		}
  	})

  	t.Run("envia eventos del canal al writer", func(t *testing.T) {
  		events := make(chan SSEEvent, 2)
  		events <- SSEEvent{Event: "first", Data: "1"}
  		events <- SSEEvent{Event: "second", Data: "2"}
  		close(events)

  		handler := SSEHandler(events)
  		ts := httptest.NewServer(handler)
  		defer ts.Close()

  		resp, err := http.Get(ts.URL)
  		if err != nil {
  			t.Fatalf("get failed: %v", err)
  		}
  		defer resp.Body.Close()

  		buf := make([]byte, 4096)
  		n, _ := resp.Body.Read(buf)
  		body := string(buf[:n])

  		for _, want := range []string{"event: first", "event: second", "data: 1", "data: 2"} {
  			if !strings.Contains(body, want) {
  				t.Errorf("body should contain %q, got %q", want, body)
  			}
  		}
  	})

  	t.Run("termina si el contexto del request se cancela", func(t *testing.T) {
  		events := make(chan SSEEvent)
  		handler := SSEHandler(events)

  		ts := httptest.NewServer(handler)
  		defer ts.Close()

  		ctx, cancel := context.WithCancel(context.Background())
  		req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL, nil)

  		done := make(chan struct{})
  		go func() {
  			resp, err := http.DefaultClient.Do(req)
  			if err == nil {
  				resp.Body.Close()
  			}
  			close(done)
  		}()

  		time.Sleep(20 * time.Millisecond)
  		cancel()

  		select {
  		case <-done:
  			// handler debe haber retornado tras la cancelacion del cliente
  		case <-time.After(2 * time.Second):
  			t.Error("handler did not return after context cancel")
  		}
  	})

  	t.Run("retorna 500 si el writer no es Flusher", func(t *testing.T) {
  		// nonFlushWriter no implementa Flusher
  		events := make(chan SSEEvent)
  		handler := SSEHandler(events)

  		rec := &nonFlushWriter{header: http.Header{}}
  		req := httptest.NewRequest("GET", "/", nil)
  		handler(rec, req)

  		if rec.status != http.StatusInternalServerError {
  			t.Errorf("got status %d, want 500", rec.status)
  		}
  	})
  }


id: ssh_check_go_infra_t0
function_id: ssh_check_go_infra
name: TestSSHCheck
file_path: functions/infra/ssh_check_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHCheck(t *testing.T) {
  	t.Run("conecta a organic-machine", func(t *testing.T) {
  		conn := sshTestConn()
  		err := SSHCheck(conn)
  		if err != nil {
  			t.Skipf("SSH no disponible: %v", err)
  		}
  	})

  	t.Run("falla con host inexistente", func(t *testing.T) {
  		conn := SSHConn{Host: "192.0.2.1", Port: 22, User: "nobody"}
  		err := SSHCheck(conn)
  		if err == nil {
  			t.Error("esperaba error con host inexistente")
  		}
  	})
  }


id: ssh_config_add_entry_go_infra_t0
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigParse_MultipleHosts
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigParse_MultipleHosts(t *testing.T) {
  	content := `
  Host prod
      HostName 10.0.0.1
      User admin
      Port 22
      IdentityFile ~/.ssh/id_prod

  Host staging
      HostName 10.0.0.2
      User deploy
      Port 2222
  `
  	entries := SSHConfigParse(content)
  	if len(entries) != 2 {
  		t.Fatalf("expected 2 entries, got %d", len(entries))
  	}
  	if entries[0].Alias != "prod" {
  		t.Errorf("expected alias prod, got %s", entries[0].Alias)
  	}
  	if entries[0].HostName != "10.0.0.1" {
  		t.Errorf("expected hostname 10.0.0.1, got %s", entries[0].HostName)
  	}
  	if entries[0].User != "admin" {
  		t.Errorf("expected user admin, got %s", entries[0].User)
  	}
  	if entries[0].Port != 22 {
  		t.Errorf("expected port 22, got %d", entries[0].Port)
  	}
  	if entries[0].IdentityFile != "~/.ssh/id_prod" {
  		t.Errorf("expected identity file ~/.ssh/id_prod, got %s", entries[0].IdentityFile)
  	}
  	if entries[1].Alias != "staging" {
  		t.Errorf("expected alias staging, got %s", entries[1].Alias)
  	}
  	if entries[1].Port != 2222 {
  		t.Errorf("expected port 2222, got %d", entries[1].Port)
  	}
  }


id: ssh_config_add_entry_go_infra_t1
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigParse_IgnoresCommentsAndBlanks
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigParse_IgnoresCommentsAndBlanks(t *testing.T) {
  	content := `
  # This is a comment
  Host myserver
      HostName 192.168.1.1
      # inline comment
      User root
  `
  	entries := SSHConfigParse(content)
  	if len(entries) != 1 {
  		t.Fatalf("expected 1 entry, got %d", len(entries))
  	}
  	if entries[0].User != "root" {
  		t.Errorf("expected user root, got %s", entries[0].User)
  	}
  }


id: ssh_config_add_entry_go_infra_t2
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigParse_IgnoresWildcards
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigParse_IgnoresWildcards(t *testing.T) {
  	content := `
  Host *
      ServerAliveInterval 60

  Host prod
      HostName 10.0.0.1

  Host 192.168.*
      User local
  `
  	entries := SSHConfigParse(content)
  	if len(entries) != 1 {
  		t.Fatalf("expected 1 entry (wildcards ignored), got %d", len(entries))
  	}
  	if entries[0].Alias != "prod" {
  		t.Errorf("expected alias prod, got %s", entries[0].Alias)
  	}
  }


id: ssh_config_add_entry_go_infra_t3
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigParse_ExtraOptions
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigParse_ExtraOptions(t *testing.T) {
  	content := `Host jump
      HostName bastion.example.com
      User ops
      ForwardAgent yes
      ProxyJump none
  `
  	entries := SSHConfigParse(content)
  	if len(entries) != 1 {
  		t.Fatalf("expected 1 entry, got %d", len(entries))
  	}
  	if entries[0].Options["ForwardAgent"] != "yes" {
  		t.Errorf("expected ForwardAgent=yes, got %s", entries[0].Options["ForwardAgent"])
  	}
  	if entries[0].Options["ProxyJump"] != "none" {
  		t.Errorf("expected ProxyJump=none, got %s", entries[0].Options["ProxyJump"])
  	}
  }


id: ssh_config_add_entry_go_infra_t4
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRender_FullEntry
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRender_FullEntry(t *testing.T) {
  	entries := []SSHConfigEntry{{
  		Alias:        "prod",
  		HostName:     "10.0.0.1",
  		User:         "admin",
  		Port:         2222,
  		IdentityFile: "~/.ssh/id_prod",
  	}}
  	result := SSHConfigRender(entries)
  	if !strings.Contains(result, "Host prod") {
  		t.Error("missing Host directive")
  	}
  	if !strings.Contains(result, "HostName 10.0.0.1") {
  		t.Error("missing HostName")
  	}
  	if !strings.Contains(result, "User admin") {
  		t.Error("missing User")
  	}
  	if !strings.Contains(result, "Port 2222") {
  		t.Error("missing Port")
  	}
  	if !strings.Contains(result, "IdentityFile ~/.ssh/id_prod") {
  		t.Error("missing IdentityFile")
  	}
  }


id: ssh_config_add_entry_go_infra_t5
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRender_MinimalEntry
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRender_MinimalEntry(t *testing.T) {
  	entries := []SSHConfigEntry{{Alias: "local"}}
  	result := SSHConfigRender(entries)
  	if result != "Host local\n" {
  		t.Errorf("expected minimal render, got %q", result)
  	}
  }


id: ssh_config_add_entry_go_infra_t6
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRender_MultipleEntries
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRender_MultipleEntries(t *testing.T) {
  	entries := []SSHConfigEntry{
  		{Alias: "a", HostName: "1.1.1.1"},
  		{Alias: "b", HostName: "2.2.2.2"},
  	}
  	result := SSHConfigRender(entries)
  	parts := strings.Split(result, "\n\n")
  	if len(parts) < 2 {
  		t.Error("expected blocks separated by blank line")
  	}
  }


id: ssh_config_add_entry_go_infra_t7
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRender_OptionsSorted
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRender_OptionsSorted(t *testing.T) {
  	entries := []SSHConfigEntry{{
  		Alias:    "test",
  		HostName: "1.1.1.1",
  		Options:  map[string]string{"ProxyJump": "bastion", "ForwardAgent": "yes"},
  	}}
  	result := SSHConfigRender(entries)
  	fwIdx := strings.Index(result, "ForwardAgent")
  	pjIdx := strings.Index(result, "ProxyJump")
  	if fwIdx < 0 || pjIdx < 0 {
  		t.Fatal("missing options in render")
  	}
  	if fwIdx > pjIdx {
  		t.Error("options should be sorted alphabetically")
  	}
  }


id: ssh_config_add_entry_go_infra_t8
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigFind_Exists
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigFind_Exists(t *testing.T) {
  	entries := []SSHConfigEntry{
  		{Alias: "prod", HostName: "10.0.0.1"},
  		{Alias: "staging", HostName: "10.0.0.2"},
  	}
  	entry, ok := SSHConfigFind(entries, "staging")
  	if !ok {
  		t.Fatal("expected to find staging")
  	}
  	if entry.HostName != "10.0.0.2" {
  		t.Errorf("expected hostname 10.0.0.2, got %s", entry.HostName)
  	}
  }


id: ssh_config_add_entry_go_infra_t9
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigFind_NotExists
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigFind_NotExists(t *testing.T) {
  	entries := []SSHConfigEntry{{Alias: "prod"}}
  	_, ok := SSHConfigFind(entries, "nope")
  	if ok {
  		t.Error("expected not found")
  	}
  }


id: ssh_config_add_entry_go_infra_t10
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigAddEntry_ToEmpty
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigAddEntry_ToEmpty(t *testing.T) {
  	entry := SSHConfigEntry{Alias: "new", HostName: "1.1.1.1"}
  	result, err := SSHConfigAddEntry(nil, entry)
  	if err != nil {
  		t.Fatal(err)
  	}
  	if len(result) != 1 || result[0].Alias != "new" {
  		t.Errorf("unexpected result: %+v", result)
  	}
  }


id: ssh_config_add_entry_go_infra_t11
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigAddEntry_ToExisting
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigAddEntry_ToExisting(t *testing.T) {
  	existing := []SSHConfigEntry{{Alias: "old"}}
  	entry := SSHConfigEntry{Alias: "new"}
  	result, err := SSHConfigAddEntry(existing, entry)
  	if err != nil {
  		t.Fatal(err)
  	}
  	if len(result) != 2 {
  		t.Fatalf("expected 2 entries, got %d", len(result))
  	}
  	// Original no mutado
  	if len(existing) != 1 {
  		t.Error("original slice was mutated")
  	}
  }


id: ssh_config_add_entry_go_infra_t12
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigAddEntry_DuplicateAlias
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigAddEntry_DuplicateAlias(t *testing.T) {
  	existing := []SSHConfigEntry{{Alias: "prod"}}
  	entry := SSHConfigEntry{Alias: "prod"}
  	_, err := SSHConfigAddEntry(existing, entry)
  	if err == nil {
  		t.Error("expected error for duplicate alias")
  	}
  }


id: ssh_config_add_entry_go_infra_t13
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRemoveEntry_Exists
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRemoveEntry_Exists(t *testing.T) {
  	entries := []SSHConfigEntry{
  		{Alias: "a"},
  		{Alias: "b"},
  		{Alias: "c"},
  	}
  	result, err := SSHConfigRemoveEntry(entries, "b")
  	if err != nil {
  		t.Fatal(err)
  	}
  	if len(result) != 2 {
  		t.Fatalf("expected 2 entries, got %d", len(result))
  	}
  	if result[0].Alias != "a" || result[1].Alias != "c" {
  		t.Errorf("unexpected order: %+v", result)
  	}
  	// Original no mutado
  	if len(entries) != 3 {
  		t.Error("original slice was mutated")
  	}
  }


id: ssh_config_add_entry_go_infra_t14
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigRemoveEntry_NotExists
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigRemoveEntry_NotExists(t *testing.T) {
  	entries := []SSHConfigEntry{{Alias: "prod"}}
  	_, err := SSHConfigRemoveEntry(entries, "nope")
  	if err == nil {
  		t.Error("expected error for missing alias")
  	}
  }


id: ssh_config_add_entry_go_infra_t15
function_id: ssh_config_add_entry_go_infra
name: TestSSHConfigParseRender_Roundtrip
file_path: functions/infra/ssh_config_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHConfigParseRender_Roundtrip(t *testing.T) {
  	original := []SSHConfigEntry{
  		{Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22, IdentityFile: "~/.ssh/id_prod"},
  		{Alias: "staging", HostName: "10.0.0.2", User: "deploy", Port: 2222},
  	}
  	text := SSHConfigRender(original)
  	parsed := SSHConfigParse(text)
  	if len(parsed) != len(original) {
  		t.Fatalf("roundtrip: expected %d entries, got %d", len(original), len(parsed))
  	}
  	for i := range original {
  		if parsed[i].Alias != original[i].Alias {
  			t.Errorf("roundtrip[%d]: alias mismatch %s != %s", i, parsed[i].Alias, original[i].Alias)
  		}
  		if parsed[i].HostName != original[i].HostName {
  			t.Errorf("roundtrip[%d]: hostname mismatch", i)
  		}
  		if parsed[i].User != original[i].User {
  			t.Errorf("roundtrip[%d]: user mismatch", i)
  		}
  		if parsed[i].Port != original[i].Port {
  			t.Errorf("roundtrip[%d]: port mismatch", i)
  		}
  	}
  }


id: ssh_download_go_infra_t0
function_id: ssh_download_go_infra
name: TestSSHUploadDownload
file_path: functions/infra/ssh_transfer_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHUploadDownload(t *testing.T) {
  	conn := skipIfNoSSH(t)

  	t.Run("upload y download roundtrip", func(t *testing.T) {
  		// Crear archivo temporal local
  		tmpDir := t.TempDir()
  		localFile := filepath.Join(tmpDir, "test_upload.txt")
  		content := "fn_registry ssh test: upload roundtrip"
  		if err := os.WriteFile(localFile, []byte(content), 0644); err != nil {
  			t.Fatal(err)
  		}

  		remotePath := "/tmp/fn_registry_ssh_test.txt"

  		// Upload
  		err := SSHUpload(conn, localFile, remotePath)
  		if err != nil {
  			t.Fatalf("SSHUpload: %v", err)
  		}

  		// Verificar que existe en remoto
  		stdout, _, code, err := SSHExec(conn, "cat "+remotePath)
  		if err != nil {
  			t.Fatalf("SSHExec cat: %v", err)
  		}
  		if code != 0 {
  			t.Fatalf("cat remoto fallo con code %d", code)
  		}
  		if strings.TrimSpace(stdout) != content {
  			t.Errorf("contenido remoto = %q, want %q", strings.TrimSpace(stdout), content)
  		}

  		// Download
  		downloadFile := filepath.Join(tmpDir, "test_download.txt")
  		err = SSHDownload(conn, remotePath, downloadFile)
  		if err != nil {
  			t.Fatalf("SSHDownload: %v", err)
  		}

  		got, err := os.ReadFile(downloadFile)
  		if err != nil {
  			t.Fatal(err)
  		}
  		if string(got) != content {
  			t.Errorf("downloaded = %q, want %q", string(got), content)
  		}

  		// Limpiar remoto
  		SSHExec(conn, "rm -f "+remotePath)
  	})
  }


id: ssh_exec_go_infra_t0
function_id: ssh_exec_go_infra
name: TestSSHExec
file_path: functions/infra/ssh_exec_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHExec(t *testing.T) {
  	conn := skipIfNoSSH(t)

  	t.Run("echo simple", func(t *testing.T) {
  		stdout, stderr, code, err := SSHExec(conn, "echo hello")
  		if err != nil {
  			t.Fatalf("SSHExec error: %v", err)
  		}
  		if code != 0 {
  			t.Errorf("exit code = %d, want 0; stderr: %s", code, stderr)
  		}
  		if strings.TrimSpace(stdout) != "hello" {
  			t.Errorf("stdout = %q, want %q", strings.TrimSpace(stdout), "hello")
  		}
  	})

  	t.Run("captura stderr", func(t *testing.T) {
  		_, stderr, code, err := SSHExec(conn, "echo error >&2")
  		if err != nil {
  			t.Fatalf("SSHExec error: %v", err)
  		}
  		if code != 0 {
  			t.Errorf("exit code = %d, want 0", code)
  		}
  		if !strings.Contains(stderr, "error") {
  			t.Errorf("stderr = %q, want contener 'error'", stderr)
  		}
  	})

  	t.Run("exit code no cero", func(t *testing.T) {
  		_, _, code, err := SSHExec(conn, "exit 42")
  		if err != nil {
  			t.Fatalf("SSHExec error: %v", err)
  		}
  		if code != 42 {
  			t.Errorf("exit code = %d, want 42", code)
  		}
  	})

  	t.Run("comando multilinea", func(t *testing.T) {
  		stdout, _, code, err := SSHExec(conn, "hostname && uname -s")
  		if err != nil {
  			t.Fatalf("SSHExec error: %v", err)
  		}
  		if code != 0 {
  			t.Errorf("exit code = %d, want 0", code)
  		}
  		lines := strings.Split(strings.TrimSpace(stdout), "\n")
  		if len(lines) < 2 {
  			t.Errorf("esperaba al menos 2 lineas, got %d: %q", len(lines), stdout)
  		}
  	})
  }


id: ssh_tunnel_close_go_infra_t0
function_id: ssh_tunnel_close_go_infra
name: TestSSHTunnelOpenClose
file_path: functions/infra/ssh_tunnel_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestSSHTunnelOpenClose(t *testing.T) {
  	conn := skipIfNoSSH(t)

  	t.Run("abre tunel y lo cierra", func(t *testing.T) {
  		// Usar puerto alto aleatorio para evitar conflictos
  		localPort := 19876
  		// Tunel a localhost:22 del remoto (el propio sshd)
  		pid, err := SSHTunnelOpen(conn, localPort, "localhost", 22)
  		if err != nil {
  			t.Fatalf("SSHTunnelOpen: %v", err)
  		}
  		if pid <= 0 {
  			t.Fatalf("PID invalido: %d", pid)
  		}

  		// Verificar que el puerto local esta escuchando
  		time.Sleep(300 * time.Millisecond)
  		c, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 2*time.Second)
  		if err != nil {
  			// Limpiar antes de fallar
  			SSHTunnelClose(pid)
  			t.Fatalf("no se puede conectar al tunel en localhost:%d: %v", localPort, err)
  		}
  		c.Close()

  		// Cerrar tunel
  		err = SSHTunnelClose(pid)
  		if err != nil {
  			t.Errorf("SSHTunnelClose: %v", err)
  		}

  		// Verificar que el puerto ya no escucha
  		time.Sleep(300 * time.Millisecond)
  		c, err = net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 1*time.Second)
  		if err == nil {
  			c.Close()
  			t.Error("el tunel sigue abierto despues de SSHTunnelClose")
  		}
  	})
  }


id: thumbnail_generate_go_infra_t0
function_id: thumbnail_generate_go_infra
name: TestThumbnailGenerate
file_path: functions/infra/thumbnail_generate_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestThumbnailGenerate(t *testing.T) {
  	t.Run("genera thumbnail PNG mas pequeno que el original", func(t *testing.T) {
  		dir := t.TempDir()
  		src := filepath.Join(dir, "src.png")
  		dst := filepath.Join(dir, "thumb.png")
  		genPNG(t, src, 400)

  		if err := ThumbnailGenerate(src, dst, 100, 100); err != nil {
  			t.Fatalf("ThumbnailGenerate: %v", err)
  		}

  		f, err := os.Open(dst)
  		if err != nil {
  			t.Fatalf("open dst: %v", err)
  		}
  		defer f.Close()
  		img, _, err := image.Decode(f)
  		if err != nil {
  			t.Fatalf("decode dst: %v", err)
  		}
  		b := img.Bounds()
  		if b.Dx() > 100 || b.Dy() > 100 {
  			t.Errorf("thumbnail %dx%d, want <= 100x100", b.Dx(), b.Dy())
  		}
  	})

  	t.Run("preserva aspect ratio", func(t *testing.T) {
  		dir := t.TempDir()
  		src := filepath.Join(dir, "src.png")
  		dst := filepath.Join(dir, "thumb.png")

  		// Imagen 400x200 (aspect 2:1)
  		img := image.NewRGBA(image.Rect(0, 0, 400, 200))
  		f, _ := os.Create(src)
  		png.Encode(f, img)
  		f.Close()

  		if err := ThumbnailGenerate(src, dst, 100, 100); err != nil {
  			t.Fatalf("err: %v", err)
  		}
  		f2, _ := os.Open(dst)
  		defer f2.Close()
  		thumb, _, _ := image.Decode(f2)
  		b := thumb.Bounds()
  		// Aspect 2:1 mantenido: 100x50
  		if b.Dx() != 100 || b.Dy() != 50 {
  			t.Errorf("thumbnail %dx%d, want 100x50 (aspect 2:1)", b.Dx(), b.Dy())
  		}
  	})

  	t.Run("rechaza extension de salida no soportada", func(t *testing.T) {
  		dir := t.TempDir()
  		src := filepath.Join(dir, "src.png")
  		dst := filepath.Join(dir, "thumb.webp")
  		genPNG(t, src, 200)
  		err := ThumbnailGenerate(src, dst, 100, 100)
  		if err == nil {
  			t.Error("got nil err, want extension no soportada")
  		}
  	})

  	t.Run("no agranda imagenes ya pequenas", func(t *testing.T) {
  		dir := t.TempDir()
  		src := filepath.Join(dir, "src.png")
  		dst := filepath.Join(dir, "thumb.png")
  		genPNG(t, src, 50)

  		if err := ThumbnailGenerate(src, dst, 200, 200); err != nil {
  			t.Fatalf("err: %v", err)
  		}
  		f, _ := os.Open(dst)
  		defer f.Close()
  		thumb, _, _ := image.Decode(f)
  		b := thumb.Bounds()
  		if b.Dx() != 50 || b.Dy() != 50 {
  			t.Errorf("thumbnail %dx%d, want 50x50 (sin agrandar)", b.Dx(), b.Dy())
  		}
  	})
  }


id: upload_handler_go_infra_t0
function_id: upload_handler_go_infra
name: TestUploadHandler
file_path: functions/infra/upload_handler_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestUploadHandler(t *testing.T) {
  	t.Run("acepta upload con imagen y responde JSON", func(t *testing.T) {
  		dir := t.TempDir()
  		cfg := StorageConfig{
  			BaseDir:      dir,
  			MaxFileSize:  1 << 20,
  			AllowedTypes: []string{"image/png"},
  		}
  		ct, body := buildPNGMultipart(t, "foto.png", "data extra")

  		req := httptest.NewRequest("POST", "/upload", body)
  		req.Header.Set("Content-Type", ct)
  		rec := httptest.NewRecorder()

  		UploadHandler(cfg).ServeHTTP(rec, req)

  		if rec.Code != http.StatusOK {
  			t.Fatalf("got %d, body=%s", rec.Code, rec.Body.String())
  		}
  		var resp struct {
  			Files []UploadedFile `json:"files"`
  		}
  		if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  			t.Fatalf("unmarshal: %v", err)
  		}
  		if len(resp.Files) != 1 {
  			t.Fatalf("got %d files, want 1", len(resp.Files))
  		}
  		if resp.Files[0].ContentType != "image/png" {
  			t.Errorf("got ContentType %q", resp.Files[0].ContentType)
  		}
  	})

  	t.Run("rechaza tipo no permitido con 415", func(t *testing.T) {
  		dir := t.TempDir()
  		cfg := StorageConfig{
  			BaseDir:      dir,
  			MaxFileSize:  1 << 20,
  			AllowedTypes: []string{"application/pdf"}, // PNG no en lista
  		}
  		ct, body := buildPNGMultipart(t, "foto.png", "x")

  		req := httptest.NewRequest("POST", "/upload", body)
  		req.Header.Set("Content-Type", ct)
  		rec := httptest.NewRecorder()

  		UploadHandler(cfg).ServeHTTP(rec, req)

  		if rec.Code != http.StatusUnsupportedMediaType {
  			t.Errorf("got %d, want 415", rec.Code)
  		}
  	})

  	t.Run("rechaza body que excede MaxFileSize con 400", func(t *testing.T) {
  		dir := t.TempDir()
  		cfg := StorageConfig{
  			BaseDir:      dir,
  			MaxFileSize:  10, // muy pequeno
  			AllowedTypes: []string{"image/png"},
  		}
  		ct, body := buildPNGMultipart(t, "foto.png", "muchos bytes extra para superar 10")

  		req := httptest.NewRequest("POST", "/upload", body)
  		req.Header.Set("Content-Type", ct)
  		rec := httptest.NewRecorder()

  		UploadHandler(cfg).ServeHTTP(rec, req)
  		if rec.Code != http.StatusBadRequest {
  			t.Errorf("got %d, want 400", rec.Code)
  		}
  	})
  }


id: upload_parse_go_infra_t0
function_id: upload_parse_go_infra
name: TestUploadParse
file_path: functions/infra/upload_parse_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestUploadParse(t *testing.T) {
  	t.Run("extrae un archivo del multipart", func(t *testing.T) {
  		ct, body := buildMultipart(t, []struct{ field, filename, content string }{
  			{"file", "a.txt", "hello"},
  		})
  		req := newMultipartReq(t, ct, body)

  		got, err := UploadParse(req, 1<<20)
  		if err != nil {
  			t.Fatalf("UploadParse: %v", err)
  		}
  		if len(got) != 1 {
  			t.Fatalf("got %d files, want 1", len(got))
  		}
  		if got[0].Filename != "a.txt" {
  			t.Errorf("got Filename %q", got[0].Filename)
  		}
  		if got[0].Size != 5 {
  			t.Errorf("got Size %d, want 5", got[0].Size)
  		}
  		buf, _ := io.ReadAll(got[0].Content)
  		if string(buf) != "hello" {
  			t.Errorf("got content %q, want hello", buf)
  		}
  	})

  	t.Run("extrae multiples archivos", func(t *testing.T) {
  		ct, body := buildMultipart(t, []struct{ field, filename, content string }{
  			{"a", "1.txt", "uno"},
  			{"b", "2.txt", "dos"},
  		})
  		req := newMultipartReq(t, ct, body)

  		got, err := UploadParse(req, 1<<20)
  		if err != nil {
  			t.Fatalf("UploadParse: %v", err)
  		}
  		if len(got) != 2 {
  			t.Fatalf("got %d files, want 2", len(got))
  		}
  	})

  	t.Run("rechaza content-type no multipart", func(t *testing.T) {
  		req := httptest.NewRequest("POST", "/upload", strings.NewReader("x"))
  		req.Header.Set("Content-Type", "application/json")
  		_, err := UploadParse(req, 1<<20)
  		if err == nil || !strings.Contains(err.Error(), "no es multipart") {
  			t.Errorf("got %v, want error de content-type", err)
  		}
  	})

  	t.Run("respeta maxSize", func(t *testing.T) {
  		ct, body := buildMultipart(t, []struct{ field, filename, content string }{
  			{"file", "big.bin", strings.Repeat("x", 1024)},
  		})
  		req := newMultipartReq(t, ct, body)
  		_, err := UploadParse(req, 100) // demasiado pequeno
  		if err == nil {
  			t.Errorf("got nil err, want max bytes error")
  		}
  	})
  }


id: write_dockerfile_go_infra_t0
function_id: write_dockerfile_go_infra
name: TestWriteDockerfile
file_path: functions/infra/write_dockerfile_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWriteDockerfile(t *testing.T) {
  	t.Run("escribe Dockerfile en directorio existente", func(t *testing.T) {
  		tmpDir := t.TempDir()
  		content := "FROM alpine:latest\nCMD [\"sh\"]\n"

  		path, err := WriteDockerfile(tmpDir, content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}

  		data, err := os.ReadFile(path)
  		if err != nil {
  			t.Fatalf("cannot read written file: %v", err)
  		}
  		if string(data) != content {
  			t.Errorf("content mismatch: got %q, want %q", string(data), content)
  		}
  	})

  	t.Run("crea directorio si no existe", func(t *testing.T) {
  		tmpDir := t.TempDir()
  		newDir := filepath.Join(tmpDir, "subdir", "nested")
  		content := "FROM scratch\n"

  		path, err := WriteDockerfile(newDir, content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		if _, statErr := os.Stat(newDir); os.IsNotExist(statErr) {
  			t.Error("expected directory to be created")
  		}
  		if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
  			t.Errorf("expected Dockerfile at %s", path)
  		}
  	})

  	t.Run("retorna path absoluto correcto", func(t *testing.T) {
  		tmpDir := t.TempDir()
  		content := "FROM ubuntu:22.04\n"

  		path, err := WriteDockerfile(tmpDir, content)
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}

  		if !filepath.IsAbs(path) {
  			t.Errorf("expected absolute path, got: %s", path)
  		}
  		if !strings.HasSuffix(path, "Dockerfile") {
  			t.Errorf("expected path to end with Dockerfile, got: %s", path)
  		}
  	})

  	t.Run("error si dir es path invalido", func(t *testing.T) {
  		// Intentar escribir en un path donde el padre es un archivo (no directorio)
  		tmpDir := t.TempDir()
  		// Crear un archivo donde esperamos un directorio
  		blockerPath := filepath.Join(tmpDir, "blocker")
  		if err := os.WriteFile(blockerPath, []byte("x"), 0o644); err != nil {
  			t.Fatal(err)
  		}

  		// Intentar usar ese archivo como directorio
  		_, err := WriteDockerfile(filepath.Join(blockerPath, "subdir"), "FROM scratch\n")
  		if err == nil {
  			t.Error("expected error when dir path goes through a file, got nil")
  		}
  	})
  }


id: ws_broadcast_go_infra_t0
function_id: ws_broadcast_go_infra
name: TestWSHub
file_path: functions/infra/ws_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWSHub(t *testing.T) {
  	t.Run("registra y desregistra clientes", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		client := &WSClient{Hub: hub, Send: make(chan []byte, 4), ID: "c1"}
  		hub.Register <- client

  		// Esperar a que el hub procese
  		time.Sleep(20 * time.Millisecond)

  		hub.Unregister <- client
  		time.Sleep(20 * time.Millisecond)

  		// El canal Send del cliente debe estar cerrado tras unregister
  		select {
  		case _, ok := <-client.Send:
  			if ok {
  				t.Error("client.Send should be closed after unregister")
  			}
  		default:
  			t.Error("client.Send should be closed (zero-value receive)")
  		}
  	})

  	t.Run("Stop cierra todos los clientes", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()

  		c1 := &WSClient{Hub: hub, Send: make(chan []byte, 4), ID: "c1"}
  		c2 := &WSClient{Hub: hub, Send: make(chan []byte, 4), ID: "c2"}
  		hub.Register <- c1
  		hub.Register <- c2
  		time.Sleep(20 * time.Millisecond)

  		hub.Stop()
  		time.Sleep(20 * time.Millisecond)

  		for _, c := range []*WSClient{c1, c2} {
  			select {
  			case _, ok := <-c.Send:
  				if ok {
  					t.Errorf("client %s Send should be closed after Stop", c.ID)
  				}
  			default:
  				t.Errorf("client %s Send should be closed (zero-value receive)", c.ID)
  			}
  		}
  	})

  	t.Run("Stop es idempotente", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		hub.Stop()
  		hub.Stop() // no debe panicar
  	})
  }

  // --- WSBroadcast ---


id: ws_broadcast_go_infra_t1
function_id: ws_broadcast_go_infra
name: TestWSBroadcast
file_path: functions/infra/ws_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWSBroadcast(t *testing.T) {
  	t.Run("envia mensaje al canal Broadcast del hub", func(t *testing.T) {
  		hub := NewWSHub()
  		err := WSBroadcast(hub, []byte("hola"))
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		select {
  		case msg := <-hub.Broadcast:
  			if string(msg) != "hola" {
  				t.Errorf("got %q, want hola", string(msg))
  			}
  		case <-time.After(time.Second):
  			t.Error("message not in Broadcast channel")
  		}
  	})

  	t.Run("retorna error si hub es nil", func(t *testing.T) {
  		err := WSBroadcast(nil, []byte("x"))
  		if err == nil {
  			t.Error("expected error for nil hub, got nil")
  		}
  	})

  	t.Run("el hub entrega el mensaje a todos los clientes registrados", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		c1 := &WSClient{Hub: hub, Send: make(chan []byte, 4), ID: "c1"}
  		c2 := &WSClient{Hub: hub, Send: make(chan []byte, 4), ID: "c2"}
  		hub.Register <- c1
  		hub.Register <- c2
  		time.Sleep(20 * time.Millisecond)

  		WSBroadcast(hub, []byte("ping"))
  		time.Sleep(20 * time.Millisecond)

  		for _, c := range []*WSClient{c1, c2} {
  			select {
  			case msg := <-c.Send:
  				if string(msg) != "ping" {
  					t.Errorf("client %s got %q, want ping", c.ID, string(msg))
  				}
  			case <-time.After(time.Second):
  				t.Errorf("client %s did not receive broadcast", c.ID)
  			}
  		}
  	})

  	t.Run("cliente lento es desconectado del hub", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		// Cliente con buffer de 1 — el segundo broadcast lo tira
  		slow := &WSClient{Hub: hub, Send: make(chan []byte, 1), ID: "slow"}
  		hub.Register <- slow
  		time.Sleep(10 * time.Millisecond)

  		// Llenar buffer y forzar drop
  		WSBroadcast(hub, []byte("1"))
  		WSBroadcast(hub, []byte("2"))
  		time.Sleep(20 * time.Millisecond)

  		// Drenar Send: deberia tener el primer mensaje y luego estar cerrado
  		got := []string{}
  		for msg := range slow.Send {
  			got = append(got, string(msg))
  		}
  		if len(got) > 1 {
  			t.Errorf("expected at most 1 message in slow client, got %d: %v", len(got), got)
  		}
  	})
  }

  // --- WSSend ---


id: ws_broadcast_go_infra_t2
function_id: ws_broadcast_go_infra
name: TestWSSend
file_path: functions/infra/ws_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWSSend(t *testing.T) {
  	t.Run("envia mensaje al canal Send del cliente", func(t *testing.T) {
  		client := &WSClient{Send: make(chan []byte, 4), ID: "c1"}
  		err := WSSend(client, []byte("hola"))
  		if err != nil {
  			t.Fatalf("unexpected error: %v", err)
  		}
  		got := <-client.Send
  		if string(got) != "hola" {
  			t.Errorf("got %q, want hola", string(got))
  		}
  	})

  	t.Run("retorna error si client es nil", func(t *testing.T) {
  		err := WSSend(nil, []byte("x"))
  		if err == nil {
  			t.Error("expected error for nil client, got nil")
  		}
  	})

  	t.Run("retorna error si el canal esta lleno", func(t *testing.T) {
  		client := &WSClient{Send: make(chan []byte, 1), ID: "c1"}
  		_ = WSSend(client, []byte("1"))
  		err := WSSend(client, []byte("2"))
  		if err == nil {
  			t.Error("expected error when send channel full, got nil")
  		}
  	})
  }

  // --- WSUpgrader & WSHandler integration ---


id: ws_broadcast_go_infra_t3
function_id: ws_broadcast_go_infra
name: TestWSUpgrader
file_path: functions/infra/ws_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWSUpgrader(t *testing.T) {
  	t.Run("upgradea conexion valida con `*` en origenes", func(t *testing.T) {
  		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			conn, err := WSUpgrader(w, r, []string{"*"})
  			if err != nil {
  				return
  			}
  			defer conn.Close(websocket.StatusNormalClosure, "")
  			// Echo
  			_, data, _ := conn.Read(r.Context())
  			conn.Write(r.Context(), websocket.MessageText, data)
  		}))
  		defer ts.Close()

  		wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
  		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  		defer cancel()

  		c, _, err := websocket.Dial(ctx, wsURL, nil)
  		if err != nil {
  			t.Fatalf("dial failed: %v", err)
  		}
  		defer c.Close(websocket.StatusNormalClosure, "")

  		if err := c.Write(ctx, websocket.MessageText, []byte("ping")); err != nil {
  			t.Fatalf("write failed: %v", err)
  		}
  		_, data, err := c.Read(ctx)
  		if err != nil {
  			t.Fatalf("read failed: %v", err)
  		}
  		if string(data) != "ping" {
  			t.Errorf("got %q, want ping", string(data))
  		}
  	})
  }


id: ws_broadcast_go_infra_t4
function_id: ws_broadcast_go_infra
name: TestWSHandler
file_path: functions/infra/ws_test.go
lang: go
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  func TestWSHandler(t *testing.T) {
  	t.Run("upgradea conexion y registra cliente en hub", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		ts := httptest.NewServer(WSHandler(hub, []string{"*"}))
  		defer ts.Close()

  		wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
  		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  		defer cancel()

  		c, _, err := websocket.Dial(ctx, wsURL, nil)
  		if err != nil {
  			t.Fatalf("dial failed: %v", err)
  		}
  		defer c.Close(websocket.StatusNormalClosure, "")

  		// Esperar a que el hub procese el Register
  		time.Sleep(50 * time.Millisecond)

  		if hub.ClientCount() != 1 {
  			t.Errorf("expected 1 client in hub, got %d", hub.ClientCount())
  		}
  	})

  	t.Run("broadcast del hub llega al cliente conectado", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		ts := httptest.NewServer(WSHandler(hub, []string{"*"}))
  		defer ts.Close()

  		wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
  		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  		defer cancel()

  		c, _, err := websocket.Dial(ctx, wsURL, nil)
  		if err != nil {
  			t.Fatalf("dial failed: %v", err)
  		}
  		defer c.Close(websocket.StatusNormalClosure, "")

  		time.Sleep(50 * time.Millisecond)

  		WSBroadcast(hub, []byte("hello-all"))

  		_, data, err := c.Read(ctx)
  		if err != nil {
  			t.Fatalf("read failed: %v", err)
  		}
  		if string(data) != "hello-all" {
  			t.Errorf("got %q, want hello-all", string(data))
  		}
  	})

  	t.Run("multiples clientes reciben el broadcast", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		ts := httptest.NewServer(WSHandler(hub, []string{"*"}))
  		defer ts.Close()

  		wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
  		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  		defer cancel()

  		const N = 3
  		conns := make([]*websocket.Conn, N)
  		for i := 0; i < N; i++ {
  			c, _, err := websocket.Dial(ctx, wsURL, nil)
  			if err != nil {
  				t.Fatalf("dial %d failed: %v", i, err)
  			}
  			conns[i] = c
  			defer c.Close(websocket.StatusNormalClosure, "")
  		}

  		time.Sleep(80 * time.Millisecond)

  		WSBroadcast(hub, []byte("multicast"))

  		var wg sync.WaitGroup
  		errs := make(chan error, N)
  		for i := 0; i < N; i++ {
  			wg.Add(1)
  			go func(idx int, conn *websocket.Conn) {
  				defer wg.Done()
  				_, data, err := conn.Read(ctx)
  				if err != nil {
  					errs <- err
  					return
  				}
  				if string(data) != "multicast" {
  					errs <- &readMismatchError{idx: idx, got: string(data)}
  				}
  			}(i, conns[i])
  		}
  		wg.Wait()
  		close(errs)
  		for e := range errs {
  			t.Errorf("client receive error: %v", e)
  		}
  	})

  	t.Run("desregistra cliente al desconectar", func(t *testing.T) {
  		hub := NewWSHub()
  		go hub.Run()
  		defer hub.Stop()

  		ts := httptest.NewServer(WSHandler(hub, []string{"*"}))
  		defer ts.Close()

  		wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
  		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  		defer cancel()

  		c, _, err := websocket.Dial(ctx, wsURL, nil)
  		if err != nil {
  			t.Fatalf("dial failed: %v", err)
  		}

  		time.Sleep(50 * time.Millisecond)
  		if hub.ClientCount() != 1 {
  			t.Fatalf("expected 1 client after dial, got %d", hub.ClientCount())
  		}

  		c.Close(websocket.StatusNormalClosure, "")
  		time.Sleep(100 * time.Millisecond)

  		if hub.ClientCount() != 0 {
  			t.Errorf("expected 0 clients after close, got %d", hub.ClientCount())
  		}
  	})
  }

  type readMismatchError struct {
  	idx int
  	got string
  }

  func (e *readMismatchError) Error() string {
  	return "client " + string(rune('0'+e.idx)) + " got " + e.got
  }


id: pass_delete_bash_infra_t0
function_id: pass_delete_bash_infra
name: pass_get
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_get ==="

  echo "  test: lee entrada existente (agentes/gitea-url)"
  got=$(pass_get agentes/gitea-url)
  assert_nonzero "lee entrada existente" "$got"

  echo "  test: falla con entrada inexistente"
  assert_fail "falla con entrada inexistente" pass_get "no/existe/xyz"

  echo ""


id: pass_delete_bash_infra_t1
function_id: pass_delete_bash_infra
name: pass_set
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_set ==="

  echo "  test: inserta valor y lo lee de vuelta"
  pass_set "$TEST_ENTRY" "test-value-12345"
  got=$(pass_get "$TEST_ENTRY")
  assert_eq "inserta y lee" "$got" "test-value-12345"

  echo "  test: sobreescribe valor existente"
  pass_set "$TEST_ENTRY" "overwritten-value"
  got=$(pass_get "$TEST_ENTRY")
  assert_eq "sobreescribe" "$got" "overwritten-value"

  # Limpiar para siguiente test
  pass_cleanup

  echo ""


id: pass_delete_bash_infra_t2
function_id: pass_delete_bash_infra
name: pass_list
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_list ==="

  echo "  test: lista todas las entradas"
  got=$(pass_list)
  assert_contains "lista todas" "$got" "dataforge-token"

  echo "  test: filtra por prefijo agentes"
  got=$(pass_list agentes)
  assert_contains "filtra agentes" "$got" "gitea-url"

  echo ""


id: pass_delete_bash_infra_t3
function_id: pass_delete_bash_infra
name: pass_generate
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_generate ==="

  echo "  test: genera password de 16 chars"
  generated=$(pass_generate "$TEST_ENTRY" 16)
  assert_eq "longitud 16" "${#generated}" "16"

  echo "  test: valor almacenado coincide"
  stored=$(pass_get "$TEST_ENTRY")
  assert_eq "stored = generated" "$stored" "$generated"

  pass_cleanup

  echo "  test: default 24 chars"
  generated=$(pass_generate "$TEST_ENTRY")
  assert_eq "longitud default 24" "${#generated}" "24"

  pass_cleanup

  echo ""


id: pass_delete_bash_infra_t4
function_id: pass_delete_bash_infra
name: pass_delete
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_delete ==="

  echo "  test: elimina entrada de test"
  pass_set "$TEST_ENTRY" "to-delete"
  pass_delete "$TEST_ENTRY"
  assert_fail "despues de delete no se puede leer" pass_get "$TEST_ENTRY"

  echo "  test: falla con entrada inexistente"
  assert_fail "delete inexistente" pass_delete "no/existe/xyz_delete_test"

  echo ""


id: pass_delete_bash_infra_t5
function_id: pass_delete_bash_infra
name: pass_sync
file_path: bash/functions/infra/pass_test.sh
lang: bash
created_at: 2026-04-18T15:45:34Z
updated_at: 2026-04-18T15:45:34Z
code:
  echo "=== pass_sync ==="

  echo "  test: sincroniza con remoto"
  got=$(pass_sync)
  assert_contains "sync retorna json" "$got" "pull"

  echo ""
  echo "================================"
  echo "Resultados: $PASS passed, $FAIL failed"
  if [ "$FAIL" -gt 0 ]; then
      exit 1
  fi


