Files
soft_usite/pkg/services/xlsx_test.go
T
Lizandro GuarnizoandClaude Sonnet 5 62c644b815 feat(portal): descarga del cronograma del proyecto en Excel
El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.

El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.

Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).

El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.

Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:12:45 -05:00

92 lines
2.6 KiB
Go

package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strings"
"testing"
)
// El .xlsx se arma a mano, así que hay que confirmar que es un zip válido con
// todas las partes y XML bien formado — Excel no perdona ninguna de las dos.
func TestEscribirXLSX(t *testing.T) {
filas := [][]string{
{"#", "Fase", "Descripción", "Estado"},
{"1", "Diseño & maquetación", "Wireframes <UX>", "Completado"},
{"2", "Integración API", "Conectar \"pasarela\" de pago", "En progreso"},
}
var buf bytes.Buffer
if err := EscribirXLSX(&buf, "Cronograma", filas); err != nil {
t.Fatalf("EscribirXLSX: %v", err)
}
z, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("no es un zip válido: %v", err)
}
requeridas := []string{
"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml",
"xl/_rels/workbook.xml.rels", "xl/styles.xml", "xl/worksheets/sheet1.xml",
}
presentes := map[string]string{}
for _, f := range z.File {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
presentes[f.Name] = string(b)
}
for _, r := range requeridas {
if _, ok := presentes[r]; !ok {
t.Errorf("falta la parte %s", r)
}
}
// Todo XML debe parsear: un & o un < sin escapar rompe el archivo entero.
for nombre, cuerpo := range presentes {
d := xml.NewDecoder(strings.NewReader(cuerpo))
for {
_, err := d.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("XML inválido en %s: %v", nombre, err)
}
}
}
hoja := presentes["xl/worksheets/sheet1.xml"]
if !strings.Contains(hoja, "Diseño &amp; maquetación") {
t.Error("el & del texto no quedó escapado")
}
if !strings.Contains(hoja, "Wireframes &lt;UX&gt;") {
t.Error("los < > del texto no quedaron escapados")
}
if !strings.Contains(hoja, `<pane ySplit="1"`) {
t.Error("falta el encabezado congelado")
}
if !strings.Contains(hoja, `r="D3"`) {
t.Error("la última celda no quedó en la posición esperada (D3)")
}
}
func TestColumna(t *testing.T) {
casos := map[int]string{0: "A", 1: "B", 25: "Z", 26: "AA", 27: "AB", 51: "AZ", 52: "BA"}
for i, esperado := range casos {
if got := columna(i); got != esperado {
t.Errorf("columna(%d) = %q, esperaba %q", i, got, esperado)
}
}
}
// Excel rechaza el archivo si el nombre de hoja pasa de 31 chars o trae : \ / ? * [ ]
func TestLimpiarNombreHoja(t *testing.T) {
if got := limpiarNombreHoja("Plan/Cron*ograma"); strings.ContainsAny(got, `:\/?*[]`) {
t.Errorf("quedaron caracteres prohibidos: %q", got)
}
largo := strings.Repeat("a", 50)
if got := limpiarNombreHoja(largo); len([]rune(got)) != 31 {
t.Errorf("no se recortó a 31: %d", len([]rune(got)))
}
}