chore: auto-commit (57 archivos)
- frontend/functions/core/format_datetime_short.md - frontend/functions/core/format_datetime_short.test.ts - frontend/functions/core/format_datetime_short.ts - frontend/functions/core/format_duration.md - frontend/functions/core/format_duration.test.ts - frontend/functions/core/format_duration.ts - frontend/functions/core/month_grid.md - frontend/functions/core/month_grid.test.ts - frontend/functions/core/month_grid.ts - frontend/functions/core/string_hash_palette.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package browser
|
||||
|
||||
// CdpSetCookie establece una cookie en el browser via Network.setCookie.
|
||||
// Util para autenticar tests e2e contra apps con sesion HttpOnly: hacer login
|
||||
// HTTP en el test, capturar el Set-Cookie, y propagar al browser antes de navegar.
|
||||
//
|
||||
// name/value/domain son obligatorios. path por defecto "/". httpOnly true por
|
||||
// defecto (replica HttpOnly de la app). secure y sameSite opcionales.
|
||||
func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
params := map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
"httpOnly": httpOnly,
|
||||
}
|
||||
_, err := c.sendCDP("Network.setCookie", params)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: cdp_set_cookie_go_browser
|
||||
name: cdp_set_cookie
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Establece una cookie en el browser via Network.setCookie del protocolo CDP. Soporta cookies HttpOnly. Util para tests e2e que necesitan autenticar el browser sin pasar por la UI de login."
|
||||
tags: [cdp, browser, cookie, e2e, auth]
|
||||
signature: "func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error"
|
||||
uses_functions: []
|
||||
uses_types:
|
||||
- cdp_conn_go_browser
|
||||
returns: ""
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_set_cookie.go"
|
||||
example: |
|
||||
conn, _ := browser.CdpConnect(9222)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// Tras hacer login HTTP en el test:
|
||||
if err := browser.CdpSetCookie(conn, "session", token, "localhost", "/", true); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
browser.CdpNavigate(conn, "http://localhost:8080/dashboard")
|
||||
params:
|
||||
- name: c
|
||||
desc: Conexion CDP abierta (de CdpConnect)
|
||||
- name: name
|
||||
desc: Nombre de la cookie
|
||||
- name: value
|
||||
desc: Valor de la cookie (token de sesion, etc.)
|
||||
- name: domain
|
||||
desc: Dominio sin protocolo (ej. "localhost", "example.com")
|
||||
- name: path
|
||||
desc: Path scope. Vacio se trata como "/"
|
||||
- name: httpOnly
|
||||
desc: Si true, cookie HttpOnly (no accesible desde JS)
|
||||
output: "error si Network.setCookie falla; nil en exito"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
- Network.setCookie es un comando CDP nativo, no requiere JS evaluate.
|
||||
- Permite cookies HttpOnly que `document.cookie` no puede setear desde JS.
|
||||
- Necesita que el dominio coincida con la URL a la que se navegara despues.
|
||||
@@ -0,0 +1,26 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// ParseDateOrDefault parses s as a date/datetime and returns the result.
|
||||
// Accepted formats: "2006-01-02" (YYYY-MM-DD) and time.RFC3339Nano.
|
||||
// Returns dflt when s is empty or does not match either format.
|
||||
// When endOfDay is true and the input matched YYYY-MM-DD, adds
|
||||
// 24*time.Hour - time.Nanosecond so the result is the last nanosecond of
|
||||
// that day (useful for inclusive end-of-range queries).
|
||||
// RFC3339Nano inputs are never adjusted regardless of endOfDay.
|
||||
func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time {
|
||||
if s == "" {
|
||||
return dflt
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
if endOfDay {
|
||||
return t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: parse_date_or_default
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time"
|
||||
description: "Parsea s como fecha (YYYY-MM-DD) o datetime (RFC3339Nano). Retorna dflt si s esta vacio o no encaja en ninguno de los dos formatos. Si endOfDay es true y el formato fue YYYY-MM-DD, ajusta al ultimo nanosegundo del dia (util para rangos de fechas inclusivos por el final)."
|
||||
tags: [date, parse, time, default, range, YYYY-MM-DD, RFC3339Nano]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- time
|
||||
params:
|
||||
- name: s
|
||||
desc: "Cadena a parsear. Formatos aceptados: '2006-01-02' (YYYY-MM-DD) o RFC3339Nano. String vacio activa el valor por defecto."
|
||||
- name: dflt
|
||||
desc: "Valor time.Time a retornar cuando s esta vacio o no coincide con ningun formato soportado."
|
||||
- name: endOfDay
|
||||
desc: "Si es true y el input fue YYYY-MM-DD, suma 24h-1ns para apuntar al ultimo nanosegundo del dia. No tiene efecto sobre RFC3339Nano ni sobre el retorno de dflt."
|
||||
output: "time.Time parseado desde s, ajustado si endOfDay=true y formato YYYY-MM-DD; o dflt si s esta vacio o es invalido."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna dflt"
|
||||
- "formato invalido retorna dflt"
|
||||
- "YYYY-MM-DD sin endOfDay retorna inicio de dia"
|
||||
- "YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia"
|
||||
- "RFC3339Nano sin endOfDay retorna timestamp exacto"
|
||||
- "RFC3339Nano con endOfDay no se ajusta"
|
||||
- "endOfDay false con dflt no lo modifica"
|
||||
test_file_path: "functions/core/parse_date_or_default_test.go"
|
||||
file_path: "functions/core/parse_date_or_default.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
dflt := time.Now().UTC()
|
||||
|
||||
// Inicio de rango (incluye el dia completo desde las 00:00)
|
||||
from := ParseDateOrDefault("2024-01-01", dflt, false)
|
||||
|
||||
// Fin de rango (incluye hasta el ultimo nanosegundo del dia)
|
||||
to := ParseDateOrDefault("2024-01-31", dflt, true)
|
||||
|
||||
// Timestamp exacto desde RFC3339Nano (no se ajusta aunque endOfDay=true)
|
||||
ts := ParseDateOrDefault("2024-01-15T12:00:00Z", dflt, true)
|
||||
|
||||
// Valor invalido → dflt
|
||||
fallback := ParseDateOrDefault("no-es-fecha", dflt, false)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Unifica las dos funciones originales `parseDateOrDefault` y `parseEndDateOrDefault`
|
||||
de `apps/kanban/backend/metrics.go:132-156` en una sola funcion parametrica.
|
||||
`time.Parse` es deterministico, por lo que la funcion es pura aunque use el paquete `time`.
|
||||
@@ -0,0 +1,65 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDateOrDefault(t *testing.T) {
|
||||
dflt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("string vacio retorna dflt", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("", dflt, false)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formato invalido retorna dflt", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("no-es-fecha", dflt, false)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("YYYY-MM-DD sin endOfDay retorna inicio de dia", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("2024-03-15", dflt, false)
|
||||
want := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("2024-03-15", dflt, true)
|
||||
want := time.Date(2024, 3, 15, 23, 59, 59, 999999999, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RFC3339Nano sin endOfDay retorna timestamp exacto", func(t *testing.T) {
|
||||
input := "2024-03-15T14:30:00.123456789Z"
|
||||
got := ParseDateOrDefault(input, dflt, false)
|
||||
want, _ := time.Parse(time.RFC3339Nano, input)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RFC3339Nano con endOfDay no se ajusta", func(t *testing.T) {
|
||||
input := "2024-03-15T00:00:00Z"
|
||||
got := ParseDateOrDefault(input, dflt, true)
|
||||
want, _ := time.Parse(time.RFC3339Nano, input)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("RFC3339Nano should not be adjusted; got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("endOfDay false con dflt no lo modifica", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("", dflt, true)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package datascience
|
||||
|
||||
// DurationStats holds descriptive statistics for a set of durations in milliseconds.
|
||||
type DurationStats struct {
|
||||
N int `json:"n"`
|
||||
AvgMs int64 `json:"avg_ms"`
|
||||
P50Ms int64 `json:"p50_ms"`
|
||||
P90Ms int64 `json:"p90_ms"`
|
||||
P99Ms int64 `json:"p99_ms"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package datascience
|
||||
|
||||
import "sort"
|
||||
|
||||
// DurationStatsFrom computes descriptive statistics for a slice of durations in milliseconds.
|
||||
// It sorts a local copy so the original slice is not mutated.
|
||||
// Returns a zero-value DurationStats when the input is empty.
|
||||
func DurationStatsFrom(durations []int64) DurationStats {
|
||||
n := len(durations)
|
||||
if n == 0 {
|
||||
return DurationStats{}
|
||||
}
|
||||
cp := make([]int64, n)
|
||||
copy(cp, durations)
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
||||
var sum int64
|
||||
for _, d := range cp {
|
||||
sum += d
|
||||
}
|
||||
return DurationStats{
|
||||
N: n,
|
||||
AvgMs: sum / int64(n),
|
||||
P50Ms: Percentile(cp, 0.5),
|
||||
P90Ms: Percentile(cp, 0.9),
|
||||
P99Ms: Percentile(cp, 0.99),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: duration_stats
|
||||
kind: function
|
||||
lang: go
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func DurationStatsFrom(durations []int64) DurationStats"
|
||||
description: "Calcula estadisticas descriptivas (N, media, P50/P90/P99) de un slice de duraciones en milisegundos. Ordena una copia local sin mutar el input. Retorna DurationStats{} para slice vacio."
|
||||
tags: [statistics, duration, percentile, metrics, int64]
|
||||
uses_functions:
|
||||
- percentile_int64_go_datascience
|
||||
uses_types:
|
||||
- DurationStats_go_datascience
|
||||
returns:
|
||||
- DurationStats_go_datascience
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- sort
|
||||
params:
|
||||
- name: durations
|
||||
desc: "Slice de duraciones en milisegundos. No necesita estar ordenado; se ordena internamente sobre una copia."
|
||||
output: "DurationStats con N, AvgMs, P50Ms, P90Ms y P99Ms calculados. DurationStats{} si el slice esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "slice vacio retorna estadisticas cero"
|
||||
- "un solo elemento produce estadisticas identicas"
|
||||
- "cinco elementos calcula media y percentiles correctos"
|
||||
- "input original no se muta"
|
||||
- "diez elementos p90 usa idx truncado"
|
||||
test_file_path: "functions/datascience/duration_stats_test.go"
|
||||
file_path: "functions/datascience/duration_stats.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
durations := []int64{50, 10, 30, 40, 20}
|
||||
stats := DurationStatsFrom(durations)
|
||||
// stats.N = 5
|
||||
// stats.AvgMs = 30
|
||||
// stats.P50Ms = 30
|
||||
// stats.P90Ms = 50
|
||||
// stats.P99Ms = 50
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ordena una copia con `sort.Slice` para no mutar el slice original.
|
||||
Compone `Percentile` (`percentile_int64_go_datascience`) para los calculos de P50/P90/P99.
|
||||
Extraido y generalizado desde `apps/kanban/backend/metrics.go:113-130`.
|
||||
@@ -0,0 +1,69 @@
|
||||
package datascience
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDurationStatsFrom(t *testing.T) {
|
||||
t.Run("slice vacio retorna estadisticas cero", func(t *testing.T) {
|
||||
got := DurationStatsFrom([]int64{})
|
||||
if got.N != 0 || got.AvgMs != 0 || got.P50Ms != 0 || got.P90Ms != 0 || got.P99Ms != 0 {
|
||||
t.Errorf("got %+v, want zero DurationStats", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("un solo elemento produce estadisticas identicas", func(t *testing.T) {
|
||||
got := DurationStatsFrom([]int64{100})
|
||||
if got.N != 1 || got.AvgMs != 100 || got.P50Ms != 100 || got.P90Ms != 100 || got.P99Ms != 100 {
|
||||
t.Errorf("got %+v, want all=100", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cinco elementos calcula media y percentiles correctos", func(t *testing.T) {
|
||||
// sorted: [10,20,30,40,50], n=5
|
||||
// P50: idx=int(4*0.5)=2 → 30
|
||||
// P90: idx=int(4*0.9)=int(3.6)=3 → 40
|
||||
// P99: idx=int(4*0.99)=int(3.96)=3 → 40
|
||||
got := DurationStatsFrom([]int64{50, 10, 30, 40, 20})
|
||||
if got.N != 5 {
|
||||
t.Errorf("N: got %v, want 5", got.N)
|
||||
}
|
||||
if got.AvgMs != 30 {
|
||||
t.Errorf("AvgMs: got %v, want 30", got.AvgMs)
|
||||
}
|
||||
if got.P50Ms != 30 {
|
||||
t.Errorf("P50Ms: got %v, want 30", got.P50Ms)
|
||||
}
|
||||
if got.P90Ms != 40 {
|
||||
t.Errorf("P90Ms: got %v, want 40", got.P90Ms)
|
||||
}
|
||||
if got.P99Ms != 40 {
|
||||
t.Errorf("P99Ms: got %v, want 40", got.P99Ms)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("input original no se muta", func(t *testing.T) {
|
||||
input := []int64{50, 10, 30}
|
||||
_ = DurationStatsFrom(input)
|
||||
if input[0] != 50 || input[1] != 10 || input[2] != 30 {
|
||||
t.Errorf("input mutated: got %v", input)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diez elementos p90 usa idx truncado", func(t *testing.T) {
|
||||
// sorted: [10..100], n=10
|
||||
// P50: idx=int(9*0.5)=4 → 50
|
||||
// P90: idx=int(9*0.9)=int(8.1)=8 → 90
|
||||
got := DurationStatsFrom([]int64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100})
|
||||
if got.N != 10 {
|
||||
t.Errorf("N: got %v, want 10", got.N)
|
||||
}
|
||||
if got.AvgMs != 55 {
|
||||
t.Errorf("AvgMs: got %v, want 55", got.AvgMs)
|
||||
}
|
||||
if got.P50Ms != 50 {
|
||||
t.Errorf("P50Ms: got %v, want 50", got.P50Ms)
|
||||
}
|
||||
if got.P90Ms != 90 {
|
||||
t.Errorf("P90Ms: got %v, want 90", got.P90Ms)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package datascience
|
||||
|
||||
// Percentile returns the value at percentile p (0.0–1.0) from a pre-sorted
|
||||
// ascending slice of int64 values.
|
||||
// Returns 0 for an empty slice.
|
||||
// idx is computed as int(float64(len-1)*p), clamped to [0, len-1].
|
||||
func Percentile(sorted []int64, p float64) int64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * p)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return sorted[idx]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: percentile_int64
|
||||
kind: function
|
||||
lang: go
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func Percentile(sorted []int64, p float64) int64"
|
||||
description: "Calcula el percentil p (0.0-1.0) de un slice de int64 pre-ordenado ascendente. Retorna 0 para slice vacio. idx = int(float64(len-1)*p), clamped a [0, len-1]."
|
||||
tags: [statistics, percentile, quantile, int64, sorted]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: sorted
|
||||
desc: "Slice de int64 pre-ordenado en orden ascendente. No se reordena internamente."
|
||||
- name: p
|
||||
desc: "Percentil a calcular, en rango [0.0, 1.0]. 0.5 = mediana, 0.9 = P90, 0.99 = P99."
|
||||
output: "El valor en la posicion del percentil p dentro del slice. Retorna 0 si el slice esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "slice vacio retorna cero"
|
||||
- "un solo elemento retorna ese elemento"
|
||||
- "p0 retorna minimo"
|
||||
- "p100 retorna maximo"
|
||||
- "p50 retorna mediana de cinco elementos"
|
||||
- "p90 de diez elementos usa idx int truncado"
|
||||
- "p99 de slice pequeno usa idx truncado a cero"
|
||||
test_file_path: "functions/datascience/percentile_int64_test.go"
|
||||
file_path: "functions/datascience/percentile_int64.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
sorted := []int64{10, 20, 30, 40, 50}
|
||||
p50 := Percentile(sorted, 0.5) // 30
|
||||
p90 := Percentile(sorted, 0.9) // 50
|
||||
p99 := Percentile(sorted, 0.99) // 50
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El input debe estar ordenado ascendente antes de llamar a esta funcion.
|
||||
`DurationStatsFrom` se encarga de ordenar antes de llamarla.
|
||||
Extraido de `apps/kanban/backend/metrics.go:99-111`.
|
||||
@@ -0,0 +1,56 @@
|
||||
package datascience
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPercentile(t *testing.T) {
|
||||
t.Run("slice vacio retorna cero", func(t *testing.T) {
|
||||
got := Percentile([]int64{}, 0.5)
|
||||
if got != 0 {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("un solo elemento retorna ese elemento", func(t *testing.T) {
|
||||
got := Percentile([]int64{42}, 0.5)
|
||||
if got != 42 {
|
||||
t.Errorf("got %v, want 42", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p0 retorna minimo", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.0)
|
||||
if got != 10 {
|
||||
t.Errorf("got %v, want 10", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p100 retorna maximo", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 1.0)
|
||||
if got != 50 {
|
||||
t.Errorf("got %v, want 50", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p50 retorna mediana de cinco elementos", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.5)
|
||||
if got != 30 {
|
||||
t.Errorf("got %v, want 30", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p90 de diez elementos usa idx int truncado", func(t *testing.T) {
|
||||
// idx = int(9 * 0.9) = int(8.1) = 8 → sorted[8] = 9
|
||||
got := Percentile([]int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.9)
|
||||
if got != 9 {
|
||||
t.Errorf("got %v, want 9", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p99 de slice pequeno usa idx truncado a cero", func(t *testing.T) {
|
||||
// idx = int(1 * 0.99) = int(0.99) = 0 → sorted[0] = 100
|
||||
got := Percentile([]int64{100, 200}, 0.99)
|
||||
if got != 100 {
|
||||
t.Errorf("got %v, want 100", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionCookieClear invalidates the named session cookie by setting
|
||||
// MaxAge=-1. The browser removes the cookie immediately on receipt.
|
||||
// It does not return an error because http.SetCookie never fails at runtime.
|
||||
func SessionCookieClear(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: http_session_cookie_clear
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieClear(w http.ResponseWriter, name string)"
|
||||
description: "Invalida la cookie de sesion en el browser fijando MaxAge=-1. Path='/', HttpOnly=true, SameSite=Lax. No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, logout, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie a invalidar (debe coincidir con el nombre usado al crearla)"
|
||||
output: "escribe el header Set-Cookie con MaxAge=-1 en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie clear setea MaxAge negativo"
|
||||
- "cookie clear valor es vacio"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_clear_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_clear.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func handleLogout(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token != "" {
|
||||
_ = infra.SessionDelete(db.conn, token)
|
||||
}
|
||||
infra.SessionCookieClear(w, "my_session")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. MaxAge=-1 hace que el browser elimine la cookie inmediatamente independientemente de la fecha Expires original. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers y nunca falla. Complemento de `http_session_cookie_set_go_infra`.
|
||||
@@ -0,0 +1,50 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionCookieClear(t *testing.T) {
|
||||
t.Run("cookie clear setea MaxAge negativo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "my_session")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.MaxAge >= 0 {
|
||||
t.Errorf("MaxAge: got %d, want negative", c.MaxAge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie clear valor es vacio", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("no cookie set")
|
||||
}
|
||||
if cookies[0].Value != "" {
|
||||
t.Errorf("Value: got %q, want empty", cookies[0].Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookieSet writes a session cookie to the response.
|
||||
// The cookie is HttpOnly, Path="/", SameSite=Lax and expires at the
|
||||
// Unix timestamp expiresAt (seconds). It does not return an error
|
||||
// because http.SetCookie never fails at runtime.
|
||||
func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(expiresAt, 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: http_session_cookie_set
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64)"
|
||||
description: "Escribe una cookie de sesion HttpOnly en la respuesta HTTP. Path='/', SameSite=Lax, Expires=time.Unix(expiresAt,0). No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http", "time"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie (p.ej. 'kanban_session')"
|
||||
- name: token
|
||||
desc: "valor del token de sesion"
|
||||
- name: expiresAt
|
||||
desc: "timestamp Unix (segundos) de expiracion de la cookie"
|
||||
output: "escribe el header Set-Cookie en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie set con nombre y token correctos"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_set_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_set.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
sess, err := infra.SessionCreate(db, userID, 7*24*time.Hour, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
infra.SessionCookieSet(w, "my_session", sess.Token, sess.ExpiresAt)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers del ResponseWriter y nunca falla. El campo `error_type` se omite porque la firma no tiene retorno de error — hay precedente en el registry (componentes C++ y otros helpers HTTP impuros sin error_type).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionCookieSet(t *testing.T) {
|
||||
t.Run("cookie set con nombre y token correctos", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
SessionCookieSet(w, "my_session", "tok_abc", expires)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.Value != "tok_abc" {
|
||||
t.Errorf("Value: got %q, want %q", c.Value, "tok_abc")
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Errorf("Path: got %q, want %q", c.Path, "/")
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Errorf("expected HttpOnly=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieSet(w, "s", "v", time.Now().Add(time.Hour).Unix())
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionTokenExtract extracts a session token from the request.
|
||||
// It checks the cookie named cookieName first; if present and non-empty,
|
||||
// that value is returned. Otherwise it checks the Authorization header
|
||||
// for a "Bearer <token>" prefix and returns the token part.
|
||||
// Returns "" if no token is found in either source.
|
||||
func SessionTokenExtract(r *http.Request, cookieName string) string {
|
||||
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: http_session_token_extract
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SessionTokenExtract(r *http.Request, cookieName string) string"
|
||||
description: "Extrae el token de sesion de un request HTTP. Comprueba primero la cookie con el nombre indicado; si no esta, parsea el header Authorization 'Bearer <token>'. Retorna cadena vacia si no hay token."
|
||||
tags: [http, session, cookie, bearer, auth, token]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: r
|
||||
desc: "request HTTP entrante"
|
||||
- name: cookieName
|
||||
desc: "nombre de la cookie de sesion a buscar (p.ej. 'kanban_session')"
|
||||
output: "token extraido de la cookie o del header Authorization; cadena vacia si no hay token en ninguna fuente"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie present retorna token de cookie"
|
||||
- "bearer header retorna token de header"
|
||||
- "cookie gana sobre bearer header"
|
||||
- "sin token retorna cadena vacia"
|
||||
test_file_path: "functions/infra/http_session_token_extract_test.go"
|
||||
file_path: "functions/infra/http_session_token_extract.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// validate token...
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. Funcion pura: solo lee el request, no muta estado. La cookie tiene precedencia sobre el header Authorization para mantener consistencia con el comportamiento del browser (la cookie es el canal primario; el header es para clientes API que no gestionan cookies).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionTokenExtract(t *testing.T) {
|
||||
const cookieName = "test_session"
|
||||
|
||||
t.Run("cookie present retorna token de cookie", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bearer header retorna token de header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_bearer" {
|
||||
t.Errorf("got %q, want %q", got, "tok_bearer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie gana sobre bearer header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q (cookie should win)", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin token retorna cadena vacia", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ApplyMigrations reads SQL files matching glob from fsys, sorts them
|
||||
// lexicographically (NNN_name.sql order), and executes every statement
|
||||
// found in each file against conn.
|
||||
//
|
||||
// If glob is empty, it defaults to "migrations/*.sql".
|
||||
//
|
||||
// Each statement is executed individually. Errors that look like
|
||||
// idempotent SQLite errors (duplicate column, already exists) are
|
||||
// silently ignored so that migrations can be replayed safely against
|
||||
// a database that was partially migrated.
|
||||
//
|
||||
// NOTE: the internal statement parser splits on ";" at the end of a
|
||||
// trimmed line. This is intentionally simple and will break if SQL
|
||||
// strings contain a literal semicolon at end-of-line. Avoid using
|
||||
// such patterns in migration files.
|
||||
func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error {
|
||||
if glob == "" {
|
||||
glob = "migrations/*.sql"
|
||||
}
|
||||
files, err := fs.Glob(fsys, glob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := fs.ReadFile(fsys, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitSQLStatements(string(b)) {
|
||||
s := strings.TrimSpace(stmt)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
if isIdempotentMigrationError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitSQLStatements splits a SQL script into individual statements.
|
||||
// Lines starting with "--" (comments) and blank lines are skipped.
|
||||
// A statement ends when a trimmed line ends with ";".
|
||||
func splitSQLStatements(s string) []string {
|
||||
out := []string{}
|
||||
cur := strings.Builder{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "--") || trim == "" {
|
||||
continue
|
||||
}
|
||||
cur.WriteString(line)
|
||||
cur.WriteString("\n")
|
||||
if strings.HasSuffix(trim, ";") {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isIdempotentMigrationError returns true for SQLite errors that arise
|
||||
// from re-applying a migration that was already applied (duplicate
|
||||
// column, table/index already exists).
|
||||
func isIdempotentMigrationError(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate column") ||
|
||||
strings.Contains(msg, "already exists")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: sqlite_apply_migrations
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error"
|
||||
description: "Aplica migraciones SQL desde un fs.FS en orden lexicografico. Lee archivos con glob (default 'migrations/*.sql'), divide por sentencias y ejecuta cada una contra conn. Errores idempotentes (duplicate column, already exists) se ignoran."
|
||||
tags: [database, sqlite, migration, schema, embed, fs]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt", "io/fs", "sort", "strings"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion *sql.DB donde se ejecutan las migraciones"
|
||||
- name: fsys
|
||||
desc: "sistema de archivos con los .sql (tipicamente embed.FS del caller)"
|
||||
- name: glob
|
||||
desc: "patron glob para seleccionar archivos (vacio = 'migrations/*.sql')"
|
||||
output: "nil si todas las migraciones se aplicaron correctamente; error del primer fallo no idempotente con nombre de archivo incluido"
|
||||
tested: true
|
||||
tests:
|
||||
- "una migracion se aplica correctamente"
|
||||
- "multiples migraciones en orden"
|
||||
- "error real se propaga"
|
||||
- "ALTER TABLE ADD COLUMN duplicado se ignora"
|
||||
test_file_path: "functions/infra/sqlite_apply_migrations_test.go"
|
||||
file_path: "functions/infra/sqlite_apply_migrations.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
db, err := infra.SQLiteOpen(path, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := infra.ApplyMigrations(db, migrationsFS, ""); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. El parser de sentencias SQL es intencionalmente simple: separa por `;` al final de una linea (ignorando comentarios `--` y lineas vacias). Esta logica falla si el SQL contiene un `;` dentro de un string literal al final de linea — evitar ese patron en los archivos de migracion.
|
||||
|
||||
Los errores idempotentes (`duplicate column`, `already exists`) se ignoran para que las migraciones sean re-ejecutables contra DBs que ya tenian parte del schema. Esto permite un flujo sin tabla `_migrations` para proyectos pequenos; para proyectos con muchas migraciones y rollback, usar `migration_up_go_infra` / `migration_down_go_infra`.
|
||||
@@ -0,0 +1,98 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// makeFS builds a simple in-memory FS with the given path→content pairs.
|
||||
func makeFS(files map[string]string) fstest.MapFS {
|
||||
m := make(fstest.MapFS, len(files))
|
||||
for path, content := range files {
|
||||
m[path] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestApplyMigrations(t *testing.T) {
|
||||
t.Run("una migracion se aplica correctamente", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT);`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Verify table exists.
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("table 'items' not created; count=%d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples migraciones en orden", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);`,
|
||||
"migrations/002_add_column.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Insert using the new column.
|
||||
if _, err := db.Exec(`INSERT INTO t (id, val) VALUES (1, 'hello')`); err != nil {
|
||||
t.Fatalf("insert failed (column may be missing): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error real se propaga", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_bad.sql": `THIS IS NOT VALID SQL;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err == nil {
|
||||
t.Errorf("expected error for invalid SQL, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ALTER TABLE ADD COLUMN duplicado se ignora", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
setup := `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);`
|
||||
if _, err := db.Exec(setup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Run ALTER that would fail with "duplicate column".
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_dup.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Errorf("duplicate column error should be ignored, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ColumnExists reports whether the named column exists in the given table
|
||||
// by querying PRAGMA table_info. Returns false if the table does not exist.
|
||||
func ColumnExists(conn *sql.DB, table, name string) (bool, error) {
|
||||
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if colName == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: sqlite_column_exists
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ColumnExists(conn *sql.DB, table, name string) (bool, error)"
|
||||
description: "Comprueba si una columna existe en una tabla SQLite consultando PRAGMA table_info. Retorna false sin error si la tabla no existe."
|
||||
tags: [database, sqlite, schema, pragma, column, migration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion SQLite abierta"
|
||||
- name: table
|
||||
desc: "nombre de la tabla a inspeccionar"
|
||||
- name: name
|
||||
desc: "nombre de la columna a buscar"
|
||||
output: "true si la columna existe, false si no existe o la tabla no existe; error si la query falla"
|
||||
tested: true
|
||||
tests:
|
||||
- "columna existente retorna true"
|
||||
- "columna inexistente retorna false"
|
||||
- "tabla inexistente retorna false sin error"
|
||||
test_file_path: "functions/infra/sqlite_column_exists_test.go"
|
||||
file_path: "functions/infra/sqlite_column_exists.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
exists, err := ColumnExists(db, "cards", "assignee_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
_, err = db.Exec(`ALTER TABLE cards ADD COLUMN assignee_id TEXT`)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. Util como comprobacion previa a ALTER TABLE ADD COLUMN en scripts de migracion que necesitan ser idempotentes. PRAGMA table_info retorna cero filas si la tabla no existe, por lo que la funcion retorna false sin error en ese caso.
|
||||
@@ -0,0 +1,59 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func openMemDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open :memory: db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestColumnExists(t *testing.T) {
|
||||
t.Run("columna existente retorna true", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("expected true for existing column 'name'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("columna inexistente retorna false", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for missing column")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tabla inexistente retorna false sin error", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
got, err := ColumnExists(db, "no_such_table", "col")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for non-existent table")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user