Go back to a compiling state and start running tests again
This commit is contained in:
parent
90d1d9ac82
commit
1bb41b6f54
4 changed files with 391 additions and 428 deletions
|
@ -12,8 +12,16 @@ use sqlx::{
|
||||||
use std::{cmp, ops::Range, path::Path, time::Duration};
|
use std::{cmp, ops::Range, path::Path, time::Duration};
|
||||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub type DefaultDb = Db<sqlx::Sqlite>;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub type DefaultDb = Db<sqlx::Postgres>;
|
||||||
|
|
||||||
pub struct Db<D: sqlx::Database> {
|
pub struct Db<D: sqlx::Database> {
|
||||||
pool: sqlx::Pool<D>,
|
pool: sqlx::Pool<D>,
|
||||||
|
#[cfg(test)]
|
||||||
|
background: Option<std::sync::Arc<gpui::executor::Background>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! test_support {
|
macro_rules! test_support {
|
||||||
|
@ -23,6 +31,10 @@ macro_rules! test_support {
|
||||||
};
|
};
|
||||||
|
|
||||||
if cfg!(test) {
|
if cfg!(test) {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(background) = $self.background.as_ref() {
|
||||||
|
background.simulate_random_delay().await;
|
||||||
|
}
|
||||||
tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build().unwrap().block_on(body)
|
tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build().unwrap().block_on(body)
|
||||||
} else {
|
} else {
|
||||||
body.await
|
body.await
|
||||||
|
@ -30,7 +42,7 @@ macro_rules! test_support {
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
trait RowsAffected {
|
pub trait RowsAffected {
|
||||||
fn rows_affected(&self) -> u64;
|
fn rows_affected(&self) -> u64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,32 +60,37 @@ impl RowsAffected for sqlx::postgres::PgQueryResult {
|
||||||
|
|
||||||
impl Db<sqlx::Sqlite> {
|
impl Db<sqlx::Sqlite> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub async fn sqlite(url: &str, max_connections: u32) -> Result<Self> {
|
pub async fn new(url: &str, max_connections: u32) -> Result<Self> {
|
||||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect(url)
|
.connect(url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Self { pool })
|
Ok(Self {
|
||||||
|
pool,
|
||||||
|
background: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db<sqlx::Postgres> {
|
impl Db<sqlx::Postgres> {
|
||||||
pub async fn postgres(url: &str, max_connections: u32) -> Result<Self> {
|
pub async fn new(url: &str, max_connections: u32) -> Result<Self> {
|
||||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect(url)
|
.connect(url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Self { pool })
|
Ok(Self {
|
||||||
|
pool,
|
||||||
|
#[cfg(test)]
|
||||||
|
background: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D> Db<D>
|
impl<D> Db<D>
|
||||||
where
|
where
|
||||||
D: sqlx::Database + sqlx::migrate::MigrateDatabase,
|
D: sqlx::Database + sqlx::migrate::MigrateDatabase,
|
||||||
for<'a> <D as sqlx::database::HasArguments<'a>>::Arguments: sqlx::IntoArguments<'a, D>,
|
|
||||||
D: for<'a> sqlx::database::HasValueRef<'a>,
|
|
||||||
D: for<'a> sqlx::database::HasArguments<'a>,
|
|
||||||
D::Connection: sqlx::migrate::Migrate,
|
D::Connection: sqlx::migrate::Migrate,
|
||||||
|
for<'a> <D as sqlx::database::HasArguments<'a>>::Arguments: sqlx::IntoArguments<'a, D>,
|
||||||
for<'a> &'a mut D::Connection: sqlx::Executor<'a, Database = D>,
|
for<'a> &'a mut D::Connection: sqlx::Executor<'a, Database = D>,
|
||||||
for<'a, 'b> &'b mut sqlx::Transaction<'a, D>: sqlx::Executor<'b, Database = D>,
|
for<'a, 'b> &'b mut sqlx::Transaction<'a, D>: sqlx::Executor<'b, Database = D>,
|
||||||
D::QueryResult: RowsAffected,
|
D::QueryResult: RowsAffected,
|
||||||
|
@ -452,19 +469,18 @@ where
|
||||||
|
|
||||||
pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
|
pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
|
||||||
test_support!(self, {
|
test_support!(self, {
|
||||||
|
let emails = invites
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.email_address.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"
|
"
|
||||||
UPDATE signups
|
UPDATE signups
|
||||||
SET email_confirmation_sent = TRUE
|
SET email_confirmation_sent = TRUE
|
||||||
WHERE email_address = ANY ($1)
|
WHERE email_address IN (SELECT value from json_each($1))
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
// .bind(
|
.bind(&serde_json::json!(emails))
|
||||||
// &invites
|
|
||||||
// .iter()
|
|
||||||
// .map(|s| s.email_address.as_str())
|
|
||||||
// .collect::<Vec<_>>(),
|
|
||||||
// )
|
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -808,7 +824,7 @@ where
|
||||||
count = excluded.count
|
count = excluded.count
|
||||||
",
|
",
|
||||||
);
|
);
|
||||||
query.build().execute(&self.pool).await?;
|
// query.build().execute(&self.pool).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
@ -1604,24 +1620,6 @@ where
|
||||||
.await?)
|
.await?)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub async fn teardown(&self, url: &str) {
|
|
||||||
test_support!(self, {
|
|
||||||
use util::ResultExt;
|
|
||||||
|
|
||||||
let query = "
|
|
||||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE pg_stat_activity.datname = current_database() AND pid <> pg_backend_pid();
|
|
||||||
";
|
|
||||||
sqlx::query(query).execute(&self.pool).await.log_err();
|
|
||||||
self.pool.close().await;
|
|
||||||
<sqlx::Sqlite as sqlx::migrate::MigrateDatabase>::drop_database(url)
|
|
||||||
.await
|
|
||||||
.log_err();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! id_type {
|
macro_rules! id_type {
|
||||||
|
@ -1833,51 +1831,37 @@ mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gpui::executor::Background;
|
use gpui::executor::Background;
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
use sqlx::{migrate::MigrateDatabase, Sqlite};
|
use sqlx::migrate::MigrateDatabase;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct TestDb {
|
pub struct TestDb {
|
||||||
pub db: Option<Arc<Db<Sqlite>>>,
|
pub db: Option<Arc<DefaultDb>>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestDb {
|
impl TestDb {
|
||||||
#[allow(clippy::await_holding_lock)]
|
pub async fn new(background: Arc<Background>) -> Self {
|
||||||
pub async fn real() -> Self {
|
|
||||||
todo!()
|
|
||||||
// eprintln!("creating database...");
|
|
||||||
// let start = std::time::Instant::now();
|
|
||||||
// let mut rng = StdRng::from_entropy();
|
|
||||||
// let url = format!("/tmp/zed-test-{}", rng.gen::<u128>());
|
|
||||||
// Sqlite::create_database(&url).await.unwrap();
|
|
||||||
// let db = Db::new(&url, 5).await.unwrap();
|
|
||||||
// db.migrate(Path::new(DEFAULT_MIGRATIONS_PATH.unwrap()), false)
|
|
||||||
// .await
|
|
||||||
// .unwrap();
|
|
||||||
|
|
||||||
// eprintln!("created database: {:?}", start.elapsed());
|
|
||||||
// Self {
|
|
||||||
// db: Some(Arc::new(db)),
|
|
||||||
// url,
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fake(background: Arc<Background>) -> Self {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let mut rng = StdRng::from_entropy();
|
let mut rng = StdRng::from_entropy();
|
||||||
let url = format!("file:db-{}?mode=memory&cache=shared", rng.gen::<u128>());
|
let url = format!("/tmp/zed-test-{}", rng.gen::<u128>());
|
||||||
let db = Db::sqlite(&url, 5).await.unwrap();
|
sqlx::Sqlite::create_database(&url).await.unwrap();
|
||||||
|
let mut db = DefaultDb::new(&url, 5).await.unwrap();
|
||||||
|
db.background = Some(background);
|
||||||
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite");
|
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite");
|
||||||
db.migrate(Path::new(migrations_path), false).await.unwrap();
|
db.migrate(Path::new(migrations_path), false).await.unwrap();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
db: Some(Arc::new(db)),
|
db: Some(Arc::new(db)),
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn db(&self) -> &Arc<Db<Sqlite>> {
|
pub fn db(&self) -> &Arc<DefaultDb> {
|
||||||
self.db.as_ref().unwrap()
|
self.db.as_ref().unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDb {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_file(&self.url).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,133 +6,125 @@ use time::OffsetDateTime;
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_get_users_by_ids() {
|
async fn test_get_users_by_ids() {
|
||||||
for test_db in [
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
TestDb::real().await,
|
let db = test_db.db();
|
||||||
TestDb::fake(build_background_executor()),
|
|
||||||
] {
|
|
||||||
let db = test_db.db();
|
|
||||||
|
|
||||||
let mut user_ids = Vec::new();
|
let mut user_ids = Vec::new();
|
||||||
for i in 1..=4 {
|
for i in 1..=4 {
|
||||||
user_ids.push(
|
user_ids.push(
|
||||||
db.create_user(
|
db.create_user(
|
||||||
&format!("user{i}@example.com"),
|
&format!("user{i}@example.com"),
|
||||||
false,
|
false,
|
||||||
NewUserParams {
|
NewUserParams {
|
||||||
github_login: format!("user{i}"),
|
github_login: format!("user{i}"),
|
||||||
github_user_id: i,
|
github_user_id: i,
|
||||||
invite_count: 0,
|
invite_count: 0,
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.user_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
db.get_users_by_ids(user_ids.clone()).await.unwrap(),
|
|
||||||
vec![
|
|
||||||
User {
|
|
||||||
id: user_ids[0],
|
|
||||||
github_login: "user1".to_string(),
|
|
||||||
github_user_id: Some(1),
|
|
||||||
email_address: Some("user1@example.com".to_string()),
|
|
||||||
admin: false,
|
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
User {
|
)
|
||||||
id: user_ids[1],
|
.await
|
||||||
github_login: "user2".to_string(),
|
.unwrap()
|
||||||
github_user_id: Some(2),
|
.user_id,
|
||||||
email_address: Some("user2@example.com".to_string()),
|
|
||||||
admin: false,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
User {
|
|
||||||
id: user_ids[2],
|
|
||||||
github_login: "user3".to_string(),
|
|
||||||
github_user_id: Some(3),
|
|
||||||
email_address: Some("user3@example.com".to_string()),
|
|
||||||
admin: false,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
User {
|
|
||||||
id: user_ids[3],
|
|
||||||
github_login: "user4".to_string(),
|
|
||||||
github_user_id: Some(4),
|
|
||||||
email_address: Some("user4@example.com".to_string()),
|
|
||||||
admin: false,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
db.get_users_by_ids(user_ids.clone()).await.unwrap(),
|
||||||
|
vec![
|
||||||
|
User {
|
||||||
|
id: user_ids[0],
|
||||||
|
github_login: "user1".to_string(),
|
||||||
|
github_user_id: Some(1),
|
||||||
|
email_address: Some("user1@example.com".to_string()),
|
||||||
|
admin: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
User {
|
||||||
|
id: user_ids[1],
|
||||||
|
github_login: "user2".to_string(),
|
||||||
|
github_user_id: Some(2),
|
||||||
|
email_address: Some("user2@example.com".to_string()),
|
||||||
|
admin: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
User {
|
||||||
|
id: user_ids[2],
|
||||||
|
github_login: "user3".to_string(),
|
||||||
|
github_user_id: Some(3),
|
||||||
|
email_address: Some("user3@example.com".to_string()),
|
||||||
|
admin: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
User {
|
||||||
|
id: user_ids[3],
|
||||||
|
github_login: "user4".to_string(),
|
||||||
|
github_user_id: Some(4),
|
||||||
|
email_address: Some("user4@example.com".to_string()),
|
||||||
|
admin: false,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_get_user_by_github_account() {
|
async fn test_get_user_by_github_account() {
|
||||||
for test_db in [
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
TestDb::real().await,
|
let db = test_db.db();
|
||||||
TestDb::fake(build_background_executor()),
|
let user_id1 = db
|
||||||
] {
|
.create_user(
|
||||||
let db = test_db.db();
|
"user1@example.com",
|
||||||
let user_id1 = db
|
false,
|
||||||
.create_user(
|
NewUserParams {
|
||||||
"user1@example.com",
|
github_login: "login1".into(),
|
||||||
false,
|
github_user_id: 101,
|
||||||
NewUserParams {
|
invite_count: 0,
|
||||||
github_login: "login1".into(),
|
},
|
||||||
github_user_id: 101,
|
)
|
||||||
invite_count: 0,
|
.await
|
||||||
},
|
.unwrap()
|
||||||
)
|
.user_id;
|
||||||
.await
|
let user_id2 = db
|
||||||
.unwrap()
|
.create_user(
|
||||||
.user_id;
|
"user2@example.com",
|
||||||
let user_id2 = db
|
false,
|
||||||
.create_user(
|
NewUserParams {
|
||||||
"user2@example.com",
|
github_login: "login2".into(),
|
||||||
false,
|
github_user_id: 102,
|
||||||
NewUserParams {
|
invite_count: 0,
|
||||||
github_login: "login2".into(),
|
},
|
||||||
github_user_id: 102,
|
)
|
||||||
invite_count: 0,
|
.await
|
||||||
},
|
.unwrap()
|
||||||
)
|
.user_id;
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.user_id;
|
|
||||||
|
|
||||||
let user = db
|
let user = db
|
||||||
.get_user_by_github_account("login1", None)
|
.get_user_by_github_account("login1", None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(user.id, user_id1);
|
assert_eq!(user.id, user_id1);
|
||||||
assert_eq!(&user.github_login, "login1");
|
assert_eq!(&user.github_login, "login1");
|
||||||
assert_eq!(user.github_user_id, Some(101));
|
assert_eq!(user.github_user_id, Some(101));
|
||||||
|
|
||||||
assert!(db
|
assert!(db
|
||||||
.get_user_by_github_account("non-existent-login", None)
|
.get_user_by_github_account("non-existent-login", None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none());
|
.is_none());
|
||||||
|
|
||||||
let user = db
|
let user = db
|
||||||
.get_user_by_github_account("the-new-login2", Some(102))
|
.get_user_by_github_account("the-new-login2", Some(102))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(user.id, user_id2);
|
assert_eq!(user.id, user_id2);
|
||||||
assert_eq!(&user.github_login, "the-new-login2");
|
assert_eq!(&user.github_login, "the-new-login2");
|
||||||
assert_eq!(user.github_user_id, Some(102));
|
assert_eq!(user.github_user_id, Some(102));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_worktree_extensions() {
|
async fn test_worktree_extensions() {
|
||||||
let test_db = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = test_db.db();
|
let db = test_db.db();
|
||||||
|
|
||||||
let user = db
|
let user = db
|
||||||
|
@ -204,7 +196,7 @@ async fn test_worktree_extensions() {
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_user_activity() {
|
async fn test_user_activity() {
|
||||||
let test_db = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = test_db.db();
|
let db = test_db.db();
|
||||||
|
|
||||||
let mut user_ids = Vec::new();
|
let mut user_ids = Vec::new();
|
||||||
|
@ -447,98 +439,90 @@ async fn test_user_activity() {
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_recent_channel_messages() {
|
async fn test_recent_channel_messages() {
|
||||||
for test_db in [
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
TestDb::real().await,
|
let db = test_db.db();
|
||||||
TestDb::fake(build_background_executor()),
|
let user = db
|
||||||
] {
|
.create_user(
|
||||||
let db = test_db.db();
|
"u@example.com",
|
||||||
let user = db
|
false,
|
||||||
.create_user(
|
NewUserParams {
|
||||||
"u@example.com",
|
github_login: "u".into(),
|
||||||
false,
|
github_user_id: 1,
|
||||||
NewUserParams {
|
invite_count: 0,
|
||||||
github_login: "u".into(),
|
},
|
||||||
github_user_id: 1,
|
)
|
||||||
invite_count: 0,
|
.await
|
||||||
},
|
.unwrap()
|
||||||
)
|
.user_id;
|
||||||
.await
|
let org = db.create_org("org", "org").await.unwrap();
|
||||||
.unwrap()
|
let channel = db.create_org_channel(org, "channel").await.unwrap();
|
||||||
.user_id;
|
for i in 0..10 {
|
||||||
let org = db.create_org("org", "org").await.unwrap();
|
db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i)
|
||||||
let channel = db.create_org_channel(org, "channel").await.unwrap();
|
|
||||||
for i in 0..10 {
|
|
||||||
db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
|
|
||||||
["5", "6", "7", "8", "9"]
|
|
||||||
);
|
|
||||||
|
|
||||||
let prev_messages = db
|
|
||||||
.get_channel_messages(channel, 4, Some(messages[0].id))
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
|
||||||
prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
|
|
||||||
["1", "2", "3", "4"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
|
||||||
|
["5", "6", "7", "8", "9"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let prev_messages = db
|
||||||
|
.get_channel_messages(channel, 4, Some(messages[0].id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
|
||||||
|
["1", "2", "3", "4"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_channel_message_nonces() {
|
async fn test_channel_message_nonces() {
|
||||||
for test_db in [
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
TestDb::real().await,
|
let db = test_db.db();
|
||||||
TestDb::fake(build_background_executor()),
|
let user = db
|
||||||
] {
|
.create_user(
|
||||||
let db = test_db.db();
|
"user@example.com",
|
||||||
let user = db
|
false,
|
||||||
.create_user(
|
NewUserParams {
|
||||||
"user@example.com",
|
github_login: "user".into(),
|
||||||
false,
|
github_user_id: 1,
|
||||||
NewUserParams {
|
invite_count: 0,
|
||||||
github_login: "user".into(),
|
},
|
||||||
github_user_id: 1,
|
)
|
||||||
invite_count: 0,
|
.await
|
||||||
},
|
.unwrap()
|
||||||
)
|
.user_id;
|
||||||
.await
|
let org = db.create_org("org", "org").await.unwrap();
|
||||||
.unwrap()
|
let channel = db.create_org_channel(org, "channel").await.unwrap();
|
||||||
.user_id;
|
|
||||||
let org = db.create_org("org", "org").await.unwrap();
|
|
||||||
let channel = db.create_org_channel(org, "channel").await.unwrap();
|
|
||||||
|
|
||||||
let msg1_id = db
|
let msg1_id = db
|
||||||
.create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
|
.create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg2_id = db
|
let msg2_id = db
|
||||||
.create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
|
.create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg3_id = db
|
let msg3_id = db
|
||||||
.create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
|
.create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg4_id = db
|
let msg4_id = db
|
||||||
.create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
|
.create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_ne!(msg1_id, msg2_id);
|
assert_ne!(msg1_id, msg2_id);
|
||||||
assert_eq!(msg1_id, msg3_id);
|
assert_eq!(msg1_id, msg3_id);
|
||||||
assert_eq!(msg2_id, msg4_id);
|
assert_eq!(msg2_id, msg4_id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_create_access_tokens() {
|
async fn test_create_access_tokens() {
|
||||||
let test_db = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = test_db.db();
|
let db = test_db.db();
|
||||||
let user = db
|
let user = db
|
||||||
.create_user(
|
.create_user(
|
||||||
|
@ -582,14 +566,14 @@ async fn test_create_access_tokens() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fuzzy_like_string() {
|
fn test_fuzzy_like_string() {
|
||||||
assert_eq!(RealDb::fuzzy_like_string("abcd"), "%a%b%c%d%");
|
assert_eq!(DefaultDb::fuzzy_like_string("abcd"), "%a%b%c%d%");
|
||||||
assert_eq!(RealDb::fuzzy_like_string("x y"), "%x%y%");
|
assert_eq!(DefaultDb::fuzzy_like_string("x y"), "%x%y%");
|
||||||
assert_eq!(RealDb::fuzzy_like_string(" z "), "%z%");
|
assert_eq!(DefaultDb::fuzzy_like_string(" z "), "%z%");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_fuzzy_search_users() {
|
async fn test_fuzzy_search_users() {
|
||||||
let test_db = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = test_db.db();
|
let db = test_db.db();
|
||||||
for (i, github_login) in [
|
for (i, github_login) in [
|
||||||
"California",
|
"California",
|
||||||
|
@ -625,7 +609,7 @@ async fn test_fuzzy_search_users() {
|
||||||
&["rhode-island", "colorado", "oregon"],
|
&["rhode-island", "colorado", "oregon"],
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn fuzzy_search_user_names(db: &Arc<TestDb>, query: &str) -> Vec<String> {
|
async fn fuzzy_search_user_names(db: &DefaultDb, query: &str) -> Vec<String> {
|
||||||
db.fuzzy_search_users(query, 10)
|
db.fuzzy_search_users(query, 10)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -637,176 +621,172 @@ async fn test_fuzzy_search_users() {
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_add_contacts() {
|
async fn test_add_contacts() {
|
||||||
for test_db in [
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
TestDb::real().await,
|
let db = test_db.db();
|
||||||
TestDb::fake(build_background_executor()),
|
|
||||||
] {
|
|
||||||
let db = test_db.db();
|
|
||||||
|
|
||||||
let mut user_ids = Vec::new();
|
let mut user_ids = Vec::new();
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
user_ids.push(
|
user_ids.push(
|
||||||
db.create_user(
|
db.create_user(
|
||||||
&format!("user{i}@example.com"),
|
&format!("user{i}@example.com"),
|
||||||
false,
|
false,
|
||||||
NewUserParams {
|
NewUserParams {
|
||||||
github_login: format!("user{i}"),
|
github_login: format!("user{i}"),
|
||||||
github_user_id: i,
|
github_user_id: i,
|
||||||
invite_count: 0,
|
invite_count: 0,
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.user_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_1 = user_ids[0];
|
|
||||||
let user_2 = user_ids[1];
|
|
||||||
let user_3 = user_ids[2];
|
|
||||||
|
|
||||||
// User starts with no contacts
|
|
||||||
assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
|
|
||||||
|
|
||||||
// User requests a contact. Both users see the pending request.
|
|
||||||
db.send_contact_request(user_1, user_2).await.unwrap();
|
|
||||||
assert!(!db.has_contact(user_1, user_2).await.unwrap());
|
|
||||||
assert!(!db.has_contact(user_2, user_1).await.unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_1).await.unwrap(),
|
|
||||||
&[Contact::Outgoing { user_id: user_2 }],
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_2).await.unwrap(),
|
|
||||||
&[Contact::Incoming {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: true
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
// User 2 dismisses the contact request notification without accepting or rejecting.
|
|
||||||
// We shouldn't notify them again.
|
|
||||||
db.dismiss_contact_notification(user_1, user_2)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
db.dismiss_contact_notification(user_2, user_1)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_2).await.unwrap(),
|
|
||||||
&[Contact::Incoming {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: false
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
// User can't accept their own contact request
|
|
||||||
db.respond_to_contact_request(user_1, user_2, true)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
// User accepts a contact request. Both users see the contact.
|
|
||||||
db.respond_to_contact_request(user_2, user_1, true)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_1).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_2,
|
|
||||||
should_notify: true
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
assert!(db.has_contact(user_1, user_2).await.unwrap());
|
|
||||||
assert!(db.has_contact(user_2, user_1).await.unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_2).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: false,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Users cannot re-request existing contacts.
|
|
||||||
db.send_contact_request(user_1, user_2).await.unwrap_err();
|
|
||||||
db.send_contact_request(user_2, user_1).await.unwrap_err();
|
|
||||||
|
|
||||||
// Users can't dismiss notifications of them accepting other users' requests.
|
|
||||||
db.dismiss_contact_notification(user_2, user_1)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_1).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_2,
|
|
||||||
should_notify: true,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Users can dismiss notifications of other users accepting their requests.
|
|
||||||
db.dismiss_contact_notification(user_1, user_2)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_1).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_2,
|
|
||||||
should_notify: false,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Users send each other concurrent contact requests and
|
|
||||||
// see that they are immediately accepted.
|
|
||||||
db.send_contact_request(user_1, user_3).await.unwrap();
|
|
||||||
db.send_contact_request(user_3, user_1).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_1).await.unwrap(),
|
|
||||||
&[
|
|
||||||
Contact::Accepted {
|
|
||||||
user_id: user_2,
|
|
||||||
should_notify: false,
|
|
||||||
},
|
},
|
||||||
Contact::Accepted {
|
)
|
||||||
user_id: user_3,
|
|
||||||
should_notify: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_3).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: false
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
// User declines a contact request. Both users see that it is gone.
|
|
||||||
db.send_contact_request(user_2, user_3).await.unwrap();
|
|
||||||
db.respond_to_contact_request(user_3, user_2, false)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
assert!(!db.has_contact(user_2, user_3).await.unwrap());
|
.user_id,
|
||||||
assert!(!db.has_contact(user_3, user_2).await.unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_2).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: false
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
db.get_contacts(user_3).await.unwrap(),
|
|
||||||
&[Contact::Accepted {
|
|
||||||
user_id: user_1,
|
|
||||||
should_notify: false
|
|
||||||
}],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user_1 = user_ids[0];
|
||||||
|
let user_2 = user_ids[1];
|
||||||
|
let user_3 = user_ids[2];
|
||||||
|
|
||||||
|
// User starts with no contacts
|
||||||
|
assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
|
||||||
|
|
||||||
|
// User requests a contact. Both users see the pending request.
|
||||||
|
db.send_contact_request(user_1, user_2).await.unwrap();
|
||||||
|
assert!(!db.has_contact(user_1, user_2).await.unwrap());
|
||||||
|
assert!(!db.has_contact(user_2, user_1).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_1).await.unwrap(),
|
||||||
|
&[Contact::Outgoing { user_id: user_2 }],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_2).await.unwrap(),
|
||||||
|
&[Contact::Incoming {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: true
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// User 2 dismisses the contact request notification without accepting or rejecting.
|
||||||
|
// We shouldn't notify them again.
|
||||||
|
db.dismiss_contact_notification(user_1, user_2)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
db.dismiss_contact_notification(user_2, user_1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_2).await.unwrap(),
|
||||||
|
&[Contact::Incoming {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// User can't accept their own contact request
|
||||||
|
db.respond_to_contact_request(user_1, user_2, true)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
// User accepts a contact request. Both users see the contact.
|
||||||
|
db.respond_to_contact_request(user_2, user_1, true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_1).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_2,
|
||||||
|
should_notify: true
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(db.has_contact(user_1, user_2).await.unwrap());
|
||||||
|
assert!(db.has_contact(user_2, user_1).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_2).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: false,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Users cannot re-request existing contacts.
|
||||||
|
db.send_contact_request(user_1, user_2).await.unwrap_err();
|
||||||
|
db.send_contact_request(user_2, user_1).await.unwrap_err();
|
||||||
|
|
||||||
|
// Users can't dismiss notifications of them accepting other users' requests.
|
||||||
|
db.dismiss_contact_notification(user_2, user_1)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_1).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_2,
|
||||||
|
should_notify: true,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Users can dismiss notifications of other users accepting their requests.
|
||||||
|
db.dismiss_contact_notification(user_1, user_2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_1).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_2,
|
||||||
|
should_notify: false,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Users send each other concurrent contact requests and
|
||||||
|
// see that they are immediately accepted.
|
||||||
|
db.send_contact_request(user_1, user_3).await.unwrap();
|
||||||
|
db.send_contact_request(user_3, user_1).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_1).await.unwrap(),
|
||||||
|
&[
|
||||||
|
Contact::Accepted {
|
||||||
|
user_id: user_2,
|
||||||
|
should_notify: false,
|
||||||
|
},
|
||||||
|
Contact::Accepted {
|
||||||
|
user_id: user_3,
|
||||||
|
should_notify: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_3).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: false
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
// User declines a contact request. Both users see that it is gone.
|
||||||
|
db.send_contact_request(user_2, user_3).await.unwrap();
|
||||||
|
db.respond_to_contact_request(user_3, user_2, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!db.has_contact(user_2, user_3).await.unwrap());
|
||||||
|
assert!(!db.has_contact(user_3, user_2).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_2).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.get_contacts(user_3).await.unwrap(),
|
||||||
|
&[Contact::Accepted {
|
||||||
|
user_id: user_1,
|
||||||
|
should_notify: false
|
||||||
|
}],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_invite_codes() {
|
async fn test_invite_codes() {
|
||||||
let postgres = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = postgres.db();
|
let db = test_db.db();
|
||||||
let NewUserResult { user_id: user1, .. } = db
|
let NewUserResult { user_id: user1, .. } = db
|
||||||
.create_user(
|
.create_user(
|
||||||
"user1@example.com",
|
"user1@example.com",
|
||||||
|
@ -1000,8 +980,8 @@ async fn test_invite_codes() {
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_signups() {
|
async fn test_signups() {
|
||||||
let postgres = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = postgres.db();
|
let db = test_db.db();
|
||||||
|
|
||||||
// people sign up on the waitlist
|
// people sign up on the waitlist
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
|
@ -1146,8 +1126,8 @@ async fn test_signups() {
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_metrics_id() {
|
async fn test_metrics_id() {
|
||||||
let postgres = TestDb::real().await;
|
let test_db = TestDb::new(build_background_executor()).await;
|
||||||
let db = postgres.db();
|
let db = test_db.db();
|
||||||
|
|
||||||
let NewUserResult {
|
let NewUserResult {
|
||||||
user_id: user1,
|
user_id: user1,
|
||||||
|
|
|
@ -6104,7 +6104,7 @@ impl TestServer {
|
||||||
.enable_time()
|
.enable_time()
|
||||||
.build()
|
.build()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.block_on(TestDb::real());
|
.block_on(TestDb::new(background.clone()));
|
||||||
let live_kit_server_id = NEXT_LIVE_KIT_SERVER_ID.fetch_add(1, SeqCst);
|
let live_kit_server_id = NEXT_LIVE_KIT_SERVER_ID.fetch_add(1, SeqCst);
|
||||||
let live_kit_server = live_kit_client::TestServer::create(
|
let live_kit_server = live_kit_client::TestServer::create(
|
||||||
format!("http://livekit.{}.test", live_kit_server_id),
|
format!("http://livekit.{}.test", live_kit_server_id),
|
||||||
|
|
|
@ -18,7 +18,7 @@ use serde::Deserialize;
|
||||||
use std::{
|
use std::{
|
||||||
env::args,
|
env::args,
|
||||||
net::{SocketAddr, TcpListener},
|
net::{SocketAddr, TcpListener},
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
@ -101,8 +101,7 @@ async fn main() -> Result<()> {
|
||||||
let migrations_path = config
|
let migrations_path = config
|
||||||
.migrations_path
|
.migrations_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(db::DEFAULT_MIGRATIONS_PATH.map(|s| s.as_ref()))
|
.unwrap_or_else(|| Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")));
|
||||||
.ok_or_else(|| anyhow!("missing MIGRATIONS_PATH environment variable"))?;
|
|
||||||
|
|
||||||
let migrations = db.migrate(&migrations_path, false).await?;
|
let migrations = db.migrate(&migrations_path, false).await?;
|
||||||
for (migration, duration) in migrations {
|
for (migration, duration) in migrations {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue