diff options
| author | Kit La Touche <kit@transneptune.net> | 2024-10-23 21:56:31 -0400 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2024-10-23 21:56:31 -0400 |
| commit | 1f769855df2d9cf2bca883a0475670f227e3678b (patch) | |
| tree | 6c94d9c868eb022588a07245df978478034ac5dd /src/channel/routes | |
| parent | 8f360dd9cc45bb14431238ccc5e3d137c020fa7b (diff) | |
| parent | 461814e5174cef1be3e07b4e4069314e9bcbedd6 (diff) | |
Merge branch 'main' into wip/mobile
Diffstat (limited to 'src/channel/routes')
| -rw-r--r-- | src/channel/routes/channel/delete.rs | 39 | ||||
| -rw-r--r-- | src/channel/routes/channel/mod.rs | 9 | ||||
| -rw-r--r-- | src/channel/routes/channel/post.rs | 58 | ||||
| -rw-r--r-- | src/channel/routes/channel/test/delete.rs | 154 | ||||
| -rw-r--r-- | src/channel/routes/channel/test/mod.rs | 2 | ||||
| -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.rs | 19 | ||||
| -rw-r--r-- | src/channel/routes/post.rs | 60 | ||||
| -rw-r--r-- | src/channel/routes/test.rs | 221 | ||||
| -rw-r--r-- | src/channel/routes/test/mod.rs | 2 | ||||
| -rw-r--r-- | src/channel/routes/test/on_create.rs | 88 |
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 - )); -} |
