mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00

- `Msg` or `msg` is now the canonical term for the substructure in a room. It includes a `chat` subtype and (in the future) other administration subtypes like member joining or leaving. - `Message` or `message` can used in human oriented context like docs and comments, but only when it is unambiguous. - `message` is not chosen in code because it's hard to type (at least for me!), and have ambiguous meaning of: - "Human readable text" in context of `ApiError`'s field. - "A unit of data transfer, datagram" in context of WebSocket Message. - `item` is not chosen because it is overly generic.
712 lines
21 KiB
Rust
712 lines
21 KiB
Rust
#![expect(clippy::unwrap_used, reason = "FIXME: random false positive")]
|
|
#![expect(clippy::toplevel_ref_arg, reason = "easy to use for fixtures")]
|
|
use std::cell::RefCell;
|
|
use std::fmt;
|
|
use std::future::{Future, IntoFuture};
|
|
use std::sync::{Arc, LazyLock};
|
|
|
|
use anyhow::Result;
|
|
use blah_types::{
|
|
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
|
|
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember,
|
|
RoomMemberList, RoomMetadata, ServerPermission, SignedChatMsg, UserKey, WithMsgId, WithSig,
|
|
};
|
|
use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
|
|
use ed25519_dalek::SigningKey;
|
|
use futures_util::TryFutureExt;
|
|
use rand::rngs::mock::StepRng;
|
|
use rand::RngCore;
|
|
use reqwest::{header, Method, StatusCode};
|
|
use rstest::{fixture, rstest};
|
|
use rusqlite::{params, Connection};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::net::TcpListener;
|
|
|
|
// Avoid name resolution.
|
|
const LOCALHOST: &str = "127.0.0.1";
|
|
|
|
static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
|
|
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
|
|
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
|
|
static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
|
|
|
|
#[fixture]
|
|
fn rng() -> impl RngCore {
|
|
rand::rngs::mock::StepRng::new(42, 1)
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
enum NoContent {}
|
|
|
|
trait ResultExt {
|
|
fn expect_api_err(self, status: StatusCode, code: &str);
|
|
}
|
|
|
|
impl<T: fmt::Debug> ResultExt for Result<T> {
|
|
#[track_caller]
|
|
fn expect_api_err(self, status: StatusCode, code: &str) {
|
|
let err = self.unwrap_err().downcast::<ApiError>().unwrap();
|
|
assert_eq!(err.status, status);
|
|
assert_eq!(err.code, code);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Server {
|
|
port: u16,
|
|
client: reqwest::Client,
|
|
rng: RefCell<StepRng>,
|
|
}
|
|
|
|
impl Server {
|
|
fn url(&self, rhs: impl fmt::Display) -> String {
|
|
format!("http://{}:{}{}", LOCALHOST, self.port, rhs)
|
|
}
|
|
|
|
fn request<Req: Serialize, Resp: DeserializeOwned>(
|
|
&self,
|
|
method: Method,
|
|
url: &str,
|
|
auth: Option<&str>,
|
|
body: Option<Req>,
|
|
) -> impl Future<Output = Result<Option<Resp>>> + use<'_, Req, Resp> {
|
|
let mut b = self.client.request(method, self.url(url));
|
|
if let Some(auth) = auth {
|
|
b = b.header(header::AUTHORIZATION, auth);
|
|
}
|
|
if let Some(body) = &body {
|
|
b = b.json(body);
|
|
}
|
|
|
|
async move {
|
|
let resp = b.send().await?;
|
|
let status = resp.status();
|
|
let resp_str = resp.text().await?;
|
|
|
|
if !status.is_success() {
|
|
#[derive(Deserialize)]
|
|
struct Resp {
|
|
error: ApiError,
|
|
}
|
|
let Resp { mut error } = serde_json::from_str(&resp_str)?;
|
|
error.status = status;
|
|
Err(error.into())
|
|
} else if resp_str.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(serde_json::from_str(&resp_str)?))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get<Resp: DeserializeOwned>(
|
|
&self,
|
|
url: &str,
|
|
auth: Option<&str>,
|
|
) -> impl Future<Output = Result<Resp>> + use<'_, Resp> {
|
|
self.request::<NoContent, Resp>(Method::GET, url, auth, None)
|
|
.map_ok(|resp| resp.unwrap())
|
|
}
|
|
|
|
fn create_room(
|
|
&self,
|
|
key: &SigningKey,
|
|
attrs: RoomAttrs,
|
|
title: &str,
|
|
) -> impl Future<Output = Result<Id>> + use<'_> {
|
|
let req = sign(
|
|
key,
|
|
&mut *self.rng.borrow_mut(),
|
|
CreateRoomPayload::Group(CreateGroup {
|
|
attrs,
|
|
members: RoomMemberList(vec![RoomMember {
|
|
permission: MemberPermission::ALL,
|
|
user: UserKey(key.verifying_key().to_bytes()),
|
|
}]),
|
|
title: title.to_string(),
|
|
}),
|
|
);
|
|
async move {
|
|
Ok(self
|
|
.request(Method::POST, "/room/create", None, Some(&req))
|
|
.await?
|
|
.unwrap())
|
|
}
|
|
}
|
|
|
|
fn join_room(
|
|
&self,
|
|
rid: Id,
|
|
key: &SigningKey,
|
|
permission: MemberPermission,
|
|
) -> impl Future<Output = Result<()>> + use<'_> {
|
|
let req = sign(
|
|
key,
|
|
&mut *self.rng.borrow_mut(),
|
|
RoomAdminPayload {
|
|
room: rid,
|
|
op: RoomAdminOp::AddMember {
|
|
permission,
|
|
user: UserKey(key.verifying_key().to_bytes()),
|
|
},
|
|
},
|
|
);
|
|
self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req))
|
|
.map_ok(|None| {})
|
|
}
|
|
|
|
fn leave_room(&self, rid: Id, key: &SigningKey) -> impl Future<Output = Result<()>> + use<'_> {
|
|
let req = sign(
|
|
key,
|
|
&mut *self.rng.borrow_mut(),
|
|
RoomAdminPayload {
|
|
room: rid,
|
|
op: RoomAdminOp::RemoveMember {
|
|
user: UserKey(key.verifying_key().to_bytes()),
|
|
},
|
|
},
|
|
);
|
|
self.request::<_, NoContent>(Method::POST, &format!("/room/{rid}/admin"), None, Some(req))
|
|
.map_ok(|None| {})
|
|
}
|
|
|
|
fn post_chat(
|
|
&self,
|
|
rid: Id,
|
|
key: &SigningKey,
|
|
text: &str,
|
|
) -> impl Future<Output = Result<WithMsgId<SignedChatMsg>>> + use<'_> {
|
|
let msg = sign(
|
|
key,
|
|
&mut *self.rng.borrow_mut(),
|
|
ChatPayload {
|
|
room: rid,
|
|
rich_text: text.into(),
|
|
},
|
|
);
|
|
async move {
|
|
let cid = self
|
|
.request::<_, Id>(
|
|
Method::POST,
|
|
&format!("/room/{rid}/msg"),
|
|
None,
|
|
Some(msg.clone()),
|
|
)
|
|
.await?
|
|
.unwrap();
|
|
Ok(WithMsgId { cid, msg })
|
|
}
|
|
}
|
|
}
|
|
|
|
#[fixture]
|
|
fn server() -> Server {
|
|
let _ = tracing_subscriber::fmt::try_init();
|
|
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
Database::maybe_init(&mut conn).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO `user` (`userkey`, `permission`) VALUES (?, ?)",
|
|
params![*ALICE, ServerPermission::ALL],
|
|
)
|
|
.unwrap();
|
|
let db = Database::from_raw(conn).unwrap();
|
|
|
|
// Use std's to avoid async, since we need no name resolution.
|
|
let listener = std::net::TcpListener::bind(format!("{LOCALHOST}:0")).unwrap();
|
|
listener.set_nonblocking(true).unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let listener = TcpListener::from_std(listener).unwrap();
|
|
|
|
// TODO: Testing config is hard to build because it does have a `Default` impl.
|
|
let config = toml::from_str(&format!(r#"base_url="http://{LOCALHOST}:{port}""#)).unwrap();
|
|
let st = AppState::new(db, config);
|
|
let router = blahd::router(Arc::new(st));
|
|
|
|
tokio::spawn(axum::serve(listener, router).into_future());
|
|
let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap();
|
|
let rng = StepRng::new(24, 1).into();
|
|
Server { port, client, rng }
|
|
}
|
|
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn smoke(server: Server) {
|
|
let got: RoomList = server.get("/room?filter=public", None).await.unwrap();
|
|
let exp = RoomList {
|
|
rooms: Vec::new(),
|
|
skip_token: None,
|
|
};
|
|
assert_eq!(got, exp);
|
|
}
|
|
|
|
fn sign<T: Serialize>(key: &SigningKey, rng: &mut dyn RngCore, payload: T) -> WithSig<T> {
|
|
WithSig::sign(key, get_timestamp(), rng, payload).unwrap()
|
|
}
|
|
|
|
fn auth(key: &SigningKey, rng: &mut impl RngCore) -> String {
|
|
serde_json::to_string(&sign(key, rng, AuthPayload {})).unwrap()
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::public(true)]
|
|
#[case::private(false)]
|
|
#[tokio::test]
|
|
async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) {
|
|
let title = "test room";
|
|
let mut room_meta = RoomMetadata {
|
|
rid: Id(0),
|
|
title: Some(title.into()),
|
|
attrs: if public {
|
|
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE
|
|
} else {
|
|
RoomAttrs::empty()
|
|
},
|
|
last_msg: None,
|
|
last_seen_cid: None,
|
|
unseen_cnt: None,
|
|
member_permission: None,
|
|
peer_user: None,
|
|
};
|
|
|
|
// Alice has permission.
|
|
let rid = server
|
|
.create_room(&ALICE_PRIV, room_meta.attrs, title)
|
|
.await
|
|
.unwrap();
|
|
room_meta.rid = rid;
|
|
|
|
// Bob has no permission.
|
|
server
|
|
.create_room(&BOB_PRIV, room_meta.attrs, title)
|
|
.await
|
|
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
|
|
|
|
// Alice can always access it.
|
|
let got_meta = server
|
|
.get::<RoomMetadata>(&format!("/room/{rid}"), Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got_meta, room_meta);
|
|
|
|
// Bob or public can access it when it is public.
|
|
for auth in [None, Some(auth(&BOB_PRIV, rng))] {
|
|
let resp = server
|
|
.get::<RoomMetadata>(&format!("/room/{rid}"), auth.as_deref())
|
|
.await;
|
|
if public {
|
|
assert_eq!(resp.unwrap(), room_meta);
|
|
} else {
|
|
resp.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
}
|
|
}
|
|
|
|
// The room appears in public list only when it is public.
|
|
let expect_list = |has: bool, perm: Option<MemberPermission>| RoomList {
|
|
rooms: if has {
|
|
vec![RoomMetadata {
|
|
member_permission: perm,
|
|
..room_meta.clone()
|
|
}]
|
|
} else {
|
|
Vec::new()
|
|
},
|
|
skip_token: None,
|
|
};
|
|
assert_eq!(
|
|
server
|
|
.get::<RoomList>("/room?filter=public", None)
|
|
.await
|
|
.unwrap(),
|
|
expect_list(public, None),
|
|
);
|
|
|
|
// Joined rooms endpoint always require authentication.
|
|
server
|
|
.get::<RoomList>("/room?filter=joined", None)
|
|
.await
|
|
.expect_api_err(StatusCode::UNAUTHORIZED, "unauthorized");
|
|
let got_joined = server
|
|
.get::<RoomList>("/room?filter=joined", Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got_joined, expect_list(true, Some(MemberPermission::ALL)));
|
|
|
|
let got_joined = server
|
|
.get::<RoomList>("/room?filter=joined", Some(&auth(&BOB_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got_joined, expect_list(false, None));
|
|
}
|
|
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn room_join_leave(server: Server, ref mut rng: impl RngCore) {
|
|
let rid_pub = server
|
|
.create_room(&ALICE_PRIV, RoomAttrs::PUBLIC_JOINABLE, "public room")
|
|
.await
|
|
.unwrap();
|
|
let rid_priv = server
|
|
.create_room(&ALICE_PRIV, RoomAttrs::empty(), "private room")
|
|
.await
|
|
.unwrap();
|
|
|
|
let join =
|
|
|rid: Id, key: &SigningKey| server.join_room(rid, key, MemberPermission::MAX_SELF_ADD);
|
|
|
|
// Ok.
|
|
join(rid_pub, &BOB_PRIV).await.unwrap();
|
|
// Already joined.
|
|
join(rid_pub, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::CONFLICT, "exists");
|
|
// Not permitted.
|
|
join(rid_priv, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
// Not exists.
|
|
join(Id::INVALID, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
// Overly high permission.
|
|
server
|
|
.join_room(rid_priv, &BOB_PRIV, MemberPermission::ALL)
|
|
.await
|
|
.expect_api_err(StatusCode::BAD_REQUEST, "deserialization");
|
|
|
|
// Bob is joined now.
|
|
assert_eq!(
|
|
server
|
|
.get::<RoomList>("/room?filter=joined", Some(&auth(&BOB_PRIV, rng)))
|
|
.await
|
|
.unwrap()
|
|
.rooms
|
|
.len(),
|
|
1,
|
|
);
|
|
|
|
let leave = |rid: Id, key: &SigningKey| server.leave_room(rid, key);
|
|
|
|
// Ok.
|
|
leave(rid_pub, &BOB_PRIV).await.unwrap();
|
|
// Already left.
|
|
leave(rid_pub, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
// Unpermitted and not inside.
|
|
leave(rid_priv, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
// Invalid room.
|
|
leave(Id::INVALID, &BOB_PRIV)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
}
|
|
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn room_chat_post_read(server: Server, ref mut rng: impl RngCore) {
|
|
let rid_pub = server
|
|
.create_room(
|
|
&ALICE_PRIV,
|
|
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE,
|
|
"public room",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let rid_priv = server
|
|
.create_room(&ALICE_PRIV, RoomAttrs::empty(), "private room")
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut chat = |rid: Id, key: &SigningKey, msg: &str| {
|
|
sign(
|
|
key,
|
|
rng,
|
|
ChatPayload {
|
|
room: rid,
|
|
rich_text: RichText::from(msg),
|
|
},
|
|
)
|
|
};
|
|
let post = |rid: Id, chat: SignedChatMsg| {
|
|
server
|
|
.request::<_, Id>(Method::POST, &format!("/room/{rid}/msg"), None, Some(chat))
|
|
.map_ok(|opt| opt.unwrap())
|
|
};
|
|
|
|
// Ok.
|
|
let chat1 = chat(rid_pub, &ALICE_PRIV, "one");
|
|
let chat2 = chat(rid_pub, &ALICE_PRIV, "two");
|
|
let cid1 = post(rid_pub, chat1.clone()).await.unwrap();
|
|
let cid2 = post(rid_pub, chat2.clone()).await.unwrap();
|
|
|
|
// Duplicated chat.
|
|
post(rid_pub, chat2.clone())
|
|
.await
|
|
.expect_api_err(StatusCode::BAD_REQUEST, "duplicated_nonce");
|
|
|
|
// Wrong room.
|
|
post(rid_pub, chat(rid_priv, &ALICE_PRIV, "wrong room"))
|
|
.await
|
|
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_request");
|
|
|
|
// Not a member.
|
|
post(rid_pub, chat(rid_pub, &BOB_PRIV, "not a member"))
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
// Is a member but without permission.
|
|
server
|
|
.join_room(rid_pub, &BOB_PRIV, MemberPermission::empty())
|
|
.await
|
|
.unwrap();
|
|
post(rid_pub, chat(rid_pub, &BOB_PRIV, "no permission"))
|
|
.await
|
|
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
|
|
|
|
// Room not exists.
|
|
post(Id::INVALID, chat(Id::INVALID, &ALICE_PRIV, "not permitted"))
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
//// Msgs listing ////
|
|
|
|
let chat1 = WithMsgId::new(cid1, chat1);
|
|
let chat2 = WithMsgId::new(cid2, chat2);
|
|
|
|
// List with default page size.
|
|
let msgs = server
|
|
.get::<RoomMsgs>(&format!("/room/{rid_pub}/msg"), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
msgs,
|
|
RoomMsgs {
|
|
msgs: vec![chat2.clone(), chat1.clone()],
|
|
skip_token: None,
|
|
},
|
|
);
|
|
|
|
// List with small page size.
|
|
let msgs = server
|
|
.get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?top=1"), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
msgs,
|
|
RoomMsgs {
|
|
msgs: vec![chat2.clone()],
|
|
skip_token: Some(cid2),
|
|
},
|
|
);
|
|
|
|
// Second page.
|
|
let msgs = server
|
|
.get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?skipToken={cid2}&top=1"), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
msgs,
|
|
RoomMsgs {
|
|
msgs: vec![chat1.clone()],
|
|
skip_token: Some(cid1),
|
|
},
|
|
);
|
|
|
|
// No more.
|
|
let msgs = server
|
|
.get::<RoomMsgs>(&format!("/room/{rid_pub}/msg?skipToken={cid1}&top=1"), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(msgs, RoomMsgs::default());
|
|
|
|
//// Private room ////
|
|
|
|
// Access without token.
|
|
server
|
|
.get::<RoomMsgs>(&format!("/room/{rid_priv}/msg"), None)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
// Not a member.
|
|
server
|
|
.get::<RoomMsgs>(
|
|
&format!("/room/{rid_priv}/msg"),
|
|
Some(&auth(&BOB_PRIV, rng)),
|
|
)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
// Ok.
|
|
let msgs = server
|
|
.get::<RoomMsgs>(
|
|
&format!("/room/{rid_priv}/msg"),
|
|
Some(&auth(&ALICE_PRIV, rng)),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(msgs, RoomMsgs::default());
|
|
}
|
|
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn last_seen(server: Server, ref mut rng: impl RngCore) {
|
|
let title = "public room";
|
|
let attrs = RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE;
|
|
let member_perm = MemberPermission::ALL;
|
|
let rid = server.create_room(&ALICE_PRIV, attrs, title).await.unwrap();
|
|
server
|
|
.join_room(rid, &BOB_PRIV, MemberPermission::MAX_SELF_ADD)
|
|
.await
|
|
.unwrap();
|
|
|
|
let alice_chat1 = server.post_chat(rid, &ALICE_PRIV, "alice1").await.unwrap();
|
|
let alice_chat2 = server.post_chat(rid, &ALICE_PRIV, "alice2").await.unwrap();
|
|
|
|
// 2 new msgs.
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
rooms,
|
|
RoomList {
|
|
rooms: vec![RoomMetadata {
|
|
rid,
|
|
title: Some(title.into()),
|
|
attrs,
|
|
last_msg: Some(alice_chat2.clone()),
|
|
last_seen_cid: None,
|
|
unseen_cnt: Some(2),
|
|
member_permission: Some(member_perm),
|
|
peer_user: None,
|
|
}],
|
|
skip_token: None,
|
|
}
|
|
);
|
|
|
|
let seen = |key: &SigningKey, cid: Id| {
|
|
server.request::<NoContent, NoContent>(
|
|
Method::POST,
|
|
&format!("/room/{rid}/msg/{cid}/seen"),
|
|
Some(&auth(key, &mut *server.rng.borrow_mut())),
|
|
None,
|
|
)
|
|
};
|
|
|
|
// Mark the first one seen.
|
|
seen(&ALICE_PRIV, alice_chat1.cid).await.unwrap();
|
|
|
|
// 1 new msg.
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
rooms,
|
|
RoomList {
|
|
rooms: vec![RoomMetadata {
|
|
rid,
|
|
title: Some(title.into()),
|
|
attrs,
|
|
last_msg: Some(alice_chat2.clone()),
|
|
last_seen_cid: Some(alice_chat1.cid),
|
|
unseen_cnt: Some(1),
|
|
member_permission: Some(member_perm),
|
|
peer_user: None,
|
|
}],
|
|
skip_token: None,
|
|
}
|
|
);
|
|
|
|
// Mark the second one seen. Now there is no new messages.
|
|
seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap();
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rooms, RoomList::default());
|
|
|
|
// Marking a seen message seen is a no-op.
|
|
seen(&ALICE_PRIV, alice_chat2.cid).await.unwrap();
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=unseen", Some(&auth(&ALICE_PRIV, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rooms, RoomList::default());
|
|
}
|
|
|
|
#[rstest]
|
|
#[tokio::test]
|
|
async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
|
|
let mut create_chat = |src: &SigningKey, tgt: &UserKey| {
|
|
let req = sign(
|
|
src,
|
|
rng,
|
|
CreateRoomPayload::PeerChat(CreatePeerChat { peer: tgt.clone() }),
|
|
);
|
|
server
|
|
.request::<_, Id>(Method::POST, "/room/create", None, Some(req))
|
|
.map_ok(|resp| resp.unwrap())
|
|
};
|
|
|
|
// Bob disallows peer chat.
|
|
create_chat(&ALICE_PRIV, &BOB)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
// Alice accepts bob.
|
|
let rid = create_chat(&BOB_PRIV, &ALICE).await.unwrap();
|
|
|
|
// Room already exists.
|
|
create_chat(&BOB_PRIV, &ALICE)
|
|
.await
|
|
.expect_api_err(StatusCode::CONFLICT, "exists");
|
|
|
|
// Peer chat room is not public.
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=public", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rooms, RoomList::default());
|
|
server
|
|
.get::<RoomMetadata>(&format!("/room/{rid}"), None)
|
|
.await
|
|
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
|
|
|
// Both alice and bob are in the room.
|
|
for (key, peer) in [(&*ALICE_PRIV, &*BOB), (&*BOB_PRIV, &*ALICE)] {
|
|
let mut expect_meta = RoomMetadata {
|
|
rid,
|
|
title: None,
|
|
attrs: RoomAttrs::PEER_CHAT,
|
|
last_msg: None,
|
|
last_seen_cid: None,
|
|
unseen_cnt: None,
|
|
member_permission: None,
|
|
peer_user: None,
|
|
};
|
|
|
|
let meta = server
|
|
.get::<RoomMetadata>(&format!("/room/{rid}"), Some(&auth(key, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(meta, expect_meta);
|
|
|
|
expect_meta.member_permission = Some(MemberPermission::MAX_PEER_CHAT);
|
|
expect_meta.peer_user = Some(peer.clone());
|
|
let rooms = server
|
|
.get::<RoomList>("/room?filter=joined", Some(&auth(key, rng)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
rooms,
|
|
RoomList {
|
|
rooms: vec![expect_meta],
|
|
skip_token: None
|
|
}
|
|
);
|
|
}
|
|
}
|