#![cfg(feature = "sqlite")]
use autumn_web::config::DatabaseConfig;
use autumn_web::db::{RuntimeConnection, create_pool};
use autumn_web::migrate::{EmbeddedMigrations, embed_migrations, run_pending_sqlite};
use autumn_web::reexports::{axum, diesel, diesel_async};
use axum::Router;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::routing::post;
use diesel_async::RunQueryDsl as _;
use diesel_async::pooled_connection::deadpool::Pool;
use tower::ServiceExt as _;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("tests/fixtures/sqlite_migrations");
const CONCURRENT_MIGRATIONS: EmbeddedMigrations =
embed_migrations!("tests/fixtures/sqlite_migrations_concurrent");
type SqlitePool = Pool<RuntimeConnection>;
#[derive(diesel::QueryableByName)]
struct Widget {
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
}
async fn create_and_read_widget(State(pool): State<SqlitePool>) -> Result<String, StatusCode> {
let mut conn = pool
.get()
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
diesel::sql_query("INSERT INTO widgets (id, name) VALUES (1, 'sprocket')")
.execute(&mut *conn)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rows: Vec<Widget> = diesel::sql_query("SELECT name FROM widgets WHERE id = 1")
.load(&mut *conn)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
rows.into_iter()
.next()
.map(|w| w.name)
.ok_or(StatusCode::NOT_FOUND)
}
#[tokio::test]
async fn registered_migrations_apply_to_sqlite_and_serve() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let db_path = tmp.path().join("migrate.db");
let url = format!("sqlite://{}", db_path.display());
let result =
run_pending_sqlite(&url, MIGRATIONS).expect("registered migrations apply on sqlite");
assert_eq!(
result.applied.len(),
1,
"exactly the one registered migration is applied (got {:?})",
result.applied
);
let again =
run_pending_sqlite(&url, MIGRATIONS).expect("re-running pending migrations is a no-op");
assert!(
again.applied.is_empty(),
"second run applies nothing (got {:?})",
again.applied
);
let config = DatabaseConfig {
url: Some(url),
..Default::default()
};
let pool: SqlitePool = create_pool(&config)
.expect("sqlite pool builds")
.expect("a url is configured");
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
let rows: Vec<Widget> = diesel::sql_query("SELECT name FROM widgets WHERE 1 = 0")
.load(&mut *conn)
.await
.expect("the migrated `widgets` table is visible to the runtime pool");
assert!(rows.is_empty(), "no rows seeded yet");
}
let app: Router = Router::new()
.route("/widgets", post(create_and_read_widget))
.with_state(pool);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/widgets")
.body(Body::empty())
.expect("build request"),
)
.await
.expect("router serves the request");
assert_eq!(
response.status(),
StatusCode::OK,
"DB-backed route against the migrated schema is 200"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(
&body[..],
b"sprocket",
"response body is the row written to and read from the migrated `widgets` table"
);
}
#[test]
fn private_in_memory_target_with_registered_migrations_is_rejected() {
for url in [
"sqlite::memory:",
":memory:",
"sqlite://:memory:",
"file::memory:",
] {
let err = run_pending_sqlite(url, MIGRATIONS)
.expect_err("private in-memory + registered migrations must be rejected");
let msg = err.to_string();
assert!(
msg.to_lowercase().contains("in-memory"),
"error names the in-memory problem (got {msg:?})"
);
assert!(
msg.contains("file-backed"),
"error gives the file-backed remedy (got {msg:?})"
);
}
}
#[test]
fn shared_cache_in_memory_target_with_registered_migrations_is_rejected() {
let err = run_pending_sqlite("file::memory:?cache=shared", MIGRATIONS).expect_err(
"shared-cache in-memory + registered migrations must be rejected: the schema is \
lost before the runtime pool anchors it",
);
let msg = err.to_string();
assert!(
msg.to_lowercase().contains("in-memory"),
"error names the in-memory problem (got {msg:?})"
);
assert!(
msg.contains("file-backed"),
"error gives the file-backed remedy (got {msg:?})"
);
assert!(
!msg.contains("`file::memory:?cache=shared`"),
"the corrected message no longer recommends a shared-cache in-memory URL as a remedy \
(got {msg:?})"
);
}
#[test]
fn concurrent_run_pending_sqlite_serializes_without_false_failure() {
use std::sync::{Arc, Barrier};
const THREADS: usize = 8;
const EXPECTED_APPLIED: usize = 6;
let tmp = tempfile::TempDir::new().expect("temp dir");
let db_path = tmp.path().join("concurrent.db");
let url: Arc<str> = Arc::from(format!("sqlite://{}", db_path.display()));
let barrier = Arc::new(Barrier::new(THREADS));
#[allow(clippy::needless_collect)]
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let url = Arc::clone(&url);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
run_pending_sqlite(&url, CONCURRENT_MIGRATIONS)
})
})
.collect();
let results: Vec<Result<_, _>> = handles
.into_iter()
.map(|h| h.join().expect("migration thread did not panic"))
.collect();
for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"concurrent migrator {i} reported a failure instead of a clean no-op: {:?}",
result.as_ref().err()
);
}
let per_thread_applied: Vec<usize> = results
.iter()
.map(|r| r.as_ref().expect("ok checked above").applied.len())
.collect();
let total_applied: usize = per_thread_applied.iter().sum();
assert_eq!(
total_applied, EXPECTED_APPLIED,
"each registered migration must apply exactly once across all racers \
(per-thread applied counts = {per_thread_applied:?})"
);
let after = run_pending_sqlite(&url, CONCURRENT_MIGRATIONS)
.expect("a post-convergence run is a clean no-op, not a failure");
assert!(
after.applied.is_empty(),
"after the concurrent batch converged, re-running applies nothing (got {:?})",
after.applied
);
}