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
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
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])
|
|
}
|
|
}
|