Files
encapi/internal/database/database_test.go
T
unkin-agent 42307f285f
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Adopt golib/pg for migrations and pool construction
encapi's schema was a cumulative IF NOT EXISTS blob re-executed inline on every
start: it grows forever, records nothing, and cannot express a change that is
not a fresh CREATE. golib owns that mechanism now, so encapi keeps the SQL and
drops the runner.

- Move the DDL verbatim into migrations/0001_init.sql, embedded via
  migrations.FS. It stays IF NOT EXISTS-guarded, so the first start against the
  live database re-runs it as a no-op and only lands the schema_migrations row.
- Build the pool with pg.NewMigrated under the advisory lock named
  encapi-migrations, and delete the inline migrate(). database.New now takes a
  context and a logger; main.go hands it the signal context so a start blocked
  on the migration lock still dies on SIGTERM.
- Render the DSN with pg.DSN. The env var contract is untouched — the fields are
  still resolved by internal/config, because encapi defaults DBUSER and DBNAME
  to "encapi" where pg.DSNFromEnv treats both as required.
- Guard the set: embedded files must match migrations/, every CREATE must be
  idempotent, the derived lock key is pinned, and a container test proves the
  adoption path over a database that predates schema_migrations.
- Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency:
  exported by the Makefile, set in the Dockerfile and the woodpecker Go steps,
  documented in the README.
2026-09-02 00:17:04 +10:00

171 lines
4.7 KiB
Go

package database
import (
"context"
"errors"
"os"
"testing"
"git.unkin.net/unkin/encapi/internal/testsupport"
"git.unkin.net/unkin/encapi/pkg/models"
)
// testDB is the shared throwaway database; testDSN reaches the same server.
var (
testDB *DB
testDSN string
)
func TestMain(m *testing.M) {
ctx := context.Background()
dsn, terminate, err := testsupport.StartPostgres(ctx)
if err != nil {
// Docker unavailable: run so tests self-skip via requireDB.
os.Exit(m.Run())
}
db, err := New(ctx, dsn, nil)
if err != nil {
terminate()
panic(err)
}
testDB = db
testDSN = dsn
code := m.Run()
db.Close()
terminate()
if code != 0 {
os.Exit(code)
}
}
func requireDB(t *testing.T) {
t.Helper()
if testDB == nil {
t.Skip("Docker unavailable; skipping database integration test")
}
}
// clean truncates all tables between tests for isolation.
func clean(t *testing.T) {
t.Helper()
_, err := testDB.Pool.Exec(context.Background(), `TRUNCATE nodes, roles, statuses CASCADE`)
if err != nil {
t.Fatalf("truncate: %v", err)
}
}
func seed(t *testing.T) {
t.Helper()
ctx := context.Background()
if err := testDB.UpsertStatus(ctx, &models.Status{Name: "testing"}); err != nil {
t.Fatalf("seed status: %v", err)
}
if err := testDB.UpsertRole(ctx, &models.Role{Name: "roles::base"}); err != nil {
t.Fatalf("seed role: %v", err)
}
}
func TestStatusCRUD(t *testing.T) {
requireDB(t)
clean(t)
ctx := context.Background()
if err := testDB.UpsertStatus(ctx, &models.Status{Name: "production", Description: "prod"}); err != nil {
t.Fatal(err)
}
got, err := testDB.GetStatus(ctx, "production")
if err != nil || got.Description != "prod" {
t.Fatalf("GetStatus = %+v, %v", got, err)
}
// upsert updates description
if err := testDB.UpsertStatus(ctx, &models.Status{Name: "production", Description: "changed"}); err != nil {
t.Fatal(err)
}
got, _ = testDB.GetStatus(ctx, "production")
if got.Description != "changed" {
t.Errorf("description = %q, want changed", got.Description)
}
list, err := testDB.ListStatuses(ctx)
if err != nil || len(list) != 1 {
t.Fatalf("ListStatuses = %v, %v", list, err)
}
if err := testDB.DeleteStatus(ctx, "production"); err != nil {
t.Fatal(err)
}
if _, err := testDB.GetStatus(ctx, "production"); !errors.Is(err, ErrNotFound) {
t.Errorf("GetStatus after delete = %v, want ErrNotFound", err)
}
}
func TestRoleCRUDWithParams(t *testing.T) {
requireDB(t)
clean(t)
ctx := context.Background()
r := &models.Role{Name: "roles::infra::storage::minio", Description: "minio", DefaultParams: map[string]any{"minio_pool": "pool1", "replicas": float64(3)}}
if err := testDB.UpsertRole(ctx, r); err != nil {
t.Fatal(err)
}
got, err := testDB.GetRole(ctx, r.Name)
if err != nil {
t.Fatal(err)
}
if got.DefaultParams["minio_pool"] != "pool1" || got.DefaultParams["replicas"] != float64(3) {
t.Errorf("default_params = %#v", got.DefaultParams)
}
if _, err := testDB.GetRole(ctx, "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("GetRole(nope) = %v, want ErrNotFound", err)
}
}
func TestNodeCRUDAndForeignKeys(t *testing.T) {
requireDB(t)
clean(t)
seed(t)
ctx := context.Background()
// node referencing an unknown role must fail the FK
badRole := &models.Node{Certname: "h1", Role: "roles::ghost", Environment: "testing"}
if err := testDB.UpsertNode(ctx, badRole); err == nil {
t.Error("expected FK violation for unknown role")
}
// node referencing an unknown environment must fail the FK
badEnv := &models.Node{Certname: "h1", Role: "roles::base", Environment: "ghost"}
if err := testDB.UpsertNode(ctx, badEnv); err == nil {
t.Error("expected FK violation for unknown environment")
}
n := &models.Node{Certname: "h1", Role: "roles::base", Environment: "testing", Params: map[string]any{"x": "y"}}
if err := testDB.UpsertNode(ctx, n); err != nil {
t.Fatal(err)
}
got, err := testDB.GetNode(ctx, "h1")
if err != nil || got.Role != "roles::base" || got.Params["x"] != "y" {
t.Fatalf("GetNode = %+v, %v", got, err)
}
// role in use cannot be deleted
if err := testDB.DeleteRole(ctx, "roles::base"); err == nil {
t.Error("expected error deleting role in use")
}
// status in use cannot be deleted
if err := testDB.DeleteStatus(ctx, "testing"); err == nil {
t.Error("expected error deleting status in use")
}
list, err := testDB.ListNodes(ctx)
if err != nil || len(list) != 1 {
t.Fatalf("ListNodes = %v, %v", list, err)
}
if err := testDB.DeleteNode(ctx, "h1"); err != nil {
t.Fatal(err)
}
if _, err := testDB.GetNode(ctx, "h1"); !errors.Is(err, ErrNotFound) {
t.Errorf("GetNode after delete = %v, want ErrNotFound", err)
}
if err := testDB.DeleteNode(ctx, "h1"); !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteNode missing = %v, want ErrNotFound", err)
}
}