Files
silo/internal/testutil/testutil.go
Forbes d08b178466 test: add comprehensive test suite for backend
Add 56 tests covering the core backend packages:

Unit tests (no database required):
- internal/partnum: 7 tests for part number generation logic
  (sequence, format templates, enum validation, constants)
- internal/schema: 8 tests for YAML schema loading, property
  merging, validation, and default application

Integration tests (require TEST_DATABASE_URL):
- internal/db/items: 10 tests for item CRUD, archive/unarchive,
  revisions, and thumbnail operations
- internal/db/relationships: 10 tests for BOM CRUD, cycle detection,
  self-reference blocking, where-used, expanded/flat BOM
- internal/db/projects: 5 tests for project CRUD and item association
- internal/api/bom_handlers: 6 HTTP handler tests for BOM endpoints
  including flat BOM, cost calculation, add/delete entries
- internal/api/items: 5 HTTP handler tests for item CRUD endpoints

Infrastructure:
- internal/testutil: shared helpers for test DB pool setup,
  migration runner, and table truncation
- internal/db/helpers_test.go: DB wrapper for integration tests
- internal/db/db.go: add NewFromPool constructor
- Makefile: add test-integration target with default DSN

Integration tests skip gracefully when TEST_DATABASE_URL is unset.
Dev-mode auth (nil authConfig) used for API handler tests.

Fixes: fmt.Errorf Go vet warning in partnum/generator.go

Closes #2
2026-02-07 01:57:10 -06:00

115 lines
2.9 KiB
Go

// Package testutil provides shared helpers for Silo tests.
package testutil
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// MustConnectTestPool connects to a test database using TEST_DATABASE_URL.
// If the env var is unset the test is skipped. Migrations are applied and
// all data tables are truncated before returning. The pool is closed
// automatically when the test finishes.
func MustConnectTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connecting to test database: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
pool.Close()
t.Fatalf("pinging test database: %v", err)
}
t.Cleanup(func() { pool.Close() })
RunMigrations(t, pool)
TruncateAll(t, pool)
return pool
}
// RunMigrations applies all SQL migration files from the migrations/
// directory. It walks upward from the current working directory to find
// the project root (containing go.mod).
func RunMigrations(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
root := findProjectRoot(t)
migDir := filepath.Join(root, "migrations")
entries, err := os.ReadDir(migDir)
if err != nil {
t.Fatalf("reading migrations directory: %v", err)
}
// Sort by filename to apply in order.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".sql" {
continue
}
sql, err := os.ReadFile(filepath.Join(migDir, e.Name()))
if err != nil {
t.Fatalf("reading migration %s: %v", e.Name(), err)
}
if _, err := pool.Exec(context.Background(), string(sql)); err != nil {
// Migrations may contain IF NOT EXISTS / CREATE OR REPLACE,
// so most "already exists" errors are fine. Log and continue.
t.Logf("migration %s: %v (may be OK if already applied)", e.Name(), err)
}
}
}
// TruncateAll removes all data from tables, leaving schema intact.
func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
_, err := pool.Exec(context.Background(), `
TRUNCATE
audit_log, sync_log, api_tokens, sessions, item_files,
item_projects, relationships, revisions, inventory, items,
projects, sequences_by_name, users, property_migrations
CASCADE
`)
if err != nil {
t.Fatalf("truncating tables: %v", err)
}
}
// findProjectRoot walks upward from cwd to find the directory containing go.mod.
func findProjectRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatalf("could not find project root (go.mod)")
}
dir = parent
}
panic(fmt.Sprintf("unreachable"))
}