Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Integration Tests
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: initium
POSTGRES_PASSWORD: initium
POSTGRES_DB: initium_test
ports:
- 15432:5432
options: >-
--health-cmd "pg_isready -U initium"
--health-interval 2s
--health-timeout 5s
--health-retries 10
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: initium
MYSQL_PASSWORD: initium
MYSQL_DATABASE: initium_test
ports:
- 13306:3306
options: >-
--health-cmd "mysqladmin ping -h localhost -u root -prootpass"
--health-interval 2s
--health-timeout 5s
--health-retries 15
http-server:
image: nginx:1-alpine
ports:
- 18080:80
options: >-
--health-cmd "wget --spider -q http://localhost:80/"
--health-interval 2s
--health-timeout 5s
--health-retries 5
steps:
- 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"
run: cargo test --test integration_test -- --test-threads=1
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### 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
- 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
- Duration unit support for all time parameters (`--timeout`, `--initial-delay`, `--max-delay`, seed phase `timeout`, seed wait-for `timeout`): accepts `ms`, `s`, `m`, `h` suffixes with decimal values (e.g. `1.5m`, `2.7s`) and combined units (e.g. `1m30s`, `2s700ms`, `18h36m4s200ms`); bare numbers default to seconds
Expand Down
43 changes: 23 additions & 20 deletions src/seed/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub trait Database: Send {
table: &str,
columns: &[String],
values: &[String],
auto_id_column: Option<&str>,
) -> Result<Option<i64>, String>;
fn row_exists(
&mut self,
Expand Down Expand Up @@ -104,6 +105,7 @@ impl Database for SqliteDb {
table: &str,
columns: &[String],
values: &[String],
_auto_id_column: Option<&str>,
) -> Result<Option<i64>, String> {
let col_list: Vec<String> = columns
.iter()
Expand Down Expand Up @@ -310,26 +312,24 @@ impl Database for PostgresDb {
table: &str,
columns: &[String],
values: &[String],
auto_id_column: Option<&str>,
) -> Result<Option<i64>, String> {
let col_list: Vec<String> = columns
.iter()
.map(|c| format!("\"{}\"", sanitize_identifier(c)))
.collect();
let placeholders: Vec<String> = (1..=values.len()).map(|i| format!("${}", i)).collect();
let value_list: Vec<String> = 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(", "),
sanitize_identifier(columns.first().map(|s| s.as_str()).unwrap_or("id"))
value_list.join(", "),
returning_col
);
let params: Vec<&(dyn postgres::types::ToSql + Sync)> = values
.iter()
.map(|v| v as &(dyn postgres::types::ToSql + Sync))
.collect();
let row = self
.client
.query_one(&sql, params.as_slice())
.query_one(&sql, &[])
.map_err(|e| format!("inserting row into '{}': {}", table, e))?;
let id: i64 = row.get(0);
Ok(Some(id))
Expand All @@ -346,21 +346,17 @@ impl Database for PostgresDb {
}
let conditions: Vec<String> = unique_columns
.iter()
.enumerate()
.map(|(i, c)| format!("\"{}\" = ${}", sanitize_identifier(c), i + 1))
.zip(unique_values.iter())
.map(|(c, v)| format!("\"{}\" = {}", sanitize_identifier(c), escape_sql_value(v)))
.collect();
let sql = format!(
"SELECT COUNT(*) FROM \"{}\" WHERE {}",
sanitize_identifier(table),
conditions.join(" AND ")
);
let params: Vec<&(dyn postgres::types::ToSql + Sync)> = unique_values
.iter()
.map(|v| v as &(dyn postgres::types::ToSql + Sync))
.collect();
let row = self
.client
.query_one(&sql, params.as_slice())
.query_one(&sql, &[])
.map_err(|e| format!("checking row existence in '{}': {}", table, e))?;
let count: i64 = row.get(0);
Ok(count > 0)
Expand Down Expand Up @@ -543,6 +539,7 @@ impl Database for MysqlDb {
table: &str,
columns: &[String],
values: &[String],
_auto_id_column: Option<&str>,
) -> Result<Option<i64>, String> {
let col_list: Vec<String> = columns
.iter()
Expand Down Expand Up @@ -712,6 +709,10 @@ fn sanitize_identifier(name: &str) -> String {
.collect()
}

fn escape_sql_value(val: &str) -> String {
format!("'{}'", val.replace('\'', "''"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -747,7 +748,7 @@ mod tests {

let columns = vec!["name".into(), "email".into()];
let values = vec!["Alice".into(), "alice@example.com".into()];
let id = db.insert_row("users", &columns, &values).unwrap();
let id = db.insert_row("users", &columns, &values, None).unwrap();
assert!(id.is_some());
assert_eq!(id.unwrap(), 1);

Expand All @@ -765,9 +766,9 @@ mod tests {
db.conn
.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)", [])
.unwrap();
db.insert_row("items", &["name".into()], &["item1".into()])
db.insert_row("items", &["name".into()], &["item1".into()], None)
.unwrap();
db.insert_row("items", &["name".into()], &["item2".into()])
db.insert_row("items", &["name".into()], &["item2".into()], None)
.unwrap();
let count = db.delete_rows("items").unwrap();
assert_eq!(count, 2);
Expand All @@ -780,7 +781,8 @@ mod tests {
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])
.unwrap();
db.begin_transaction().unwrap();
db.insert_row("t", &["v".into()], &["a".into()]).unwrap();
db.insert_row("t", &["v".into()], &["a".into()], None)
.unwrap();
db.rollback_transaction().unwrap();
let count: i64 = db
.conn
Expand All @@ -789,7 +791,8 @@ mod tests {
assert_eq!(count, 0);

db.begin_transaction().unwrap();
db.insert_row("t", &["v".into()], &["b".into()]).unwrap();
db.insert_row("t", &["v".into()], &["b".into()], None)
.unwrap();
db.commit_transaction().unwrap();
let count: i64 = db
.conn
Expand Down
42 changes: 26 additions & 16 deletions src/seed/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ 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)?;
}
}

for ss in &seed_sets {
self.execute_seed_set(ss)?;
}
Expand Down Expand Up @@ -140,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)]);
Expand Down Expand Up @@ -242,7 +251,8 @@ impl<'a> SeedExecutor<'a> {
continue;
}

let generated_id = self.db.insert_row(table, &columns, &values)?;
let auto_id_col = ts.auto_id.as_ref().map(|a| a.column.as_str());
let generated_id = self.db.insert_row(table, &columns, &values, auto_id_col)?;

if let Some(ref_key) = ref_name {
let mut ref_map = HashMap::new();
Expand Down
52 changes: 43 additions & 9 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,51 @@
# Integration Tests
# Tests

This directory contains integration tests for Initium.
## Unit Tests

## Running
Run unit tests with:

Integration tests require external services (Postgres, HTTP servers, etc.) and are not run in standard `cargo test`.
```bash
cargo test
```

## Integration Tests

Integration tests require Docker Compose services (Postgres 16, MySQL 8.0, nginx).

```bash
# Run unit tests only (default)
make test
# Start services
docker compose -f tests/docker-compose.yml up -d --wait

# Integration tests require docker-compose (future)
# docker-compose -f tests/docker-compose.yml up -d
# cargo test --test integration
# Run integration tests
INTEGRATION=1 cargo test --test integration_test -- --test-threads=1

# Stop services
docker compose -f tests/docker-compose.yml down
```

### Test scenarios

| Test | Description |
| ------------------------------------- | -------------------------------------------------------- |
| `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_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 |
| `test_exec_command` | exec echo captures output in logs |
| `test_exec_failing_command` | exec false returns exit code 1 |
| `test_seed_postgres` | seed PostgreSQL with refs, idempotency, and reset |
| `test_seed_mysql` | seed MySQL with refs and idempotency |
| `test_seed_postgres_create_database` | seed creates a PostgreSQL database via create_if_missing |
| `test_seed_postgres_create_schema` | seed creates a PostgreSQL schema via create_if_missing |
| `test_seed_mysql_create_database` | seed creates a MySQL database via create_if_missing |
| `test_seed_postgres_create_nonexistent_db_alpha` | create-if-missing with known non-existing PG database |
| `test_seed_postgres_create_nonexistent_db_beta` | create-if-missing with second non-existing PG database + idempotency |
| `test_seed_mysql_create_nonexistent_db_alpha` | create-if-missing with known non-existing MySQL database |
| `test_seed_mysql_create_nonexistent_db_beta` | create-if-missing with second non-existing MySQL database + idempotency |

### CI

Integration tests run automatically via `.github/workflows/integration.yml` using GitHub Actions service containers.
39 changes: 39 additions & 0 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: initium
POSTGRES_PASSWORD: initium
POSTGRES_DB: initium_test
ports:
- "15432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U initium"]
interval: 2s
timeout: 5s
retries: 10

mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: initium
MYSQL_PASSWORD: initium
MYSQL_DATABASE: initium_test
ports:
- "13306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootpass"]
interval: 2s
timeout: 5s
retries: 15

http-server:
image: nginx:1-alpine
ports:
- "18080:80"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
interval: 2s
timeout: 5s
retries: 5
Loading