crates/collab/migrations/20220913211150_create_signups.down.sql 🔗
@@ -0,0 +1,6 @@
+DROP TABLE signups;
+
+ALTER TABLE users
+ DROP COLUMN metrics_id;
+
+DROP SEQUENCE metrics_id_seq;
Max Brunsfeld created
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(-)
@@ -0,0 +1,6 @@
+DROP TABLE signups;
+
+ALTER TABLE users
+ DROP COLUMN metrics_id;
+
+DROP SEQUENCE metrics_id_seq;
@@ -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");
@@ -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,
- ¶ms.github_login,
- params.email_address.as_deref(),
+ let (user_id, inviter_id) =
+ // Creating a user via the normal signup process
+ if let Some(email_confirmation_code) = params.email_confirmation_code {
+ app.db
+ .create_user_from_invite(
+ &Invite {
+ email_address: params.email_address,
+ email_confirmation_code,
+ },
+ NewUserParams {
+ github_login: params.github_login,
+ invite_count: params.invite_count,
+ },
+ )
+ .await?
+ }
+ // Creating a user as an admin
+ else {
+ (
+ app.db
+ .create_user(¶ms.github_login, ¶ms.email_address, false)
+ .await?,
+ None,
)
- .await?;
+ };
+
+ if let Some(inviter_id) = inviter_id {
rpc_server
- .invite_code_redeemed(&invite_code, invitee_id)
+ .invite_code_redeemed(inviter_id, user_id)
.await
.trace_err();
- invitee_id
- } else {
- app.db
- .create_user(
- ¶ms.github_login,
- params.email_address.as_deref(),
- params.admin,
- )
- .await?
- };
+ }
let user = app
.db
@@ -175,7 +184,9 @@ async fn update_user(
}
if let Some(invite_count) = params.invite_count {
- app.db.set_invite_count(user_id, invite_count).await?;
+ app.db
+ .set_invite_count_for_user(user_id, invite_count)
+ .await?;
rpc_server.invite_count_updated(user_id).await.trace_err();
}
@@ -428,30 +439,39 @@ async fn create_signup(
Ok(())
}
-async fn redeem_signup(
- Json(redemption): Json<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(¶ms).await?;
- Ok(())
+) -> Result<Json<Invite>> {
+ Ok(Json(
+ app.db
+ .create_invite_from_code(¶ms.invite_code, ¶ms.email_address)
+ .await?,
+ ))
}
#[derive(Deserialize)]
-pub struct GetSignupInvitesParams {
+pub struct GetUnsentInvitesParams {
pub count: usize,
}
-async fn get_signup_invites(
- Query(params): Query<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(¶ms).await?;
+ Ok(())
}
@@ -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>>,
@@ -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()
+}
@@ -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()
};
@@ -4,6 +4,8 @@ mod db;
mod env;
mod rpc;
+#[cfg(test)]
+mod db_tests;
#[cfg(test)]
mod integration_tests;
@@ -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(())
}