From 759750ed01ed268d833a11b3bbf2a93edabec501 Mon Sep 17 00:00:00 2001 From: reivilibre Date: Wed, 22 Jan 2025 18:44:25 +0000 Subject: [PATCH] Add some tests for the syn2mas `MasWriter` (#3800) --- Cargo.lock | 4 + crates/syn2mas/Cargo.toml | 7 + crates/syn2mas/src/mas_writer/mod.rs | 173 +++++++++++++++++- ...syn2mas__mas_writer__test__write_user.snap | 11 ++ ...riter__test__write_user_with_password.snap | 18 ++ 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap create mode 100644 crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap diff --git a/Cargo.lock b/Cargo.lock index 217b8683e..95cd2c2fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6111,10 +6111,14 @@ dependencies = [ name = "syn2mas" version = "0.13.0-rc.1" dependencies = [ + "anyhow", "chrono", "compact_str", "futures-util", + "insta", + "mas-storage-pg", "rand", + "serde", "sqlx", "thiserror 2.0.11", "thiserror-ext", diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml index 1b9c051d8..f508ceeaa 100644 --- a/crates/syn2mas/Cargo.toml +++ b/crates/syn2mas/Cargo.toml @@ -23,5 +23,12 @@ rand.workspace = true uuid = "1.10.0" ulid = { workspace = true, features = ["uuid"] } +[dev-dependencies] +mas-storage-pg.workspace = true + +anyhow.workspace = true +insta.workspace = true +serde.workspace = true + [lints] workspace = true diff --git a/crates/syn2mas/src/mas_writer/mod.rs b/crates/syn2mas/src/mas_writer/mod.rs index aaab50c93..fff13eabb 100644 --- a/crates/syn2mas/src/mas_writer/mod.rs +++ b/crates/syn2mas/src/mas_writer/mod.rs @@ -667,5 +667,176 @@ impl<'writer, 'conn> MasUserWriteBuffer<'writer, 'conn> { #[cfg(test)] mod test { - // TODO test me + use std::collections::{BTreeMap, BTreeSet}; + + use chrono::DateTime; + use futures_util::TryStreamExt; + + use serde::Serialize; + use sqlx::{Column, PgConnection, PgPool, Row}; + use uuid::Uuid; + + use crate::{ + mas_writer::{MasNewUser, MasNewUserPassword}, + LockedMasDatabase, MasWriter, + }; + + /// A snapshot of a whole database + #[derive(Default, Serialize)] + #[serde(transparent)] + struct DatabaseSnapshot { + tables: BTreeMap, + } + + #[derive(Serialize)] + #[serde(transparent)] + struct TableSnapshot { + rows: BTreeSet, + } + + #[derive(PartialEq, Eq, PartialOrd, Ord, Serialize)] + #[serde(transparent)] + struct RowSnapshot { + columns_to_values: BTreeMap>, + } + + const SKIPPED_TABLES: &[&str] = &["_sqlx_migrations"]; + + /// Produces a serialisable snapshot of a database, usable for snapshot testing + /// + /// For brevity, empty tables, as well as [`SKIPPED_TABLES`], will not be included in the snapshot. + async fn snapshot_database(conn: &mut PgConnection) -> DatabaseSnapshot { + let mut out = DatabaseSnapshot::default(); + let table_names: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema();", + ) + .fetch_all(&mut *conn) + .await + .unwrap(); + + for table_name in table_names { + if SKIPPED_TABLES.contains(&table_name.as_str()) { + continue; + } + + let column_names: Vec = sqlx::query_scalar( + "SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = current_schema();" + ).bind(&table_name).fetch_all(&mut *conn).await.expect("failed to get column names for table for snapshotting"); + + let column_name_list = column_names + .iter() + // stringify all the values for simplicity + .map(|column_name| format!("{column_name}::TEXT AS \"{column_name}\"")) + .collect::>() + .join(", "); + + let table_rows = sqlx::query(&format!("SELECT {column_name_list} FROM {table_name};")) + .fetch(&mut *conn) + .map_ok(|row| { + let mut columns_to_values = BTreeMap::new(); + for (idx, column) in row.columns().iter().enumerate() { + columns_to_values.insert(column.name().to_owned(), row.get(idx)); + } + RowSnapshot { columns_to_values } + }) + .try_collect::>() + .await + .expect("failed to fetch rows from table for snapshotting"); + + if !table_rows.is_empty() { + out.tables + .insert(table_name, TableSnapshot { rows: table_rows }); + } + } + + out + } + + /// Make a snapshot assertion against the database. + macro_rules! assert_db_snapshot { + ($db: expr) => { + let db_snapshot = snapshot_database($db).await; + ::insta::assert_yaml_snapshot!(db_snapshot); + }; + } + + /// Runs some code with a `MasWriter`. + /// + /// The callback is responsible for `finish`ing the `MasWriter`. + async fn make_mas_writer<'conn>( + pool: &PgPool, + main_conn: &'conn mut PgConnection, + ) -> MasWriter<'conn> { + let mut writer_conns = Vec::new(); + for _ in 0..2 { + writer_conns.push( + pool.acquire() + .await + .expect("failed to acquire MasWriter writer connection") + .detach(), + ); + } + let locked_main_conn = LockedMasDatabase::try_new(main_conn) + .await + .expect("failed to lock MAS database") + .expect_left("MAS database is already locked"); + MasWriter::new(locked_main_conn, writer_conns) + .await + .expect("failed to construct MasWriter") + } + + /// Tests writing a single user, without a password. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user(pool: PgPool) { + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: Uuid::from_u128(1u128), + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } + + /// Tests writing a single user, with a password. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_write_user_with_password(pool: PgPool) { + const USER_ID: Uuid = Uuid::from_u128(1u128); + + let mut conn = pool.acquire().await.unwrap(); + let mut writer = make_mas_writer(&pool, &mut conn).await; + + writer + .write_users(vec![MasNewUser { + user_id: USER_ID, + username: "alice".to_owned(), + created_at: DateTime::default(), + locked_at: None, + can_request_admin: false, + }]) + .await + .expect("failed to write user"); + writer + .write_passwords(vec![MasNewUserPassword { + user_password_id: Uuid::from_u128(42u128), + user_id: USER_ID, + hashed_password: "$bcrypt$aaaaaaaaaaa".to_owned(), + created_at: DateTime::default(), + }]) + .await + .expect("failed to write password"); + + writer.finish().await.expect("failed to finish MasWriter"); + + assert_db_snapshot!(&mut conn); + } } diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap new file mode 100644 index 000000000..62d12ad5a --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user.snap @@ -0,0 +1,11 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap new file mode 100644 index 000000000..13f8db6a8 --- /dev/null +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_password.snap @@ -0,0 +1,18 @@ +--- +source: crates/syn2mas/src/mas_writer/mod.rs +expression: db_snapshot +--- +user_passwords: + - created_at: "1970-01-01 00:00:00+00" + hashed_password: $bcrypt$aaaaaaaaaaa + upgraded_from_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + user_password_id: 00000000-0000-0000-0000-00000000002a + version: "1" +users: + - can_request_admin: "false" + created_at: "1970-01-01 00:00:00+00" + locked_at: ~ + primary_user_email_id: ~ + user_id: 00000000-0000-0000-0000-000000000001 + username: alice