Update APIs and DB interactions to reflect email confirmation step

Max Brunsfeld created

Change summary

crates/collab/migrations/20220913211150_create_signups.down.sql |    6 
crates/collab/migrations/20220913211150_create_signups.up.sql   |    6 
crates/collab/src/api.rs                                        |  106 
crates/collab/src/db.rs                                         | 1375 --
crates/collab/src/db_tests.rs                                   | 1071 ++
crates/collab/src/integration_tests.rs                          |   14 
crates/collab/src/main.rs                                       |    2 
crates/collab/src/rpc.rs                                        |   41 
8 files changed, 1,350 insertions(+), 1,271 deletions(-)

Detailed changes

crates/collab/migrations/20220913211150_create_signups.sql → 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");
 

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<rpc::Server>, state: Arc<AppState>) -> Router<Bod
         .route("/user_activity/counts", get(get_active_user_counts))
         .route("/project_metadata", get(get_project_metadata))
         .route("/signups", post(create_signup))
-        .route("/signup/redeem", post(redeem_signup))
-        .route("/signups_invites", get(get_signup_invites))
-        .route("/signups_invites_sent", post(record_signup_invites_sent))
+        .route("/user_invites", post(create_invite_from_code))
+        .route("/unsent_invites", get(get_unsent_invites))
+        .route("/sent_invites", post(record_sent_invites))
         .layer(
             ServiceBuilder::new()
                 .layer(Extension(state))
@@ -113,9 +113,9 @@ async fn get_users(
 #[derive(Deserialize, Debug)]
 struct CreateUserParams {
     github_login: String,
-    invite_code: Option<String>,
-    email_address: Option<String>,
-    admin: bool,
+    email_address: String,
+    email_confirmation_code: Option<String>,
+    invite_count: i32,
 }
 
 async fn create_user(
@@ -123,29 +123,38 @@ async fn create_user(
     Extension(app): Extension<Arc<AppState>>,
     Extension(rpc_server): Extension<Arc<rpc::Server>>,
 ) -> Result<Json<User>> {
-    let user_id = if let Some(invite_code) = params.invite_code {
-        let invitee_id = app
-            .db
-            .redeem_invite_code(
-                &invite_code,
-                &params.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(&params.github_login, &params.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(
-                &params.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<SignupRedemption>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> 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<Vec<SignupInvite>>,
+async fn create_invite_from_code(
+    Json(params): Json<CreateInviteFromCodeParams>,
     Extension(app): Extension<Arc<AppState>>,
-) -> Result<()> {
-    app.db.record_signup_invites_sent(&params).await?;
-    Ok(())
+) -> Result<Json<Invite>> {
+    Ok(Json(
+        app.db
+            .create_invite_from_code(&params.invite_code, &params.email_address)
+            .await?,
+    ))
 }
 
 #[derive(Deserialize)]
-pub struct GetSignupInvitesParams {
+pub struct GetUnsentInvitesParams {
     pub count: usize,
 }
 
-async fn get_signup_invites(
-    Query(params): Query<GetSignupInvitesParams>,
+async fn get_unsent_invites(
+    Query(params): Query<GetUnsentInvitesParams>,
     Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<SignupInvite>>> {
-    Ok(Json(app.db.get_signup_invites(params.count).await?))
+) -> Result<Json<Vec<Invite>>> {
+    Ok(Json(app.db.get_unsent_invites(params.count).await?))
+}
+
+async fn record_sent_invites(
+    Json(params): Json<Vec<Invite>>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    app.db.record_sent_invites(&params).await?;
+    Ok(())
 }

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<UserId>;
     async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>>;
@@ -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<Vec<SignupInvite>>;
-    async fn record_signup_invites_sent(&self, signups: &[SignupInvite]) -> Result<()>;
-    async fn redeem_signup(&self, redemption: SignupRedemption) -> Result<UserId>;
-
-    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<Option<(String, u32)>>;
     async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
-    async fn redeem_invite_code(
+    async fn create_invite_from_code(&self, code: &str, email_address: &str) -> Result<Invite>;
+
+    async fn create_signup(&self, signup: Signup) -> Result<()>;
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>>;
+    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<UserId>;
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<(UserId, Option<UserId>)>;
 
     /// Registers a new project for the given user.
     async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
@@ -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<Vec<String>>;
-    #[cfg(any(test, feature = "seed-support"))]
 
+    #[cfg(any(test, feature = "seed-support"))]
     async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
     #[cfg(any(test, feature = "seed-support"))]
     async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@@ -135,6 +133,7 @@ pub trait Db: Send + Sync {
     async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
     async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
         -> Result<bool>;
+
     #[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<MessageId>,
     ) -> Result<Vec<ChannelMessage>>;
+
     #[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<UserId> {
         let query = "
@@ -247,7 +260,7 @@ impl Db for PostgresDb {
     }
 
     async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
-        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<Vec<SignupInvite>> {
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
         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::<Vec<_>>(),
@@ -407,36 +420,41 @@ impl Db for PostgresDb {
         Ok(())
     }
 
-    async fn redeem_signup(&self, redemption: SignupRedemption) -> Result<UserId> {
+    async fn create_user_from_invite(
+        &self,
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<(UserId, Option<UserId>)> {
         let mut tx = self.pool.begin().await?;
-        let signup_id: i32 = sqlx::query_scalar(
+
+        let (signup_id, inviting_user_id): (i32, Option<UserId>) = 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<UserId> = 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<UserId> {
+    async fn create_invite_from_code(&self, code: &str, email_address: &str) -> Result<Invite> {
         let mut tx = self.pool.begin().await?;
 
-        let inviter_id: Option<UserId> = sqlx::query_scalar(
+        let existing_user: Option<UserId> = 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<String, usize>,
+    pub end: OffsetDateTime,
+    pub extensions: HashMap<String, usize>,
 }
 
 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::<HashMap<_, _>>()
-                ),
-                (
-                    101,
-                    [("ts".into(), 2), ("md".into(), 1),]
-                        .into_iter()
-                        .collect::<HashMap<_, _>>()
-                )
-            ]
-            .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::<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")]
-    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<dyn Db>, query: &str) -> Vec<String> {
-            db.fuzzy_search_users(query, 10)
-                .await
-                .unwrap()
-                .into_iter()
-                .map(|user| user.github_login)
-                .collect::<Vec<_>>()
-        }
-    }
-
-    #[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::<Vec<_>>();
-        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::<Vec<_>>();
-        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<Arc<dyn Db>>,
-        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::<u128>());
-            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<Background>) -> Self {
-            Self {
-                db: Some(Arc::new(FakeDb::new(background))),
-                url: Default::default(),
-            }
-        }
-
-        pub fn db(&self) -> &Arc<dyn Db> {
-            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<Background>,
         pub users: Mutex<BTreeMap<UserId, User>>,

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::<HashMap<_, _>>()
+            ),
+            (
+                101,
+                [("ts".into(), 2), ("md".into(), 1),]
+                    .into_iter()
+                    .collect::<HashMap<_, _>>()
+            )
+        ]
+        .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::<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")]
+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<dyn Db>, query: &str) -> Vec<String> {
+        db.fuzzy_search_users(query, 10)
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|user| user.github_login)
+            .collect::<Vec<_>>()
+    }
+}
+
+#[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::<Vec<_>>();
+    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::<Vec<_>>();
+    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<Background> {
+    Deterministic::new(0).build_background()
+}

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()
         };

crates/collab/src/rpc.rs 🔗

@@ -541,27 +541,30 @@ impl Server {
 
     pub async fn invite_code_redeemed(
         self: &Arc<Self>,
-        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(())
     }