From f6256c697f8c00bfe2c525ced901c4a3cc0be3c0 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Tue, 24 Feb 2026 22:10:24 +0100 Subject: [PATCH 1/8] feat: add integration tests with docker-compose (#7) - wait-for, render, fetch, exec, seed PostgreSQL/MySQL with cross-table refs, idempotency, reset, create database/schema, create-if-missing tests --- src/seed/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/seed/db.rs b/src/seed/db.rs index e8ba35d..e3ad9b8 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -318,13 +318,13 @@ impl Database for PostgresDb { .iter() .map(|c| format!("\"{}\"", sanitize_identifier(c))) .collect(); - let value_list: Vec = values.iter().map(|v| escape_sql_value(v)).collect(); + let placeholders: Vec = (1..=values.len()).map(|i| format!("${}", i)).collect(); let returning_col = sanitize_identifier(auto_id_column.unwrap_or("id")); let sql = format!( "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", sanitize_identifier(table), col_list.join(", "), - value_list.join(", "), + placeholders.join(", "), returning_col ); let row = self From e25da675049f877d27644325c2cc97148b59e5f8 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Tue, 24 Feb 2026 22:15:53 +0100 Subject: [PATCH 2/8] fix: use inline escaped values in postgres insert_row/row_exists to fix TEXT-to-INTEGER coercion --- src/seed/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/seed/db.rs b/src/seed/db.rs index e3ad9b8..e8ba35d 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -318,13 +318,13 @@ impl Database for PostgresDb { .iter() .map(|c| format!("\"{}\"", sanitize_identifier(c))) .collect(); - let placeholders: Vec = (1..=values.len()).map(|i| format!("${}", i)).collect(); + let value_list: Vec = values.iter().map(|v| escape_sql_value(v)).collect(); let returning_col = sanitize_identifier(auto_id_column.unwrap_or("id")); let sql = format!( "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", sanitize_identifier(table), col_list.join(", "), - placeholders.join(", "), + value_list.join(", "), returning_col ); let row = self From 9942cd7db1e03acbf7047e25aeb58cd3032bdb53 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Tue, 24 Feb 2026 22:19:04 +0100 Subject: [PATCH 3/8] fix: reverse seed set order during reset to respect foreign key constraints --- src/seed/executor.rs | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/seed/executor.rs b/src/seed/executor.rs index 2594e9b..7980824 100644 --- a/src/seed/executor.rs +++ b/src/seed/executor.rs @@ -76,14 +76,11 @@ impl<'a> SeedExecutor<'a> { } let mut seed_sets: Vec<&SeedSet> = phase.seed_sets.iter().collect(); - seed_sets.sort_by_key(|s| s.order); - if self.reset { - for ss in seed_sets.iter().rev() { - self.reset_seed_set(ss)?; - } + seed_sets.sort_by_key(|s| std::cmp::Reverse(s.order)); + } else { + seed_sets.sort_by_key(|s| s.order); } - for ss in &seed_sets { self.execute_seed_set(ss)?; } @@ -147,27 +144,25 @@ impl<'a> SeedExecutor<'a> { } } - fn reset_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> { - let name = &ss.name; - self.log - .info("reset mode: clearing seed set data", &[("seed_set", name)]); - let mut tables: Vec<&TableSeed> = ss.tables.iter().collect(); - tables.sort_by_key(|t| std::cmp::Reverse(t.order)); - for ts in &tables { - let count = self.db.delete_rows(&ts.table)?; - self.log.info( - "deleted rows", - &[("table", &ts.table), ("count", &count.to_string())], - ); - } - self.db.remove_seed_mark(&self.tracking_table, name)?; - Ok(()) - } - fn execute_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> { let name = &ss.name; self.log.info("processing seed set", &[("seed_set", name)]); + if self.reset { + self.log + .info("reset mode: clearing seed set data", &[("seed_set", name)]); + let mut tables: Vec<&TableSeed> = ss.tables.iter().collect(); + tables.sort_by_key(|t| std::cmp::Reverse(t.order)); + for ts in &tables { + let count = self.db.delete_rows(&ts.table)?; + self.log.info( + "deleted rows", + &[("table", &ts.table), ("count", &count.to_string())], + ); + } + self.db.remove_seed_mark(&self.tracking_table, name)?; + } + if self.db.is_seed_applied(&self.tracking_table, name)? { self.log .info("seed set already applied, skipping", &[("seed_set", name)]); From 20809e4ccd5b6f0a8807f02cf8f4fe721bc2429e Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Tue, 24 Feb 2026 22:22:46 +0100 Subject: [PATCH 4/8] fix: separate reset (reverse) and seed (forward) passes for FK-safe reset mode --- src/seed/executor.rs | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/seed/executor.rs b/src/seed/executor.rs index 7980824..2594e9b 100644 --- a/src/seed/executor.rs +++ b/src/seed/executor.rs @@ -76,11 +76,14 @@ impl<'a> SeedExecutor<'a> { } let mut seed_sets: Vec<&SeedSet> = phase.seed_sets.iter().collect(); + seed_sets.sort_by_key(|s| s.order); + if self.reset { - seed_sets.sort_by_key(|s| std::cmp::Reverse(s.order)); - } else { - seed_sets.sort_by_key(|s| s.order); + for ss in seed_sets.iter().rev() { + self.reset_seed_set(ss)?; + } } + for ss in &seed_sets { self.execute_seed_set(ss)?; } @@ -144,25 +147,27 @@ impl<'a> SeedExecutor<'a> { } } + fn reset_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> { + let name = &ss.name; + self.log + .info("reset mode: clearing seed set data", &[("seed_set", name)]); + let mut tables: Vec<&TableSeed> = ss.tables.iter().collect(); + tables.sort_by_key(|t| std::cmp::Reverse(t.order)); + for ts in &tables { + let count = self.db.delete_rows(&ts.table)?; + self.log.info( + "deleted rows", + &[("table", &ts.table), ("count", &count.to_string())], + ); + } + self.db.remove_seed_mark(&self.tracking_table, name)?; + Ok(()) + } + fn execute_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> { let name = &ss.name; self.log.info("processing seed set", &[("seed_set", name)]); - if self.reset { - self.log - .info("reset mode: clearing seed set data", &[("seed_set", name)]); - let mut tables: Vec<&TableSeed> = ss.tables.iter().collect(); - tables.sort_by_key(|t| std::cmp::Reverse(t.order)); - for ts in &tables { - let count = self.db.delete_rows(&ts.table)?; - self.log.info( - "deleted rows", - &[("table", &ts.table), ("count", &count.to_string())], - ); - } - self.db.remove_seed_mark(&self.tracking_table, name)?; - } - if self.db.is_seed_applied(&self.tracking_table, name)? { self.log .info("seed set already applied, skipping", &[("seed_set", name)]); From 7cf446ee508a16776e22e994dbe7e62b71ae657b Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Wed, 25 Feb 2026 08:24:56 +0100 Subject: [PATCH 5/8] refactor: remove placeholder seed_sets from test specs and move inline seed specs to files --- CHANGELOG.md | 5 +- tests/input/create-db-mysql.yaml | 15 +++ tests/input/create-db-postgres.yaml | 15 +++ .../create-nonexistent-db-alpha-mysql.yaml | 10 ++ .../create-nonexistent-db-alpha-postgres.yaml | 10 ++ .../create-nonexistent-db-beta-mysql.yaml | 10 ++ .../create-nonexistent-db-beta-postgres.yaml | 10 ++ tests/input/create-schema-postgres.yaml | 15 +++ tests/{fixtures => input}/seed-mysql.yaml | 0 tests/{fixtures => input}/seed-postgres.yaml | 0 tests/{fixtures => input}/template.conf.tmpl | 0 tests/integration_test.rs | 95 +++++-------------- 12 files changed, 112 insertions(+), 73 deletions(-) create mode 100644 tests/input/create-db-mysql.yaml create mode 100644 tests/input/create-db-postgres.yaml create mode 100644 tests/input/create-nonexistent-db-alpha-mysql.yaml create mode 100644 tests/input/create-nonexistent-db-alpha-postgres.yaml create mode 100644 tests/input/create-nonexistent-db-beta-mysql.yaml create mode 100644 tests/input/create-nonexistent-db-beta-postgres.yaml create mode 100644 tests/input/create-schema-postgres.yaml rename tests/{fixtures => input}/seed-mysql.yaml (100%) rename tests/{fixtures => input}/seed-postgres.yaml (100%) rename tests/{fixtures => input}/template.conf.tmpl (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2eb6f9..1ad5b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Removed placeholder `seed_sets` from create-if-missing integration test YAML files; phases with only `create_if_missing` no longer need empty seed sets (`seed_sets` was already optional via `#[serde(default)]`) + ### Added - Integration tests with docker-compose for end-to-end testing against real Postgres 16, MySQL 8.0, and nginx services (`tests/integration_test.rs`): wait-for TCP/HTTP/timeout/multiple targets, render template, fetch HTTP, exec command, seed PostgreSQL and MySQL with cross-table reference verification, create database/schema, idempotency, and reset mode - Additional create-if-missing integration tests: 2 PostgreSQL and 2 MySQL tests using known non-existing database names (`initium_noexist_alpha`, `initium_noexist_beta`) to verify database creation, existence checks, and idempotent re-runs - `tests/docker-compose.yml` with Postgres, MySQL, and HTTP health-check server definitions -- `tests/fixtures/` with seed spec files and template for integration tests +- `tests/input/` with seed spec files and template for integration tests - Separate GitHub Actions workflow (`.github/workflows/integration.yml`) for integration tests with service containers - Helm chart unit tests using helm-unittest plugin (`charts/initium/tests/deployment_test.yaml`) covering deployment rendering, securityContext enforcement, disabled sampleDeployment, multiple initContainers, extraVolumes/extraVolumeMounts, image configuration, workdir mount, and labels - `helm unittest` step added to CI helm-lint job with automatic plugin installation diff --git a/tests/input/create-db-mysql.yaml b/tests/input/create-db-mysql.yaml new file mode 100644 index 0000000..5b49c8d --- /dev/null +++ b/tests/input/create-db-mysql.yaml @@ -0,0 +1,15 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_db + order: 1 + database: initium_created_db + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: products + rows: [] diff --git a/tests/input/create-db-postgres.yaml b/tests/input/create-db-postgres.yaml new file mode 100644 index 0000000..d41efb0 --- /dev/null +++ b/tests/input/create-db-postgres.yaml @@ -0,0 +1,15 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_db + order: 1 + database: initium_created_db + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: departments + rows: [] diff --git a/tests/input/create-nonexistent-db-alpha-mysql.yaml b/tests/input/create-nonexistent-db-alpha-mysql.yaml new file mode 100644 index 0000000..1bcae1b --- /dev/null +++ b/tests/input/create-nonexistent-db-alpha-mysql.yaml @@ -0,0 +1,10 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_alpha + order: 1 + database: initium_noexist_alpha + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-alpha-postgres.yaml b/tests/input/create-nonexistent-db-alpha-postgres.yaml new file mode 100644 index 0000000..fc1fa37 --- /dev/null +++ b/tests/input/create-nonexistent-db-alpha-postgres.yaml @@ -0,0 +1,10 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_alpha + order: 1 + database: initium_noexist_alpha + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-beta-mysql.yaml b/tests/input/create-nonexistent-db-beta-mysql.yaml new file mode 100644 index 0000000..462ade6 --- /dev/null +++ b/tests/input/create-nonexistent-db-beta-mysql.yaml @@ -0,0 +1,10 @@ +database: + driver: mysql + url_env: MYSQL_URL + tracking_table: initium_seed + +phases: + - name: create_beta + order: 1 + database: initium_noexist_beta + create_if_missing: true diff --git a/tests/input/create-nonexistent-db-beta-postgres.yaml b/tests/input/create-nonexistent-db-beta-postgres.yaml new file mode 100644 index 0000000..e823f8f --- /dev/null +++ b/tests/input/create-nonexistent-db-beta-postgres.yaml @@ -0,0 +1,10 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_beta + order: 1 + database: initium_noexist_beta + create_if_missing: true diff --git a/tests/input/create-schema-postgres.yaml b/tests/input/create-schema-postgres.yaml new file mode 100644 index 0000000..81f2d5b --- /dev/null +++ b/tests/input/create-schema-postgres.yaml @@ -0,0 +1,15 @@ +database: + driver: postgres + url_env: POSTGRES_URL + tracking_table: initium_seed + +phases: + - name: create_schema + order: 1 + schema: test_analytics + create_if_missing: true + seed_sets: + - name: placeholder + tables: + - table: departments + rows: [] diff --git a/tests/fixtures/seed-mysql.yaml b/tests/input/seed-mysql.yaml similarity index 100% rename from tests/fixtures/seed-mysql.yaml rename to tests/input/seed-mysql.yaml diff --git a/tests/fixtures/seed-postgres.yaml b/tests/input/seed-postgres.yaml similarity index 100% rename from tests/fixtures/seed-postgres.yaml rename to tests/input/seed-postgres.yaml diff --git a/tests/fixtures/template.conf.tmpl b/tests/input/template.conf.tmpl similarity index 100% rename from tests/fixtures/template.conf.tmpl rename to tests/input/template.conf.tmpl diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2d16faa..74092fa 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -18,9 +18,9 @@ fn integration_enabled() -> bool { std::env::var("INTEGRATION").map_or(false, |v| v == "1") } -fn fixtures_dir() -> String { +fn input_dir() -> String { let manifest = env!("CARGO_MANIFEST_DIR"); - format!("{}/tests/fixtures", manifest) + format!("{}/tests/input", manifest) } const PG_URL: &str = "postgres://initium:initium@localhost:15432/initium_test"; @@ -215,7 +215,7 @@ fn test_render_template() { return; } let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let template = format!("{}/template.conf.tmpl", fixtures_dir()); + let template = format!("{}/template.conf.tmpl", input_dir()); let out = Command::new(initium_bin()) .args([ @@ -352,7 +352,7 @@ fn test_seed_postgres() { ) .expect("failed to create postgres tables"); - let spec = format!("{}/seed-postgres.yaml", fixtures_dir()); + let spec = format!("{}/seed-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) @@ -469,7 +469,7 @@ fn test_seed_mysql() { ) .unwrap(); - let spec = format!("{}/seed-mysql.yaml", fixtures_dir()); + let spec = format!("{}/seed-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_URL_STR) @@ -549,16 +549,9 @@ fn test_seed_postgres_create_database() { let mut client = pg_client(); let _ = client.batch_execute("DROP DATABASE IF EXISTS initium_created_db"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_db\n order: 1\n database: initium_created_db\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-db-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -585,7 +578,7 @@ fn test_seed_postgres_create_database() { // Idempotent re-run let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to re-run seed"); @@ -609,16 +602,9 @@ fn test_seed_postgres_create_schema() { let mut client = pg_client(); let _ = client.batch_execute("DROP SCHEMA IF EXISTS test_analytics CASCADE"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-schema-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_schema\n order: 1\n schema: test_analytics\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-schema-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -659,16 +645,9 @@ fn test_seed_mysql_create_database() { let mut conn = mysql_root_conn(); let _ = conn.query_drop("DROP DATABASE IF EXISTS initium_created_db"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-seed.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_db\n order: 1\n database: initium_created_db\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-db-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -689,7 +668,7 @@ fn test_seed_mysql_create_database() { // Idempotent re-run let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to re-run seed"); @@ -723,16 +702,9 @@ fn test_seed_postgres_create_nonexistent_db_alpha() { .get(0); assert_eq!(count, 0, "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-alpha.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_alpha\n order: 1\n database: initium_noexist_alpha\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-alpha-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -783,16 +755,9 @@ fn test_seed_postgres_create_nonexistent_db_beta() { .get(0); assert_eq!(count, 0, "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-beta.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: postgres\n url_env: POSTGRES_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_beta\n order: 1\n database: initium_noexist_beta\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: departments\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-beta-postgres.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to run seed"); @@ -820,7 +785,7 @@ fn test_seed_postgres_create_nonexistent_db_beta() { // Re-run to verify idempotency — should not fail let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("POSTGRES_URL", PG_URL) .output() .expect("failed to re-run seed"); @@ -854,16 +819,9 @@ fn test_seed_mysql_create_nonexistent_db_alpha() { .unwrap(); assert_eq!(count, Some(0), "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-alpha.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_alpha\n order: 1\n database: initium_noexist_alpha\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-alpha-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -908,16 +866,9 @@ fn test_seed_mysql_create_nonexistent_db_beta() { .unwrap(); assert_eq!(count, Some(0), "database should not exist before test"); - let workdir = tempfile::TempDir::new().expect("failed to create tempdir"); - let spec_path = workdir.path().join("create-db-beta.yaml"); - std::fs::write( - &spec_path, - "database:\n driver: mysql\n url_env: MYSQL_URL\n tracking_table: initium_seed\n\nphases:\n - name: create_beta\n order: 1\n database: initium_noexist_beta\n create_if_missing: true\n seed_sets:\n - name: placeholder\n tables:\n - table: products\n rows: []\n", - ) - .expect("failed to write spec"); - + let spec = format!("{}/create-nonexistent-db-beta-mysql.yaml", input_dir()); let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to run seed"); @@ -939,7 +890,7 @@ fn test_seed_mysql_create_nonexistent_db_beta() { // Re-run to verify idempotency — should not fail let out = Command::new(initium_bin()) - .args(["seed", "--spec", spec_path.to_str().unwrap()]) + .args(["seed", "--spec", &spec]) .env("MYSQL_URL", MYSQL_ROOT_URL_STR) .output() .expect("failed to re-run seed"); From 591e32be8e74a921f5ce655ba0afbaba19d13972 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Wed, 25 Feb 2026 12:52:54 +0100 Subject: [PATCH 6/8] Update .github/workflows/integration.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/integration.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 80c39bd..3dbfe61 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -51,10 +51,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install psql and mysql clients - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq postgresql-client default-mysql-client - name: Run integration tests env: INTEGRATION: "1" From 33881ef0c35c6534ab5a371e916dc561afd5cc2a Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Wed, 25 Feb 2026 12:54:52 +0100 Subject: [PATCH 7/8] fix: PostgreSQL insert_row fails for tables with text primary keys (#22) The insert_row function always used RETURNING COALESCE(CAST(id AS BIGINT), 0) even when no auto_id_column was specified, defaulting to the id column. For tables with text primary keys (like NetBird's accounts, users, groups), CAST to BIGINT fails with a generic db error. Fix: Only use RETURNING clause when auto_id_column is explicitly set. When no auto_id is needed, use a simple INSERT without RETURNING. --- src/seed/db.rs | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/seed/db.rs b/src/seed/db.rs index e8ba35d..6d99e33 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -319,20 +319,34 @@ impl Database for PostgresDb { .map(|c| format!("\"{}\"", sanitize_identifier(c))) .collect(); let value_list: Vec = values.iter().map(|v| escape_sql_value(v)).collect(); - let returning_col = sanitize_identifier(auto_id_column.unwrap_or("id")); - let sql = format!( - "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", - sanitize_identifier(table), - col_list.join(", "), - value_list.join(", "), - returning_col - ); - let row = self - .client - .query_one(&sql, &[]) - .map_err(|e| format!("inserting row into '{}': {}", table, e))?; - let id: i64 = row.get(0); - Ok(Some(id)) + + if let Some(auto_col) = auto_id_column { + let returning_col = sanitize_identifier(auto_col); + let sql = format!( + "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)", + sanitize_identifier(table), + col_list.join(", "), + value_list.join(", "), + returning_col + ); + let row = self + .client + .query_one(&sql, &[]) + .map_err(|e| format!("inserting row into '{}': {}", table, e))?; + let id: i64 = row.get(0); + Ok(Some(id)) + } else { + let sql = format!( + "INSERT INTO \"{}\" ({}) VALUES ({})", + sanitize_identifier(table), + col_list.join(", "), + value_list.join(", "), + ); + self.client + .execute(&sql, &[]) + .map_err(|e| format!("inserting row into '{}': {}", table, e))?; + Ok(None) + } } fn row_exists( From 840dd5ccda218b07c41eee9e5b738e8b99f4f9d8 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Wed, 25 Feb 2026 13:08:08 +0100 Subject: [PATCH 8/8] fix: address PR #21 review comments - remove unused CI client install, add cfg feature guards, fix README test name, document escaped literals rationale, reword CHANGELOG --- CHANGELOG.md | 2 +- src/seed/db.rs | 5 +++++ tests/README.md | 2 +- tests/integration_test.rs | 15 +++++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad5b80..b8ad71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -- Removed placeholder `seed_sets` from create-if-missing integration test YAML files; phases with only `create_if_missing` no longer need empty seed sets (`seed_sets` was already optional via `#[serde(default)]`) +- Clarified that seed phases with only `create_if_missing` can omit the `seed_sets` field entirely (`seed_sets` defaults to empty via `#[serde(default)]`); updated integration test YAML specs accordingly ### Added - Integration tests with docker-compose for end-to-end testing against real Postgres 16, MySQL 8.0, and nginx services (`tests/integration_test.rs`): wait-for TCP/HTTP/timeout/multiple targets, render template, fetch HTTP, exec command, seed PostgreSQL and MySQL with cross-table reference verification, create database/schema, idempotency, and reset mode diff --git a/src/seed/db.rs b/src/seed/db.rs index 6d99e33..ff5e219 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -307,6 +307,11 @@ impl Database for PostgresDb { Ok(()) } + // Seed values are untyped strings that may target columns of any type + // (INTEGER, TEXT, etc.). Parameterized queries with $N send values as + // TEXT, which postgres cannot implicitly cast to other types. String + // literals have type `unknown` and auto-cast to the target column type, + // so we use escaped literals here. Identifiers are still sanitized. fn insert_row( &mut self, table: &str, diff --git a/tests/README.md b/tests/README.md index 2a11133..e94d32a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,7 +30,7 @@ docker compose -f tests/docker-compose.yml down | `test_waitfor_tcp_postgres` | wait-for TCP against Postgres succeeds | | `test_waitfor_tcp_mysql` | wait-for TCP against MySQL succeeds | | `test_waitfor_http_server` | wait-for HTTP against nginx returns 200 | -| `test_waitfor_nonexistent_service` | wait-for against closed port fails with exit code 1 | +| `test_waitfor_nonexistent_service_timeout` | wait-for against closed port fails with exit code 1 | | `test_waitfor_multiple_targets` | wait-for with Postgres + MySQL + HTTP all reachable | | `test_render_template` | render envsubst template produces correct output | | `test_fetch_from_http_server` | fetch from nginx writes HTML to file | diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 74092fa..05204ba 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -23,19 +23,25 @@ fn input_dir() -> String { format!("{}/tests/input", manifest) } +#[cfg(feature = "postgres")] const PG_URL: &str = "postgres://initium:initium@localhost:15432/initium_test"; +#[cfg(feature = "mysql")] const MYSQL_URL_STR: &str = "mysql://initium:initium@localhost:13306/initium_test"; +#[cfg(feature = "mysql")] const MYSQL_ROOT_URL_STR: &str = "mysql://root:rootpass@localhost:13306/initium_test"; +#[cfg(feature = "postgres")] fn pg_client() -> postgres::Client { postgres::Client::connect(PG_URL, postgres::NoTls).expect("failed to connect to postgres") } +#[cfg(feature = "mysql")] fn mysql_conn() -> mysql::PooledConn { let pool = mysql::Pool::new(MYSQL_URL_STR).expect("failed to connect to mysql"); pool.get_conn().expect("failed to get mysql connection") } +#[cfg(feature = "mysql")] fn mysql_root_conn() -> mysql::PooledConn { let pool = mysql::Pool::new(MYSQL_ROOT_URL_STR).expect("failed to connect to mysql as root"); pool.get_conn() @@ -335,6 +341,7 @@ fn test_exec_failing_command() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create tables, seed, verify // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres() { if !integration_enabled() { @@ -448,6 +455,7 @@ fn test_seed_postgres() { // --------------------------------------------------------------------------- // seed: MySQL — create tables, seed, verify // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql() { if !integration_enabled() { @@ -540,6 +548,7 @@ fn test_seed_mysql() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create database via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_database() { if !integration_enabled() { @@ -593,6 +602,7 @@ fn test_seed_postgres_create_database() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create schema via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_schema() { if !integration_enabled() { @@ -635,6 +645,7 @@ fn test_seed_postgres_create_schema() { // --------------------------------------------------------------------------- // seed: MySQL — create database via seed phase // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_database() { if !integration_enabled() { @@ -683,6 +694,7 @@ fn test_seed_mysql_create_database() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create non-existing database and seed data into it // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_nonexistent_db_alpha() { if !integration_enabled() { @@ -736,6 +748,7 @@ fn test_seed_postgres_create_nonexistent_db_alpha() { // --------------------------------------------------------------------------- // seed: PostgreSQL — create a second non-existing database with different name // --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] #[test] fn test_seed_postgres_create_nonexistent_db_beta() { if !integration_enabled() { @@ -800,6 +813,7 @@ fn test_seed_postgres_create_nonexistent_db_beta() { // --------------------------------------------------------------------------- // seed: MySQL — create non-existing database and verify // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_nonexistent_db_alpha() { if !integration_enabled() { @@ -847,6 +861,7 @@ fn test_seed_mysql_create_nonexistent_db_alpha() { // --------------------------------------------------------------------------- // seed: MySQL — create a second non-existing database with different name // --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] #[test] fn test_seed_mysql_create_nonexistent_db_beta() { if !integration_enabled() {