diff --git a/cmd/server/tests/schema_idempotency_test.go b/cmd/server/tests/schema_idempotency_test.go new file mode 100644 index 0000000..aadcfc3 --- /dev/null +++ b/cmd/server/tests/schema_idempotency_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "testing" + + "bookhoard/internal/config" + "bookhoard/internal/database" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +// TestSchemaInitializationIsIdempotent replays the application's production +// schema initializer twice against the same database. schema.sql runs on +// EVERY app startup, so it must apply cleanly to a database where a previous +// startup already applied it. A regression here crash-loops the production +// container on its second boot (e.g. SQLSTATE 2BP01: DROP FUNCTION refused +// while a trigger created by the previous run still references it). +// +// Uses a dedicated pool: production Initialize holds the advisory lock on one +// connection while executing the schema on another, but the shared test pool +// is capped at MaxConns=1 and would deadlock. +func TestSchemaInitializationIsIdempotent(t *testing.T) { + if testing.Short() { + t.Skip("skipping schema re-initialization in -short mode") + } + + // Bring up the standard test environment (seeds users, starts server). + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + // Dedicated pool with default connection limits for the initializer. + poolCfg, err := pgxpool.ParseConfig(config.LoadConfig().DatabaseURL()) + require.NoError(t, err, "failed to parse database URL") + pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg) + require.NoError(t, err, "failed to connect for schema initialization test") + defer func() { pool.Close() }() + + ctx := context.Background() + + // First boot: applies the schema to the (possibly fresh) database. + if err := database.Initialize(ctx, pool); err != nil { + t.Fatalf("first schema initialization failed: %v", err) + } + + // Second boot against the now-initialized database: this is exactly + // where non-idempotent statements (bare CREATE TRIGGER, DROP FUNCTION + // with dependent objects, etc.) blow up. + if err := database.Initialize(ctx, pool); err != nil { + t.Fatalf("second schema initialization failed: %v — schema.sql must stay idempotent across restarts", err) + } +} diff --git a/database/schema/schema.sql b/database/schema/schema.sql index bdfccd0..57c180c 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -259,8 +259,11 @@ CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search); CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search); --- Drop existing trigger if exists +-- Drop existing triggers if exists. Both must be dropped BEFORE the function: +-- dropping a function that triggers still reference fails (SQLSTATE 2BP01), +-- which would abort the schema transaction on every startup after the first. DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items; +DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items; -- Drop existing function if exists DROP FUNCTION IF EXISTS set_library_type_name(); @@ -279,6 +282,14 @@ CREATE TRIGGER set_library_type_name_on_insert FOR EACH ROW EXECUTE FUNCTION set_library_type_name(); +-- Keep library_type_name truthful when an item is repointed to another +-- library (cross-library move detection reuses the same function; the row +-- keeps its ID while its library changes). +CREATE TRIGGER set_library_type_name_on_library_change + BEFORE UPDATE OF library_id ON media_items + FOR EACH ROW + EXECUTE FUNCTION set_library_type_name(); + -- Add GIN indexes for pg_trgm fuzzy search performance CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm ON media_items USING GIN (author gin_trgm_ops);