summaryrefslogtreecommitdiff
path: root/src/channel/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/channel/routes')
-rw-r--r--src/channel/routes/channel/delete.rs39
-rw-r--r--src/channel/routes/channel/mod.rs9
-rw-r--r--src/channel/routes/channel/post.rs58
-rw-r--r--src/channel/routes/channel/test/delete.rs154
-rw-r--r--src/channel/routes/channel/test/mod.rs2
-rw-r--r--src/channel/routes/channel/test/post.rs (renamed from src/channel/routes/test/on_send.rs)71
-rw-r--r--src/channel/routes/mod.rs19
-rw-r--r--src/channel/routes/post.rs60
-rw-r--r--src/channel/routes/test.rs221
-rw-r--r--src/channel/routes/test/mod.rs2
-rw-r--r--src/channel/routes/test/on_create.rs88
11 files changed, 616 insertions, 107 deletions
diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs
new file mode 100644
index 0000000..91eb506
--- /dev/null
+++ b/src/channel/routes/channel/delete.rs
@@ -0,0 +1,39 @@
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+use crate::{
+ app::App,
+ channel::app,
+ clock::RequestedAt,
+ error::{Internal, NotFound},
+ token::extract::Identity,
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ Path(channel): Path<super::PathInfo>,
+ RequestedAt(deleted_at): RequestedAt,
+ _: Identity,
+) -> Result<StatusCode, Error> {
+ app.channels().delete(&channel, &deleted_at).await?;
+
+ Ok(StatusCode::ACCEPTED)
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct Error(#[from] pub app::Error);
+
+impl IntoResponse for Error {
+ fn into_response(self) -> Response {
+ let Self(error) = self;
+ #[allow(clippy::match_wildcard_for_single_variants)]
+ match error {
+ app::Error::NotFound(_) | app::Error::Deleted(_) => NotFound(error).into_response(),
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
diff --git a/src/channel/routes/channel/mod.rs b/src/channel/routes/channel/mod.rs
new file mode 100644
index 0000000..31a9142
--- /dev/null
+++ b/src/channel/routes/channel/mod.rs
@@ -0,0 +1,9 @@
+use crate::channel::Id;
+
+pub mod delete;
+pub mod post;
+
+#[cfg(test)]
+mod test;
+
+type PathInfo = Id;
diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs
new file mode 100644
index 0000000..b51e691
--- /dev/null
+++ b/src/channel/routes/channel/post.rs
@@ -0,0 +1,58 @@
+use axum::{
+ extract::{Json, Path, State},
+ http::StatusCode,
+ response::{self, IntoResponse},
+};
+
+use crate::{
+ app::App,
+ clock::RequestedAt,
+ error::{Internal, NotFound},
+ message::{app::SendError, Body, Message},
+ token::extract::Identity,
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ Path(channel): Path<super::PathInfo>,
+ RequestedAt(sent_at): RequestedAt,
+ identity: Identity,
+ Json(request): Json<Request>,
+) -> Result<Response, Error> {
+ let message = app
+ .messages()
+ .send(&channel, &identity.login, &sent_at, &request.body)
+ .await?;
+
+ Ok(Response(message))
+}
+
+#[derive(serde::Deserialize)]
+pub struct Request {
+ pub body: Body,
+}
+
+#[derive(Debug)]
+pub struct Response(pub Message);
+
+impl IntoResponse for Response {
+ fn into_response(self) -> response::Response {
+ let Self(message) = self;
+ (StatusCode::ACCEPTED, Json(message)).into_response()
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct Error(#[from] pub SendError);
+
+impl IntoResponse for Error {
+ fn into_response(self) -> response::Response {
+ let Self(error) = self;
+ #[allow(clippy::match_wildcard_for_single_variants)]
+ match error {
+ SendError::ChannelNotFound(_) => NotFound(error).into_response(),
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
diff --git a/src/channel/routes/channel/test/delete.rs b/src/channel/routes/channel/test/delete.rs
new file mode 100644
index 0000000..e9af12f
--- /dev/null
+++ b/src/channel/routes/channel/test/delete.rs
@@ -0,0 +1,154 @@
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+};
+
+use crate::{
+ channel::{app, routes::channel::delete},
+ test::fixtures,
+};
+
+#[tokio::test]
+pub async fn delete_channel() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let channel = fixtures::channel::create(&app, &fixtures::now()).await;
+
+ // Send the request
+
+ let deleter = fixtures::identity::create(&app, &fixtures::now()).await;
+ let response = delete::handler(
+ State(app.clone()),
+ Path(channel.id.clone()),
+ fixtures::now(),
+ deleter,
+ )
+ .await
+ .expect("deleting a valid channel succeeds");
+
+ // Verify the response
+
+ assert_eq!(response, StatusCode::ACCEPTED);
+
+ // Verify the semantics
+
+ let snapshot = app.boot().snapshot().await.expect("boot always succeeds");
+ assert!(!snapshot.channels.contains(&channel));
+}
+
+#[tokio::test]
+pub async fn delete_invalid_channel_id() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+
+ // Send the request
+
+ let deleter = fixtures::identity::create(&app, &fixtures::now()).await;
+ let channel = fixtures::channel::fictitious();
+ let delete::Error(error) = delete::handler(
+ State(app.clone()),
+ Path(channel.clone()),
+ fixtures::now(),
+ deleter,
+ )
+ .await
+ .expect_err("deleting a nonexistent channel fails");
+
+ // Verify the response
+
+ assert!(matches!(error, app::Error::NotFound(id) if id == channel));
+}
+
+#[tokio::test]
+pub async fn delete_deleted() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let channel = fixtures::channel::create(&app, &fixtures::now()).await;
+
+ app.channels()
+ .delete(&channel.id, &fixtures::now())
+ .await
+ .expect("deleting a recently-sent channel succeeds");
+
+ // Send the request
+
+ let deleter = fixtures::identity::create(&app, &fixtures::now()).await;
+ let delete::Error(error) = delete::handler(
+ State(app.clone()),
+ Path(channel.id.clone()),
+ fixtures::now(),
+ deleter,
+ )
+ .await
+ .expect_err("deleting a deleted channel fails");
+
+ // Verify the response
+
+ assert!(matches!(error, app::Error::Deleted(id) if id == channel.id));
+}
+
+#[tokio::test]
+pub async fn delete_expired() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let channel = fixtures::channel::create(&app, &fixtures::ancient()).await;
+
+ app.channels()
+ .expire(&fixtures::now())
+ .await
+ .expect("expiring channels always succeeds");
+
+ // Send the request
+
+ let deleter = fixtures::identity::create(&app, &fixtures::now()).await;
+ let delete::Error(error) = delete::handler(
+ State(app.clone()),
+ Path(channel.id.clone()),
+ fixtures::now(),
+ deleter,
+ )
+ .await
+ .expect_err("deleting an expired channel fails");
+
+ // Verify the response
+
+ assert!(matches!(error, app::Error::Deleted(id) if id == channel.id));
+}
+
+#[tokio::test]
+pub async fn delete_purged() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let channel = fixtures::channel::create(&app, &fixtures::ancient()).await;
+
+ app.channels()
+ .expire(&fixtures::old())
+ .await
+ .expect("expiring channels always succeeds");
+
+ app.channels()
+ .purge(&fixtures::now())
+ .await
+ .expect("purging channels always succeeds");
+
+ // Send the request
+
+ let deleter = fixtures::identity::create(&app, &fixtures::now()).await;
+ let delete::Error(error) = delete::handler(
+ State(app.clone()),
+ Path(channel.id.clone()),
+ fixtures::now(),
+ deleter,
+ )
+ .await
+ .expect_err("deleting a purged channel fails");
+
+ // Verify the response
+
+ assert!(matches!(error, app::Error::NotFound(id) if id == channel.id));
+}
diff --git a/src/channel/routes/channel/test/mod.rs b/src/channel/routes/channel/test/mod.rs
new file mode 100644
index 0000000..78bf86e
--- /dev/null
+++ b/src/channel/routes/channel/test/mod.rs
@@ -0,0 +1,2 @@
+mod delete;
+mod post;
diff --git a/src/channel/routes/test/on_send.rs b/src/channel/routes/channel/test/post.rs
index 293cc56..67e7d36 100644
--- a/src/channel/routes/test/on_send.rs
+++ b/src/channel/routes/channel/test/post.rs
@@ -2,9 +2,8 @@ use axum::extract::{Json, Path, State};
use futures::stream::StreamExt;
use crate::{
- channel,
- channel::routes,
- event::{self, Sequenced},
+ channel::{self, routes::channel::post},
+ event::Sequenced,
message::{self, app::SendError},
test::fixtures::{self, future::Immediately as _},
};
@@ -14,7 +13,7 @@ async fn messages_in_order() {
// Set up the environment
let app = fixtures::scratch_app().await;
- let sender = fixtures::login::create(&app, &fixtures::now()).await;
+ let sender = fixtures::identity::create(&app, &fixtures::now()).await;
let channel = fixtures::channel::create(&app, &fixtures::now()).await;
// Call the endpoint (twice)
@@ -25,17 +24,17 @@ async fn messages_in_order() {
];
for (sent_at, body) in &requests {
- let request = routes::SendRequest { body: body.clone() };
+ let request = post::Request { body: body.clone() };
- routes::on_send(
+ let _ = post::handler(
State(app.clone()),
Path(channel.id.clone()),
sent_at.clone(),
sender.clone(),
- Json(request.clone()),
+ Json(request),
)
.await
- .expect("sending to a valid channel");
+ .expect("sending to a valid channel succeeds");
}
// Verify the semantics
@@ -44,8 +43,8 @@ async fn messages_in_order() {
.events()
.subscribe(None)
.await
- .expect("subscribing to a valid channel")
- .filter(fixtures::filter::messages())
+ .expect("subscribing to a valid channel succeeds")
+ .filter_map(fixtures::message::events)
.take(requests.len());
let events = events.collect::<Vec<_>>().immediately().await;
@@ -54,8 +53,8 @@ async fn messages_in_order() {
assert_eq!(*sent_at, event.at());
assert!(matches!(
event,
- event::Event::Message(message::Event::Sent(event))
- if event.message.sender == sender.id
+ message::Event::Sent(event)
+ if event.message.sender == sender.login.id
&& event.message.body == message
));
}
@@ -66,24 +65,62 @@ async fn nonexistent_channel() {
// Set up the environment
let app = fixtures::scratch_app().await;
- let login = fixtures::login::create(&app, &fixtures::now()).await;
+ let sender = fixtures::identity::create(&app, &fixtures::now()).await;
// Call the endpoint
let sent_at = fixtures::now();
let channel = channel::Id::generate();
- let request = routes::SendRequest {
+ let request = post::Request {
body: fixtures::message::propose(),
};
- let routes::SendErrorResponse(error) = routes::on_send(
+ let post::Error(error) = post::handler(
State(app),
Path(channel.clone()),
sent_at,
- login,
+ sender,
Json(request),
)
.await
- .expect_err("sending to a nonexistent channel");
+ .expect_err("sending to a nonexistent channel fails");
+
+ // Verify the structure of the response
+
+ assert!(matches!(
+ error,
+ SendError::ChannelNotFound(error_channel) if channel == error_channel
+ ));
+}
+
+#[tokio::test]
+async fn deleted_channel() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let sender = fixtures::identity::create(&app, &fixtures::now()).await;
+ let channel = fixtures::channel::create(&app, &fixtures::now()).await;
+
+ app.channels()
+ .delete(&channel.id, &fixtures::now())
+ .await
+ .expect("deleting a new channel succeeds");
+
+ // Call the endpoint
+
+ let sent_at = fixtures::now();
+ let channel = channel::Id::generate();
+ let request = post::Request {
+ body: fixtures::message::propose(),
+ };
+ let post::Error(error) = post::handler(
+ State(app),
+ Path(channel.clone()),
+ sent_at,
+ sender,
+ Json(request),
+ )
+ .await
+ .expect_err("sending to a deleted channel fails");
// Verify the structure of the response
diff --git a/src/channel/routes/mod.rs b/src/channel/routes/mod.rs
new file mode 100644
index 0000000..696bd72
--- /dev/null
+++ b/src/channel/routes/mod.rs
@@ -0,0 +1,19 @@
+use axum::{
+ routing::{delete, post},
+ Router,
+};
+
+use crate::app::App;
+
+mod channel;
+mod post;
+
+#[cfg(test)]
+mod test;
+
+pub fn router() -> Router<App> {
+ Router::new()
+ .route("/api/channels", post(post::handler))
+ .route("/api/channels/:channel", post(channel::post::handler))
+ .route("/api/channels/:channel", delete(channel::delete::handler))
+}
diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs
new file mode 100644
index 0000000..810445c
--- /dev/null
+++ b/src/channel/routes/post.rs
@@ -0,0 +1,60 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{self, IntoResponse},
+};
+
+use crate::{
+ app::App,
+ channel::{app, Channel},
+ clock::RequestedAt,
+ error::Internal,
+ name::Name,
+ token::extract::Identity,
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ _: Identity, // requires auth, but doesn't actually care who you are
+ RequestedAt(created_at): RequestedAt,
+ Json(request): Json<Request>,
+) -> Result<Response, Error> {
+ let channel = app
+ .channels()
+ .create(&request.name, &created_at)
+ .await
+ .map_err(Error)?;
+
+ Ok(Response(channel))
+}
+
+#[derive(serde::Deserialize)]
+pub struct Request {
+ pub name: Name,
+}
+
+#[derive(Debug)]
+pub struct Response(pub Channel);
+
+impl IntoResponse for Response {
+ fn into_response(self) -> response::Response {
+ let Self(channel) = self;
+ (StatusCode::ACCEPTED, Json(channel)).into_response()
+ }
+}
+
+#[derive(Debug)]
+pub struct Error(pub app::CreateError);
+
+impl IntoResponse for Error {
+ fn into_response(self) -> response::Response {
+ let Self(error) = self;
+ #[allow(clippy::match_wildcard_for_single_variants)]
+ match error {
+ app::CreateError::DuplicateName(_) => {
+ (StatusCode::CONFLICT, error.to_string()).into_response()
+ }
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs
new file mode 100644
index 0000000..216eba1
--- /dev/null
+++ b/src/channel/routes/test.rs
@@ -0,0 +1,221 @@
+use std::future;
+
+use axum::extract::{Json, State};
+use futures::stream::StreamExt as _;
+
+use super::post;
+use crate::{
+ channel::app,
+ name::Name,
+ test::fixtures::{self, future::Immediately as _},
+};
+
+#[tokio::test]
+async fn new_channel() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let creator = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Call the endpoint
+
+ let name = fixtures::channel::propose();
+ let request = post::Request { name: name.clone() };
+ let post::Response(response) =
+ post::handler(State(app.clone()), creator, fixtures::now(), Json(request))
+ .await
+ .expect("creating a channel in an empty app succeeds");
+
+ // Verify the structure of the response
+
+ assert_eq!(name, response.name);
+
+ // Verify the semantics
+
+ let snapshot = app.boot().snapshot().await.expect("boot always succeeds");
+ assert!(snapshot.channels.iter().any(|channel| channel == &response));
+
+ let channel = app
+ .channels()
+ .get(&response.id)
+ .await
+ .expect("searching for channels by ID never fails")
+ .expect("the newly-created channel exists");
+ assert_eq!(response, channel);
+
+ let mut events = app
+ .events()
+ .subscribe(None)
+ .await
+ .expect("subscribing never fails")
+ .filter_map(fixtures::channel::events)
+ .filter_map(fixtures::channel::created)
+ .filter(|event| future::ready(event.channel == response));
+
+ let event = events
+ .next()
+ .immediately()
+ .await
+ .expect("creation event published");
+
+ assert_eq!(event.channel, response);
+}
+
+#[tokio::test]
+async fn duplicate_name() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let creator = fixtures::identity::create(&app, &fixtures::now()).await;
+ let channel = fixtures::channel::create(&app, &fixtures::now()).await;
+
+ // Call the endpoint
+
+ let request = post::Request {
+ name: channel.name.clone(),
+ };
+ let post::Error(error) =
+ post::handler(State(app.clone()), creator, fixtures::now(), Json(request))
+ .await
+ .expect_err("duplicate channel name should fail the request");
+
+ // Verify the structure of the response
+
+ assert!(matches!(
+ error,
+ app::CreateError::DuplicateName(name) if channel.name == name
+ ));
+}
+
+#[tokio::test]
+async fn conflicting_canonical_name() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let creator = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ let existing_name = Name::from("rijksmuseum");
+ app.channels()
+ .create(&existing_name, &fixtures::now())
+ .await
+ .expect("creating a channel in an empty environment succeeds");
+
+ let conflicting_name = Name::from("r\u{0133}ksmuseum");
+
+ // Call the endpoint
+
+ let request = post::Request {
+ name: conflicting_name.clone(),
+ };
+ let post::Error(error) =
+ post::handler(State(app.clone()), creator, fixtures::now(), Json(request))
+ .await
+ .expect_err("duplicate channel name should fail the request");
+
+ // Verify the structure of the response
+
+ assert!(matches!(
+ error,
+ app::CreateError::DuplicateName(name) if conflicting_name == name
+ ));
+}
+
+#[tokio::test]
+async fn name_reusable_after_delete() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let creator = fixtures::identity::create(&app, &fixtures::now()).await;
+ let name = fixtures::channel::propose();
+
+ // Call the endpoint (first time)
+
+ let request = post::Request { name: name.clone() };
+ let post::Response(response) = post::handler(
+ State(app.clone()),
+ creator.clone(),
+ fixtures::now(),
+ Json(request),
+ )
+ .await
+ .expect("new channel in an empty app");
+
+ // Delete the channel
+
+ app.channels()
+ .delete(&response.id, &fixtures::now())
+ .await
+ .expect("deleting a newly-created channel succeeds");
+
+ // Call the endpoint (second time)
+
+ let request = post::Request { name: name.clone() };
+ let post::Response(response) =
+ post::handler(State(app.clone()), creator, fixtures::now(), Json(request))
+ .await
+ .expect("creation succeeds after original channel deleted");
+
+ // Verify the structure of the response
+
+ assert_eq!(name, response.name);
+
+ // Verify the semantics
+
+ let channel = app
+ .channels()
+ .get(&response.id)
+ .await
+ .expect("searching for channels by ID never fails")
+ .expect("the newly-created channel exists");
+ assert_eq!(response, channel);
+}
+
+#[tokio::test]
+async fn name_reusable_after_expiry() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let creator = fixtures::identity::create(&app, &fixtures::ancient()).await;
+ let name = fixtures::channel::propose();
+
+ // Call the endpoint (first time)
+
+ let request = post::Request { name: name.clone() };
+ let post::Response(_) = post::handler(
+ State(app.clone()),
+ creator.clone(),
+ fixtures::ancient(),
+ Json(request),
+ )
+ .await
+ .expect("new channel in an empty app");
+
+ // Delete the channel
+
+ app.channels()
+ .expire(&fixtures::now())
+ .await
+ .expect("expiry always succeeds");
+
+ // Call the endpoint (second time)
+
+ let request = post::Request { name: name.clone() };
+ let post::Response(response) =
+ post::handler(State(app.clone()), creator, fixtures::now(), Json(request))
+ .await
+ .expect("creation succeeds after original channel expired");
+
+ // Verify the structure of the response
+
+ assert_eq!(name, response.name);
+
+ // Verify the semantics
+
+ let channel = app
+ .channels()
+ .get(&response.id)
+ .await
+ .expect("searching for channels by ID never fails")
+ .expect("the newly-created channel exists");
+ assert_eq!(response, channel);
+}
diff --git a/src/channel/routes/test/mod.rs b/src/channel/routes/test/mod.rs
deleted file mode 100644
index 3e5aa17..0000000
--- a/src/channel/routes/test/mod.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-mod on_create;
-mod on_send;
diff --git a/src/channel/routes/test/on_create.rs b/src/channel/routes/test/on_create.rs
deleted file mode 100644
index eeecc7f..0000000
--- a/src/channel/routes/test/on_create.rs
+++ /dev/null
@@ -1,88 +0,0 @@
-use axum::extract::{Json, State};
-use futures::stream::StreamExt as _;
-
-use crate::{
- channel::{self, app, routes},
- event,
- test::fixtures::{self, future::Immediately as _},
-};
-
-#[tokio::test]
-async fn new_channel() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let creator = fixtures::login::create(&app, &fixtures::now()).await;
-
- // Call the endpoint
-
- let name = fixtures::channel::propose();
- let request = routes::CreateRequest { name };
- let Json(response_channel) = routes::on_create(
- State(app.clone()),
- creator,
- fixtures::now(),
- Json(request.clone()),
- )
- .await
- .expect("new channel in an empty app");
-
- // Verify the structure of the response
-
- assert_eq!(request.name, response_channel.name);
-
- // Verify the semantics
-
- let snapshot = app.boot().snapshot().await.expect("boot always succeeds");
- assert!(snapshot
- .channels
- .iter()
- .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id));
-
- let mut events = app
- .events()
- .subscribe(None)
- .await
- .expect("subscribing never fails")
- .filter(fixtures::filter::created());
-
- let event = events
- .next()
- .immediately()
- .await
- .expect("creation event published");
-
- assert!(matches!(
- event,
- event::Event::Channel(channel::Event::Created(event))
- if event.channel == response_channel
- ));
-}
-
-#[tokio::test]
-async fn duplicate_name() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let creator = fixtures::login::create(&app, &fixtures::now()).await;
- let channel = fixtures::channel::create(&app, &fixtures::now()).await;
-
- // Call the endpoint
-
- let request = routes::CreateRequest { name: channel.name };
- let routes::CreateError(error) = routes::on_create(
- State(app.clone()),
- creator,
- fixtures::now(),
- Json(request.clone()),
- )
- .await
- .expect_err("duplicate channel name");
-
- // Verify the structure of the response
-
- assert!(matches!(
- error,
- app::CreateError::DuplicateName(name) if request.name == name
- ));
-}