Skip to content

Commit 7fcbd8a

Browse files
feat: add integration tests with docker-compose (#7) (#20)
* 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 * fix: use inline escaped values in postgres insert_row/row_exists to fix TEXT-to-INTEGER coercion * fix: reverse seed set order during reset to respect foreign key constraints * fix: separate reset (reverse) and seed (forward) passes for FK-safe reset mode
1 parent 2fa86bc commit 7fcbd8a

10 files changed

Lines changed: 1228 additions & 45 deletions

File tree

.github/workflows/integration.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Integration Tests
2+
on:
3+
workflow_dispatch:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
permissions:
9+
contents: read
10+
jobs:
11+
integration:
12+
runs-on: ubuntu-latest
13+
services:
14+
postgres:
15+
image: postgres:16-alpine
16+
env:
17+
POSTGRES_USER: initium
18+
POSTGRES_PASSWORD: initium
19+
POSTGRES_DB: initium_test
20+
ports:
21+
- 15432:5432
22+
options: >-
23+
--health-cmd "pg_isready -U initium"
24+
--health-interval 2s
25+
--health-timeout 5s
26+
--health-retries 10
27+
mysql:
28+
image: mysql:8.0
29+
env:
30+
MYSQL_ROOT_PASSWORD: rootpass
31+
MYSQL_USER: initium
32+
MYSQL_PASSWORD: initium
33+
MYSQL_DATABASE: initium_test
34+
ports:
35+
- 13306:3306
36+
options: >-
37+
--health-cmd "mysqladmin ping -h localhost -u root -prootpass"
38+
--health-interval 2s
39+
--health-timeout 5s
40+
--health-retries 15
41+
http-server:
42+
image: nginx:1-alpine
43+
ports:
44+
- 18080:80
45+
options: >-
46+
--health-cmd "wget --spider -q http://localhost:80/"
47+
--health-interval 2s
48+
--health-timeout 5s
49+
--health-retries 5
50+
steps:
51+
- uses: actions/checkout@v4
52+
- uses: dtolnay/rust-toolchain@stable
53+
- uses: Swatinem/rust-cache@v2
54+
- name: Install psql and mysql clients
55+
run: |
56+
sudo apt-get update -qq
57+
sudo apt-get install -y -qq postgresql-client default-mysql-client
58+
- name: Run integration tests
59+
env:
60+
INTEGRATION: "1"
61+
run: cargo test --test integration_test -- --test-threads=1

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- 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
12+
- 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
13+
- `tests/docker-compose.yml` with Postgres, MySQL, and HTTP health-check server definitions
14+
- `tests/fixtures/` with seed spec files and template for integration tests
15+
- Separate GitHub Actions workflow (`.github/workflows/integration.yml`) for integration tests with service containers
1116
- 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
1217
- `helm unittest` step added to CI helm-lint job with automatic plugin installation
1318
- 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

src/seed/db.rs

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub trait Database: Send {
88
table: &str,
99
columns: &[String],
1010
values: &[String],
11+
auto_id_column: Option<&str>,
1112
) -> Result<Option<i64>, String>;
1213
fn row_exists(
1314
&mut self,
@@ -104,6 +105,7 @@ impl Database for SqliteDb {
104105
table: &str,
105106
columns: &[String],
106107
values: &[String],
108+
_auto_id_column: Option<&str>,
107109
) -> Result<Option<i64>, String> {
108110
let col_list: Vec<String> = columns
109111
.iter()
@@ -310,26 +312,24 @@ impl Database for PostgresDb {
310312
table: &str,
311313
columns: &[String],
312314
values: &[String],
315+
auto_id_column: Option<&str>,
313316
) -> Result<Option<i64>, String> {
314317
let col_list: Vec<String> = columns
315318
.iter()
316319
.map(|c| format!("\"{}\"", sanitize_identifier(c)))
317320
.collect();
318-
let placeholders: Vec<String> = (1..=values.len()).map(|i| format!("${}", i)).collect();
321+
let value_list: Vec<String> = values.iter().map(|v| escape_sql_value(v)).collect();
322+
let returning_col = sanitize_identifier(auto_id_column.unwrap_or("id"));
319323
let sql = format!(
320324
"INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING COALESCE(CAST(\"{}\" AS BIGINT), 0)",
321325
sanitize_identifier(table),
322326
col_list.join(", "),
323-
placeholders.join(", "),
324-
sanitize_identifier(columns.first().map(|s| s.as_str()).unwrap_or("id"))
327+
value_list.join(", "),
328+
returning_col
325329
);
326-
let params: Vec<&(dyn postgres::types::ToSql + Sync)> = values
327-
.iter()
328-
.map(|v| v as &(dyn postgres::types::ToSql + Sync))
329-
.collect();
330330
let row = self
331331
.client
332-
.query_one(&sql, params.as_slice())
332+
.query_one(&sql, &[])
333333
.map_err(|e| format!("inserting row into '{}': {}", table, e))?;
334334
let id: i64 = row.get(0);
335335
Ok(Some(id))
@@ -346,21 +346,17 @@ impl Database for PostgresDb {
346346
}
347347
let conditions: Vec<String> = unique_columns
348348
.iter()
349-
.enumerate()
350-
.map(|(i, c)| format!("\"{}\" = ${}", sanitize_identifier(c), i + 1))
349+
.zip(unique_values.iter())
350+
.map(|(c, v)| format!("\"{}\" = {}", sanitize_identifier(c), escape_sql_value(v)))
351351
.collect();
352352
let sql = format!(
353353
"SELECT COUNT(*) FROM \"{}\" WHERE {}",
354354
sanitize_identifier(table),
355355
conditions.join(" AND ")
356356
);
357-
let params: Vec<&(dyn postgres::types::ToSql + Sync)> = unique_values
358-
.iter()
359-
.map(|v| v as &(dyn postgres::types::ToSql + Sync))
360-
.collect();
361357
let row = self
362358
.client
363-
.query_one(&sql, params.as_slice())
359+
.query_one(&sql, &[])
364360
.map_err(|e| format!("checking row existence in '{}': {}", table, e))?;
365361
let count: i64 = row.get(0);
366362
Ok(count > 0)
@@ -543,6 +539,7 @@ impl Database for MysqlDb {
543539
table: &str,
544540
columns: &[String],
545541
values: &[String],
542+
_auto_id_column: Option<&str>,
546543
) -> Result<Option<i64>, String> {
547544
let col_list: Vec<String> = columns
548545
.iter()
@@ -712,6 +709,10 @@ fn sanitize_identifier(name: &str) -> String {
712709
.collect()
713710
}
714711

712+
fn escape_sql_value(val: &str) -> String {
713+
format!("'{}'", val.replace('\'', "''"))
714+
}
715+
715716
#[cfg(test)]
716717
mod tests {
717718
use super::*;
@@ -747,7 +748,7 @@ mod tests {
747748

748749
let columns = vec!["name".into(), "email".into()];
749750
let values = vec!["Alice".into(), "alice@example.com".into()];
750-
let id = db.insert_row("users", &columns, &values).unwrap();
751+
let id = db.insert_row("users", &columns, &values, None).unwrap();
751752
assert!(id.is_some());
752753
assert_eq!(id.unwrap(), 1);
753754

@@ -765,9 +766,9 @@ mod tests {
765766
db.conn
766767
.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)", [])
767768
.unwrap();
768-
db.insert_row("items", &["name".into()], &["item1".into()])
769+
db.insert_row("items", &["name".into()], &["item1".into()], None)
769770
.unwrap();
770-
db.insert_row("items", &["name".into()], &["item2".into()])
771+
db.insert_row("items", &["name".into()], &["item2".into()], None)
771772
.unwrap();
772773
let count = db.delete_rows("items").unwrap();
773774
assert_eq!(count, 2);
@@ -780,7 +781,8 @@ mod tests {
780781
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])
781782
.unwrap();
782783
db.begin_transaction().unwrap();
783-
db.insert_row("t", &["v".into()], &["a".into()]).unwrap();
784+
db.insert_row("t", &["v".into()], &["a".into()], None)
785+
.unwrap();
784786
db.rollback_transaction().unwrap();
785787
let count: i64 = db
786788
.conn
@@ -789,7 +791,8 @@ mod tests {
789791
assert_eq!(count, 0);
790792

791793
db.begin_transaction().unwrap();
792-
db.insert_row("t", &["v".into()], &["b".into()]).unwrap();
794+
db.insert_row("t", &["v".into()], &["b".into()], None)
795+
.unwrap();
793796
db.commit_transaction().unwrap();
794797
let count: i64 = db
795798
.conn

src/seed/executor.rs

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ impl<'a> SeedExecutor<'a> {
7777

7878
let mut seed_sets: Vec<&SeedSet> = phase.seed_sets.iter().collect();
7979
seed_sets.sort_by_key(|s| s.order);
80+
81+
if self.reset {
82+
for ss in seed_sets.iter().rev() {
83+
self.reset_seed_set(ss)?;
84+
}
85+
}
86+
8087
for ss in &seed_sets {
8188
self.execute_seed_set(ss)?;
8289
}
@@ -140,25 +147,27 @@ impl<'a> SeedExecutor<'a> {
140147
}
141148
}
142149

150+
fn reset_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> {
151+
let name = &ss.name;
152+
self.log
153+
.info("reset mode: clearing seed set data", &[("seed_set", name)]);
154+
let mut tables: Vec<&TableSeed> = ss.tables.iter().collect();
155+
tables.sort_by_key(|t| std::cmp::Reverse(t.order));
156+
for ts in &tables {
157+
let count = self.db.delete_rows(&ts.table)?;
158+
self.log.info(
159+
"deleted rows",
160+
&[("table", &ts.table), ("count", &count.to_string())],
161+
);
162+
}
163+
self.db.remove_seed_mark(&self.tracking_table, name)?;
164+
Ok(())
165+
}
166+
143167
fn execute_seed_set(&mut self, ss: &SeedSet) -> Result<(), String> {
144168
let name = &ss.name;
145169
self.log.info("processing seed set", &[("seed_set", name)]);
146170

147-
if self.reset {
148-
self.log
149-
.info("reset mode: clearing seed set data", &[("seed_set", name)]);
150-
let mut tables: Vec<&TableSeed> = ss.tables.iter().collect();
151-
tables.sort_by_key(|t| std::cmp::Reverse(t.order));
152-
for ts in &tables {
153-
let count = self.db.delete_rows(&ts.table)?;
154-
self.log.info(
155-
"deleted rows",
156-
&[("table", &ts.table), ("count", &count.to_string())],
157-
);
158-
}
159-
self.db.remove_seed_mark(&self.tracking_table, name)?;
160-
}
161-
162171
if self.db.is_seed_applied(&self.tracking_table, name)? {
163172
self.log
164173
.info("seed set already applied, skipping", &[("seed_set", name)]);
@@ -242,7 +251,8 @@ impl<'a> SeedExecutor<'a> {
242251
continue;
243252
}
244253

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

247257
if let Some(ref_key) = ref_name {
248258
let mut ref_map = HashMap::new();

tests/README.md

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,51 @@
1-
# Integration Tests
1+
# Tests
22

3-
This directory contains integration tests for Initium.
3+
## Unit Tests
44

5-
## Running
5+
Run unit tests with:
66

7-
Integration tests require external services (Postgres, HTTP servers, etc.) and are not run in standard `cargo test`.
7+
```bash
8+
cargo test
9+
```
10+
11+
## Integration Tests
12+
13+
Integration tests require Docker Compose services (Postgres 16, MySQL 8.0, nginx).
814

915
```bash
10-
# Run unit tests only (default)
11-
make test
16+
# Start services
17+
docker compose -f tests/docker-compose.yml up -d --wait
1218

13-
# Integration tests require docker-compose (future)
14-
# docker-compose -f tests/docker-compose.yml up -d
15-
# cargo test --test integration
19+
# Run integration tests
20+
INTEGRATION=1 cargo test --test integration_test -- --test-threads=1
21+
22+
# Stop services
23+
docker compose -f tests/docker-compose.yml down
1624
```
1725

26+
### Test scenarios
27+
28+
| Test | Description |
29+
| ------------------------------------- | -------------------------------------------------------- |
30+
| `test_waitfor_tcp_postgres` | wait-for TCP against Postgres succeeds |
31+
| `test_waitfor_tcp_mysql` | wait-for TCP against MySQL succeeds |
32+
| `test_waitfor_http_server` | wait-for HTTP against nginx returns 200 |
33+
| `test_waitfor_nonexistent_service` | wait-for against closed port fails with exit code 1 |
34+
| `test_waitfor_multiple_targets` | wait-for with Postgres + MySQL + HTTP all reachable |
35+
| `test_render_template` | render envsubst template produces correct output |
36+
| `test_fetch_from_http_server` | fetch from nginx writes HTML to file |
37+
| `test_exec_command` | exec echo captures output in logs |
38+
| `test_exec_failing_command` | exec false returns exit code 1 |
39+
| `test_seed_postgres` | seed PostgreSQL with refs, idempotency, and reset |
40+
| `test_seed_mysql` | seed MySQL with refs and idempotency |
41+
| `test_seed_postgres_create_database` | seed creates a PostgreSQL database via create_if_missing |
42+
| `test_seed_postgres_create_schema` | seed creates a PostgreSQL schema via create_if_missing |
43+
| `test_seed_mysql_create_database` | seed creates a MySQL database via create_if_missing |
44+
| `test_seed_postgres_create_nonexistent_db_alpha` | create-if-missing with known non-existing PG database |
45+
| `test_seed_postgres_create_nonexistent_db_beta` | create-if-missing with second non-existing PG database + idempotency |
46+
| `test_seed_mysql_create_nonexistent_db_alpha` | create-if-missing with known non-existing MySQL database |
47+
| `test_seed_mysql_create_nonexistent_db_beta` | create-if-missing with second non-existing MySQL database + idempotency |
48+
49+
### CI
50+
51+
Integration tests run automatically via `.github/workflows/integration.yml` using GitHub Actions service containers.

tests/docker-compose.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
services:
2+
postgres:
3+
image: postgres:16-alpine
4+
environment:
5+
POSTGRES_USER: initium
6+
POSTGRES_PASSWORD: initium
7+
POSTGRES_DB: initium_test
8+
ports:
9+
- "15432:5432"
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready -U initium"]
12+
interval: 2s
13+
timeout: 5s
14+
retries: 10
15+
16+
mysql:
17+
image: mysql:8.0
18+
environment:
19+
MYSQL_ROOT_PASSWORD: rootpass
20+
MYSQL_USER: initium
21+
MYSQL_PASSWORD: initium
22+
MYSQL_DATABASE: initium_test
23+
ports:
24+
- "13306:3306"
25+
healthcheck:
26+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootpass"]
27+
interval: 2s
28+
timeout: 5s
29+
retries: 15
30+
31+
http-server:
32+
image: nginx:1-alpine
33+
ports:
34+
- "18080:80"
35+
healthcheck:
36+
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
37+
interval: 2s
38+
timeout: 5s
39+
retries: 5

0 commit comments

Comments
 (0)