# types

id: merge_op_py_core
name: merge_op
lang: py
domain: core
version: 1.0.0
algebraic: sum
definition:
  class MergeOp(str, Enum):
  PATCH = "patch"
  SUM = "sum"
  IMMUTABLE = "immutable"

tags: ["memory","merge","strategy","enum","schema"]
uses_types: []
file_path: python/types/core/merge_op.py
created_at: 2026-04-05T11:20:39Z
updated_at: 2026-04-05T11:20:39Z
content_hash: 64f8a5f6fcd47db7b8d2ba0ad05f616fe60618b00355a73d760806e81fdff75e
description:
  Estrategia de merge cuando se actualiza un campo de memoria existente. PATCH aplica cambios parciales (search/replace en strings), SUM acumula valores numericos, IMMUTABLE protege contra modificacion.

notes:
  Hereda de `str` para serializacion JSON directa.
  Usado por `MemoryField` como valor default `MergeOp.PATCH`.
  - `PATCH`: para strings aplica search/replace; para otros tipos reemplaza directamente.
  - `SUM`: acumula numeros; util para contadores de eventos o frecuencias.
  - `IMMUTABLE`: protege campos que no deben cambiar una vez establecidos (ej: fecha de creacion, ID).

code:
  """MergeOp — estrategia de merge para campos de memoria."""

  from enum import Enum


  class MergeOp(str, Enum):
      """Estrategia de merge cuando se actualiza un campo de memoria existente.

      Valores posibles:
          PATCH     — aplica cambios parciales (search/replace en strings,
                      replace directo para otros tipos).
          SUM       — acumula valores numericos (contadores).
          IMMUTABLE — protege el campo contra modificacion despues de creado.
      """

      PATCH = "patch"
      SUM = "sum"
      IMMUTABLE = "immutable"


id: message_py_core
name: message
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["message","conversation","multipart","llm","agent","history","jsonl","serialization","tokens"]
uses_types: ["part_py_core","text_part_py_core","context_part_py_core","tool_part_py_core"]
file_path: python/types/core/message.py
created_at: 2026-04-05T11:21:35Z
updated_at: 2026-04-05T11:21:35Z
content_hash: 0fc1dd8991fdc81e5aaf7945f8fd47d4273e75a3bd88798d1f32127dedeb7175
definition:
  @dataclass
  class Message:
      id: str
      role: str
      parts: list[Part]
      created_at: datetime | None = None

description:
  Modelo de mensaje multipart para conversaciones con agentes. Soporta texto, referencias a contexto (memoria/recurso/skill) y llamadas a herramientas en un mensaje unificado. Disenado para ser serializable a JSONL para persistencia.

notes:
  El campo `role` acepta "user" o "assistant" por convencion, pero no se valida — el tipo es abierto para compatibilidad con futuros roles (e.g., "system", "tool").

  La estimacion de tokens (ceil(chars / 4)) es una heuristica. No sustituye a un tokenizador real, pero es util para presupuesto rapido sin dependencias externas.

  `to_dict()` usa `dataclasses.asdict()` sobre cada Part, que serializa recursivamente incluyendo el campo `type` discriminador — esto garantiza que `from_dict()` puede reconstruir el tipo correcto.

  Las factories `create_user` y `create_assistant` generan IDs con `uuid.uuid4()` y timestamps UTC automaticamente, simplificando la construccion en pipelines de conversacion.

documentation:
  ## Metodos

  - `content` (property) — texto del primer `TextPart`. Vacio si no hay TextPart.
  - `estimated_tokens` (property) — estimacion `ceil(total_chars / 4)`. Cuenta chars de TextParts, abstracts de ContextParts, y JSON de tool_input + tool_output de ToolParts.
  - `to_dict() -> dict` — serializa a dict listo para `json.dumps()`. Timestamps en ISO 8601.
  - `to_jsonl() -> str` — serializa a string JSON de una sola linea.
  - `from_dict(data: dict) -> Message` (classmethod) — deserializa usando `part_from_dict()` para cada parte.
  - `create_user(content: str) -> Message` (classmethod) — factory: role="user", un TextPart, UUID y timestamp UTC automaticos.
  - `create_assistant(content, context_refs=None, tool_calls=None) -> Message` (classmethod) — factory: role="assistant", orden de partes: ContextParts → TextPart → ToolParts.
  - `get_context_parts() -> list[ContextPart]` — filtra partes de tipo ContextPart.
  - `get_tool_parts() -> list[ToolPart]` — filtra partes de tipo ToolPart.
  - `find_tool_part(tool_id: str) -> ToolPart | None` — busca un ToolPart por tool_id.

code:
  """Message — modelo de mensaje multipart para conversaciones con agentes."""

  import dataclasses
  import json
  import math
  import uuid
  from dataclasses import dataclass, field
  from datetime import datetime, timezone
  from typing import ClassVar

  from context_part import ContextPart
  from part import Part, part_from_dict
  from text_part import TextPart
  from tool_part import ToolPart


  @dataclass
  class Message:
      """Modelo de mensaje multipart para conversaciones con agentes.

      Soporta texto, referencias a contexto (memoria/recurso/skill) y llamadas
      a herramientas en un mensaje unificado. Disenado para ser serializable a
      JSONL para persistencia.

      Attributes:
          id:         Identificador unico del mensaje.
          role:       Rol del emisor: "user" | "assistant".
          parts:      Lista de partes del mensaje (TextPart, ContextPart, ToolPart).
          created_at: Timestamp de creacion en UTC.
      """

      id: str
      role: str
      parts: list[Part]
      created_at: datetime | None = None

      # --- Properties ---

      @property
      def content(self) -> str:
          """Texto del primer TextPart del mensaje. Vacio si no hay TextPart."""
          for part in self.parts:
              if isinstance(part, TextPart):
                  return part.text
          return ""

      @property
      def estimated_tokens(self) -> int:
          """Estimacion de tokens consumidos por el mensaje.

          Cuenta caracteres de TextParts, abstracts de ContextParts, e inputs/outputs
          de ToolParts. Divide entre 4 (heuristica estandar: ~4 chars por token).

          Returns:
              Numero entero de tokens estimados (ceil).
          """
          total_chars = 0
          for part in self.parts:
              if isinstance(part, TextPart):
                  total_chars += len(part.text)
              elif isinstance(part, ContextPart):
                  total_chars += len(part.abstract)
              elif isinstance(part, ToolPart):
                  if part.tool_input:
                      total_chars += len(json.dumps(part.tool_input))
                  total_chars += len(part.tool_output)
          return math.ceil(total_chars / 4)

      # --- Serialization ---

      def to_dict(self) -> dict:
          """Serializa el mensaje a un dict compatible con JSONL.

          Returns:
              Dict con todos los campos del mensaje listos para json.dumps().
          """
          return {
              "id": self.id,
              "role": self.role,
              "parts": [dataclasses.asdict(p) for p in self.parts],
              "created_at": self.created_at.isoformat() if self.created_at else None,
          }

      def to_jsonl(self) -> str:
          """Serializa el mensaje a un string JSON en una sola linea (JSONL).

          Returns:
              String JSON sin saltos de linea internos.
          """
          return json.dumps(self.to_dict(), ensure_ascii=False)

      @classmethod
      def from_dict(cls, data: dict) -> "Message":
          """Deserializa un Message desde un dict.

          Args:
              data: Dict con los campos del mensaje. Los dicts de 'parts'
                    se deserializan con part_from_dict().

          Returns:
              Instancia de Message reconstruida.
          """
          parts = [part_from_dict(p) for p in data.get("parts", [])]
          created_at = None
          if data.get("created_at"):
              created_at = datetime.fromisoformat(data["created_at"])
          return cls(
              id=data["id"],
              role=data["role"],
              parts=parts,
              created_at=created_at,
          )

      # --- Factories ---

      @classmethod
      def create_user(cls, content: str) -> "Message":
          """Crea un mensaje de usuario con un unico TextPart.

          Args:
              content: Texto del mensaje.

          Returns:
              Message con role="user" y un TextPart.
          """
          return cls(
              id=str(uuid.uuid4()),
              role="user",
              parts=[TextPart(text=content)],
              created_at=datetime.now(timezone.utc),
          )

      @classmethod
      def create_assistant(
          cls,
          content: str,
          context_refs: list[ContextPart] | None = None,
          tool_calls: list[ToolPart] | None = None,
      ) -> "Message":
          """Crea un mensaje de asistente con texto, contextos opcionales y tool calls opcionales.

          El orden de las partes es: ContextParts, TextPart, ToolParts.

          Args:
              content:      Texto principal de la respuesta del asistente.
              context_refs: Referencias a contextos que el asistente uso para responder.
              tool_calls:   Llamadas a herramientas realizadas por el asistente.

          Returns:
              Message con role="assistant" y las partes compuestas.
          """
          parts: list[Part] = []
          if context_refs:
              parts.extend(context_refs)
          parts.append(TextPart(text=content))
          if tool_calls:
              parts.extend(tool_calls)
          return cls(
              id=str(uuid.uuid4()),
              role="assistant",
              parts=parts,
              created_at=datetime.now(timezone.utc),
          )

      # --- Accessors ---

      def get_context_parts(self) -> list[ContextPart]:
          """Devuelve todas las partes de tipo ContextPart del mensaje.

          Returns:
              Lista (posiblemente vacia) de ContextParts.
          """
          return [p for p in self.parts if isinstance(p, ContextPart)]

      def get_tool_parts(self) -> list[ToolPart]:
          """Devuelve todas las partes de tipo ToolPart del mensaje.

          Returns:
              Lista (posiblemente vacia) de ToolParts.
          """
          return [p for p in self.parts if isinstance(p, ToolPart)]

      def find_tool_part(self, tool_id: str) -> ToolPart | None:
          """Busca un ToolPart por su tool_id.

          Args:
              tool_id: Identificador de la invocacion de la herramienta.

          Returns:
              El ToolPart encontrado, o None si no existe.
          """
          for part in self.parts:
              if isinstance(part, ToolPart) and part.tool_id == tool_id:
                  return part
          return None


id: node_type_py_core
name: node_type
lang: py
domain: core
version: 1.0.0
algebraic: sum
definition:
  class NodeType(str, Enum):
  ROOT = "root"
  SECTION = "section"

tags: ["parsing","document","tree","enum","node","core"]
uses_types: []
file_path: python/types/core/node_type.py
created_at: 2026-04-05T11:21:03Z
updated_at: 2026-04-05T11:21:03Z
content_hash: d46f3f559c1ca9b81e41a4eb65e1a361c3897967f9bcdd69b8a6818a4dc7272a
description:
  Tipos de nodo en un arbol de documento parseado. Solo ROOT y SECTION — todo el contenido detallado permanece como markdown dentro del campo content del ResourceNode.

examples:
  ```python
  from node_type import NodeType

  print(NodeType.ROOT.value)     # "root"
  print(NodeType.SECTION.value)  # "section"

  node_type = NodeType("section")
  print(node_type == NodeType.SECTION)  # True
  ```

notes:
  Diseno intencionalmente simple. Evita la decomposicion fina de nodos (no hay PARAGRAPH, CODE_BLOCK, TABLE, LIST, etc.) para mantener el arbol manejable y el contenido preservado en formato markdown. Hereda de `str` para serializar directamente a JSON sin conversion manual.

documentation:
  ## Valores

  - `ROOT` — nodo raiz del arbol, unico por documento.
  - `SECTION` — seccion o subseccion del documento (cualquier nivel).

code:
  """NodeType — tipos de nodo en un arbol de documento parseado."""

  from enum import Enum


  class NodeType(str, Enum):
      """Tipos de nodo en un arbol de documento parseado.

      Valores posibles:
          ROOT    — nodo raiz del arbol, unico por documento.
          SECTION — seccion o subseccion del documento.

      Diseno intencionalmente simple: no hay PARAGRAPH, CODE_BLOCK, TABLE, etc.
      Todo el contenido detallado permanece como markdown dentro del campo
      content del ResourceNode.
      """

      ROOT = "root"
      SECTION = "section"


id: parse_result_py_core
name: parse_result
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["parsing","document","result","tree","pipeline","core"]
uses_types: ["resource_node_py_core","node_type_py_core"]
file_path: python/types/core/parse_result.py
created_at: 2026-04-05T11:21:25Z
updated_at: 2026-04-05T11:21:25Z
content_hash: b3262f47068d0371695df97907a27e5a7c5e9aca1495399cc5df8f882c7a0663
definition:
  @dataclass
  class ParseResult:
      root: ResourceNode
      source_path: str | None = None
      source_format: str | None = None
      parser_name: str | None = None
      parser_version: str | None = None
      parse_time: float | None = None
      parse_timestamp: datetime | None = None
      meta: dict[str, Any] = field(default_factory=dict)
      warnings: list[str] = field(default_factory=list)

description:
  Resultado completo de parsear un documento. Contiene el arbol de nodos, metadata del proceso y warnings. Es el tipo de retorno unificado de todos los parsers del registry.

examples:
  ```python
  import time
  from node_type import NodeType
  from resource_node import ResourceNode
  from parse_result import ParseResult, create_parse_result

  root = ResourceNode(type=NodeType.ROOT, title="Documento")
  intro = ResourceNode(type=NodeType.SECTION, title="Intro", level=1, content="...")
  root.add_child(intro)

  result = create_parse_result(
      root=root,
      source_path="/docs/mi_doc.md",
      source_format="markdown",
      parser_name="MarkdownParser",
      parse_time=0.042,
  )

  print(result.success)          # True (sin warnings)
  print(len(result.get_all_nodes()))   # 2 (root + intro)
  print(len(result.get_sections()))    # 1 (solo intro, root es ROOT)
  print(result.get_sections(min_level=1, max_level=1))  # [intro]
  ```

notes:
  El tipo de retorno unificado permite que los consumidores (pipelines, agentes, APIs) trabajen con el mismo contrato independientemente del formato de origen. El campo `warnings` captura problemas no fatales (e.g., encoding issues, headings malformados) sin lanzar excepciones. `get_all_nodes()` usa BFS para preservar el orden natural de lectura del documento.

documentation:
  ## Propiedades y metodos

  - `success -> bool` (property) — True si `warnings` esta vacio.
  - `get_all_nodes() -> list[ResourceNode]` — flatten BFS del arbol completo (raiz + todos los descendientes).
  - `get_sections(min_level=0, max_level=10) -> list[ResourceNode]` — filtra nodos de tipo SECTION dentro del rango de niveles.

  ## Helper

  ```python
  def create_parse_result(
      root, source_path=None, source_format=None,
      parser_name=None, parse_time=None, meta=None, warnings=None
  ) -> ParseResult
  ```

  Construye un ParseResult con valores por defecto sensatos. Evita tener que instanciar `ParseResult` directamente cuando no se necesitan todos los campos.

code:
  """ParseResult — resultado completo de parsear un documento."""

  from __future__ import annotations

  from dataclasses import dataclass, field
  from datetime import datetime
  from typing import Any

  from node_type import NodeType
  from resource_node import ResourceNode


  @dataclass
  class ParseResult:
      """Resultado completo de parsear un documento.

      Contiene el arbol de nodos, metadata del proceso de parsing y
      lista de warnings. Es el tipo de retorno unificado de todos los
      parsers del registry.

      Attributes:
          root:             Nodo raiz del arbol de documento.
          source_path:      Path o URL del documento origen.
          source_format:    Formato del documento ("pdf", "markdown", "html", "docx", etc.).
          parser_name:      Nombre del parser usado ("PDFParser", "MarkdownParser", etc.).
          parser_version:   Version del parser.
          parse_time:       Duracion del parsing en segundos.
          parse_timestamp:  Momento en que se realizo el parsing.
          meta:             Metadata adicional del proceso.
          warnings:         Lista de advertencias no fatales durante el parsing.
      """

      root: ResourceNode
      source_path: str | None = None
      source_format: str | None = None
      parser_name: str | None = None
      parser_version: str | None = None
      parse_time: float | None = None
      parse_timestamp: datetime | None = None
      meta: dict[str, Any] = field(default_factory=dict)
      warnings: list[str] = field(default_factory=list)

      @property
      def success(self) -> bool:
          """True si el parsing no genero warnings."""
          return len(self.warnings) == 0

      def get_all_nodes(self) -> list[ResourceNode]:
          """Retorna todos los nodos del arbol en orden de visita (BFS).

          Returns:
              Lista plana de ResourceNode incluyendo el nodo raiz y todos
              sus descendientes.
          """
          result: list[ResourceNode] = []
          queue = [self.root]
          while queue:
              node = queue.pop(0)
              result.append(node)
              queue.extend(node.children)
          return result

      def get_sections(
          self, min_level: int = 0, max_level: int = 10
      ) -> list[ResourceNode]:
          """Retorna todos los nodos SECTION dentro del rango de niveles indicado.

          Args:
              min_level: Nivel minimo de seccion (inclusive).
              max_level: Nivel maximo de seccion (inclusive).

          Returns:
              Lista de ResourceNode de tipo SECTION dentro del rango.
          """
          return [
              node
              for node in self.get_all_nodes()
              if node.type == NodeType.SECTION
              and min_level <= node.level <= max_level
          ]


  def create_parse_result(
      root: ResourceNode,
      source_path: str | None = None,
      source_format: str | None = None,
      parser_name: str | None = None,
      parse_time: float | None = None,
      meta: dict[str, Any] | None = None,
      warnings: list[str] | None = None,
  ) -> ParseResult:
      """Helper para construir un ParseResult con valores por defecto sensatos.

      Args:
          root:          Nodo raiz del arbol de documento.
          source_path:   Path o URL del documento origen.
          source_format: Formato del documento.
          parser_name:   Nombre del parser.
          parse_time:    Duracion del parsing en segundos.
          meta:          Metadata adicional.
          warnings:      Lista de warnings.

      Returns:
          ParseResult construido con los parametros dados.
      """
      return ParseResult(
          root=root,
          source_path=source_path,
          source_format=source_format,
          parser_name=parser_name,
          parse_time=parse_time,
          meta=meta or {},
          warnings=warnings or [],
      )


id: part_py_core
name: part
lang: py
domain: core
version: 1.0.0
algebraic: sum
tags: ["message","part","union","sum_type","multipart","llm","discriminator","deserialization"]
uses_types: ["text_part_py_core","context_part_py_core","tool_part_py_core"]
file_path: python/types/core/part.py
created_at: 2026-04-05T11:21:03Z
updated_at: 2026-04-05T11:21:03Z
content_hash: 92bceeb39a522a93f5da90a34d6bce2f6b11bb201c285632989f08e691971a60
definition:
  Part = Union[TextPart, ContextPart, ToolPart]

  def part_from_dict(data: dict) -> Part:
      """Deserializa un Part desde dict, usando el campo 'type' como discriminador."""

description:
  Tipo suma de las partes posibles de un mensaje multipart: texto, referencia a contexto o llamada a herramienta. Incluye part_from_dict() para deserializacion discriminada por el campo 'type'.

notes:
  `Part` es un alias de tipo (`Union`) sobre los tres product types concretos. No es una clase — no se instancia directamente.

  `part_from_dict()` usa el campo `"type"` como discriminador: `"text"` → `TextPart`, `"context"` → `ContextPart`, `"tool"` → `ToolPart`. Lanza `ValueError` si el tipo es desconocido o falta el campo.

  Los campos del dict se pasan directamente al constructor del tipo concreto (excepto `"type"`, que ya es el valor por defecto). Esto permite round-trip: `part_from_dict(dataclasses.asdict(part))` devuelve una copia equivalente.

code:
  """Part — tipo suma de las partes posibles de un mensaje multipart."""

  from typing import Union

  from context_part import ContextPart
  from text_part import TextPart
  from tool_part import ToolPart

  Part = Union[TextPart, ContextPart, ToolPart]

  _PART_TYPES: dict[str, type] = {
      "text": TextPart,
      "context": ContextPart,
      "tool": ToolPart,
  }


  def part_from_dict(data: dict) -> Part:
      """Deserializa un Part desde un dict usando el campo 'type' como discriminador.

      Args:
          data: Dict con al menos el campo 'type' y los campos del Part correspondiente.

      Returns:
          La instancia del Part concreto (TextPart, ContextPart o ToolPart).

      Raises:
          ValueError: Si el campo 'type' no esta presente o es desconocido.
      """
      kind = data.get("type")
      if kind is None:
          raise ValueError("El dict no contiene el campo 'type' requerido para deserializar Part")
      cls = _PART_TYPES.get(kind)
      if cls is None:
          raise ValueError(f"Tipo de Part desconocido: '{kind}'. Valores validos: {list(_PART_TYPES)}")
      fields = {k: v for k, v in data.items() if k != "type"}
      return cls(**fields)


id: query_plan_py_core
name: query_plan
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["retrieval","plan","rag","intent","llm","search"]
uses_types: ["typed_query_py_core"]
file_path: python/types/core/query_plan.py
created_at: 2026-04-05T11:21:03Z
updated_at: 2026-04-05T11:21:03Z
content_hash: 733a05db518d8694e2c774d738467fa27e083429941bf0310cd51466f26fc61b
definition:
  @dataclass
  class QueryPlan:
      queries: list[TypedQuery]
      session_context: str
      reasoning: str

description:
  Plan de busqueda generado por analisis de intenciones (LLM). Contiene multiples queries tipadas y el razonamiento detras de la descomposicion.

notes:
  `session_context` resume el estado de la conversacion o sesion que el LLM analizo para generar el plan. Permite al retriever entender el contexto sin acceder al historial completo.

  `reasoning` es la cadena de pensamiento del LLM — util para debugging y observabilidad. Documenta por que se decidio descomponer la busqueda en esas queries especificas con esos tipos de contexto.

  Las queries en la lista se ordenan convencionalmente por `priority` (1=alta) para que el retriever pueda ejecutar primero las mas importantes y aplicar early stopping si ya tiene suficiente contexto.

code:
  """QueryPlan — plan de busqueda generado por analisis de intenciones (LLM)."""

  from __future__ import annotations

  from dataclasses import dataclass

  from typed_query import TypedQuery


  @dataclass
  class QueryPlan:
      """Plan de busqueda generado por analisis de intenciones.

      Contiene multiples queries tipadas y el razonamiento detras de la
      descomposicion. Generado tipicamente por un LLM que analiza el contexto
      de sesion y las intenciones del usuario.

      Attributes:
          queries:         Lista de TypedQueries a ejecutar, ordenadas por prioridad.
          session_context: Resumen del contexto de sesion que guio la descomposicion.
          reasoning:       Razonamiento del LLM sobre como descomponer la busqueda.
      """

      queries: list[TypedQuery]
      session_context: str
      reasoning: str


id: query_result_py_core
name: query_result
lang: py
domain: core
version: 1.0.0
algebraic: product
description: Resultado de una sola TypedQuery con los contextos encontrados y los directorios que se buscaron.
tags: ["retrieval","result","rag","query","search"]
uses_types: ["typed_query_py_core","matched_context_py_core"]
file_path: python/types/core/query_result.py
created_at: 2026-04-05T11:22:16Z
updated_at: 2026-04-05T11:22:16Z
content_hash: b909209661d97f5402f67e58d3a28d11199a0cca2005167d5180621730355bac
definition:
  @dataclass
  class QueryResult:
      query: TypedQuery
      matched_contexts: list[MatchedContext]
      searched_directories: list[str]

notes:
  Representa la respuesta a una unica TypedQuery dentro de un QueryPlan. El campo `searched_directories` permite auditar que directorios fueron efectivamente explorados, lo que puede diferir de los `target_directories` de la query si algunos no estaban disponibles.

  `matched_contexts` se ordena convencionalmente por score descendente, pero el tipo no lo impone — es responsabilidad del retriever.

  Un `FindResult` agrega multiples `QueryResult` para dar la vision consolidada por tipo de contexto.

code:
  """QueryResult — resultado de una sola TypedQuery con contextos encontrados."""

  from __future__ import annotations

  from dataclasses import dataclass, field

  from matched_context import MatchedContext
  from typed_query import TypedQuery


  @dataclass
  class QueryResult:
      """Resultado de una sola TypedQuery con los contextos encontrados.

      Attributes:
          query:                La TypedQuery que genero estos resultados.
          matched_contexts:     Lista de contextos encontrados, ordenados por score desc.
          searched_directories: URIs de los directorios que se exploraron durante la busqueda.
      """

      query: TypedQuery
      matched_contexts: list[MatchedContext] = field(default_factory=list)
      searched_directories: list[str] = field(default_factory=list)


id: related_context_py_core
name: related_context
lang: py
domain: core
version: 1.0.0
algebraic: product
definition:
  @dataclass
  class RelatedContext:
  uri: str
  abstract: str

tags: ["retrieval","context","relation","rag","reference"]
uses_types: []
file_path: python/types/core/related_context.py
created_at: 2026-04-05T11:20:39Z
updated_at: 2026-04-05T11:20:39Z
content_hash: 6c66c1238466e589642dad7cdebc54dfb261ff3684e31f687e439a1450c4b1b7
description:
  Referencia minima a un contexto relacionado. Contiene el URI del recurso y un abstract de su contenido. Se usa como elemento de la lista `relations` dentro de MatchedContext.

notes:
  Tipo minimalista intencionalmente. No replica el score ni el nivel de detalle del contexto padre — solo provee suficiente informacion para que el consumidor pueda decidir si explorar el recurso relacionado.

code:
  """RelatedContext — referencia a un contexto relacionado por URI y abstract."""

  from dataclasses import dataclass


  @dataclass
  class RelatedContext:
      """Referencia minima a un contexto relacionado.

      Attributes:
          uri:      Identificador unico del recurso relacionado.
          abstract: Resumen breve del contenido del recurso.
      """

      uri: str
      abstract: str


id: resource_content_type_py_core
name: resource_content_type
lang: py
domain: core
version: 1.0.0
algebraic: sum
tags: ["content-type","resource","enum","media","indexing"]
uses_types: []
file_path: python/types/core/resource_content_type.py
created_at: 2026-04-05T11:20:30Z
updated_at: 2026-04-05T11:20:30Z
content_hash: 4ac2b626e88147292b64a94ca82f1998631106651b204e7d0a0282336d3444dd
definition:
  class ResourceContentType(str, Enum):
      TEXT = "text"
      IMAGE = "image"
      VIDEO = "video"
      AUDIO = "audio"
      BINARY = "binary"

description:
  Tipo de contenido de un recurso indexable. Sum type con cinco variantes: text, image, video, audio, binary. Hereda de str para serializar directamente como string JSON.

notes:
  Hereda de `str` ademas de `Enum` para que las variantes serialicen directamente como strings (`"text"`, `"image"`, etc.) sin necesidad de `.value`. Util al almacenar en bases de datos vectoriales o JSON sin conversion extra.

  Usado por `Context` para describir el tipo de contenido del recurso que representa.

code:
  """ResourceContentType — tipo de contenido de un recurso indexable."""

  from enum import Enum


  class ResourceContentType(str, Enum):
      """Tipo de contenido de un recurso.

      Variantes:
          TEXT:   Contenido de texto plano o estructurado (markdown, code, etc.).
          IMAGE:  Imagen (PNG, JPEG, WebP, etc.).
          VIDEO:  Video (MP4, WebM, etc.).
          AUDIO:  Audio (MP3, WAV, etc.).
          BINARY: Contenido binario arbitrario (PDF, ZIP, etc.).
      """

      TEXT = "text"
      IMAGE = "image"
      VIDEO = "video"
      AUDIO = "audio"
      BINARY = "binary"


id: resource_node_py_core
name: resource_node
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["parsing","document","tree","node","hierarchy","markdown","core"]
uses_types: ["node_type_py_core"]
file_path: python/types/core/resource_node.py
created_at: 2026-04-05T11:21:19Z
updated_at: 2026-04-05T11:21:19Z
content_hash: 791dc473fb095ed010fadbde65d9c5a794ffaec2c5abb331b81a75f6293f0baa
definition:
  @dataclass
  class ResourceNode:
      type: NodeType
      title: str | None = None
      level: int = 0
      content: str = ""
      children: list["ResourceNode"] = field(default_factory=list)
      meta: dict[str, Any] = field(default_factory=dict)

description:
  Nodo en un arbol jerarquico de documento. Preserva la estructura natural del documento (secciones, subsecciones) sin perder contenido. Resultado intermedio de parsing — los parsers producen arboles de ResourceNode.

examples:
  ```python
  from node_type import NodeType
  from resource_node import ResourceNode

  root = ResourceNode(type=NodeType.ROOT, title="Mi Documento", level=0)

  section = ResourceNode(
      type=NodeType.SECTION,
      title="Introduccion",
      level=1,
      content="Este documento describe...",
  )
  root.add_child(section)

  subsection = ResourceNode(
      type=NodeType.SECTION,
      title="Contexto",
      level=2,
      content="El contexto es el siguiente...",
  )
  section.add_child(subsection)

  print(root.get_abstract())      # "Mi Documento"
  print(section.get_text())       # "Este documento describe...\n\nEl contexto es el siguiente..."
  print(section.get_text(include_children=False))  # "Este documento describe..."

  data = root.to_dict()
  restored = ResourceNode.from_dict(data)
  ```

notes:
  El campo `content` almacena el markdown del nodo directamente — no se parsea internamente. Cada nodo representa una seccion del documento delimitada por su heading y el siguiente heading del mismo nivel. Los parsers construyen el arbol llamando `add_child` en orden de aparicion en el documento. El metodo `from_dict` es el inverso exacto de `to_dict` y permite round-trip sin perdida de informacion.

documentation:
  ## Metodos

  - `add_child(child: ResourceNode) -> None` — agrega un nodo hijo a la lista de children.
  - `get_text(include_children: bool = True) -> str` — contenido concatenado del nodo y sus descendientes de forma recursiva.
  - `get_abstract(max_length: int = 256) -> str` — resumen corto: el titulo o los primeros N caracteres del contenido.
  - `get_overview(max_length: int = 4000) -> str` — overview con el contenido del nodo y lista de titulos de las secciones hijas.
  - `to_dict() -> dict` — serializacion recursiva a diccionario (JSON-compatible).
  - `from_dict(data: dict) -> ResourceNode` — deserializacion desde diccionario (classmethod).

code:
  """ResourceNode — nodo en un arbol jerarquico de documento."""

  from __future__ import annotations

  from dataclasses import dataclass, field
  from typing import Any

  from node_type import NodeType


  @dataclass
  class ResourceNode:
      """Nodo en un arbol jerarquico de documento.

      Preserva la estructura natural del documento (secciones, subsecciones)
      sin perder contenido. Cada nodo puede tener hijos (subsecciones) y
      metadata arbitraria.

      Attributes:
          type:     Tipo del nodo (ROOT o SECTION).
          title:    Titulo de la seccion, None para el nodo raiz sin titulo.
          level:    Nivel de profundidad (0=root, 1=top section, 2=subsection, etc.).
          content:  Contenido markdown de este nodo (sin incluir el de hijos).
          children: Lista de nodos hijos (subsecciones).
          meta:     Metadata arbitraria del nodo.
      """

      type: NodeType
      title: str | None = None
      level: int = 0
      content: str = ""
      children: list[ResourceNode] = field(default_factory=list)
      meta: dict[str, Any] = field(default_factory=dict)

      def add_child(self, child: ResourceNode) -> None:
          """Agrega un nodo hijo a la lista de children.

          Args:
              child: Nodo hijo a agregar.
          """
          self.children.append(child)

      def get_text(self, include_children: bool = True) -> str:
          """Retorna el contenido concatenado del nodo y opcionalmente sus hijos.

          Args:
              include_children: Si True, incluye el contenido de todos los
                                descendientes de forma recursiva.

          Returns:
              String con el contenido completo.
          """
          parts = [self.content] if self.content else []
          if include_children:
              for child in self.children:
                  child_text = child.get_text(include_children=True)
                  if child_text:
                      parts.append(child_text)
          return "\n\n".join(parts)

      def get_abstract(self, max_length: int = 256) -> str:
          """Retorna un resumen corto: el titulo o los primeros N caracteres del contenido.

          Args:
              max_length: Longitud maxima del resumen en caracteres.

          Returns:
              String con el titulo o inicio del contenido, truncado si es necesario.
          """
          if self.title:
              return self.title[:max_length]
          text = self.content.strip()
          if len(text) <= max_length:
              return text
          return text[:max_length].rstrip() + "..."

      def get_overview(self, max_length: int = 4000) -> str:
          """Retorna un overview con el contenido del nodo y lista de secciones hijas.

          Args:
              max_length: Longitud maxima del overview en caracteres.

          Returns:
              String con el contenido del nodo seguido de los titulos de sus hijos.
          """
          parts = []
          if self.title:
              parts.append(f"# {self.title}")
          if self.content:
              parts.append(self.content)
          if self.children:
              child_titles = [
                  f"- {child.title}" if child.title else f"- (section level {child.level})"
                  for child in self.children
              ]
              parts.append("## Sections\n" + "\n".join(child_titles))
          overview = "\n\n".join(parts)
          if len(overview) <= max_length:
              return overview
          return overview[:max_length].rstrip() + "..."

      def to_dict(self) -> dict:
          """Serializa el nodo y sus descendientes a un diccionario.

          Returns:
              dict con todos los campos del nodo, con children serializado
              recursivamente.
          """
          return {
              "type": self.type.value,
              "title": self.title,
              "level": self.level,
              "content": self.content,
              "children": [child.to_dict() for child in self.children],
              "meta": self.meta,
          }

      @classmethod
      def from_dict(cls, data: dict) -> ResourceNode:
          """Deserializa un dict (producido por to_dict) a un ResourceNode.

          Args:
              data: Diccionario con los campos del nodo.

          Returns:
              ResourceNode reconstruido con todos sus descendientes.
          """
          return cls(
              type=NodeType(data["type"]),
              title=data.get("title"),
              level=data.get("level", 0),
              content=data.get("content", ""),
              children=[cls.from_dict(c) for c in data.get("children", [])],
              meta=data.get("meta", {}),
          )


id: round_summary_py_core
name: round_summary
lang: py
domain: core
version: 1.0.0
algebraic: product
description: Resumen de una ronda de simulacion multi-agente con tiempos, acciones y conteos.
tags: ["simulation","round","summary","multi-agent","timeline"]
uses_types: ["agent_action_py_core"]
file_path: python/types/core/round_summary.py
created_at: 2026-04-05T11:19:14Z
updated_at: 2026-04-05T11:19:14Z
content_hash: 0592f2496b047ced5043f15441dc5aede4b674aae7194fca388bf2e81ac9f38e
definition:
  @dataclass
  class RoundSummary:
      round_num: int
      start_time: str
      end_time: str | None = None
      simulated_hour: float = 0.0
      actions: list[AgentAction] = field(default_factory=list)
      active_agents: int = 0

examples:
  ```python
  summary = RoundSummary(
      round_num=1,
      start_time="2024-01-01T12:00:00Z",
      end_time="2024-01-01T12:01:00Z",
      simulated_hour=8.0,
      actions=[action1, action2],
      active_agents=5,
  )
  print(summary.to_dict())
  ```

notes:
  Tipo producto — todos los campos obligatorios estan siempre presentes. `end_time` es `None` mientras la ronda este en progreso. `actions` contiene instancias de `AgentAction` y se serializa llamando `to_dict()` en cada elemento. Util para replay de simulaciones ronda por ronda y analisis post-simulacion.

code:
  """Resumen de una ronda de simulacion multi-agente."""

  from __future__ import annotations

  from dataclasses import dataclass, field

  from agent_action import AgentAction


  @dataclass
  class RoundSummary:
      """Resumen de una ronda de simulacion multi-agente.

      Agrupa las acciones de todos los agentes en esa ronda con tiempos y conteos.
      Permite replay de simulaciones ronda por ronda, visualizacion de timeline,
      y analisis post-simulacion.

      Attributes:
          round_num: Numero de ronda de la simulacion.
          start_time: Timestamp ISO 8601 de inicio de la ronda.
          end_time: Timestamp ISO 8601 de fin de la ronda (None si aun corre).
          simulated_hour: Hora simulada dentro de la ronda (0.0 por defecto).
          actions: Lista de acciones realizadas por los agentes en esta ronda.
          active_agents: Numero de agentes activos durante la ronda.
      """

      round_num: int
      start_time: str
      end_time: str | None = None
      simulated_hour: float = 0.0
      actions: list[AgentAction] = field(default_factory=list)
      active_agents: int = 0

      def to_dict(self) -> dict:
          """Convierte el resumen de ronda a un diccionario serializable."""
          return {
              "round_num": self.round_num,
              "start_time": self.start_time,
              "end_time": self.end_time,
              "simulated_hour": self.simulated_hour,
              "actions": [a.to_dict() for a in self.actions],
              "active_agents": self.active_agents,
          }


id: runner_status_py_core
name: runner_status
lang: py
domain: core
version: 1.0.0
algebraic: sum
description: Maquina de estados para un runner de simulacion o pipeline de larga duracion.
tags: ["simulation","runner","status","state-machine","enum"]
uses_types: []
file_path: python/types/core/runner_status.py
created_at: 2026-04-05T11:18:45Z
updated_at: 2026-04-05T11:18:45Z
content_hash: 2a7bd0e9ea9440ffdf57d5329adfb7b864cc78e3cd55ef10510c4e16d5e59ffc
definition:
  class RunnerStatus(str, Enum):
      IDLE = "idle"
      STARTING = "starting"
      RUNNING = "running"
      PAUSED = "paused"
      STOPPING = "stopping"
      STOPPED = "stopped"
      COMPLETED = "completed"
      FAILED = "failed"

notes:
  Tipo suma — exactamente uno de los estados es activo en cada momento. Hereda de `str` para que sea serializable a JSON directamente.

documentation:
  ## Transiciones validas

  ```
  IDLE -> STARTING -> RUNNING
  RUNNING -> PAUSED -> RUNNING   (resume)
  RUNNING -> STOPPING -> STOPPED
  RUNNING -> COMPLETED
  RUNNING -> FAILED
  STARTING -> FAILED
  ```

code:
  """Estado de un runner de simulacion o pipeline de larga duracion."""

  from enum import Enum


  class RunnerStatus(str, Enum):
      """Maquina de estados para un runner de simulacion o pipeline de larga duracion.

      Transiciones validas:
          IDLE -> STARTING -> RUNNING
          RUNNING -> PAUSED -> RUNNING  (resume)
          RUNNING -> STOPPING -> STOPPED
          RUNNING -> COMPLETED
          RUNNING -> FAILED
          STARTING -> FAILED
      """

      IDLE = "idle"
      STARTING = "starting"
      RUNNING = "running"
      PAUSED = "paused"
      STOPPING = "stopping"
      STOPPED = "stopped"
      COMPLETED = "completed"
      FAILED = "failed"


id: task_py_core
name: task
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["task","async","background","progress","tracking","polling"]
uses_types: ["task_status_py_core"]
file_path: python/types/core/task.py
created_at: 2026-04-05T11:18:49Z
updated_at: 2026-04-05T11:18:49Z
content_hash: df66c44f51b008ced6984a4a56b0f76f321ac8c3f67da7afd7a105ac0f6c5837
definition:
  @dataclass
  class Task:
      task_id: str
      task_type: str
      status: TaskStatus
      created_at: datetime
      updated_at: datetime
      progress: int = 0
      message: str = ""
      result: dict | None = None
      error: str | None = None
      metadata: dict = field(default_factory=dict)
      progress_detail: dict = field(default_factory=dict)

description:
  Tarea de larga duracion con seguimiento de progreso. Product type que modela el ciclo de vida completo: creacion, progreso incremental, completado exitoso o fallo. Incluye to_dict() para serializacion JSON con timestamps ISO 8601.

notes:
  El campo `progress` es un entero 0-100. El campo `progress_detail` permite informar detalles granulares (etapa actual, items procesados) sin romper la interfaz del tipo.

  El metodo `to_dict()` serializa `status` como string via `.value` y los timestamps como strings ISO 8601, haciendo el resultado directamente serializable con `json.dumps()`.

  El campo `result` almacena el output de la tarea al completar. El campo `error` almacena la descripcion del fallo. Solo uno de los dos deberia estar poblado en un estado terminal.

code:
  """Task — tarea de larga duracion con seguimiento de progreso."""

  from dataclasses import dataclass, field
  from datetime import datetime, timezone

  from task_status import TaskStatus


  @dataclass
  class Task:
      """Tarea de larga duracion con seguimiento de progreso.

      Attributes:
          task_id:         UUID que identifica la tarea de forma unica.
          task_type:       Tipo libre: "graph_build", "simulation", "report_generation", etc.
          status:          Estado actual del ciclo de vida (TaskStatus).
          created_at:      Timestamp de creacion (UTC).
          updated_at:      Timestamp de ultima actualizacion (UTC).
          progress:        Porcentaje de avance 0-100.
          message:         Mensaje de estado legible por el usuario.
          result:          Resultado serializado al completar, None si no ha completado.
          error:           Descripcion del error al fallar, None si no ha fallado.
          metadata:        Datos arbitrarios asociados a la tarea (parametros de entrada, etc.).
          progress_detail: Detalles granulares de progreso (etapa actual, items procesados, etc.).
      """

      task_id: str
      task_type: str
      status: TaskStatus
      created_at: datetime
      updated_at: datetime
      progress: int = 0
      message: str = ""
      result: dict | None = None
      error: str | None = None
      metadata: dict = field(default_factory=dict)
      progress_detail: dict = field(default_factory=dict)

      def to_dict(self) -> dict:
          """Serializa la tarea a dict con timestamps en formato ISO 8601.

          Returns:
              dict con todos los campos de la tarea listos para JSON.
          """
          return {
              "task_id": self.task_id,
              "task_type": self.task_type,
              "status": self.status.value,
              "created_at": self.created_at.isoformat(),
              "updated_at": self.updated_at.isoformat(),
              "progress": self.progress,
              "message": self.message,
              "result": self.result,
              "error": self.error,
              "metadata": self.metadata,
              "progress_detail": self.progress_detail,
          }


id: task_status_py_core
name: task_status
lang: py
domain: core
version: 1.0.0
algebraic: sum
tags: ["task","status","enum","async","background"]
uses_types: []
file_path: python/types/core/task_status.py
created_at: 2026-04-05T11:18:45Z
updated_at: 2026-04-05T11:18:45Z
content_hash: 54df263cd377decbaf2ce32456315548e85ccd359add15a4ce36b857af7c0664
definition:
  class TaskStatus(str, Enum):
      PENDING = "pending"
      PROCESSING = "processing"
      COMPLETED = "completed"
      FAILED = "failed"

description:
  Estado de una tarea de larga duracion (graph building, simulation, report generation). Enum sum type con cuatro variantes: pending, processing, completed, failed.

notes:
  Hereda de `str` para que los valores sean directamente serializables a JSON sin conversion adicional.
  Usado por `Task` para expresar el ciclo de vida de una tarea background.

code:
  """TaskStatus — estado de una tarea de larga duracion."""

  from enum import Enum


  class TaskStatus(str, Enum):
      """Estado de una tarea de larga duracion.

      Valores posibles:
          PENDING    — creada, en cola.
          PROCESSING — en ejecucion activa.
          COMPLETED  — terminada con exito.
          FAILED     — terminada con error.
      """

      PENDING = "pending"
      PROCESSING = "processing"
      COMPLETED = "completed"
      FAILED = "failed"


id: text_part_py_core
name: text_part
lang: py
domain: core
version: 1.0.0
algebraic: product
definition:
  @dataclass
  class TextPart:
  text: str = ""
  type: str = "text"

tags: ["message","part","text","multipart","llm","conversation"]
uses_types: []
file_path: python/types/core/text_part.py
created_at: 2026-04-05T11:20:17Z
updated_at: 2026-04-05T11:20:17Z
content_hash: 991651a24a877b6fa46c110c0de9c99cbcc0fb8edda72cf485a85a41f444bd90
description:
  Parte de texto plano en un mensaje multipart. Contiene el contenido textual y el discriminador literal 'text' para deserializacion por tipo.

notes:
  El campo `type` actua como discriminador literal en el tipo suma `Part`. Siempre vale `"text"`.

  Es el tipo de parte mas basico: un contenedor simple para texto plano dentro de un `Message`.

code:
  """TextPart — parte de texto de un mensaje multipart."""

  from dataclasses import dataclass, field


  @dataclass
  class TextPart:
      """Parte de texto plano en un mensaje multipart.

      Attributes:
          text: Contenido textual de la parte.
          type: Discriminador literal, siempre "text".
      """

      text: str = ""
      type: str = field(default="text", init=True)


id: tool_part_py_core
name: tool_part
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["message","part","tool","tool_call","function_call","multipart","llm","tracing","tokens"]
uses_types: []
file_path: python/types/core/tool_part.py
created_at: 2026-04-05T11:20:41Z
updated_at: 2026-04-05T11:20:41Z
content_hash: 7a30e5aee84e87eb1c92b8d47884b670192e02ec4d8aa95f77175f2d1a2f3907
definition:
  @dataclass
  class ToolPart:
      tool_id: str = ""
      tool_name: str = ""
      tool_input: dict | None = None
      tool_output: str = ""
      tool_status: str = "pending"
      duration_ms: float | None = None
      prompt_tokens: int | None = None
      completion_tokens: int | None = None
      type: str = "tool"

description:
  Registro de una llamada a herramienta (tool call) dentro de un mensaje multipart. Captura input, output, estado del ciclo de vida y metricas de uso de tokens para trazabilidad y presupuesto.

notes:
  `tool_status` modela el ciclo de vida de la llamada: "pending" (encolada), "running" (en ejecucion), "completed" (finalizada con exito), "error" (fallida).

  `tool_input` es un dict arbitrario con los argumentos. `tool_output` es siempre string para mantener compatibilidad con la API de herramientas de LLMs (OpenAI, Anthropic).

  `duration_ms`, `prompt_tokens` y `completion_tokens` son opcionales — se poblan cuando la herramienta instrumenta su ejecucion. Utiles para presupuesto de tokens y profiling.

  El campo `type` actua como discriminador literal en el tipo suma `Part`. Siempre vale `"tool"`.

code:
  """ToolPart — registro de una llamada a herramienta en un mensaje multipart."""

  from dataclasses import dataclass, field


  @dataclass
  class ToolPart:
      """Registro de una llamada a herramienta (tool call) dentro de un mensaje multipart.

      Attributes:
          tool_id:           Identificador unico de la invocacion de la herramienta.
          tool_name:         Nombre de la herramienta invocada.
          tool_input:        Argumentos de entrada pasados a la herramienta.
          tool_output:       Resultado devuelto por la herramienta como string.
          tool_status:       Estado de la llamada: "pending" | "running" | "completed" | "error".
          duration_ms:       Duracion de la llamada en milisegundos.
          prompt_tokens:     Tokens de entrada consumidos por la herramienta (si aplica).
          completion_tokens: Tokens de salida generados por la herramienta (si aplica).
          type:              Discriminador literal, siempre "tool".
      """

      tool_id: str = ""
      tool_name: str = ""
      tool_input: dict | None = None
      tool_output: str = ""
      tool_status: str = "pending"
      duration_ms: float | None = None
      prompt_tokens: int | None = None
      completion_tokens: int | None = None
      type: str = field(default="tool", init=True)


id: typed_query_py_core
name: typed_query
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["retrieval","query","rag","intent","search","plan"]
uses_types: ["context_type_py_core"]
file_path: python/types/core/typed_query.py
created_at: 2026-04-05T11:21:03Z
updated_at: 2026-04-05T11:21:03Z
content_hash: 5ef9fba530c4f0be0725c75c3fe048b07e57f49f4911feb37afaa5ada783ecf9
definition:
  @dataclass
  class TypedQuery:
      query: str
      context_type: ContextType | None
      intent: str
      priority: int = 3
      target_directories: list[str] = field(default_factory=list)

description:
  Query tipada que resulta del analisis de intenciones. Un plan de busqueda se descompone en multiples TypedQueries, cada una apuntando a un tipo de contexto diferente.

notes:
  `priority` sigue la convencion 1=alta, 5=baja (similar a niveles de issue). Valor por defecto 3 es prioridad media.

  `context_type = None` indica que la query aplica a todos los tipos de contexto sin filtro. Util para queries generales que el retriever distribuye entre todas las categorias.

  `target_directories` vacio significa buscar en todos los directorios disponibles. Con URIs especificos, el retriever limita la busqueda al subconjunto indicado — util para reducir ruido en colecciones grandes.

code:
  """TypedQuery — query tipada resultante del analisis de intenciones."""

  from __future__ import annotations

  from dataclasses import dataclass, field

  from context_type import ContextType


  @dataclass
  class TypedQuery:
      """Query tipada que resulta del analisis de intenciones.

      Un plan de busqueda se descompone en multiples TypedQueries, cada una
      apuntando a un tipo de contexto diferente. Permite estrategias de
      retrieval especificas por categoria.

      Attributes:
          query:              Texto de la query de busqueda.
          context_type:       Tipo de contexto objetivo (MEMORY, RESOURCE, SKILL) o None para todos.
          intent:             Descripcion de la intencion detras de esta query.
          priority:           Prioridad 1-5 (1 es mayor prioridad).
          target_directories: URIs de directorios donde buscar. Lista vacia = buscar en todos.
      """

      query: str
      context_type: ContextType | None
      intent: str
      priority: int = 3
      target_directories: list[str] = field(default_factory=list)


id: deduplication_result_py_datascience
name: deduplication_result
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["deduplication","entity","merge","knowledge-graph","nlp","datascience"]
uses_types: ["entity_candidate_py_datascience"]
file_path: python/types/datascience/deduplication_result.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T11:22:32Z
content_hash: 7d4de365b887aa83eba1882ffb31655f9258b0ab610370a03bc0ab0ce07b45e5
definition:
  @dataclass
  class DeduplicationResult:
      entities: list[EntityCandidate]
      entity_id_map: dict[str, str]
      name_to_id: dict[str, str]
      merge_log: list[dict] = field(default_factory=list)
      total_before: int = 0
      total_after: int = 0

description:
  Resultado de deduplicacion de entidades. name_to_id mapea todos los nombres originales (incluyendo aliases mergeados) a su ID final para resolver relaciones.

examples:
  ```python
  from python.types.datascience.deduplication_result import DeduplicationResult
  from python.types.datascience.entity_candidate import EntityCandidate

  result = DeduplicationResult(
      entities=[
          EntityCandidate(
              name="John Smith",
              name_normalized="john smith",
              merged_from=["John Smith", "Smith, John"],
          ),
      ],
      entity_id_map={"john smith": "ent_001"},
      name_to_id={
          "John Smith": "ent_001",
          "Smith, John": "ent_001",
          "john smith": "ent_001",
      },
      merge_log=[
          {"merged": ["John Smith", "Smith, John"], "into": "John Smith", "score": 0.92}
      ],
      total_before=2,
      total_after=1,
  )
  ```

notes:
  El `name_to_id` es la clave para la resolucion de relaciones. Cuando una relacion dice `from_name="Smith, John"`, se puede buscar en `name_to_id` y encontrar el ID `ent_001` aunque el nombre canonico sea `"John Smith"`. `entity_id_map` es un subset que usa solo nombres normalizados, util para lookup durante la misma fase de deduplicacion.

documentation:
  ## Campos

  - `entities`: lista final de `EntityCandidate` despues de deduplicacion
  - `entity_id_map`: mapa `nombre_normalizado → entity_id` para lookup rapido
  - `name_to_id`: mapa de TODOS los nombres originales (incluyendo aliases) a su ID final
  - `merge_log`: lista de eventos de merge, cada uno con `{"merged": [...], "into": "...", "score": 0.92}`
  - `total_before`: numero de entidades antes de deduplicacion
  - `total_after`: numero de entidades despues de deduplicacion

code:
  """DeduplicationResult — resultado del proceso de deduplicacion de entidades."""

  from dataclasses import dataclass, field

  from python.types.datascience.entity_candidate import EntityCandidate


  @dataclass
  class DeduplicationResult:
      """Resultado de deduplicacion de entidades.

      El `name_to_id` mapea TODOS los nombres originales (incluyendo los
      mergeados) a su ID final, permitiendo resolver relaciones que usan
      cualquier variante del nombre.
      """

      entities: list[EntityCandidate]
      entity_id_map: dict[str, str]
      name_to_id: dict[str, str]
      merge_log: list[dict] = field(default_factory=list)
      total_before: int = 0
      total_after: int = 0


id: entity_candidate_py_datascience
name: entity_candidate
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["extraction","entity","llm","knowledge-graph","nlp","datascience"]
uses_types: []
file_path: python/types/datascience/entity_candidate.py
created_at: 2026-04-05T11:22:16Z
updated_at: 2026-04-05T11:22:16Z
content_hash: 3b4a628f28e455edb147fef5e427e2a45cda45ddf1c7fdf9d342525bcd457c0e
definition:
  @dataclass
  class EntityCandidate:
      name: str
      name_normalized: str = ""
      type_ref: str = ""
      type_label: str = ""
      attributes: dict = field(default_factory=dict)
      confidence: float = 0.0
      source_chunk_indices: list[int] = field(default_factory=list)
      merged_from: list[str] = field(default_factory=list)

description:
  Candidato de entidad extraido por el LLM. Puede venir de un solo chunk o ser el resultado de mergear multiples extracciones. merged_from rastrea los nombres originales para debugging.

examples:
  ```python
  from python.types.datascience.entity_candidate import EntityCandidate

  candidate = EntityCandidate(
      name="John Smith",
      name_normalized="john smith",
      type_ref="osint_person_go_cybersecurity",
      type_label="Person",
      attributes={"role": "CEO", "company": "Acme Corp"},
      confidence=0.92,
      source_chunk_indices=[0, 3],
      merged_from=["John Smith", "Smith, John"],
  )

  print(candidate.to_dict())
  ```

notes:
  Tipo inmutable en su intencion pero declarado como dataclass mutable para permitir que las fases de deduplicacion rellenen `name_normalized` y que el pipeline complete los campos opcionales progresivamente. `merged_from` es la fuente de verdad para auditar que entidades se colapsaron juntas.

documentation:
  ## Campos

  - `name`: nombre crudo extraido del texto
  - `name_normalized`: nombre normalizado para deduplicacion (se llena en la fase de dedup)
  - `type_ref`: registry type ID del tipo de entidad (ej: `osint_person_go_cybersecurity`)
  - `type_label`: label humano del tipo (ej: `"Person"`)
  - `attributes`: metadata extraida del texto como diccionario libre
  - `confidence`: score de confianza 0.0-1.0
  - `source_chunk_indices`: indices de los chunks donde aparecio la entidad
  - `merged_from`: nombres originales si el candidato fue creado por merge de varios

code:
  """EntityCandidate — candidato de entidad extraido por el LLM."""

  from dataclasses import dataclass, field


  @dataclass
  class EntityCandidate:
      """Candidato de entidad extraido por el LLM.

      Puede venir de un solo chunk o ser el resultado de mergear multiples
      extracciones. `merged_from` rastrea los nombres originales para debugging.
      """

      name: str
      name_normalized: str = ""
      type_ref: str = ""
      type_label: str = ""
      attributes: dict = field(default_factory=dict)
      confidence: float = 0.0
      source_chunk_indices: list[int] = field(default_factory=list)
      merged_from: list[str] = field(default_factory=list)

      def to_dict(self) -> dict:
          """Serializa el candidato a un diccionario."""
          return {
              "name": self.name,
              "name_normalized": self.name_normalized,
              "type_ref": self.type_ref,
              "type_label": self.type_label,
              "attributes": self.attributes,
              "confidence": self.confidence,
              "source_chunk_indices": self.source_chunk_indices,
              "merged_from": self.merged_from,
          }


id: extraction_result_py_datascience
name: extraction_result
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["extraction","result","pipeline","knowledge-graph","datascience"]
file_path: python/types/datascience/extraction_result.py
created_at: 2026-04-05T11:22:24Z
updated_at: 2026-04-05T11:22:24Z
content_hash: 648dba0953c99ddea4782c5b004b6ba087fb6972dd6e1a200b7576a6d897b7b6
definition:
  @dataclass
  class ExtractionResult:
      entities: list[EntityCandidate]
      relations: list[RelationCandidate]
      stats: ExtractionStats = field(default_factory=ExtractionStats)

description:
  Resultado final del pipeline de extraccion. Contiene las listas deduplicadas de entidades y relaciones junto con las estadisticas del proceso completo.

uses_types:
  ["entity_candidate_py_datascience","relation_candidate_py_datascience","extraction_stats_py_datascience"]

examples:
  ```python
  from python.types.datascience.entity_candidate import EntityCandidate
  from python.types.datascience.extraction_result import ExtractionResult
  from python.types.datascience.extraction_stats import ExtractionStats
  from python.types.datascience.relation_candidate import RelationCandidate

  result = ExtractionResult(
      entities=[
          EntityCandidate(name="John Smith", type_label="Person", confidence=0.9),
      ],
      relations=[
          RelationCandidate(
              from_name="John Smith",
              to_name="Acme Corp",
              from_id="ent_001",
              to_id="ent_002",
              relation_type="works_at",
          ),
      ],
      stats=ExtractionStats(total_chunks=10, final_entities_count=1, final_relations_count=1),
  )
  ```

notes:
  Tipo de salida del pipeline completo de extraccion. Agrupa todo lo necesario para persistir el resultado en una base de datos de grafos o para pasarlo a la siguiente fase del pipeline (enriquecimiento, visualizacion, etc.).

documentation:
  ## Campos

  - `entities`: lista final de `EntityCandidate` deduplicados
  - `relations`: lista final de `RelationCandidate` deduplicadas con IDs resueltos
  - `stats`: `ExtractionStats` con metricas del proceso completo

code:
  """ExtractionResult — resultado final del pipeline de extraccion."""

  from dataclasses import dataclass, field

  from python.types.datascience.entity_candidate import EntityCandidate
  from python.types.datascience.extraction_stats import ExtractionStats
  from python.types.datascience.relation_candidate import RelationCandidate


  @dataclass
  class ExtractionResult:
      """Resultado final del pipeline de extraccion de entidades y relaciones.

      Contiene las listas deduplicadas de entidades y relaciones junto con
      las estadisticas del proceso completo.
      """

      entities: list[EntityCandidate]
      relations: list[RelationCandidate]
      stats: ExtractionStats = field(default_factory=ExtractionStats)


id: extraction_stats_py_datascience
name: extraction_stats
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["extraction","stats","reporting","debugging","datascience"]
uses_types: []
file_path: python/types/datascience/extraction_stats.py
created_at: 2026-04-05T11:22:16Z
updated_at: 2026-04-05T11:22:16Z
content_hash: 653c9fd93a92be02e97bd1cbfd55b12487e03725753528c876faa6bd071df0a6
definition:
  @dataclass
  class ExtractionStats:
      total_chunks: int = 0
      total_chars: int = 0
      raw_entities_count: int = 0
      final_entities_count: int = 0
      entities_merged: int = 0
      raw_relations_count: int = 0
      final_relations_count: int = 0
      relations_merged: int = 0
      relations_discarded: int = 0
      entity_types_found: dict[str, int] = field(default_factory=dict)
      relation_types_found: dict[str, int] = field(default_factory=dict)
      processing_time_seconds: float = 0.0

description:
  Estadisticas del proceso de extraccion de entidades y relaciones. Registra conteos antes y despues de deduplicacion, tiempo de procesamiento y distribucion de tipos.

examples:
  ```python
  from python.types.datascience.extraction_stats import ExtractionStats

  stats = ExtractionStats(
      total_chunks=42,
      total_chars=85000,
      raw_entities_count=120,
      final_entities_count=34,
      entities_merged=86,
      raw_relations_count=200,
      final_relations_count=67,
      relations_discarded=5,
      entity_types_found={"Person": 18, "Organization": 16},
      processing_time_seconds=12.4,
  )
  ```

notes:
  Acumulable progresivamente durante el pipeline. Util para detectar documentos con baja densidad de informacion (pocos entities por chunk) o pipelines lentos. El ratio `entities_merged / raw_entities_count` indica que tan redundante fue el texto fuente.

documentation:
  ## Campos

  - `total_chunks`: numero total de chunks procesados
  - `total_chars`: caracteres totales procesados
  - `raw_entities_count`: entidades extraidas antes de deduplicacion
  - `final_entities_count`: entidades restantes despues de deduplicacion
  - `entities_merged`: cuantas entidades se colapsaron en otras
  - `raw_relations_count`: relaciones extraidas antes de deduplicacion
  - `final_relations_count`: relaciones restantes despues de deduplicacion
  - `relations_merged`: relaciones duplicadas que se colapsaron
  - `relations_discarded`: self-loops o relaciones sin match de entidad descartadas
  - `entity_types_found`: mapa `type_ref → count` de entidades por tipo
  - `relation_types_found`: mapa `relation_type → count` de relaciones por tipo
  - `processing_time_seconds`: tiempo total del pipeline en segundos

code:
  """ExtractionStats — estadisticas del proceso de extraccion."""

  from dataclasses import dataclass, field


  @dataclass
  class ExtractionStats:
      """Estadisticas del proceso de extraccion.

      Util para reporting y debugging. Registra conteos antes y despues de
      deduplicacion, tiempo de procesamiento y distribucion de tipos encontrados.
      """

      total_chunks: int = 0
      total_chars: int = 0
      raw_entities_count: int = 0
      final_entities_count: int = 0
      entities_merged: int = 0
      raw_relations_count: int = 0
      final_relations_count: int = 0
      relations_merged: int = 0
      relations_discarded: int = 0
      entity_types_found: dict[str, int] = field(default_factory=dict)
      relation_types_found: dict[str, int] = field(default_factory=dict)
      processing_time_seconds: float = 0.0


id: relation_candidate_py_datascience
name: relation_candidate
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["extraction","relation","llm","knowledge-graph","nlp","datascience"]
uses_types: []
file_path: python/types/datascience/relation_candidate.py
created_at: 2026-04-05T11:22:16Z
updated_at: 2026-04-05T11:22:16Z
content_hash: 16ae73b1752306e04dae0fea236f4897098fcd347e30be51bdf795430996008f
definition:
  @dataclass
  class RelationCandidate:
      from_name: str
      to_name: str
      from_id: str = ""
      to_id: str = ""
      relation_type: str = ""
      description: str = ""
      confidence: float = 0.0
      source_chunk_index: int = -1

description:
  Candidato de relacion entre dos entidades extraido por el LLM. from_id y to_id se resuelven durante la deduplicacion.

examples:
  ```python
  from python.types.datascience.relation_candidate import RelationCandidate

  rel = RelationCandidate(
      from_name="John Smith",
      to_name="Acme Corp",
      relation_type="works_at",
      description="John Smith es CEO de Acme Corp",
      confidence=0.88,
      source_chunk_index=2,
  )

  print(rel.to_dict())
  ```

notes:
  Los campos `from_id` y `to_id` empiezan vacios y se resuelven contra el `name_to_id` del `DeduplicationResult`. Relaciones donde no se puede resolver alguno de los IDs se descartan y se cuentan en `ExtractionStats.relations_discarded`. Los self-loops (`from_id == to_id`) tambien se descartan.

documentation:
  ## Campos

  - `from_name`: nombre crudo de la entidad origen tal como aparece en el texto
  - `to_name`: nombre crudo de la entidad destino
  - `from_id`: entity ID resuelto (se llena en la fase de deduplicacion)
  - `to_id`: entity ID resuelto del destino
  - `relation_type`: tipo de relacion segun los presets del dominio
  - `description`: descripcion textual libre de la relacion
  - `confidence`: score de confianza 0.0-1.0
  - `source_chunk_index`: indice del chunk del que se extrajo (-1 si es desconocido)

code:
  """RelationCandidate — candidato de relacion extraido por el LLM."""

  from dataclasses import dataclass


  @dataclass
  class RelationCandidate:
      """Candidato de relacion entre dos entidades extraido por el LLM.

      `from_name` y `to_name` contienen los nombres crudos del texto. `from_id`
      y `to_id` se llenan durante la fase de deduplicacion cuando se resuelven
      contra los EntityCandidate finales.
      """

      from_name: str
      to_name: str
      from_id: str = ""
      to_id: str = ""
      relation_type: str = ""
      description: str = ""
      confidence: float = 0.0
      source_chunk_index: int = -1

      def to_dict(self) -> dict:
          """Serializa el candidato a un diccionario."""
          return {
              "from_name": self.from_name,
              "to_name": self.to_name,
              "from_id": self.from_id,
              "to_id": self.to_id,
              "relation_type": self.relation_type,
              "description": self.description,
              "confidence": self.confidence,
              "source_chunk_index": self.source_chunk_index,
          }


id: score_distribution_py_datascience
name: score_distribution
lang: py
domain: datascience
version: 1.0.0
algebraic: product
tags: ["retrieval","score","distribution","statistics","debugging","rag"]
uses_types: []
file_path: python/types/datascience/score_distribution.py
created_at: 2026-04-05T11:21:35Z
updated_at: 2026-04-05T11:21:35Z
content_hash: f3ef867ae93759d697915548bad451d12fb969bde50e461cea0eb959f6efebf1
definition:
  @dataclass
  class ScoreDistribution:
      scores: list[tuple[str, float]]
      min_score: float = 0.0
      max_score: float = 0.0
      mean_score: float = 0.0
      threshold: float = 0.0

description:
  Estadisticas de distribucion de scores para visualizacion y debugging de retrieval. Incluye classmethod from_scores() que calcula stats automaticamente y to_dict() con count y above_threshold.

notes:
  `from_scores(uri_scores, threshold)` es el constructor principal. Ordena los scores de mayor a menor y calcula min, max y mean automaticamente. El parametro `threshold` solo se almacena en el campo del mismo nombre — el filtrado real lo aplica el retriever.

  `to_dict()` incluye `count` (total de items) y `above_threshold` (items con score >= threshold). Estos campos adicionales facilitan el logging y la observabilidad sin necesidad de recalcular en el consumidor.

  Los scores estan en rango 0.0-1.0 tipicamente, aunque el tipo no lo valida — la responsabilidad de normalizacion es del retriever.

code:
  """ScoreDistribution — estadisticas de distribucion de scores para retrieval."""

  from __future__ import annotations

  from dataclasses import dataclass, field


  @dataclass
  class ScoreDistribution:
      """Estadisticas de distribucion de scores para visualizacion y debugging de retrieval.

      Attributes:
          scores:     Lista de tuplas (uri, score) ordenadas de mayor a menor score.
          min_score:  Score minimo en la distribucion.
          max_score:  Score maximo en la distribucion.
          mean_score: Score medio de la distribucion.
          threshold:  Umbral aplicado para filtrar resultados.
      """

      scores: list[tuple[str, float]] = field(default_factory=list)
      min_score: float = 0.0
      max_score: float = 0.0
      mean_score: float = 0.0
      threshold: float = 0.0

      @classmethod
      def from_scores(
          cls,
          uri_scores: list[tuple[str, float]],
          threshold: float = 0.0,
      ) -> ScoreDistribution:
          """Construye ScoreDistribution calculando estadisticas desde una lista de scores.

          Args:
              uri_scores: Lista de tuplas (uri, score), no necesariamente ordenada.
              threshold:  Umbral de inclusion. Solo afecta el campo `threshold` del resultado.

          Returns:
              ScoreDistribution con scores ordenados desc y estadisticas calculadas.
          """
          if not uri_scores:
              return cls(scores=[], threshold=threshold)

          sorted_scores = sorted(uri_scores, key=lambda x: x[1], reverse=True)
          raw_scores = [s for _, s in sorted_scores]

          return cls(
              scores=sorted_scores,
              min_score=min(raw_scores),
              max_score=max(raw_scores),
              mean_score=sum(raw_scores) / len(raw_scores),
              threshold=threshold,
          )

      def to_dict(self) -> dict:
          """Serializa la distribucion a dict incluyendo count y above_threshold.

          Returns:
              dict con scores, stats, count total y count de scores sobre el umbral.
          """
          above = [s for _, s in self.scores if s >= self.threshold]
          return {
              "scores": [{"uri": uri, "score": score} for uri, score in self.scores],
              "min_score": self.min_score,
              "max_score": self.max_score,
              "mean_score": self.mean_score,
              "threshold": self.threshold,
              "count": len(self.scores),
              "above_threshold": len(above),
          }


id: BQClient_py_infra
name: BQClient
lang: py
domain: infra
version: 1.0.0
algebraic: product
definition:
  @dataclass
  class BQClient:
  project_id: str
  _client: bigquery.Client

tags: ["bigquery","gcp","client","google-cloud"]
uses_types: []
file_path: python/types/infra/bq_client.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
content_hash: 73b45291163bf58bfe764258322ce6d2cb8e30928e4be59347fb7c8dc013a34e
description:
  Cliente para Google BigQuery. Contiene el project_id y el cliente oficial del SDK. Se obtiene con bq_auth().

examples:
  ```python
  from bigquery import bq_auth, BQClient

  client: BQClient = bq_auth("my-project")
  print(client.project_id)  # "my-project"
  client.close()
  ```

notes:
  Wrapper sobre `google.cloud.bigquery.Client`. El campo `_client` es interno y no serializable.
  Se usa como context manager: `with bq_auth() as client: ...`

code:
  """Tipo BQClient para Google BigQuery."""

  from dataclasses import dataclass, field

  # Nota: la implementacion real esta en python/functions/bigquery/client.py
  # Este archivo existe solo como referencia del tipo para el registry.

  @dataclass
  class BQClient:
      """Cliente para Google BigQuery.

      Attributes:
          project_id: ID del proyecto GCP.
          _client: Cliente oficial de BigQuery (interno, no serializable).
      """
      project_id: str
      _client: object = field(repr=False, default=None)


id: classified_file_py_infra
name: classified_file
lang: py
domain: infra
version: 1.0.0
algebraic: product
definition:
  @dataclass
  class ClassifiedFile:
  path: str
  rel_path: str
  classification: str

tags: ["file","directory","scan","classification","infra"]
uses_types: []
file_path: python/types/infra/classified_file.py
created_at: 2026-04-05T11:19:41Z
updated_at: 2026-04-05T11:19:41Z
content_hash: 8d988b0df4dee410165203fe2f5783825d504adbd41a9c1005d52d7fdf0e55da
description:
  Archivo clasificado durante un escaneo de directorio. Contiene el path absoluto, el path relativo a la raiz del escaneo (con forward slashes), y la clasificacion: 'processable' o 'unsupported'.

examples:
  ```python
  from classified_file import ClassifiedFile

  f = ClassifiedFile(
      path="/home/user/docs/report.pdf",
      rel_path="docs/report.pdf",
      classification="processable",
  )
  ```

notes:
  Tipo producto inmutable usado como elemento de `DirectoryScanResult.processable` y `DirectoryScanResult.unsupported`. El campo `rel_path` siempre usa forward slashes independientemente del OS para garantizar consistencia cross-platform.

documentation:
  ## Campos

  - `path` — path absoluto del archivo en el sistema de archivos.
  - `rel_path` — path relativo a la raiz del escaneo, siempre con forward slashes (`/`).
  - `classification` — `"processable"` si la extension esta entre las soportadas, `"unsupported"` en caso contrario.

code:
  """ClassifiedFile — archivo clasificado durante un escaneo de directorio."""

  from dataclasses import dataclass


  @dataclass
  class ClassifiedFile:
      """Archivo clasificado durante un escaneo de directorio.

      Contiene el path absoluto, el path relativo a la raiz del escaneo,
      y la clasificacion del archivo segun su extension.
      """

      path: str
      rel_path: str
      classification: str  # "processable" | "unsupported"


id: directory_scan_result_py_infra
name: directory_scan_result
lang: py
domain: infra
version: 1.0.0
algebraic: product
tags: ["directory","scan","filesystem","result","infra"]
uses_types: ["classified_file_py_infra"]
file_path: python/types/infra/directory_scan_result.py
created_at: 2026-04-05T11:20:13Z
updated_at: 2026-04-05T11:20:18Z
content_hash: e66fec59793d2c103a8384d05c8e4b1a6ad22c27f07d583830b09e9b1427dea6
definition:
  @dataclass
  class DirectoryScanResult:
      root: str
      processable: list[ClassifiedFile] = field(default_factory=list)
      unsupported: list[ClassifiedFile] = field(default_factory=list)
      skipped: list[str] = field(default_factory=list)
      warnings: list[str] = field(default_factory=list)

description:
  Resultado de un escaneo de arbol de directorios. Agrupa archivos procesables, no soportados, paths saltados (con razon) y warnings generados durante el escaneo.

examples:
  ```python
  from scan_directory import scan_directory

  result = scan_directory("/data/docs", supported_extensions={".pdf", ".md"})
  print(f"Procesables: {len(result.processable)}")
  print(f"No soportados: {len(result.unsupported)}")
  for s in result.skipped:
      print(f"  Saltado: {s}")
  ```

notes:
  Producido exclusivamente por `scan_directory`. Los campos `processable` y `unsupported` estan ordenados por `rel_path`. El campo `skipped` incluye tanto directorios podados como archivos individuales ignorados (dot files, symlinks, archivos vacios).

documentation:
  ## Campos

  - `root` — path absoluto del directorio raiz escaneado.
  - `processable` — lista de `ClassifiedFile` con clasificacion `"processable"`.
  - `unsupported` — lista de `ClassifiedFile` con clasificacion `"unsupported"`.
  - `skipped` — lista de strings con formato `"path (reason)"` para archivos/dirs omitidos.
  - `warnings` — mensajes de advertencia no fatales generados durante el escaneo.

code:
  """DirectoryScanResult — resultado de un escaneo de arbol de directorios."""

  from dataclasses import dataclass, field

  from classified_file import ClassifiedFile


  @dataclass
  class DirectoryScanResult:
      """Resultado de un escaneo de arbol de directorios.

      Agrupa los archivos procesables, no soportados, los paths saltados
      y los warnings generados durante el escaneo.
      """

      root: str
      processable: list[ClassifiedFile] = field(default_factory=list)
      unsupported: list[ClassifiedFile] = field(default_factory=list)
      skipped: list[str] = field(default_factory=list)   # "path (reason)"
      warnings: list[str] = field(default_factory=list)

      def all_processable_files(self) -> list[ClassifiedFile]:
          """Alias de processable para una API mas clara."""
          return self.processable


id: pdf_doc_py_infra
name: pdf_doc
lang: py
domain: infra
version: 1.0.0
algebraic: product
tags: ["pdf","document","builder","fpdf2","infra"]
uses_types: []
file_path: python/types/infra/pdf_doc.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 6bc2a49ad60d4315563e1552be255369eca3d04b3788d385b4af7b4eaf3b0030
definition:
  @dataclass
  class PDFDoc:
      fpdf: Any = None
      pages: list = field(default_factory=list)
      title: str = ""
      author: str = ""
      subject: str = ""
      default_font: str = "Helvetica"
      margin_left: float = 20.0
      margin_right: float = 20.0
      margin_top: float = 20.0
      margin_bottom: float = 20.0
      header_text: str = ""
      footer_text: str = ""
      page_numbers: bool = False

description:
  Wrapper de documento PDF generado con fpdf2. Contiene el objeto FPDF interno con el estado mutable del documento y la configuracion de metadatos, margenes, header y footer.

examples:
  ```python
  from pdf_doc import PDFDoc

  # El doc se crea via pdf_create, no directamente
  # doc = pdf_create(title="Mi Reporte", author="Agente")
  # doc.title  =>  "Mi Reporte"
  # doc.fpdf   =>  <fpdf2.FPDF object>
  ```

notes:
  Este tipo es el carrier del estado del builder. Todas las funciones `pdf_add_*` reciben un `PDFDoc` y retornan uno modificado (API fluent). El campo `fpdf` contiene el estado mutable — no es thread-safe. Creado exclusivamente via `pdf_create_py_infra`.

documentation:
  ## Campos

  - `fpdf` — objeto `fpdf2.FPDF` con el estado interno del documento. Se inicializa en `pdf_create`. No serializable directamente — usar `pdf_save` para obtener bytes o archivo.
  - `pages` — lista de dicts con metadata de cada pagina añadida: `{orientation, width, height}`.
  - `title` — titulo del documento (metadata PDF, visible en propiedades del PDF reader).
  - `author` — autor del documento (metadata PDF).
  - `subject` — asunto del documento (metadata PDF).
  - `default_font` — fuente por defecto para texto: `Helvetica`, `Times`, `Courier`. Por defecto `Helvetica`.
  - `margin_left`, `margin_right`, `margin_top`, `margin_bottom` — margenes del documento en milimetros. Por defecto `20.0` mm cada uno.
  - `header_text` — texto a mostrar en el header de cada pagina. Vacio = sin header.
  - `footer_text` — texto del footer. Acepta `{n}` y `{total}` como placeholders. Vacio = sin footer.
  - `page_numbers` — si `True`, incluye numero de pagina en el footer.

code:
  """PDFDoc — wrapper de documento PDF sobre fpdf2."""

  from dataclasses import dataclass, field
  from typing import Any


  @dataclass
  class PDFDoc:
      """Wrapper de documento PDF generado con fpdf2.

      Contiene el objeto FPDF interno con el estado del documento
      y la configuracion de metadatos y margenes del documento.
      La lista `pages` registra las paginas añadidas como metadata.
      """

      fpdf: Any = None            # objeto fpdf2.FPDF — estado mutable del documento
      pages: list = field(default_factory=list)  # lista de dicts con metadata de cada pagina
      title: str = ""
      author: str = ""
      subject: str = ""
      default_font: str = "Helvetica"
      margin_left: float = 20.0    # mm
      margin_right: float = 20.0   # mm
      margin_top: float = 20.0     # mm
      margin_bottom: float = 20.0  # mm
      header_text: str = ""        # texto del header recurrente (vacio = sin header)
      footer_text: str = ""        # texto del footer recurrente (vacio = sin footer)
      page_numbers: bool = False   # mostrar numeracion de pagina en footer


id: pdf_page_py_infra
name: pdf_page
lang: py
domain: infra
version: 1.0.0
algebraic: product
tags: ["pdf","page","layout","infra"]
uses_types: []
file_path: python/types/infra/pdf_page.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 74205184764f54038b8a4c1033f0c58f1a9dd69be695de66d84e88662eee6cc2
definition:
  @dataclass
  class PDFPage:
      width: float = 210.0
      height: float = 297.0
      orientation: str = "portrait"

description:
  Configuracion de una pagina dentro de un documento PDF. Define dimensiones y orientacion en milimetros. Por defecto A4 vertical (210 x 297 mm).

examples:
  ```python
  from pdf_page import PDFPage

  # A4 vertical (defecto)
  pagina_a4 = PDFPage()

  # A4 horizontal
  pagina_horizontal = PDFPage(width=297.0, height=210.0, orientation="landscape")

  # Carta americana
  carta = PDFPage(width=215.9, height=279.4)
  ```

notes:
  Tipo producto de solo configuracion. No tiene comportamiento — es metadata de layout. Las dimensiones se pasan a fpdf2 al llamar `add_page()`.

documentation:
  ## Campos

  - `width` — ancho de la pagina en milimetros. A4 = 210.0, Letter = 215.9. Por defecto `210.0`.
  - `height` — alto de la pagina en milimetros. A4 = 297.0, Letter = 279.4. Por defecto `297.0`.
  - `orientation` — orientacion de la pagina: `portrait` (vertical) o `landscape` (horizontal). Por defecto `portrait`.

code:
  """PDFPage — configuracion de pagina para PDF."""

  from dataclasses import dataclass


  @dataclass
  class PDFPage:
      """Configuracion de una pagina dentro de un documento PDF.

      Define dimensiones y orientacion. Las medidas son en milimetros.
      Por defecto A4 vertical (210 x 297 mm).
      """

      width: float = 210.0        # mm — A4 ancho
      height: float = 297.0       # mm — A4 alto
      orientation: str = "portrait"  # portrait, landscape


id: pdf_style_py_infra
name: pdf_style
lang: py
domain: infra
version: 1.0.0
algebraic: product
tags: ["pdf","style","typography","infra"]
uses_types: []
file_path: python/types/infra/pdf_style.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 732780620cbec547dc6d23062dff1b78aa5bcfab21c900e8f837b7d52d23780f
definition:
  @dataclass
  class PDFStyle:
      font_family: str = "Helvetica"
      font_size: float = 12.0
      color: str = "#000000"
      alignment: str = "left"
      line_height: float = 1.2
      margin_top: float = 0.0
      margin_bottom: float = 2.0

description:
  Configuracion de estilo de texto para generacion de PDF. Agrupa fuente, tamaño, color, alineacion y margenes verticales para aplicar a bloques de texto y celdas de tabla.

examples:
  ```python
  from pdf_style import PDFStyle

  # Titulo grande centrado
  titulo = PDFStyle(font_family="Helvetica-Bold", font_size=22.0, alignment="center")

  # Texto de pie en gris
  pie = PDFStyle(font_size=9.0, color="#666666")

  # Cuerpo justificado con margen
  cuerpo = PDFStyle(font_size=11.0, alignment="justify", margin_bottom=4.0)
  ```

notes:
  Tipo producto inmutable por convencion. Las fuentes built-in de fpdf2 (`Helvetica`, `Times`, `Courier` y sus variantes `-Bold`, `-Oblique`, `-BoldOblique`) no requieren archivos .ttf externos. Para fuentes custom, registrarlas en fpdf2 antes de usarlas.

documentation:
  ## Campos

  - `font_family` — nombre de fuente fpdf2: `Helvetica`, `Helvetica-Bold`, `Helvetica-Oblique`, `Times`, `Courier`. Por defecto `Helvetica`.
  - `font_size` — tamaño en puntos tipograficos. Por defecto `12.0`.
  - `color` — color hex RGB (ej: `"#000000"` negro, `"#FF0000"` rojo). Por defecto negro.
  - `alignment` — alineacion horizontal del texto: `left`, `center`, `right`, `justify`. Por defecto `left`.
  - `line_height` — multiplicador de interlineado respecto al tamaño de fuente. `1.2` da un interlineado comodo.
  - `margin_top` — espacio vertical en mm antes del bloque de texto. Por defecto `0.0`.
  - `margin_bottom` — espacio vertical en mm despues del bloque de texto. Por defecto `2.0`.

code:
  """PDFStyle — configuracion de estilo de texto para PDF."""

  from dataclasses import dataclass, field


  @dataclass
  class PDFStyle:
      """Configuracion de estilo de texto para generacion de PDF.

      Agrupa fuente, tamaño, color, alineacion y margenes verticales
      para aplicar a bloques de texto y celdas de tabla.
      """

      font_family: str = "Helvetica"
      font_size: float = 12.0
      color: str = "#000000"      # hex color RGB
      alignment: str = "left"     # left, center, right, justify
      line_height: float = 1.2
      margin_top: float = 0.0     # mm de margen antes del bloque
      margin_bottom: float = 2.0  # mm de margen despues del bloque


# 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-18T16:00:15Z
updated_at: 2026-04-18T16:00:15Z
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-18T16:00:15Z
updated_at: 2026-04-18T16:00:15Z
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-18T16:00:15Z
updated_at: 2026-04-18T16:00:15Z
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-18T16:00:15Z
updated_at: 2026-04-18T16:00:15Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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-21T19:19:49Z
updated_at: 2026-04-21T19:19:49Z
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)
  	}
  }


