diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbe5aee..7a27779 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,8 +2,6 @@ name: test on: push: - pull_request: - types: [synchronize, review_requested] env: CARGO_TERM_COLOR: always @@ -12,7 +10,53 @@ permissions: contents: read jobs: - build: + test-unit: + runs-on: ubuntu-latest + steps: + - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --verbose + + test-beta: + runs-on: ubuntu-latest + needs: [test-unit] + strategy: + fail-fast: false + matrix: + toolchain: + - beta + - nightly + steps: + - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + - run: cargo test --verbose + + test-functional: + needs: [test-unit] + runs-on: ubuntu-latest + steps: + - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --verbose --features=test-e2e + env: + CODEMP_TEST_USERNAME_ALICE: ${{ secrets.CODEMP_TEST_USERNAME_ALICE }} + CODEMP_TEST_PASSWORD_ALICE: ${{ secrets.CODEMP_TEST_PASSWORD_ALICE }} + CODEMP_TEST_USERNAME_BOB: ${{ secrets.CODEMP_TEST_USERNAME_BOB }} + CODEMP_TEST_PASSWORD_BOB: ${{ secrets.CODEMP_TEST_PASSWORD_BOB }} + + test-build: + needs: [test-functional] runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -26,34 +70,10 @@ jobs: - js - py - luajit - - lua54 - toolchain: - - stable - - beta steps: - uses: arduino/setup-protoc@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.toolchain }} + - uses: dtolnay/rust-toolchain@stable - run: cargo build --release --verbose --features=${{ matrix.features }} - - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - toolchain: - - stable - - beta - steps: - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.toolchain }} - - run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index 4ec0fa1..f5a1f49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ default = [] # extra async-trait = ["dep:async-trait"] serialize = ["dep:serde", "uuid/serde"] +# special tests which require more setup +test-e2e = [] # ffi java = ["lazy_static", "jni", "tracing-subscriber", "jni-toolbox"] js = ["napi-build", "tracing-subscriber", "napi", "napi-derive"] diff --git a/dist/java/src/mp/code/Client.java b/dist/java/src/mp/code/Client.java index 465d10a..113aa65 100644 --- a/dist/java/src/mp/code/Client.java +++ b/dist/java/src/mp/code/Client.java @@ -128,7 +128,8 @@ public final class Client { /** * Leaves a workspace. * @param workspaceId the id of the workspaces to leave - * @return true if it succeeded (usually fails if the workspace wasn't active) + * @return true if it succeeded or wasn't in the workspace; false if there are still + * leftover references around */ public boolean leaveWorkspace(String workspaceId) { return leave_workspace(this.ptr, workspaceId); diff --git a/src/lib.rs b/src/lib.rs index 3179b7f..7177d8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,6 +125,9 @@ pub mod ext; /// language-specific ffi "glue" pub mod ffi; +#[cfg(any(feature = "test-e2e", test))] +pub mod tests; + /// internal network services and interceptors pub(crate) mod network; diff --git a/src/tests/client.rs b/src/tests/client.rs new file mode 100644 index 0000000..dcd4dc0 --- /dev/null +++ b/src/tests/client.rs @@ -0,0 +1,369 @@ +use super::{ + assert_or_err, + fixtures::{ClientFixture, ScopedFixture, WorkspaceFixture}, +}; +use crate::api::{AsyncReceiver, AsyncSender}; + +#[tokio::test] +async fn test_workspace_creation_and_deletion() { + super::fixture! { + ClientFixture::of("alice") => |client| { + let workspace_name = uuid::Uuid::new_v4().to_string(); + + client.create_workspace(&workspace_name).await?; + + // we can't error, so we return empty vec which will be interpreted as err + let workspace_list_before = client.fetch_owned_workspaces().await.unwrap_or_default(); + + let res = client.delete_workspace(&workspace_name).await; + + // we can and should err here, because empty vec will be counted as success! + let workspace_list_after = client.fetch_owned_workspaces().await?; + + assert_or_err!(workspace_list_before.contains(&workspace_name)); + res?; + assert_or_err!(workspace_list_after.contains(&workspace_name) == false); + + Ok(()) + } + }; +} + +#[tokio::test] +async fn test_attach_and_leave_workspace() { + super::fixture! { + ClientFixture::of("alice") => |client| { + let workspace_name = uuid::Uuid::new_v4().to_string(); + + client.create_workspace(&workspace_name).await?; + + // leaving a workspace you are not attached to, returns true + let leave_workspace_before = client.leave_workspace(&workspace_name); + + let attach_workspace_that_exists = match client.attach_workspace(&workspace_name).await { + Ok(_) => true, + Err(e) => { + eprintln!("error attaching to workspace: {e}"); + false + }, + }; + + // leaving a workspace you are attached to, returns true + // when there is only one reference to it. + let leave_workspace_after = client.leave_workspace(&workspace_name); + + let _ = client.delete_workspace(&workspace_name).await; + + assert_or_err!(leave_workspace_before, "leaving a workspace you are not attached to returned false, should return true."); + assert_or_err!(attach_workspace_that_exists, "attaching a workspace that exists failed with error"); + assert_or_err!(leave_workspace_after, "leaving a workspace with a single reference returned false."); + + Ok(()) + } + } +} + +#[tokio::test] +async fn test_invite_user_to_workspace() { + let client_alice = ClientFixture::of("alice") + .setup() + .await + .expect("failed setting up alice's client"); + let client_bob = ClientFixture::of("bob") + .setup() + .await + .expect("failed setting up bob's client"); + let ws_name = uuid::Uuid::new_v4().to_string(); + + // after this we can't just fail anymore: we need to cleanup, so store errs + client_alice + .create_workspace(&ws_name) + .await + .expect("failed creating workspace"); + let could_invite = client_alice + .invite_to_workspace(&ws_name, &client_bob.current_user().name) + .await; + let ws_list = client_bob + .fetch_joined_workspaces() + .await + .unwrap_or_default(); // can't fail, empty is err + let could_delete = client_alice.delete_workspace(&ws_name).await; + + could_invite.expect("could not invite bob"); + assert!(ws_list.contains(&ws_name)); + could_delete.expect("could not delete workspace"); +} + +#[tokio::test] +async fn test_workspace_lookup() { + super::fixture! { + WorkspaceFixture::one("alice", "test-lookup") => |client, workspace| { + assert_or_err!(client.get_workspace(&workspace.id()).is_some()); + assert_or_err!(client.get_workspace(&uuid::Uuid::new_v4().to_string()).is_none()); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_leave_workspace_with_dangling_ref() { + super::fixture! { + WorkspaceFixture::one("alice", "test-dangling-ref") => |client, workspace| { + assert_or_err!(client.leave_workspace(&workspace.id()) == false); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_lookup_after_leave() { + super::fixture! { + WorkspaceFixture::one("alice", "test-lookup-after-leave") => |client, workspace| { + client.leave_workspace(&workspace.id()); + assert_or_err!(client.get_workspace(&workspace.id()).is_none()); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_attach_after_leave() { + super::fixture! { + ClientFixture::of("alice") => |client| { + let ws_name = uuid::Uuid::new_v4().to_string(); + client.create_workspace(&ws_name).await?; + + let could_attach = client.attach_workspace(&ws_name).await.is_ok(); + let clean_leave = client.leave_workspace(&ws_name); + // TODO this is very server specific! disconnect may be instant or caught with next + // keepalive, let's arbitrarily say that after 20 seconds we should have been disconnected + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + let could_attach_again = client.attach_workspace(&ws_name).await; + let could_delete = client.delete_workspace(&ws_name).await; + + assert_or_err!(could_attach); + assert_or_err!(clean_leave); + could_attach_again?; + could_delete?; + + Ok(()) + } + } +} + +#[tokio::test] +async fn test_active_workspaces() { + super::fixture! { + WorkspaceFixture::one("alice", "test-active-workspaces") => |client, workspace| { + assert_or_err!(client.active_workspaces().contains(&workspace.id())); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_cant_create_same_workspace_more_than_once() { + super::fixture! { + WorkspaceFixture::one("alice", "test-create-multiple-times") => |client, workspace| { + assert_or_err!(client.create_workspace(workspace.id()).await.is_err(), "created same workspace twice"); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_attaching_to_non_existing_is_error() { + super::fixture! { + ClientFixture::of("alice") => |client| { + let workspace_name = uuid::Uuid::new_v4().to_string(); + + // we don't create any workspace. + // client.create_workspace(workspace_name).await?; + assert_or_err!(client.attach_workspace(&workspace_name).await.is_err()); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_deleting_workspace_twice_is_an_error() { + super::fixture! { + WorkspaceFixture::one("alice", "test-delete-twice") => |client, workspace| { + let workspace_name = workspace.id(); + + client.delete_workspace(&workspace_name).await?; + assert_or_err!(client.delete_workspace(&workspace_name).await.is_err()); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_cannot_invite_self() { + super::fixture! { + WorkspaceFixture::one("alice", "test-invite-self") => |client, workspace| { + assert_or_err!(client.invite_to_workspace(workspace.id(), &client.current_user().name).await.is_err()); + Ok(()) + } + } +} + +#[tokio::test] +async fn test_cannot_invite_to_nonexisting() { + super::fixture! { + WorkspaceFixture::two("alice", "bob", "test-invite-self") => |client, _ws, client_bob, _workspace_bob| { + assert_or_err!(client.invite_to_workspace(uuid::Uuid::new_v4().to_string(), &client_bob.current_user().name).await.is_err()); + Ok(()) + } + } +} + +#[tokio::test] +async fn cannot_delete_others_workspaces() { + WorkspaceFixture::two("alice", "bob", "test-cannot-delete-others-workspaces") + .with(|(_, ws_alice, client_bob, _)| { + let ws_alice = ws_alice.clone(); + let client_bob = client_bob.clone(); + async move { + assert_or_err!( + client_bob.delete_workspace(&ws_alice.id()).await.is_err(), + "bob was allowed to delete a workspace he didn't own!" + ); + Ok(()) + } + }) + .await +} + +#[tokio::test] +async fn test_buffer_search() { + WorkspaceFixture::one("alice", "test-buffer-search") + .with(|(_, workspace_alice)| { + let buffer_name = uuid::Uuid::new_v4().to_string(); + let workspace_alice = workspace_alice.clone(); + + async move { + workspace_alice.create_buffer(&buffer_name).await?; + assert_or_err!(!workspace_alice + .search_buffers(Some(&buffer_name[0..4])) + .is_empty()); + assert_or_err!(workspace_alice.search_buffers(Some("_")).is_empty()); + workspace_alice.delete_buffer(&buffer_name).await?; + Ok(()) + } + }) + .await; +} + +#[tokio::test] +async fn test_send_operation() { + WorkspaceFixture::two("alice", "bob", "test-send-operation") + .with(|(_, workspace_alice, _, workspace_bob)| { + let buffer_name = uuid::Uuid::new_v4().to_string(); + let workspace_alice = workspace_alice.clone(); + let workspace_bob = workspace_bob.clone(); + + async move { + workspace_alice.create_buffer(&buffer_name).await?; + let alice = workspace_alice.attach_buffer(&buffer_name).await?; + let bob = workspace_bob.attach_buffer(&buffer_name).await?; + + alice.send(crate::api::TextChange { + start_idx: 0, + end_idx: 0, + content: "hello world".to_string(), + })?; + + let result = bob.recv().await?; + assert_or_err!(result.change.start_idx == 0); + assert_or_err!(result.change.end_idx == 0); + assert_or_err!(result.change.content == "hello world"); + + Ok(()) + } + }) + .await; +} + +#[tokio::test] +async fn test_content_converges() { + WorkspaceFixture::two("alice", "bob", "test-content-converges") + .with(|(_, workspace_alice, _, workspace_bob)| { + let buffer_name = uuid::Uuid::new_v4().to_string(); + let workspace_alice = workspace_alice.clone(); + let workspace_bob = workspace_bob.clone(); + + async move { + workspace_alice.create_buffer(&buffer_name).await?; + let alice = workspace_alice.attach_buffer(&buffer_name).await?; + let bob = workspace_bob.attach_buffer(&buffer_name).await?; + + let mut join_set = tokio::task::JoinSet::new(); + + let _alice = alice.clone(); + join_set.spawn(async move { + for i in 0..10 { + _alice.content().await?; + _alice.send(crate::api::TextChange { + start_idx: 7 * i, + end_idx: 7 * i, + content: format!("alice{i} "), // TODO generate a random string instead!! + })?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Ok::<(), crate::errors::ControllerError>(()) + }); + + let _bob = bob.clone(); + join_set.spawn(async move { + for i in 0..10 { + _bob.content().await?; + _bob.send(crate::api::TextChange { + start_idx: 5 * i, + end_idx: 5 * i, + content: format!("bob{i} "), // TODO generate a random string instead!! + })?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Ok::<(), crate::errors::ControllerError>(()) + }); + + while let Some(x) = join_set.join_next().await { + x??; + } + + // test runners may be slow, give 1s to catch up, just in case + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // TODO is there a nicer way to make sure we received all changes? + for i in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + match bob.try_recv().await? { + Some(change) => bob.ack(change.version), + None => break, + } + eprintln!("bob more to recv at attempt #{i}"); + } + + for i in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + match alice.try_recv().await? { + Some(change) => alice.ack(change.version), + None => break, + } + eprintln!("alice more to recv at attempt #{i}"); + } + + let alice_content = alice.content().await?; + let bob_content = bob.content().await?; + + eprintln!("alice: {alice_content}"); + eprintln!("bob : {bob_content}"); + + assert_or_err!(alice_content == bob_content); + + Ok(()) + } + }) + .await; +} diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs new file mode 100644 index 0000000..b52712c --- /dev/null +++ b/src/tests/fixtures.rs @@ -0,0 +1,291 @@ +use std::{error::Error, future::Future}; + +#[allow(async_fn_in_trait)] +pub trait ScopedFixture { + async fn setup(&mut self) -> Result>; + + async fn cleanup(&mut self, resource: Option) { + drop(resource) + } + + async fn inner_with(mut self, cb: impl FnOnce(&mut T) -> F) -> Result<(), Box> + where + Self: Sized, + F: Future>>, + { + match self.setup().await { + Ok(mut t) => { + let res = cb(&mut t).await; + self.cleanup(Some(t)).await; + res + } + Err(e) => { + self.cleanup(None).await; + Err(e) + } + } + } + + async fn with(self, cb: impl FnOnce(&mut T) -> F) + where + Self: Sized, + F: Future>>, + { + if let Err(e) = self.inner_with(cb).await { + panic!("{e}"); + } + } +} + +pub struct ClientFixture { + name: String, + username: Option, + password: Option, +} + +impl ClientFixture { + pub fn of(name: &str) -> Self { + Self { + name: name.to_string(), + username: None, + password: None, + } + } +} + +impl ScopedFixture for ClientFixture { + async fn setup(&mut self) -> Result> { + let upper = self.name.to_uppercase(); + let username = self.username.clone().unwrap_or_else(|| { + std::env::var(format!("CODEMP_TEST_USERNAME_{upper}")).unwrap_or_default() + }); + let password = self.password.clone().unwrap_or_else(|| { + std::env::var(format!("CODEMP_TEST_PASSWORD_{upper}")).unwrap_or_default() + }); + let client = crate::Client::connect(crate::api::Config { + username, + password, + tls: Some(false), + ..Default::default() + }) + .await?; + + Ok(client) + } +} + +pub struct WorkspaceFixture { + user: String, + invitee: Option, + workspace: String, +} + +impl WorkspaceFixture { + pub fn of(user: &str, invitee: &str, workspace: &str) -> Self { + Self { + user: user.to_string(), + invitee: Some(invitee.to_string()), + workspace: workspace.to_string(), + } + } + + pub fn one(user: &str, ws: &str) -> Self { + Self { + user: user.to_string(), + invitee: None, + workspace: format!("{ws}-{}", uuid::Uuid::new_v4()), + } + } + + pub fn two(user: &str, invite: &str, ws: &str) -> Self { + Self { + user: user.to_string(), + invitee: Some(invite.to_string()), + workspace: format!("{ws}-{}", uuid::Uuid::new_v4()), + } + } +} + +impl ScopedFixture<(crate::Client, crate::Workspace)> for WorkspaceFixture { + async fn setup(&mut self) -> Result<(crate::Client, crate::Workspace), Box> { + let client = ClientFixture::of(&self.user).setup().await?; + client.create_workspace(&self.workspace).await?; + let workspace = client.attach_workspace(&self.workspace).await?; + Ok((client, workspace)) + } + + async fn cleanup(&mut self, resource: Option<(crate::Client, crate::Workspace)>) { + if let Some((client, _workspace)) = resource { + client.leave_workspace(&self.workspace); + if let Err(e) = client.delete_workspace(&self.workspace).await { + eprintln!("could not delete workspace: {e}"); + } + } + } +} + +impl + ScopedFixture<( + crate::Client, + crate::Workspace, + crate::Client, + crate::Workspace, + )> for WorkspaceFixture +{ + async fn setup( + &mut self, + ) -> Result< + ( + crate::Client, + crate::Workspace, + crate::Client, + crate::Workspace, + ), + Box, + > { + let client = ClientFixture::of(&self.user).setup().await?; + let invitee_client = ClientFixture::of( + &self + .invitee + .clone() + .unwrap_or(uuid::Uuid::new_v4().to_string()), + ) + .setup() + .await?; + client.create_workspace(&self.workspace).await?; + client + .invite_to_workspace(&self.workspace, invitee_client.current_user().name.clone()) + .await?; + let workspace = client.attach_workspace(&self.workspace).await?; + let invitee_workspace = invitee_client.attach_workspace(&self.workspace).await?; + Ok((client, workspace, invitee_client, invitee_workspace)) + } + + async fn cleanup( + &mut self, + resource: Option<( + crate::Client, + crate::Workspace, + crate::Client, + crate::Workspace, + )>, + ) { + if let Some((client, _, _, _)) = resource { + client.leave_workspace(&self.workspace); + if let Err(e) = client.delete_workspace(&self.workspace).await { + eprintln!("could not delete workspace: {e}"); + } + } + } +} + +pub struct BufferFixture { + user: String, + invitee: Option, + workspace: String, + buffer: String, +} + +impl BufferFixture { + pub fn of(user: &str, invitee: &str, workspace: &str, buffer: &str) -> Self { + Self { + user: user.to_string(), + invitee: Some(invitee.to_string()), + workspace: workspace.to_string(), + buffer: buffer.to_string(), + } + } + + pub fn one(user: &str, ws: &str, buf: &str) -> Self { + Self { + user: user.to_string(), + invitee: None, + workspace: format!("{ws}-{}", uuid::Uuid::new_v4()), + buffer: buf.to_string(), + } + } + + pub fn two(user: &str, invite: &str, ws: &str, buf: &str) -> Self { + Self { + user: user.to_string(), + invitee: Some(invite.to_string()), + workspace: format!("{ws}-{}", uuid::Uuid::new_v4()), + buffer: buf.to_string(), + } + } +} + +impl + ScopedFixture<( + crate::Client, + crate::Workspace, + crate::buffer::Controller, + crate::Client, + crate::Workspace, + crate::buffer::Controller, + )> for BufferFixture +{ + async fn setup( + &mut self, + ) -> Result< + ( + crate::Client, + crate::Workspace, + crate::buffer::Controller, + crate::Client, + crate::Workspace, + crate::buffer::Controller, + ), + Box, + > { + let client = ClientFixture::of(&self.user).setup().await?; + let invitee_client = ClientFixture::of( + &self + .invitee + .clone() + .unwrap_or(uuid::Uuid::new_v4().to_string()), + ) + .setup() + .await?; + client.create_workspace(&self.workspace).await?; + client + .invite_to_workspace(&self.workspace, invitee_client.current_user().name.clone()) + .await?; + + let workspace = client.attach_workspace(&self.workspace).await?; + workspace.create_buffer(&self.buffer).await?; + let buffer = workspace.attach_buffer(&self.buffer).await?; + + let invitee_workspace = invitee_client.attach_workspace(&self.workspace).await?; + let invitee_buffer = invitee_workspace.attach_buffer(&self.buffer).await?; + + Ok(( + client, + workspace, + buffer, + invitee_client, + invitee_workspace, + invitee_buffer, + )) + } + + async fn cleanup( + &mut self, + resource: Option<( + crate::Client, + crate::Workspace, + crate::buffer::Controller, + crate::Client, + crate::Workspace, + crate::buffer::Controller, + )>, + ) { + if let Some((client, _, _, _, _, _)) = resource { + // buffer deletion is implied in workspace deletion + client.leave_workspace(&self.workspace); + if let Err(e) = client.delete_workspace(&self.workspace).await { + eprintln!("could not delete workspace: {e}"); + } + } + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..6349652 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,100 @@ +#[cfg(all(test, feature = "test-e2e"))] +mod client; + +#[cfg(all(test, feature = "test-e2e"))] +mod server; + +pub mod fixtures; +use crate::errors::{ConnectionError, RemoteError}; + +#[derive(Debug)] +pub struct AssertionError(String); + +impl AssertionError { + pub fn new(msg: &str) -> Self { + Self(msg.to_string()) + } +} + +impl std::fmt::Display for AssertionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for AssertionError {} + +impl From for AssertionError { + fn from(value: ConnectionError) -> Self { + match value { + ConnectionError::Transport(error) => AssertionError::new(&format!( + "Connection::Transport error during setup of a test: {}", + error, + )), + ConnectionError::Remote(remote_error) => AssertionError::new(&format!( + "Connection::Remote error during setup of a test: {}", + remote_error, + )), + } + } +} + +impl From for AssertionError { + fn from(value: RemoteError) -> Self { + AssertionError::new(&format!("Remote error during setup of a test: {}", value,)) + } +} + +#[macro_export] +macro_rules! assert_or_err { + ($s:expr) => { + #[allow(clippy::bool_comparison)] + if !$s { + return Err($crate::tests::AssertionError::new(&format!( + "assertion failed at line {}: {}", + std::line!(), + stringify!($s) + )) + .into()); + } + }; + ($s:expr, $msg:literal) => { + #[allow(clippy::bool_comparison)] + if !$s { + return Err($crate::tests::AssertionError::new(&format!( + "{} (line {})", + $msg, + std::line!(), + )) + .into()); + } + }; + ($s:expr, raw $msg:literal) => { + #[allow(clippy::bool_comparison)] + if !$s { + return Err($crate::tests::AssertionError::new($msg).into()); + } + }; +} + +pub use assert_or_err; + +#[macro_export] +macro_rules! fixture { + ($fixture:expr => | $($arg:ident),* | $body:expr) => { + #[allow(unused_parens)] + $fixture + .with(|($($arg),*)| { + $( + let $arg = $arg.clone(); + )* + + async move { + $body + } + }) + .await; + }; +} + +pub use fixture; diff --git a/src/tests/server.rs b/src/tests/server.rs new file mode 100644 index 0000000..ebcf4ce --- /dev/null +++ b/src/tests/server.rs @@ -0,0 +1,92 @@ +use super::{ + assert_or_err, + fixtures::{ClientFixture, ScopedFixture, WorkspaceFixture}, +}; + +#[tokio::test] +async fn test_buffer_create() { + WorkspaceFixture::one("alice", "test-buffer-create") + .with(|(_, workspace_alice)| { + let buffer_name = uuid::Uuid::new_v4().to_string(); + let workspace_alice = workspace_alice.clone(); + + async move { + workspace_alice.create_buffer(&buffer_name).await?; + assert_or_err!(vec![buffer_name.clone()] == workspace_alice.fetch_buffers().await?); + workspace_alice.delete_buffer(&buffer_name).await?; + + Ok(()) + } + }) + .await; +} + +#[tokio::test] +async fn test_cant_create_buffer_twice() { + WorkspaceFixture::one("alice", "test-cant-create-buffer-twice") + .with(|(_, ws)| { + let ws = ws.clone(); + async move { + ws.create_buffer("cacca").await?; + assert!( + ws.create_buffer("cacca").await.is_err(), + "alice could create again the same buffer" + ); + Ok(()) + } + }) + .await; +} + +#[tokio::test] +#[ignore] // TODO reference server has no concept of buffer ownership yet! +async fn cannot_delete_others_buffers() { + WorkspaceFixture::two("alice", "bob", "test-cannot-delete-others-buffers") + .with(|(_, workspace_alice, _, workspace_bob)| { + let buffer_name = uuid::Uuid::new_v4().to_string(); + let workspace_alice = workspace_alice.clone(); + let workspace_bob = workspace_bob.clone(); + + async move { + workspace_alice.create_buffer(&buffer_name).await?; + assert_or_err!(workspace_bob.delete_buffer(&buffer_name).await.is_err()); + Ok(()) + } + }) + .await; +} + +#[tokio::test] // TODO split down this test in smaller checks +async fn test_workspace_interactions() { + if let Err(e) = async { + let client_alice = ClientFixture::of("alice").setup().await?; + let client_bob = ClientFixture::of("bob").setup().await?; + let workspace_name = format!("test-workspace-interactions-{}", uuid::Uuid::new_v4()); + + client_alice.create_workspace(&workspace_name).await?; + let owned_workspaces = client_alice.fetch_owned_workspaces().await?; + assert_or_err!(owned_workspaces.contains(&workspace_name)); + client_alice.attach_workspace(&workspace_name).await?; + assert_or_err!(vec![workspace_name.clone()] == client_alice.active_workspaces()); + + client_alice + .invite_to_workspace(&workspace_name, &client_bob.current_user().name) + .await?; + client_bob.attach_workspace(&workspace_name).await?; + assert_or_err!(client_bob + .fetch_joined_workspaces() + .await? + .contains(&workspace_name)); + + assert_or_err!(client_bob.leave_workspace(&workspace_name)); + assert_or_err!(client_alice.leave_workspace(&workspace_name)); + + client_alice.delete_workspace(&workspace_name).await?; + + Ok::<(), Box>(()) + } + .await + { + panic!("{e}"); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index f8054fc..787166a 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -185,10 +185,10 @@ impl Workspace { /// /// This will stop and drop its [`buffer::Controller`]. /// - /// Returns `true` if connectly dropped or wasn't present, `false` if dropped but wasn't last ref - /// - /// If this method returns `false` you have a dangling ref, maybe just waiting for garbage - /// collection or maybe preventing the controller from being dropped completely + /// Returns `true` if it was connectly dropped or wasn't present, `false` if it was dropped but + /// wasn't the last existing reference to it. If this method returns `false` it means you have + /// a dangling reference somewhere. It may just be waiting for garbage collection, but as long + /// as it exists, it will prevent the controller from being completely dropped. #[allow(clippy::redundant_pattern_matching)] // all cases are clearer this way pub fn detach_buffer(&self, path: &str) -> bool { match self.0.buffers.remove(path) {