diff --git a/crates/collab/migrations/20220913211150_create_signups.down.sql b/crates/collab/migrations/20220913211150_create_signups.down.sql new file mode 100644 index 0000000000000000000000000000000000000000..6ef51842c9ae3800752c62d7419df132cdf16134 --- /dev/null +++ b/crates/collab/migrations/20220913211150_create_signups.down.sql @@ -0,0 +1,6 @@ +DROP TABLE signups; + +ALTER TABLE users + DROP COLUMN metrics_id; + +DROP SEQUENCE metrics_id_seq; diff --git a/crates/collab/migrations/20220913211150_create_signups.sql b/crates/collab/migrations/20220913211150_create_signups.up.sql similarity index 81% rename from crates/collab/migrations/20220913211150_create_signups.sql rename to crates/collab/migrations/20220913211150_create_signups.up.sql index 783cdf8c0aca8eb87d7b4711d66d90b4499cb230..9acb313fd631da8c5463b25602dbcf5fdb516f80 100644 --- a/crates/collab/migrations/20220913211150_create_signups.sql +++ b/crates/collab/migrations/20220913211150_create_signups.up.sql @@ -8,16 +8,18 @@ CREATE TABLE IF NOT EXISTS "signups" ( "metrics_id" INTEGER NOT NULL DEFAULT nextval('metrics_id_seq'), "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "user_id" INTEGER REFERENCES users (id), + "inviting_user_id" INTEGER REFERENCES users (id), "platform_mac" BOOLEAN NOT NULL, "platform_linux" BOOLEAN NOT NULL, "platform_windows" BOOLEAN NOT NULL, "platform_unknown" BOOLEAN NOT NULL, - "editor_features" VARCHAR[] NOT NULL, - "programming_languages" VARCHAR[] NOT NULL + "editor_features" VARCHAR[], + "programming_languages" VARCHAR[] ); +CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address"); CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent"); diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 42825d25ff682241a28a341b310f81931a782172..26521ceb27f956491f9e19665813ac69d7e4cd04 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -1,6 +1,6 @@ use crate::{ auth, - db::{ProjectId, Signup, SignupInvite, SignupRedemption, User, UserId}, + db::{Invite, NewUserParams, ProjectId, Signup, User, UserId}, rpc::{self, ResultExt}, AppState, Error, Result, }; @@ -46,9 +46,9 @@ pub fn routes(rpc_server: &Arc, state: Arc) -> Router, - email_address: Option, - admin: bool, + email_address: String, + email_confirmation_code: Option, + invite_count: i32, } async fn create_user( @@ -123,29 +123,38 @@ async fn create_user( Extension(app): Extension>, Extension(rpc_server): Extension>, ) -> Result> { - let user_id = if let Some(invite_code) = params.invite_code { - let invitee_id = app - .db - .redeem_invite_code( - &invite_code, - ¶ms.github_login, - params.email_address.as_deref(), + let (user_id, inviter_id) = + // Creating a user via the normal signup process + if let Some(email_confirmation_code) = params.email_confirmation_code { + app.db + .create_user_from_invite( + &Invite { + email_address: params.email_address, + email_confirmation_code, + }, + NewUserParams { + github_login: params.github_login, + invite_count: params.invite_count, + }, + ) + .await? + } + // Creating a user as an admin + else { + ( + app.db + .create_user(¶ms.github_login, ¶ms.email_address, false) + .await?, + None, ) - .await?; + }; + + if let Some(inviter_id) = inviter_id { rpc_server - .invite_code_redeemed(&invite_code, invitee_id) + .invite_code_redeemed(inviter_id, user_id) .await .trace_err(); - invitee_id - } else { - app.db - .create_user( - ¶ms.github_login, - params.email_address.as_deref(), - params.admin, - ) - .await? - }; + } let user = app .db @@ -175,7 +184,9 @@ async fn update_user( } if let Some(invite_count) = params.invite_count { - app.db.set_invite_count(user_id, invite_count).await?; + app.db + .set_invite_count_for_user(user_id, invite_count) + .await?; rpc_server.invite_count_updated(user_id).await.trace_err(); } @@ -428,30 +439,39 @@ async fn create_signup( Ok(()) } -async fn redeem_signup( - Json(redemption): Json, - Extension(app): Extension>, -) -> Result<()> { - app.db.redeem_signup(redemption).await?; - Ok(()) +#[derive(Deserialize)] +pub struct CreateInviteFromCodeParams { + invite_code: String, + email_address: String, } -async fn record_signup_invites_sent( - Json(params): Json>, +async fn create_invite_from_code( + Json(params): Json, Extension(app): Extension>, -) -> Result<()> { - app.db.record_signup_invites_sent(¶ms).await?; - Ok(()) +) -> Result> { + Ok(Json( + app.db + .create_invite_from_code(¶ms.invite_code, ¶ms.email_address) + .await?, + )) } #[derive(Deserialize)] -pub struct GetSignupInvitesParams { +pub struct GetUnsentInvitesParams { pub count: usize, } -async fn get_signup_invites( - Query(params): Query, +async fn get_unsent_invites( + Query(params): Query, Extension(app): Extension>, -) -> Result>> { - Ok(Json(app.db.get_signup_invites(params.count).await?)) +) -> Result>> { + Ok(Json(app.db.get_unsent_invites(params.count).await?)) +} + +async fn record_sent_invites( + Json(params): Json>, + Extension(app): Extension>, +) -> Result<()> { + app.db.record_sent_invites(¶ms).await?; + Ok(()) } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 86dca6de98e7cfc35ca7feae4a2b88b25dc0023c..9c1ab84570606ef48c7927770e3144394fa2e241 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,5 +1,3 @@ -use std::{cmp, ops::Range, time::Duration}; - use crate::{Error, Result}; use anyhow::{anyhow, Context}; use async_trait::async_trait; @@ -9,6 +7,7 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; pub use sqlx::postgres::PgPoolOptions as DbOptions; use sqlx::{types::Uuid, FromRow, QueryBuilder, Row}; +use std::{cmp, ops::Range, time::Duration}; use time::{OffsetDateTime, PrimitiveDateTime}; #[async_trait] @@ -16,7 +15,7 @@ pub trait Db: Send + Sync { async fn create_user( &self, github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, ) -> Result; async fn get_all_users(&self, page: u32, limit: u32) -> Result>; @@ -30,20 +29,19 @@ pub trait Db: Send + Sync { async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()>; async fn destroy_user(&self, id: UserId) -> Result<()>; - async fn create_signup(&self, signup: Signup) -> Result<()>; - async fn get_signup_invites(&self, count: usize) -> Result>; - async fn record_signup_invites_sent(&self, signups: &[SignupInvite]) -> Result<()>; - async fn redeem_signup(&self, redemption: SignupRedemption) -> Result; - - async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()>; + async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()>; async fn get_invite_code_for_user(&self, id: UserId) -> Result>; async fn get_user_for_invite_code(&self, code: &str) -> Result; - async fn redeem_invite_code( + async fn create_invite_from_code(&self, code: &str, email_address: &str) -> Result; + + async fn create_signup(&self, signup: Signup) -> Result<()>; + async fn get_unsent_invites(&self, count: usize) -> Result>; + async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>; + async fn create_user_from_invite( &self, - code: &str, - login: &str, - email_address: Option<&str>, - ) -> Result; + invite: &Invite, + user: NewUserParams, + ) -> Result<(UserId, Option)>; /// Registers a new project for the given user. async fn register_project(&self, host_user_id: UserId) -> Result; @@ -120,8 +118,8 @@ pub trait Db: Send + Sync { max_access_token_count: usize, ) -> Result<()>; async fn get_access_token_hashes(&self, user_id: UserId) -> Result>; - #[cfg(any(test, feature = "seed-support"))] + #[cfg(any(test, feature = "seed-support"))] async fn find_org_by_slug(&self, slug: &str) -> Result>; #[cfg(any(test, feature = "seed-support"))] async fn create_org(&self, name: &str, slug: &str) -> Result; @@ -135,6 +133,7 @@ pub trait Db: Send + Sync { async fn get_accessible_channels(&self, user_id: UserId) -> Result>; async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId) -> Result; + #[cfg(any(test, feature = "seed-support"))] async fn add_channel_member( &self, @@ -156,10 +155,12 @@ pub trait Db: Send + Sync { count: usize, before_id: Option, ) -> Result>; + #[cfg(test)] async fn teardown(&self, url: &str); + #[cfg(test)] - fn as_fake(&self) -> Option<&tests::FakeDb>; + fn as_fake(&self) -> Option<&FakeDb>; } pub struct PostgresDb { @@ -175,6 +176,18 @@ impl PostgresDb { .context("failed to connect to postgres database")?; Ok(Self { pool }) } + + pub fn fuzzy_like_string(string: &str) -> String { + let mut result = String::with_capacity(string.len() * 2 + 1); + for c in string.chars() { + if c.is_alphanumeric() { + result.push('%'); + result.push(c); + } + } + result.push('%'); + result + } } #[async_trait] @@ -184,7 +197,7 @@ impl Db for PostgresDb { async fn create_user( &self, github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, ) -> Result { let query = " @@ -247,7 +260,7 @@ impl Db for PostgresDb { } async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result> { - let like_string = fuzzy_like_string(name_query); + let like_string = Self::fuzzy_like_string(name_query); let query = " SELECT users.* FROM users @@ -371,16 +384,16 @@ impl Db for PostgresDb { Ok(()) } - async fn get_signup_invites(&self, count: usize) -> Result> { + async fn get_unsent_invites(&self, count: usize) -> Result> { Ok(sqlx::query_as( " - SELECT - email_address, email_confirmation_code - FROM signups - WHERE - NOT email_confirmation_sent AND - platform_mac - LIMIT $1 + SELECT + email_address, email_confirmation_code + FROM signups + WHERE + NOT email_confirmation_sent AND + platform_mac + LIMIT $1 ", ) .bind(count as i32) @@ -388,16 +401,16 @@ impl Db for PostgresDb { .await?) } - async fn record_signup_invites_sent(&self, signups: &[SignupInvite]) -> Result<()> { + async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> { sqlx::query( " - UPDATE signups - SET email_confirmation_sent = 't' - WHERE email_address = ANY ($1) + UPDATE signups + SET email_confirmation_sent = 't' + WHERE email_address = ANY ($1) ", ) .bind( - &signups + &invites .iter() .map(|s| s.email_address.as_str()) .collect::>(), @@ -407,36 +420,41 @@ impl Db for PostgresDb { Ok(()) } - async fn redeem_signup(&self, redemption: SignupRedemption) -> Result { + async fn create_user_from_invite( + &self, + invite: &Invite, + user: NewUserParams, + ) -> Result<(UserId, Option)> { let mut tx = self.pool.begin().await?; - let signup_id: i32 = sqlx::query_scalar( + + let (signup_id, inviting_user_id): (i32, Option) = sqlx::query_as( " - SELECT id + SELECT id, inviting_user_id FROM signups WHERE email_address = $1 AND email_confirmation_code = $2 AND - email_confirmation_sent AND user_id is NULL ", ) - .bind(&redemption.email_address) - .bind(&redemption.email_confirmation_code) - .fetch_one(&mut tx) - .await?; + .bind(&invite.email_address) + .bind(&invite.email_confirmation_code) + .fetch_optional(&mut tx) + .await? + .ok_or_else(|| anyhow!("no such invite"))?; - let user_id: i32 = sqlx::query_scalar( + let user_id: UserId = sqlx::query_scalar( " INSERT INTO users - (email_address, github_login, admin, invite_count, invite_code) + (email_address, github_login, admin, invite_count, invite_code) VALUES - ($1, $2, 'f', $3, $4) + ($1, $2, 'f', $3, $4) RETURNING id ", ) - .bind(&redemption.email_address) - .bind(&redemption.github_login) - .bind(&redemption.invite_count) + .bind(&invite.email_address) + .bind(&user.github_login) + .bind(&user.invite_count) .bind(random_invite_code()) .fetch_one(&mut tx) .await?; @@ -453,13 +471,47 @@ impl Db for PostgresDb { .execute(&mut tx) .await?; + if let Some(inviting_user_id) = inviting_user_id { + let id: Option = sqlx::query_scalar( + " + UPDATE users + SET invite_count = invite_count - 1 + WHERE id = $1 AND invite_count > 0 + RETURNING id + ", + ) + .bind(&inviting_user_id) + .fetch_optional(&mut tx) + .await?; + + if id.is_none() { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } + + sqlx::query( + " + INSERT INTO contacts + (user_id_a, user_id_b, a_to_b, should_notify, accepted) + VALUES + ($1, $2, 't', 't', 't') + ", + ) + .bind(inviting_user_id) + .bind(user_id) + .execute(&mut tx) + .await?; + } + tx.commit().await?; - Ok(UserId(user_id)) + Ok((user_id, inviting_user_id)) } // invite codes - async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> { + async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()> { let mut tx = self.pool.begin().await?; if count > 0 { sqlx::query( @@ -527,83 +579,82 @@ impl Db for PostgresDb { }) } - async fn redeem_invite_code( - &self, - code: &str, - login: &str, - email_address: Option<&str>, - ) -> Result { + async fn create_invite_from_code(&self, code: &str, email_address: &str) -> Result { let mut tx = self.pool.begin().await?; - let inviter_id: Option = sqlx::query_scalar( + let existing_user: Option = sqlx::query_scalar( " - UPDATE users - SET invite_count = invite_count - 1 - WHERE - invite_code = $1 AND - invite_count > 0 - RETURNING id + SELECT id + FROM users + WHERE email_address = $1 ", ) - .bind(code) + .bind(email_address) .fetch_optional(&mut tx) .await?; + if existing_user.is_some() { + Err(anyhow!("email address is already in use"))?; + } - let inviter_id = match inviter_id { - Some(inviter_id) => inviter_id, - None => { - if sqlx::query_scalar::<_, i32>("SELECT 1 FROM users WHERE invite_code = $1") - .bind(code) - .fetch_optional(&mut tx) - .await? - .is_some() - { - Err(Error::Http( - StatusCode::UNAUTHORIZED, - "no invites remaining".to_string(), - ))? - } else { - Err(Error::Http( - StatusCode::NOT_FOUND, - "invite code not found".to_string(), - ))? - } - } - }; - - let invitee_id = sqlx::query_scalar( + let row: Option<(UserId, i32)> = sqlx::query_as( " - INSERT INTO users - (github_login, email_address, admin, inviter_id, invite_code, invite_count) - VALUES - ($1, $2, 'f', $3, $4, $5) - RETURNING id + SELECT id, invite_count + FROM users + WHERE invite_code = $1 ", ) - .bind(login) - .bind(email_address) - .bind(inviter_id) - .bind(random_invite_code()) - .bind(5) - .fetch_one(&mut tx) - .await - .map(UserId)?; + .bind(code) + .fetch_optional(&mut tx) + .await?; - sqlx::query( + let (inviter_id, invite_count) = match row { + Some(row) => row, + None => Err(Error::Http( + StatusCode::NOT_FOUND, + "invite code not found".to_string(), + ))?, + }; + + if invite_count == 0 { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } + + let email_confirmation_code: String = sqlx::query_scalar( " - INSERT INTO contacts - (user_id_a, user_id_b, a_to_b, should_notify, accepted) - VALUES - ($1, $2, 't', 't', 't') + INSERT INTO signups + ( + email_address, + email_confirmation_code, + email_confirmation_sent, + inviting_user_id, + platform_linux, + platform_mac, + platform_windows, + platform_unknown + ) + VALUES + ($1, $2, 'f', $3, 'f', 'f', 'f', 't') + ON CONFLICT (email_address) + DO UPDATE SET + inviting_user_id = excluded.inviting_user_id + RETURNING email_confirmation_code ", ) - .bind(inviter_id) - .bind(invitee_id) - .execute(&mut tx) + .bind(&email_address) + .bind(&random_email_confirmation_code()) + .bind(&inviter_id) + .fetch_one(&mut tx) .await?; tx.commit().await?; - Ok(invitee_id) + + Ok(Invite { + email_address: email_address.into(), + email_confirmation_code, + }) } // projects @@ -1418,7 +1469,7 @@ impl Db for PostgresDb { } #[cfg(test)] - fn as_fake(&self) -> Option<&tests::FakeDb> { + fn as_fake(&self) -> Option<&FakeDb> { None } } @@ -1495,19 +1546,19 @@ pub struct UserActivitySummary { #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ProjectActivitySummary { - id: ProjectId, - duration: Duration, - max_collaborators: usize, + pub id: ProjectId, + pub duration: Duration, + pub max_collaborators: usize, } #[derive(Clone, Debug, PartialEq, Serialize)] pub struct UserActivityPeriod { - project_id: ProjectId, + pub project_id: ProjectId, #[serde(with = "time::serde::iso8601")] - start: OffsetDateTime, + pub start: OffsetDateTime, #[serde(with = "time::serde::iso8601")] - end: OffsetDateTime, - extensions: HashMap, + pub end: OffsetDateTime, + pub extensions: HashMap, } id_type!(OrgId); @@ -1580,31 +1631,17 @@ pub struct Signup { } #[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)] -pub struct SignupInvite { +pub struct Invite { pub email_address: String, pub email_confirmation_code: String, } #[derive(Debug, Serialize, Deserialize)] -pub struct SignupRedemption { - pub email_address: String, - pub email_confirmation_code: String, +pub struct NewUserParams { pub github_login: String, pub invite_count: i32, } -fn fuzzy_like_string(string: &str) -> String { - let mut result = String::with_capacity(string.len() * 2 + 1); - for c in string.chars() { - if c.is_alphanumeric() { - result.push('%'); - result.push(c); - } - } - result.push('%'); - result -} - fn random_invite_code() -> String { nanoid::nanoid!(16) } @@ -1614,11 +1651,14 @@ fn random_email_confirmation_code() -> String { } #[cfg(test)] -pub mod tests { +pub use test::*; + +#[cfg(test)] +mod test { use super::*; use anyhow::anyhow; use collections::BTreeMap; - use gpui::executor::{Background, Deterministic}; + use gpui::executor::Background; use lazy_static::lazy_static; use parking_lot::Mutex; use rand::prelude::*; @@ -1629,1077 +1669,6 @@ pub mod tests { use std::{path::Path, sync::Arc}; use util::post_inc; - #[tokio::test(flavor = "multi_thread")] - async fn test_get_users_by_ids() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - - let user = db.create_user("user", None, false).await.unwrap(); - let friend1 = db.create_user("friend-1", None, false).await.unwrap(); - let friend2 = db.create_user("friend-2", None, false).await.unwrap(); - let friend3 = db.create_user("friend-3", None, false).await.unwrap(); - - assert_eq!( - db.get_users_by_ids(vec![user, friend1, friend2, friend3]) - .await - .unwrap(), - vec![ - User { - id: user, - github_login: "user".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend1, - github_login: "friend-1".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend2, - github_login: "friend-2".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend3, - github_login: "friend-3".to_string(), - admin: false, - ..Default::default() - } - ] - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_create_users() { - let db = TestDb::postgres().await; - let db = db.db(); - - // Create the first batch of users, ensuring invite counts are assigned - // correctly and the respective invite codes are unique. - let user_ids_batch_1 = db - .create_users(vec![ - ("user1".to_string(), "hi@user1.com".to_string(), 5), - ("user2".to_string(), "hi@user2.com".to_string(), 4), - ("user3".to_string(), "hi@user3.com".to_string(), 3), - ]) - .await - .unwrap(); - assert_eq!(user_ids_batch_1.len(), 3); - - let users = db.get_users_by_ids(user_ids_batch_1.clone()).await.unwrap(); - assert_eq!(users.len(), 3); - assert_eq!(users[0].github_login, "user1"); - assert_eq!(users[0].email_address.as_deref(), Some("hi@user1.com")); - assert_eq!(users[0].invite_count, 5); - assert_eq!(users[1].github_login, "user2"); - assert_eq!(users[1].email_address.as_deref(), Some("hi@user2.com")); - assert_eq!(users[1].invite_count, 4); - assert_eq!(users[2].github_login, "user3"); - assert_eq!(users[2].email_address.as_deref(), Some("hi@user3.com")); - assert_eq!(users[2].invite_count, 3); - - let invite_code_1 = users[0].invite_code.clone().unwrap(); - let invite_code_2 = users[1].invite_code.clone().unwrap(); - let invite_code_3 = users[2].invite_code.clone().unwrap(); - assert_ne!(invite_code_1, invite_code_2); - assert_ne!(invite_code_1, invite_code_3); - assert_ne!(invite_code_2, invite_code_3); - - // Create the second batch of users and include a user that is already in the database, ensuring - // the invite count for the existing user is updated without changing their invite code. - let user_ids_batch_2 = db - .create_users(vec![ - ("user2".to_string(), "hi@user2.com".to_string(), 10), - ("user4".to_string(), "hi@user4.com".to_string(), 2), - ]) - .await - .unwrap(); - assert_eq!(user_ids_batch_2.len(), 2); - assert_eq!(user_ids_batch_2[0], user_ids_batch_1[1]); - - let users = db.get_users_by_ids(user_ids_batch_2).await.unwrap(); - assert_eq!(users.len(), 2); - assert_eq!(users[0].github_login, "user2"); - assert_eq!(users[0].email_address.as_deref(), Some("hi@user2.com")); - assert_eq!(users[0].invite_count, 10); - assert_eq!(users[0].invite_code, Some(invite_code_2.clone())); - assert_eq!(users[1].github_login, "user4"); - assert_eq!(users[1].email_address.as_deref(), Some("hi@user4.com")); - assert_eq!(users[1].invite_count, 2); - - let invite_code_4 = users[1].invite_code.clone().unwrap(); - assert_ne!(invite_code_4, invite_code_1); - assert_ne!(invite_code_4, invite_code_2); - assert_ne!(invite_code_4, invite_code_3); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_worktree_extensions() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - - let user = db.create_user("user_1", None, false).await.unwrap(); - let project = db.register_project(user).await.unwrap(); - - db.update_worktree_extensions(project, 100, Default::default()) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 100, - [("rs".to_string(), 5), ("md".to_string(), 3)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 100, - [("rs".to_string(), 6), ("md".to_string(), 5)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 101, - [("ts".to_string(), 2), ("md".to_string(), 1)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - - assert_eq!( - db.get_project_extensions(project).await.unwrap(), - [ - ( - 100, - [("rs".into(), 6), ("md".into(), 5),] - .into_iter() - .collect::>() - ), - ( - 101, - [("ts".into(), 2), ("md".into(), 1),] - .into_iter() - .collect::>() - ) - ] - .into_iter() - .collect() - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_user_activity() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - - let user_1 = db.create_user("user_1", None, false).await.unwrap(); - let user_2 = db.create_user("user_2", None, false).await.unwrap(); - let user_3 = db.create_user("user_3", None, false).await.unwrap(); - let project_1 = db.register_project(user_1).await.unwrap(); - db.update_worktree_extensions( - project_1, - 1, - HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]), - ) - .await - .unwrap(); - let project_2 = db.register_project(user_2).await.unwrap(); - let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60); - - // User 2 opens a project - let t1 = t0 + Duration::from_secs(10); - db.record_user_activity(t0..t1, &[(user_2, project_2)]) - .await - .unwrap(); - - let t2 = t1 + Duration::from_secs(10); - db.record_user_activity(t1..t2, &[(user_2, project_2)]) - .await - .unwrap(); - - // User 1 joins the project - let t3 = t2 + Duration::from_secs(10); - db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)]) - .await - .unwrap(); - - // User 1 opens another project - let t4 = t3 + Duration::from_secs(10); - db.record_user_activity( - t3..t4, - &[ - (user_2, project_2), - (user_1, project_2), - (user_1, project_1), - ], - ) - .await - .unwrap(); - - // User 3 joins that project - let t5 = t4 + Duration::from_secs(10); - db.record_user_activity( - t4..t5, - &[ - (user_2, project_2), - (user_1, project_2), - (user_1, project_1), - (user_3, project_1), - ], - ) - .await - .unwrap(); - - // User 2 leaves - let t6 = t5 + Duration::from_secs(5); - db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)]) - .await - .unwrap(); - - let t7 = t6 + Duration::from_secs(60); - let t8 = t7 + Duration::from_secs(10); - db.record_user_activity(t7..t8, &[(user_1, project_1)]) - .await - .unwrap(); - - assert_eq!( - db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(), - &[ - UserActivitySummary { - id: user_1, - github_login: "user_1".to_string(), - project_activity: vec![ - ProjectActivitySummary { - id: project_1, - duration: Duration::from_secs(25), - max_collaborators: 2 - }, - ProjectActivitySummary { - id: project_2, - duration: Duration::from_secs(30), - max_collaborators: 2 - } - ] - }, - UserActivitySummary { - id: user_2, - github_login: "user_2".to_string(), - project_activity: vec![ProjectActivitySummary { - id: project_2, - duration: Duration::from_secs(50), - max_collaborators: 2 - }] - }, - UserActivitySummary { - id: user_3, - github_login: "user_3".to_string(), - project_activity: vec![ProjectActivitySummary { - id: project_1, - duration: Duration::from_secs(15), - max_collaborators: 2 - }] - }, - ] - ); - - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(56), false) - .await - .unwrap(), - 0 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(56), true) - .await - .unwrap(), - 0 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(54), false) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(54), true) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(30), false) - .await - .unwrap(), - 2 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(30), true) - .await - .unwrap(), - 2 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(10), false) - .await - .unwrap(), - 3 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(10), true) - .await - .unwrap(), - 3 - ); - assert_eq!( - db.get_active_user_count(t0..t1, Duration::from_secs(5), false) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t1, Duration::from_secs(5), true) - .await - .unwrap(), - 0 - ); - - assert_eq!( - db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(), - &[ - UserActivityPeriod { - project_id: project_1, - start: t3, - end: t6, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - UserActivityPeriod { - project_id: project_2, - start: t3, - end: t5, - extensions: Default::default(), - }, - ] - ); - assert_eq!( - db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(), - &[ - UserActivityPeriod { - project_id: project_2, - start: t2, - end: t5, - extensions: Default::default(), - }, - UserActivityPeriod { - project_id: project_1, - start: t3, - end: t6, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - UserActivityPeriod { - project_id: project_1, - start: t7, - end: t8, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - ] - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_recent_channel_messages() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - let user = db.create_user("user", None, false).await.unwrap(); - let org = db.create_org("org", "org").await.unwrap(); - 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::>(), - ["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::>(), - ["1", "2", "3", "4"] - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_channel_message_nonces() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - let user = db.create_user("user", None, false).await.unwrap(); - let org = db.create_org("org", "org").await.unwrap(); - let channel = db.create_org_channel(org, "channel").await.unwrap(); - - let msg1_id = db - .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) - .await - .unwrap(); - let msg2_id = db - .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2) - .await - .unwrap(); - let msg3_id = db - .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1) - .await - .unwrap(); - let msg4_id = db - .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2) - .await - .unwrap(); - - assert_ne!(msg1_id, msg2_id); - assert_eq!(msg1_id, msg3_id); - assert_eq!(msg2_id, msg4_id); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_create_access_tokens() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - let user = db.create_user("the-user", None, false).await.unwrap(); - - db.create_access_token_hash(user, "h1", 3).await.unwrap(); - db.create_access_token_hash(user, "h2", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h2".to_string(), "h1".to_string()] - ); - - db.create_access_token_hash(user, "h3", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h3".to_string(), "h2".to_string(), "h1".to_string(),] - ); - - db.create_access_token_hash(user, "h4", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h4".to_string(), "h3".to_string(), "h2".to_string(),] - ); - - db.create_access_token_hash(user, "h5", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h5".to_string(), "h4".to_string(), "h3".to_string()] - ); - } - - #[test] - fn test_fuzzy_like_string() { - assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%"); - assert_eq!(fuzzy_like_string("x y"), "%x%y%"); - assert_eq!(fuzzy_like_string(" z "), "%z%"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_fuzzy_search_users() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - for github_login in [ - "California", - "colorado", - "oregon", - "washington", - "florida", - "delaware", - "rhode-island", - ] { - db.create_user(github_login, None, false).await.unwrap(); - } - - assert_eq!( - fuzzy_search_user_names(db, "clr").await, - &["colorado", "California"] - ); - assert_eq!( - fuzzy_search_user_names(db, "ro").await, - &["rhode-island", "colorado", "oregon"], - ); - - async fn fuzzy_search_user_names(db: &Arc, query: &str) -> Vec { - db.fuzzy_search_users(query, 10) - .await - .unwrap() - .into_iter() - .map(|user| user.github_login) - .collect::>() - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_add_contacts() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - - let user_1 = db.create_user("user1", None, false).await.unwrap(); - let user_2 = db.create_user("user2", None, false).await.unwrap(); - let user_3 = db.create_user("user3", None, false).await.unwrap(); - - // User starts with no contacts - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - vec![Contact::Accepted { - user_id: user_1, - should_notify: false - }], - ); - - // 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::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Outgoing { user_id: user_2 } - ], - ); - assert_eq!( - db.get_contacts(user_2).await.unwrap(), - &[ - Contact::Incoming { - user_id: user_1, - should_notify: true - }, - Contact::Accepted { - user_id: user_2, - should_notify: false - }, - ] - ); - - // 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 - }, - Contact::Accepted { - user_id: user_2, - 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_1, - should_notify: false - }, - 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, - }, - Contact::Accepted { - user_id: user_2, - 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_1, - should_notify: false - }, - 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_1, - should_notify: false - }, - 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_1, - should_notify: false - }, - 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 - }, - Contact::Accepted { - user_id: user_3, - 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 - }, - Contact::Accepted { - user_id: user_2, - should_notify: false - } - ] - ); - assert_eq!( - db.get_contacts(user_3).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_3, - should_notify: false - } - ], - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_invite_codes() { - let postgres = TestDb::postgres().await; - let db = postgres.db(); - let user1 = db.create_user("user-1", None, false).await.unwrap(); - - // Initially, user 1 has no invite code - assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); - - // Setting invite count to 0 when no code is assigned does not assign a new code - db.set_invite_count(user1, 0).await.unwrap(); - assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); - - // User 1 creates an invite code that can be used twice. - db.set_invite_count(user1, 2).await.unwrap(); - let (invite_code, invite_count) = - db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 2); - - // User 2 redeems the invite code and becomes a contact of user 1. - let user2 = db - .redeem_invite_code(&invite_code, "user-2", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user2).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: false - } - ] - ); - - // User 3 redeems the invite code and becomes a contact of user 1. - let user3 = db - .redeem_invite_code(&invite_code, "user-3", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 0); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - }, - Contact::Accepted { - user_id: user3, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user3).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user3, - should_notify: false - }, - ] - ); - - // Trying to reedem the code for the third time results in an error. - db.redeem_invite_code(&invite_code, "user-4", None) - .await - .unwrap_err(); - - // Invite count can be updated after the code has been created. - db.set_invite_count(user1, 2).await.unwrap(); - let (latest_code, invite_count) = - db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 - assert_eq!(invite_count, 2); - - // User 4 can now redeem the invite code and becomes a contact of user 1. - let user4 = db - .redeem_invite_code(&invite_code, "user-4", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - }, - Contact::Accepted { - user_id: user3, - should_notify: true - }, - Contact::Accepted { - user_id: user4, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user4).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user4, - should_notify: false - }, - ] - ); - - // An existing user cannot redeem invite codes. - db.redeem_invite_code(&invite_code, "user-2", None) - .await - .unwrap_err(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - - // Ensure invited users get invite codes too. - assert_eq!( - db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, - 5 - ); - assert_eq!( - db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, - 5 - ); - assert_eq!( - db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, - 5 - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_signups() { - let postgres = TestDb::postgres().await; - let db = postgres.db(); - - // people sign up on the waitlist - for i in 0..8 { - db.create_signup(Signup { - email_address: format!("person-{i}@example.com"), - platform_mac: true, - platform_linux: true, - platform_windows: false, - editor_features: vec!["speed".into()], - programming_languages: vec!["rust".into(), "c".into()], - }) - .await - .unwrap(); - } - - // retrieve the next batch of signup emails to send - let signups_batch1 = db.get_signup_invites(3).await.unwrap(); - let addresses = signups_batch1 - .iter() - .map(|s| &s.email_address) - .collect::>(); - assert_eq!( - addresses, - &[ - "person-0@example.com", - "person-1@example.com", - "person-2@example.com" - ] - ); - assert_ne!( - signups_batch1[0].email_confirmation_code, - signups_batch1[1].email_confirmation_code - ); - - // the waitlist isn't updated until we record that the emails - // were successfully sent. - let signups_batch = db.get_signup_invites(3).await.unwrap(); - assert_eq!(signups_batch, signups_batch1); - - // once the emails go out, we can retrieve the next batch - // of signups. - db.record_signup_invites_sent(&signups_batch1) - .await - .unwrap(); - let signups_batch2 = db.get_signup_invites(3).await.unwrap(); - let addresses = signups_batch2 - .iter() - .map(|s| &s.email_address) - .collect::>(); - assert_eq!( - addresses, - &[ - "person-3@example.com", - "person-4@example.com", - "person-5@example.com" - ] - ); - - // user completes the signup process by providing their - // github account. - let user_id = db - .redeem_signup(SignupRedemption { - email_address: signups_batch1[0].email_address.clone(), - email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), - github_login: "person-0".into(), - invite_count: 5, - }) - .await - .unwrap(); - let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); - assert_eq!(user.github_login, "person-0"); - assert_eq!(user.email_address.as_deref(), Some("person-0@example.com")); - assert_eq!(user.invite_count, 5); - - // cannot redeem the same signup again. - db.redeem_signup(SignupRedemption { - email_address: signups_batch1[0].email_address.clone(), - email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), - github_login: "some-other-github_account".into(), - invite_count: 5, - }) - .await - .unwrap_err(); - - // cannot redeem a signup with the wrong confirmation code. - db.redeem_signup(SignupRedemption { - email_address: signups_batch1[1].email_address.clone(), - email_confirmation_code: "the-wrong-code".to_string(), - github_login: "person-1".into(), - invite_count: 5, - }) - .await - .unwrap_err(); - } - - pub struct TestDb { - pub db: Option>, - pub url: String, - } - - impl TestDb { - #[allow(clippy::await_holding_lock)] - pub async fn postgres() -> Self { - lazy_static! { - static ref LOCK: Mutex<()> = Mutex::new(()); - } - - let _guard = LOCK.lock(); - let mut rng = StdRng::from_entropy(); - let name = format!("zed-test-{}", rng.gen::()); - let url = format!("postgres://postgres@localhost/{}", name); - let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")); - Postgres::create_database(&url) - .await - .expect("failed to create test db"); - let db = PostgresDb::new(&url, 5).await.unwrap(); - let migrator = Migrator::new(migrations_path).await.unwrap(); - migrator.run(&db.pool).await.unwrap(); - Self { - db: Some(Arc::new(db)), - url, - } - } - - pub fn fake(background: Arc) -> Self { - Self { - db: Some(Arc::new(FakeDb::new(background))), - url: Default::default(), - } - } - - pub fn db(&self) -> &Arc { - self.db.as_ref().unwrap() - } - } - - impl Drop for TestDb { - fn drop(&mut self) { - if let Some(db) = self.db.take() { - futures::executor::block_on(db.teardown(&self.url)); - } - } - } - pub struct FakeDb { background: Arc, pub users: Mutex>, @@ -2753,7 +1722,7 @@ pub mod tests { async fn create_user( &self, github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, ) -> Result { self.background.simulate_random_delay().await; @@ -2771,7 +1740,7 @@ pub mod tests { User { id: user_id, github_login: github_login.to_string(), - email_address: email_address.map(str::to_string), + email_address: Some(email_address.to_string()), admin, invite_code: None, invite_count: 0, @@ -2843,24 +1812,25 @@ pub mod tests { unimplemented!() } - async fn get_signup_invites(&self, _count: usize) -> Result> { + async fn get_unsent_invites(&self, _count: usize) -> Result> { unimplemented!() } - async fn record_signup_invites_sent(&self, _signups: &[SignupInvite]) -> Result<()> { + async fn record_sent_invites(&self, _invites: &[Invite]) -> Result<()> { unimplemented!() } - async fn redeem_signup( + async fn create_user_from_invite( &self, - _redemption: SignupRedemption, - ) -> Result { + _invite: &Invite, + _user: NewUserParams, + ) -> Result<(UserId, Option)> { unimplemented!() } // invite codes - async fn set_invite_count(&self, _id: UserId, _count: u32) -> Result<()> { + async fn set_invite_count_for_user(&self, _id: UserId, _count: u32) -> Result<()> { unimplemented!() } @@ -2873,12 +1843,11 @@ pub mod tests { unimplemented!() } - async fn redeem_invite_code( + async fn create_invite_from_code( &self, _code: &str, - _login: &str, - _email_address: Option<&str>, - ) -> Result { + _email_address: &str, + ) -> Result { unimplemented!() } @@ -3316,7 +2285,52 @@ pub mod tests { } } - fn build_background_executor() -> Arc { - Deterministic::new(0).build_background() + pub struct TestDb { + pub db: Option>, + pub url: String, + } + + impl TestDb { + #[allow(clippy::await_holding_lock)] + pub async fn postgres() -> Self { + lazy_static! { + static ref LOCK: Mutex<()> = Mutex::new(()); + } + + let _guard = LOCK.lock(); + let mut rng = StdRng::from_entropy(); + let name = format!("zed-test-{}", rng.gen::()); + let url = format!("postgres://postgres@localhost/{}", name); + let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")); + Postgres::create_database(&url) + .await + .expect("failed to create test db"); + let db = PostgresDb::new(&url, 5).await.unwrap(); + let migrator = Migrator::new(migrations_path).await.unwrap(); + migrator.run(&db.pool).await.unwrap(); + Self { + db: Some(Arc::new(db)), + url, + } + } + + pub fn fake(background: Arc) -> Self { + Self { + db: Some(Arc::new(FakeDb::new(background))), + url: Default::default(), + } + } + + pub fn db(&self) -> &Arc { + self.db.as_ref().unwrap() + } + } + + impl Drop for TestDb { + fn drop(&mut self) { + if let Some(db) = self.db.take() { + futures::executor::block_on(db.teardown(&self.url)); + } + } } } diff --git a/crates/collab/src/db_tests.rs b/crates/collab/src/db_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa9a0b69954f55117e10b7799c4fd9603b3eb29c --- /dev/null +++ b/crates/collab/src/db_tests.rs @@ -0,0 +1,1071 @@ +use super::db::*; +use collections::HashMap; +use gpui::executor::{Background, Deterministic}; +use std::{sync::Arc, time::Duration}; +use time::OffsetDateTime; + +#[tokio::test(flavor = "multi_thread")] +async fn test_get_users_by_ids() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + + let user1 = db.create_user("u1", "u1@example.com", false).await.unwrap(); + let user2 = db.create_user("u2", "u2@example.com", false).await.unwrap(); + let user3 = db.create_user("u3", "u3@example.com", false).await.unwrap(); + let user4 = db.create_user("u4", "u4@example.com", false).await.unwrap(); + + assert_eq!( + db.get_users_by_ids(vec![user1, user2, user3, user4]) + .await + .unwrap(), + vec![ + User { + id: user1, + github_login: "u1".to_string(), + email_address: Some("u1@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user2, + github_login: "u2".to_string(), + email_address: Some("u2@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user3, + github_login: "u3".to_string(), + email_address: Some("u3@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user4, + github_login: "u4".to_string(), + email_address: Some("u4@example.com".to_string()), + admin: false, + ..Default::default() + } + ] + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_create_users() { + let db = TestDb::postgres().await; + let db = db.db(); + + // Create the first batch of users, ensuring invite counts are assigned + // correctly and the respective invite codes are unique. + let user_ids_batch_1 = db + .create_users(vec![ + ("user1".to_string(), "hi@user1.com".to_string(), 5), + ("user2".to_string(), "hi@user2.com".to_string(), 4), + ("user3".to_string(), "hi@user3.com".to_string(), 3), + ]) + .await + .unwrap(); + assert_eq!(user_ids_batch_1.len(), 3); + + let users = db.get_users_by_ids(user_ids_batch_1.clone()).await.unwrap(); + assert_eq!(users.len(), 3); + assert_eq!(users[0].github_login, "user1"); + assert_eq!(users[0].email_address.as_deref(), Some("hi@user1.com")); + assert_eq!(users[0].invite_count, 5); + assert_eq!(users[1].github_login, "user2"); + assert_eq!(users[1].email_address.as_deref(), Some("hi@user2.com")); + assert_eq!(users[1].invite_count, 4); + assert_eq!(users[2].github_login, "user3"); + assert_eq!(users[2].email_address.as_deref(), Some("hi@user3.com")); + assert_eq!(users[2].invite_count, 3); + + let invite_code_1 = users[0].invite_code.clone().unwrap(); + let invite_code_2 = users[1].invite_code.clone().unwrap(); + let invite_code_3 = users[2].invite_code.clone().unwrap(); + assert_ne!(invite_code_1, invite_code_2); + assert_ne!(invite_code_1, invite_code_3); + assert_ne!(invite_code_2, invite_code_3); + + // Create the second batch of users and include a user that is already in the database, ensuring + // the invite count for the existing user is updated without changing their invite code. + let user_ids_batch_2 = db + .create_users(vec![ + ("user2".to_string(), "hi@user2.com".to_string(), 10), + ("user4".to_string(), "hi@user4.com".to_string(), 2), + ]) + .await + .unwrap(); + assert_eq!(user_ids_batch_2.len(), 2); + assert_eq!(user_ids_batch_2[0], user_ids_batch_1[1]); + + let users = db.get_users_by_ids(user_ids_batch_2).await.unwrap(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].github_login, "user2"); + assert_eq!(users[0].email_address.as_deref(), Some("hi@user2.com")); + assert_eq!(users[0].invite_count, 10); + assert_eq!(users[0].invite_code, Some(invite_code_2.clone())); + assert_eq!(users[1].github_login, "user4"); + assert_eq!(users[1].email_address.as_deref(), Some("hi@user4.com")); + assert_eq!(users[1].invite_count, 2); + + let invite_code_4 = users[1].invite_code.clone().unwrap(); + assert_ne!(invite_code_4, invite_code_1); + assert_ne!(invite_code_4, invite_code_2); + assert_ne!(invite_code_4, invite_code_3); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_worktree_extensions() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + + let user = db.create_user("u1", "u1@example.com", false).await.unwrap(); + let project = db.register_project(user).await.unwrap(); + + db.update_worktree_extensions(project, 100, Default::default()) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 100, + [("rs".to_string(), 5), ("md".to_string(), 3)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 100, + [("rs".to_string(), 6), ("md".to_string(), 5)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 101, + [("ts".to_string(), 2), ("md".to_string(), 1)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + + assert_eq!( + db.get_project_extensions(project).await.unwrap(), + [ + ( + 100, + [("rs".into(), 6), ("md".into(), 5),] + .into_iter() + .collect::>() + ), + ( + 101, + [("ts".into(), 2), ("md".into(), 1),] + .into_iter() + .collect::>() + ) + ] + .into_iter() + .collect() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_user_activity() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + + let user_1 = db.create_user("u1", "u1@example.com", false).await.unwrap(); + let user_2 = db.create_user("u2", "u2@example.com", false).await.unwrap(); + let user_3 = db.create_user("u3", "u3@example.com", false).await.unwrap(); + let project_1 = db.register_project(user_1).await.unwrap(); + db.update_worktree_extensions( + project_1, + 1, + HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]), + ) + .await + .unwrap(); + let project_2 = db.register_project(user_2).await.unwrap(); + let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60); + + // User 2 opens a project + let t1 = t0 + Duration::from_secs(10); + db.record_user_activity(t0..t1, &[(user_2, project_2)]) + .await + .unwrap(); + + let t2 = t1 + Duration::from_secs(10); + db.record_user_activity(t1..t2, &[(user_2, project_2)]) + .await + .unwrap(); + + // User 1 joins the project + let t3 = t2 + Duration::from_secs(10); + db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)]) + .await + .unwrap(); + + // User 1 opens another project + let t4 = t3 + Duration::from_secs(10); + db.record_user_activity( + t3..t4, + &[ + (user_2, project_2), + (user_1, project_2), + (user_1, project_1), + ], + ) + .await + .unwrap(); + + // User 3 joins that project + let t5 = t4 + Duration::from_secs(10); + db.record_user_activity( + t4..t5, + &[ + (user_2, project_2), + (user_1, project_2), + (user_1, project_1), + (user_3, project_1), + ], + ) + .await + .unwrap(); + + // User 2 leaves + let t6 = t5 + Duration::from_secs(5); + db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)]) + .await + .unwrap(); + + let t7 = t6 + Duration::from_secs(60); + let t8 = t7 + Duration::from_secs(10); + db.record_user_activity(t7..t8, &[(user_1, project_1)]) + .await + .unwrap(); + + assert_eq!( + db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(), + &[ + UserActivitySummary { + id: user_1, + github_login: "u1".to_string(), + project_activity: vec![ + ProjectActivitySummary { + id: project_1, + duration: Duration::from_secs(25), + max_collaborators: 2 + }, + ProjectActivitySummary { + id: project_2, + duration: Duration::from_secs(30), + max_collaborators: 2 + } + ] + }, + UserActivitySummary { + id: user_2, + github_login: "u2".to_string(), + project_activity: vec![ProjectActivitySummary { + id: project_2, + duration: Duration::from_secs(50), + max_collaborators: 2 + }] + }, + UserActivitySummary { + id: user_3, + github_login: "u3".to_string(), + project_activity: vec![ProjectActivitySummary { + id: project_1, + duration: Duration::from_secs(15), + max_collaborators: 2 + }] + }, + ] + ); + + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(56), false) + .await + .unwrap(), + 0 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(56), true) + .await + .unwrap(), + 0 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(54), false) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(54), true) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(30), false) + .await + .unwrap(), + 2 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(30), true) + .await + .unwrap(), + 2 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(10), false) + .await + .unwrap(), + 3 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(10), true) + .await + .unwrap(), + 3 + ); + assert_eq!( + db.get_active_user_count(t0..t1, Duration::from_secs(5), false) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t1, Duration::from_secs(5), true) + .await + .unwrap(), + 0 + ); + + assert_eq!( + db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(), + &[ + UserActivityPeriod { + project_id: project_1, + start: t3, + end: t6, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + UserActivityPeriod { + project_id: project_2, + start: t3, + end: t5, + extensions: Default::default(), + }, + ] + ); + assert_eq!( + db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(), + &[ + UserActivityPeriod { + project_id: project_2, + start: t2, + end: t5, + extensions: Default::default(), + }, + UserActivityPeriod { + project_id: project_1, + start: t3, + end: t6, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + UserActivityPeriod { + project_id: project_1, + start: t7, + end: t8, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_recent_channel_messages() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + let user = db.create_user("u", "u@example.com", false).await.unwrap(); + let org = db.create_org("org", "org").await.unwrap(); + 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::>(), + ["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::>(), + ["1", "2", "3", "4"] + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_channel_message_nonces() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + let user = db.create_user("u", "u@example.com", false).await.unwrap(); + let org = db.create_org("org", "org").await.unwrap(); + let channel = db.create_org_channel(org, "channel").await.unwrap(); + + let msg1_id = db + .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg2_id = db + .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + let msg3_id = db + .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg4_id = db + .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + + assert_ne!(msg1_id, msg2_id); + assert_eq!(msg1_id, msg3_id); + assert_eq!(msg2_id, msg4_id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_create_access_tokens() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + let user = db.create_user("u1", "u1@example.com", false).await.unwrap(); + + db.create_access_token_hash(user, "h1", 3).await.unwrap(); + db.create_access_token_hash(user, "h2", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h2".to_string(), "h1".to_string()] + ); + + db.create_access_token_hash(user, "h3", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h3".to_string(), "h2".to_string(), "h1".to_string(),] + ); + + db.create_access_token_hash(user, "h4", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h4".to_string(), "h3".to_string(), "h2".to_string(),] + ); + + db.create_access_token_hash(user, "h5", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h5".to_string(), "h4".to_string(), "h3".to_string()] + ); +} + +#[test] +fn test_fuzzy_like_string() { + assert_eq!(PostgresDb::fuzzy_like_string("abcd"), "%a%b%c%d%"); + assert_eq!(PostgresDb::fuzzy_like_string("x y"), "%x%y%"); + assert_eq!(PostgresDb::fuzzy_like_string(" z "), "%z%"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fuzzy_search_users() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + for github_login in [ + "California", + "colorado", + "oregon", + "washington", + "florida", + "delaware", + "rhode-island", + ] { + db.create_user(github_login, &format!("{github_login}@example.com"), false) + .await + .unwrap(); + } + + assert_eq!( + fuzzy_search_user_names(db, "clr").await, + &["colorado", "California"] + ); + assert_eq!( + fuzzy_search_user_names(db, "ro").await, + &["rhode-island", "colorado", "oregon"], + ); + + async fn fuzzy_search_user_names(db: &Arc, query: &str) -> Vec { + db.fuzzy_search_users(query, 10) + .await + .unwrap() + .into_iter() + .map(|user| user.github_login) + .collect::>() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_add_contacts() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + + let user_1 = db.create_user("u1", "u1@example.com", false).await.unwrap(); + let user_2 = db.create_user("u2", "u2@example.com", false).await.unwrap(); + let user_3 = db.create_user("u3", "u3@example.com", false).await.unwrap(); + + // User starts with no contacts + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + vec![Contact::Accepted { + user_id: user_1, + should_notify: false + }], + ); + + // 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::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Outgoing { user_id: user_2 } + ], + ); + assert_eq!( + db.get_contacts(user_2).await.unwrap(), + &[ + Contact::Incoming { + user_id: user_1, + should_notify: true + }, + Contact::Accepted { + user_id: user_2, + should_notify: false + }, + ] + ); + + // 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 + }, + Contact::Accepted { + user_id: user_2, + 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_1, + should_notify: false + }, + 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, + }, + Contact::Accepted { + user_id: user_2, + 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_1, + should_notify: false + }, + 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_1, + should_notify: false + }, + 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_1, + should_notify: false + }, + 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 + }, + Contact::Accepted { + user_id: user_3, + 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 + }, + Contact::Accepted { + user_id: user_2, + should_notify: false + } + ] + ); + assert_eq!( + db.get_contacts(user_3).await.unwrap(), + &[ + Contact::Accepted { + user_id: user_1, + should_notify: false + }, + Contact::Accepted { + user_id: user_3, + should_notify: false + } + ], + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invite_codes() { + let postgres = TestDb::postgres().await; + let db = postgres.db(); + let user1 = db.create_user("u1", "u1@example.com", false).await.unwrap(); + + // Initially, user 1 has no invite code + assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); + + // Setting invite count to 0 when no code is assigned does not assign a new code + db.set_invite_count_for_user(user1, 0).await.unwrap(); + assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); + + // User 1 creates an invite code that can be used twice. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 2); + + // User 2 redeems the invite code and becomes a contact of user 1. + let user2_invite = db + .create_invite_from_code(&invite_code, "u2@example.com") + .await + .unwrap(); + let (user2, inviter) = db + .create_user_from_invite( + &user2_invite, + NewUserParams { + github_login: "user2".into(), + invite_count: 7, + }, + ) + .await + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!(inviter, Some(user1)); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user2, + should_notify: true + } + ] + ); + assert_eq!( + db.get_contacts(user2).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user2, + should_notify: false + } + ] + ); + assert_eq!( + db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, + 7 + ); + + // User 3 redeems the invite code and becomes a contact of user 1. + let user3_invite = db + .create_invite_from_code(&invite_code, "u3@example.com") + .await + .unwrap(); + let (user3, inviter) = db + .create_user_from_invite( + &user3_invite, + NewUserParams { + github_login: "user-3".into(), + invite_count: 3, + }, + ) + .await + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 0); + assert_eq!(inviter, Some(user1)); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user2, + should_notify: true + }, + Contact::Accepted { + user_id: user3, + should_notify: true + } + ] + ); + assert_eq!( + db.get_contacts(user3).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user3, + should_notify: false + }, + ] + ); + assert_eq!( + db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, + 3 + ); + + // Trying to reedem the code for the third time results in an error. + db.create_invite_from_code(&invite_code, "u4@example.com") + .await + .unwrap_err(); + + // Invite count can be updated after the code has been created. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 + assert_eq!(invite_count, 2); + + // User 4 can now redeem the invite code and becomes a contact of user 1. + let user4_invite = db + .create_invite_from_code(&invite_code, "u4@example.com") + .await + .unwrap(); + let (user4, _) = db + .create_user_from_invite( + &user4_invite, + NewUserParams { + github_login: "user-4".into(), + invite_count: 5, + }, + ) + .await + .unwrap(); + + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user2, + should_notify: true + }, + Contact::Accepted { + user_id: user3, + should_notify: true + }, + Contact::Accepted { + user_id: user4, + should_notify: true + } + ] + ); + assert_eq!( + db.get_contacts(user4).await.unwrap(), + [ + Contact::Accepted { + user_id: user1, + should_notify: false + }, + Contact::Accepted { + user_id: user4, + should_notify: false + }, + ] + ); + assert_eq!( + db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, + 5 + ); + + // An existing user cannot redeem invite codes. + db.create_invite_from_code(&invite_code, "u2@example.com") + .await + .unwrap_err(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_signups() { + let postgres = TestDb::postgres().await; + let db = postgres.db(); + + // people sign up on the waitlist + for i in 0..8 { + db.create_signup(Signup { + email_address: format!("person-{i}@example.com"), + platform_mac: true, + platform_linux: true, + platform_windows: false, + editor_features: vec!["speed".into()], + programming_languages: vec!["rust".into(), "c".into()], + }) + .await + .unwrap(); + } + + // retrieve the next batch of signup emails to send + let signups_batch1 = db.get_unsent_invites(3).await.unwrap(); + let addresses = signups_batch1 + .iter() + .map(|s| &s.email_address) + .collect::>(); + assert_eq!( + addresses, + &[ + "person-0@example.com", + "person-1@example.com", + "person-2@example.com" + ] + ); + assert_ne!( + signups_batch1[0].email_confirmation_code, + signups_batch1[1].email_confirmation_code + ); + + // the waitlist isn't updated until we record that the emails + // were successfully sent. + let signups_batch = db.get_unsent_invites(3).await.unwrap(); + assert_eq!(signups_batch, signups_batch1); + + // once the emails go out, we can retrieve the next batch + // of signups. + db.record_sent_invites(&signups_batch1).await.unwrap(); + let signups_batch2 = db.get_unsent_invites(3).await.unwrap(); + let addresses = signups_batch2 + .iter() + .map(|s| &s.email_address) + .collect::>(); + assert_eq!( + addresses, + &[ + "person-3@example.com", + "person-4@example.com", + "person-5@example.com" + ] + ); + + // user completes the signup process by providing their + // github account. + let (user_id, inviter_id) = db + .create_user_from_invite( + &Invite { + email_address: signups_batch1[0].email_address.clone(), + email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), + }, + NewUserParams { + github_login: "person-0".into(), + invite_count: 5, + }, + ) + .await + .unwrap(); + let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); + assert!(inviter_id.is_none()); + assert_eq!(user.github_login, "person-0"); + assert_eq!(user.email_address.as_deref(), Some("person-0@example.com")); + assert_eq!(user.invite_count, 5); + + // cannot redeem the same signup again. + db.create_user_from_invite( + &Invite { + email_address: signups_batch1[0].email_address.clone(), + email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), + }, + NewUserParams { + github_login: "some-other-github_account".into(), + invite_count: 5, + }, + ) + .await + .unwrap_err(); + + // cannot redeem a signup with the wrong confirmation code. + db.create_user_from_invite( + &Invite { + email_address: signups_batch1[1].email_address.clone(), + email_confirmation_code: "the-wrong-code".to_string(), + }, + NewUserParams { + github_login: "person-1".into(), + invite_count: 5, + }, + ) + .await + .unwrap_err(); +} + +fn build_background_executor() -> Arc { + Deterministic::new(0).build_background() +} diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 6b512d950ff6f9a0e7a2c782a88bce7c60d918ee..1a4e4381c15dd8a5d353265ceb01285d5fa69edb 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -1,5 +1,5 @@ use crate::{ - db::{tests::TestDb, ProjectId, UserId}, + db::{ProjectId, TestDb, UserId}, rpc::{Executor, Server, Store}, AppState, }; @@ -4640,7 +4640,10 @@ async fn test_random_collaboration( let mut server = TestServer::start(cx.foreground(), cx.background()).await; let db = server.app_state.db.clone(); - let host_user_id = db.create_user("host", None, false).await.unwrap(); + let host_user_id = db + .create_user("host", "host@example.com", false) + .await + .unwrap(); let mut available_guests = vec![ "guest-1".to_string(), "guest-2".to_string(), @@ -4649,7 +4652,10 @@ async fn test_random_collaboration( ]; for username in &available_guests { - let guest_user_id = db.create_user(username, None, false).await.unwrap(); + let guest_user_id = db + .create_user(username, &format!("{username}@example.com"), false) + .await + .unwrap(); assert_eq!(*username, format!("guest-{}", guest_user_id)); server .app_state @@ -5157,7 +5163,7 @@ impl TestServer { } else { self.app_state .db - .create_user(name, None, false) + .create_user(name, &format!("{name}@example.com"), false) .await .unwrap() }; diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 2c2c6a94f4dff18c6a6bd18fe501971d9831bb1f..272d52cc954140b71d18d2b1296e6613753be581 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -4,6 +4,8 @@ mod db; mod env; mod rpc; +#[cfg(test)] +mod db_tests; #[cfg(test)] mod integration_tests; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index dab7df3e674c9d835383318e4f0892c23c52c1f5..4fc022995f2344173c90c18e67b45b3683d869e1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -541,27 +541,30 @@ impl Server { pub async fn invite_code_redeemed( self: &Arc, - code: &str, + inviter_id: UserId, invitee_id: UserId, ) -> Result<()> { - let user = self.app_state.db.get_user_for_invite_code(code).await?; - let store = self.store().await; - let invitee_contact = store.contact_for_user(invitee_id, true); - for connection_id in store.connection_ids_for_user(user.id) { - self.peer.send( - connection_id, - proto::UpdateContacts { - contacts: vec![invitee_contact.clone()], - ..Default::default() - }, - )?; - self.peer.send( - connection_id, - proto::UpdateInviteInfo { - url: format!("{}{}", self.app_state.invite_link_prefix, code), - count: user.invite_count as u32, - }, - )?; + if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? { + if let Some(code) = &user.invite_code { + let store = self.store().await; + let invitee_contact = store.contact_for_user(invitee_id, true); + for connection_id in store.connection_ids_for_user(inviter_id) { + self.peer.send( + connection_id, + proto::UpdateContacts { + contacts: vec![invitee_contact.clone()], + ..Default::default() + }, + )?; + self.peer.send( + connection_id, + proto::UpdateInviteInfo { + url: format!("{}{}", self.app_state.invite_link_prefix, &code), + count: user.invite_count as u32, + }, + )?; + } + } } Ok(()) }