package services // Regression tests for the xlsx-generator pitfalls reported by m on // 2026-05-19: // // 1. Excel showed a "Repairs required" prompt on opening the .xlsx. // Root cause: SetPanes call passed only Freeze + YSplit; the // resulting XML missed topLeftCell + activePane, which // Excel rejects. Fix in buildXLSX: complete the Panes struct // (TopLeftCell="A2", ActivePane="bottomLeft", Selection on // bottomLeft). // // 2. Windows Explorer / Excel's File→Info showed Modified=2006-09-16 // ("xuri" — excelize's first-commit defaults). Root cause: // SetDocProps was never called, so the canned default leaked // through. Fix in buildXLSX: SetDocProps({Created, Modified} = // meta.GeneratedAt; Creator = "Paliad ()"). // // The tests are always-on (no env var gate) so a future writer // regression shows up loudly in `go test`. Developer-convenience hatch // at the bottom: set DUMP_EXPORT=1 to additionally write the bundle + // xlsx to /tmp for opening in real Excel. import ( "archive/zip" "bytes" "io" "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/xuri/excelize/v2" ) // fixturePersonalExport builds a tiny in-memory bundle + the raw xlsx // for the regression assertions and the optional /tmp dump. func fixturePersonalExport(t *testing.T) (bundle []byte, xlsxBytes []byte, meta ExportMeta) { t.Helper() meta = ExportMeta{ SchemaVersion: 1, FirmName: "HLC", Scope: ExportScopePersonal, GeneratedAt: time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC), GeneratedByID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), GeneratedByEml: "m@hlc.de", GeneratedByLbl: "m", RowCounts: map[string]int{"projects": 1, "deadlines": 0}, } sheets := []collectedSheet{ {name: "projects", columns: []string{"id", "title", "umlauts"}, rows: [][]string{{"u1", "Acme", "Müller"}}}, {name: "deadlines", columns: []string{"id", "due_date"}, rows: nil}, } bundle = assembleBundleForTest(t, sheets, meta) var err error xlsxBytes, err = buildXLSX(sheets, meta) if err != nil { t.Fatalf("buildXLSX: %v", err) } return bundle, xlsxBytes, meta } // TestXLSX_DocProps_NotExcelizeDefault pins fix #2. // // Before the fix: core.xml had Created=Modified="2006-09-16T00:00:00Z" // (xuri's first commit). Now we expect both to equal meta.GeneratedAt // in RFC 3339 UTC, and Creator to be "Paliad ()". func TestXLSX_DocProps_NotExcelizeDefault(t *testing.T) { _, xlsxBytes, meta := fixturePersonalExport(t) fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes)) if err != nil { t.Fatalf("excelize.OpenReader: %v", err) } defer fl.Close() props, err := fl.GetDocProps() if err != nil { t.Fatalf("GetDocProps: %v", err) } wantTS := meta.GeneratedAt.UTC().Format(time.RFC3339) if props.Created != wantTS { t.Errorf("Created = %q, want %q (excelize-default leak)", props.Created, wantTS) } if props.Modified != wantTS { t.Errorf("Modified = %q, want %q (excelize-default leak)", props.Modified, wantTS) } if props.Creator == "xuri" || props.Creator == "" { t.Errorf("Creator = %q, want non-empty non-xuri (e.g. \"Paliad (HLC)\")", props.Creator) } if !strings.Contains(props.Creator, "Paliad") { t.Errorf("Creator = %q, expected to contain \"Paliad\"", props.Creator) } } // TestXLSX_DocProps_TracksGeneratedAt pins that docProps stays bound to // meta.GeneratedAt across different timestamps — belt-and-braces vs // the fixed-fixture timestamp in the previous test. func TestXLSX_DocProps_TracksGeneratedAt(t *testing.T) { for _, ts := range []time.Time{ time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2027, 12, 31, 23, 59, 59, 0, time.UTC), time.Now().UTC().Truncate(time.Second), } { meta := ExportMeta{ SchemaVersion: 1, FirmName: "HLC", Scope: ExportScopePersonal, GeneratedAt: ts, RowCounts: map[string]int{"projects": 0}, } xlsxBytes, err := buildXLSX([]collectedSheet{ {name: "projects", columns: []string{"id"}, rows: nil}, }, meta) if err != nil { t.Fatalf("buildXLSX: %v", err) } fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes)) if err != nil { t.Fatalf("OpenReader: %v", err) } props, err := fl.GetDocProps() _ = fl.Close() if err != nil { t.Fatalf("GetDocProps: %v", err) } want := ts.Format(time.RFC3339) if props.Modified != want { t.Errorf("Modified = %q, want %q", props.Modified, want) } } } // TestXLSX_PaneXML_IsCompleteAndValid pins fix #1. // // excelize accepts the half-broken // shape on re-read (its parser is permissive), but Excel rejects it // with "Repairs required". To detect the regression without spinning // up Office, we read the raw worksheet XML out of the in-memory xlsx // zip and assert that the pane element has both topLeftCell + activePane. func TestXLSX_PaneXML_IsCompleteAndValid(t *testing.T) { _, xlsxBytes, _ := fixturePersonalExport(t) zr, err := zip.NewReader(bytes.NewReader(xlsxBytes), int64(len(xlsxBytes))) if err != nil { t.Fatalf("xlsx is not a valid zip: %v", err) } // sheet1 = __meta (no pane). sheet2 = projects, sheet3 = deadlines — // both have the frozen header. for _, target := range []string{"xl/worksheets/sheet2.xml", "xl/worksheets/sheet3.xml"} { var body []byte for _, f := range zr.File { if f.Name == target { rc, err := f.Open() if err != nil { t.Fatalf("open %s: %v", target, err) } body, _ = io.ReadAll(rc) rc.Close() break } } if body == nil { t.Fatalf("missing %s in xlsx zip", target) } s := string(body) if !strings.Contains(s, `topLeftCell="A2"`) { t.Errorf("%s pane missing topLeftCell — Excel will prompt 'repairs required'.\nXML: %s", target, s) } if !strings.Contains(s, `activePane="bottomLeft"`) { t.Errorf("%s pane missing activePane — Excel will prompt 'repairs required'.\nXML: %s", target, s) } if !strings.Contains(s, `state="frozen"`) { t.Errorf("%s pane missing state=frozen.\nXML: %s", target, s) } } } // TestXLSX_NoExcelizeBuildDefaults guards against any future regression // where a code path writes the .xlsx without first overriding excelize's // canned defaults. Cheap byte-level assertions. func TestXLSX_NoExcelizeBuildDefaults(t *testing.T) { _, xlsxBytes, _ := fixturePersonalExport(t) if bytes.Contains(xlsxBytes, []byte("2006-09-16T00:00:00Z")) { t.Errorf("xlsx leaks excelize default Created/Modified=2006-09-16 — SetDocProps not called?") } if bytes.Contains(xlsxBytes, []byte(`xuri`)) { t.Errorf("xlsx leaks excelize default Creator=xuri — SetDocProps not called?") } } // TestXLSX_OpensCleanly is the catch-all: round-trip the file through // excelize and confirm sheet names, row counts, and GetDocProps work. func TestXLSX_OpensCleanly(t *testing.T) { _, xlsxBytes, _ := fixturePersonalExport(t) fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes)) if err != nil { t.Fatalf("OpenReader: %v", err) } defer fl.Close() wantSheets := []string{"__meta", "projects", "deadlines"} got := fl.GetSheetList() if len(got) != len(wantSheets) { t.Fatalf("sheet list length = %d, want %d (%v vs %v)", len(got), len(wantSheets), got, wantSheets) } for i, want := range wantSheets { if got[i] != want { t.Errorf("sheet[%d] = %q, want %q", i, got[i], want) } } rows, err := fl.GetRows("projects") if err != nil { t.Fatalf("GetRows(projects): %v", err) } if len(rows) != 2 { t.Fatalf("projects rows = %d, want 2 (header + 1)", len(rows)) } if rows[0][0] != "id" || rows[1][0] != "u1" || rows[1][2] != "Müller" { t.Errorf("projects rows = %v, want header=[id title umlauts] row=[u1 Acme Müller]", rows) } } // TestBundle_ZipEntryMTime_TracksGeneratedAt pins the outer-zip side of // fix #2. Pre-fix every entry was stamped 2000-01-01 (the deterministic // constant) so Windows showed extracted files with a stale Modified // column. Now they carry meta.GeneratedAt. func TestBundle_ZipEntryMTime_TracksGeneratedAt(t *testing.T) { bundle, _, meta := fixturePersonalExport(t) zr, err := zip.NewReader(bytes.NewReader(bundle), int64(len(bundle))) if err != nil { t.Fatalf("bundle not a valid zip: %v", err) } want := meta.GeneratedAt.UTC() for _, f := range zr.File { got := f.Modified.UTC() // Zip stores mtime at 2-second resolution; allow ≤2s drift. diff := got.Sub(want) if diff < -2*time.Second || diff > 2*time.Second { t.Errorf("zip entry %q Modified = %v, want ~%v", f.Name, got, want) } // Specifically catch the old 2000-01-01 stamp. if got.Year() == 2000 && got.Month() == 1 && got.Day() == 1 { t.Errorf("zip entry %q stamped 2000-01-01 — old deterministic-constant regression", f.Name) } } } // TestDumpExport is the developer-convenience hatch. Skipped by default; // set DUMP_EXPORT=1 to write artifacts to /tmp for opening in real Excel. func TestDumpExport(t *testing.T) { if os.Getenv("DUMP_EXPORT") == "" { t.Skip("set DUMP_EXPORT=1 to dump artifacts to /tmp/paliad-export-debug.{zip,xlsx}") } bundle, xlsxBytes, _ := fixturePersonalExport(t) if err := os.WriteFile("/tmp/paliad-export-debug.zip", bundle, 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile("/tmp/paliad-export-debug.xlsx", xlsxBytes, 0o644); err != nil { t.Fatal(err) } t.Logf("wrote /tmp/paliad-export-debug.zip (%d bytes) + .xlsx (%d bytes)", len(bundle), len(xlsxBytes)) }