Implements issue #141 — .kc server-side metadata integration Phase 1. When a .kc file is uploaded, the server extracts silo/manifest.json and silo/metadata.json from the ZIP archive and indexes them into the item_metadata table. Plain .fcstd files continue to work unchanged. Extraction is best-effort: failures are logged but do not block the upload. New packages: - internal/kc: ZIP extraction library (Extract, Manifest, Metadata types) - internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields, UpdateLifecycle, SetTags) New API endpoints under /api/items/{partNumber}: - GET /metadata — read indexed metadata (viewer) - PUT /metadata — merge fields into JSONB (editor) - PATCH /metadata/lifecycle — transition lifecycle state (editor) - PATCH /metadata/tags — add/remove tags (editor) SSE events: metadata.updated, metadata.lifecycle, metadata.tags Lifecycle transitions (Phase 1): draft→review→released→obsolete, review→draft (reject). Closes #141
116 lines
3.1 KiB
Go
116 lines
3.1 KiB
Go
// Package testutil provides shared helpers for Silo tests.
|
|
package testutil
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
item_metadata, item_dependencies, approval_signatures, item_approvals, item_macros,
|
|
settings_overrides, module_state,
|
|
job_log, jobs, job_definitions, runners,
|
|
dag_cross_edges, dag_edges, dag_nodes,
|
|
audit_log, sync_log, api_tokens, sessions, item_files,
|
|
item_projects, relationships, revisions, inventory, items,
|
|
locations, 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
|
|
}
|
|
}
|