test(backup): add TEST_DATABASE_URL-gated live smokes for org export
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Two complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
This commit is contained in:
mAi
2026-05-26 18:19:55 +02:00
parent 935ea23038
commit 8125caf49a

View File

@@ -0,0 +1,99 @@
package services
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
// the way the backup runner does at the start of every run, then asserts
// that every spec the registry declares either keeps all its ORDER BY
// columns or — if any are missing — composes a fallback SELECT that the
// DB can still execute. Catches the m/paliad#140 class of bug
// (hardcoded ORDER BY against a renamed column) before deploy.
//
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
// REPEATABLE READ tx, never writes.
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
specs := orgSheetSpecs()
sheets, err := resolveOrgSheets(ctx, pool, specs)
if err != nil {
t.Fatalf("resolveOrgSheets: %v", err)
}
if len(sheets) != len(specs) {
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
}
// Each resolved SELECT must run cleanly against the live schema.
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
// table (some are large) but still exercise the ORDER BY clause.
for _, sq := range sheets {
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
}
}
}
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
// Discards the bytes — this is a "does it crash" smoke, the bug class
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
// against a missing column).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestWriteOrg_LiveSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
svc := NewExportService(pool, "test-firm")
var buf bytes.Buffer
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
ActorID: uuid.New(),
ActorEmail: "backup-smoke@test.local",
ActorLabel: "Backup Smoke",
})
if err != nil {
t.Fatalf("WriteOrg: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("WriteOrg wrote no bytes")
}
// Spot-check meta fills.
if meta.Scope != ExportScopeOrg {
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
}
if len(meta.RowCounts) != len(orgSheetSpecs()) {
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
}
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
}
}