From e5f72711c5a17c5db24e209b14f82d426eceb86e Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Thu, 19 Sep 2024 01:25:31 -0400 Subject: Write tests. --- Cargo.lock | 18 ++ Cargo.toml | 4 + docs/testing.md | 27 +++ src/channel/app.rs | 2 +- src/channel/routes.rs | 19 +- src/channel/routes/test/list.rs | 64 ++++++ src/channel/routes/test/mod.rs | 3 + src/channel/routes/test/on_create.rs | 58 ++++++ src/channel/routes/test/on_send.rs | 148 ++++++++++++++ src/clock.rs | 18 +- src/error.rs | 2 + src/events/app.rs | 5 +- src/events/repo/broadcast.rs | 2 +- src/events/routes.rs | 11 +- src/events/routes/test.rs | 368 +++++++++++++++++++++++++++++++++++ src/id.rs | 2 +- src/lib.rs | 2 + src/login/app.rs | 19 ++ src/login/extract.rs | 9 + src/login/routes.rs | 5 + src/login/routes/test/boot.rs | 9 + src/login/routes/test/login.rs | 137 +++++++++++++ src/login/routes/test/logout.rs | 86 ++++++++ src/login/routes/test/mod.rs | 3 + src/repo/channel.rs | 2 +- src/repo/login/store.rs | 2 +- src/test/fixtures/channel.rs | 24 +++ src/test/fixtures/error.rs | 14 ++ src/test/fixtures/future.rs | 55 ++++++ src/test/fixtures/identity.rs | 27 +++ src/test/fixtures/login.rs | 44 +++++ src/test/fixtures/message.rs | 26 +++ src/test/fixtures/mod.rs | 28 +++ src/test/mod.rs | 1 + 34 files changed, 1226 insertions(+), 18 deletions(-) create mode 100644 docs/testing.md create mode 100644 src/channel/routes/test/list.rs create mode 100644 src/channel/routes/test/mod.rs create mode 100644 src/channel/routes/test/on_create.rs create mode 100644 src/channel/routes/test/on_send.rs create mode 100644 src/events/routes/test.rs create mode 100644 src/login/routes/test/boot.rs create mode 100644 src/login/routes/test/login.rs create mode 100644 src/login/routes/test/logout.rs create mode 100644 src/login/routes/test/mod.rs create mode 100644 src/test/fixtures/channel.rs create mode 100644 src/test/fixtures/error.rs create mode 100644 src/test/fixtures/future.rs create mode 100644 src/test/fixtures/identity.rs create mode 100644 src/test/fixtures/login.rs create mode 100644 src/test/fixtures/message.rs create mode 100644 src/test/fixtures/mod.rs create mode 100644 src/test/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f9d1cfe..a5f7197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,6 +472,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + [[package]] name = "digest" version = "0.10.7" @@ -537,6 +543,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "faker_rand" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300d2ddbf2245b5b5e723995e0961033121b4fc2be9045fb661af82bd739ffb6" +dependencies = [ + "deunicode", + "lazy_static", + "rand", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -767,6 +784,7 @@ dependencies = [ "axum-extra", "chrono", "clap", + "faker_rand", "futures", "headers", "password-hash", diff --git a/Cargo.toml b/Cargo.toml index a80aed0..50e0862 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,7 @@ thiserror = "1.0.63" tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } uuid = { version = "1.10.0", features = ["v4"] } + +[dev-dependencies] +faker_rand = "0.1.1" +rand = "0.8.5" diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c6f3654 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,27 @@ +# Testing + +Run `cargo test`. + +Most of the tests for this project use an integration-based testing approach: + +* Spin up a scratch copy of the application, using an in-memory database; +* Perform operations against that database using the `app` abstraction to set up the scenario; +* Interact with route endpoint functions directly to perform the test; +* Inspect both the return values from the endpoint function to validate the results; and +* Perform additional operations against the scratch database using the `app` absraction to validate behaviour. + +Tests can vary around this theme. + +Rather than hard-coding test data, tests use `faker_rand` and similar tools to synthesize data on a run-by-run basis. While this makes tests marginally less predictable, it avoids some of the issues of bad programmer-determined static testing data in favour of bad programmer-determined random testing data. Improvements in this area would be welcome - `proptest` would be ideal. + +The code for carrying out testing interactions lives in `src/test/fixtures`. By convention, only `crate::test::fixtures` is imported, with qualified names from there to communicate the specific kinds of fixtures that a test is using. + +## Style + +Prefer writing "flat" fixtures that do one thing, over compound fixtures that do several, so that the test itself conveys as much information as possible about the scenario under test. For example, even though nearly all tests involve an app with at least one user provisioned, there are separate fixtures for creating a scratch app and provisioning a user, and no fixture for "an app and its main user." + +Prefer role-specific names for test values: use, for example, `sender` for a login related to sending messages, rather than `login`. Fixture data is cheap, so make as many entities as make sense for the test. They'll vanish at the end of the test anyways. + +Prefer testing a single endpoint at a time. Other interactions, which may be needed to set up the scenario or verify the results, should be done against the `app` abstraction directly. It's okay if this leads to redundant tests (see for example `src/channel/routes/test/on_send.rs` and `src/events/routes/test.rs`, which overlap heavily). + +Panicking in tests is fine. Panic messages should describe why the preconditions were expected, and can be terse. diff --git a/src/channel/app.rs b/src/channel/app.rs index 48e3e3c..3c92d76 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -78,7 +78,7 @@ impl<'a> Channels<'a> { channel: &channel::Id, subscribed_at: &DateTime, resume_at: Option<&str>, - ) -> Result, EventsError> { + ) -> Result + std::fmt::Debug, EventsError> { // Somewhat arbitrarily, expire after 90 days. let expire_at = subscribed_at.to_owned() - TimeDelta::days(90); diff --git a/src/channel/routes.rs b/src/channel/routes.rs index 383ec58..674c876 100644 --- a/src/channel/routes.rs +++ b/src/channel/routes.rs @@ -17,14 +17,17 @@ use crate::{ }, }; +#[cfg(test)] +mod test; + pub fn router() -> Router { Router::new() - .route("/api/channels", get(list_channels)) + .route("/api/channels", get(list)) .route("/api/channels", post(on_create)) .route("/api/channels/:channel", post(on_send)) } -async fn list_channels(State(app): State, _: Login) -> Result { +async fn list(State(app): State, _: Login) -> Result { let channels = app.channels().all().await?; let response = Channels(channels); @@ -40,7 +43,7 @@ impl IntoResponse for Channels { } } -#[derive(serde::Deserialize)] +#[derive(Clone, serde::Deserialize)] struct CreateRequest { name: String, } @@ -59,6 +62,7 @@ async fn on_create( Ok(Json(channel)) } +#[derive(Debug)] struct CreateError(app::CreateError); impl IntoResponse for CreateError { @@ -73,20 +77,20 @@ impl IntoResponse for CreateError { } } -#[derive(serde::Deserialize)] +#[derive(Clone, serde::Deserialize)] struct SendRequest { message: String, } async fn on_send( + State(app): State, Path(channel): Path, RequestedAt(sent_at): RequestedAt, - State(app): State, login: Login, - Json(form): Json, + Json(request): Json, ) -> Result { app.channels() - .send(&login, &channel, &form.message, &sent_at) + .send(&login, &channel, &request.message, &sent_at) .await // Could impl `From` here, but it's more code and this is used once. .map_err(ErrorResponse)?; @@ -94,6 +98,7 @@ async fn on_send( Ok(StatusCode::ACCEPTED) } +#[derive(Debug)] struct ErrorResponse(EventsError); impl IntoResponse for ErrorResponse { diff --git a/src/channel/routes/test/list.rs b/src/channel/routes/test/list.rs new file mode 100644 index 0000000..f7f7b44 --- /dev/null +++ b/src/channel/routes/test/list.rs @@ -0,0 +1,64 @@ +use axum::extract::State; + +use crate::{channel::routes, test::fixtures}; + +#[tokio::test] +async fn empty_list() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let viewer = fixtures::login::create(&app).await; + + // Call the endpoint + + let routes::Channels(channels) = routes::list(State(app), viewer) + .await + .expect("always succeeds"); + + // Verify the semantics + + assert!(channels.is_empty()); +} + +#[tokio::test] +async fn one_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let viewer = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + + // Call the endpoint + + let routes::Channels(channels) = routes::list(State(app), viewer) + .await + .expect("always succeeds"); + + // Verify the semantics + + assert!(channels.contains(&channel)); +} + +#[tokio::test] +async fn multiple_channels() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let viewer = fixtures::login::create(&app).await; + let channels = vec![ + fixtures::channel::create(&app).await, + fixtures::channel::create(&app).await, + ]; + + // Call the endpoint + + let routes::Channels(response_channels) = routes::list(State(app), viewer) + .await + .expect("always succeeds"); + + // Verify the semantics + + assert!(channels + .into_iter() + .all(|channel| response_channels.contains(&channel))); +} diff --git a/src/channel/routes/test/mod.rs b/src/channel/routes/test/mod.rs new file mode 100644 index 0000000..ab663eb --- /dev/null +++ b/src/channel/routes/test/mod.rs @@ -0,0 +1,3 @@ +mod list; +mod on_create; +mod on_send; diff --git a/src/channel/routes/test/on_create.rs b/src/channel/routes/test/on_create.rs new file mode 100644 index 0000000..df23deb --- /dev/null +++ b/src/channel/routes/test/on_create.rs @@ -0,0 +1,58 @@ +use axum::extract::{Json, State}; + +use crate::{ + channel::{app, routes}, + test::fixtures, +}; + +#[tokio::test] +async fn new_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app).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, 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 channels = app.channels().all().await.expect("always succeeds"); + + assert!(channels.contains(&response_channel)); +} + +#[tokio::test] +async fn duplicate_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + + // Call the endpoint + + let request = routes::CreateRequest { name: channel.name }; + let routes::CreateError(error) = + routes::on_create(State(app.clone()), creator, Json(request.clone())) + .await + .expect_err("duplicate channel name"); + + // Verify the structure of the response + + fixtures::error::expected!( + error, + app::CreateError::DuplicateName(name), + assert_eq!(request.name, name), + ); +} diff --git a/src/channel/routes/test/on_send.rs b/src/channel/routes/test/on_send.rs new file mode 100644 index 0000000..eab7c32 --- /dev/null +++ b/src/channel/routes/test/on_send.rs @@ -0,0 +1,148 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, +}; +use futures::stream::StreamExt; + +use crate::{ + channel::{app, routes}, + repo::channel, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn channel_exists() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let request = routes::SendRequest { + message: fixtures::message::propose(), + }; + let status = routes::on_send( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request.clone()), + ) + .await + .expect("sending to a valid channel"); + + // Verify the structure of the response + + assert_eq!(StatusCode::ACCEPTED, status); + + // Verify the semantics + + let subscribed_at = fixtures::now(); + let mut events = app + .channels() + .events(&channel.id, &subscribed_at, None) + .await + .expect("subscribing to a valid channel"); + + let event = events + .next() + .immediately() + .await + .expect("event received by subscribers"); + + assert_eq!(request.message, event.body); + assert_eq!(sender, event.sender); + assert_eq!(*sent_at, event.sent_at); +} + +#[tokio::test] +async fn messages_in_order() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + + // Call the endpoint (twice) + + let requests = vec![ + ( + fixtures::now(), + routes::SendRequest { + message: fixtures::message::propose(), + }, + ), + ( + fixtures::now(), + routes::SendRequest { + message: fixtures::message::propose(), + }, + ), + ]; + + for (sent_at, request) in &requests { + routes::on_send( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request.clone()), + ) + .await + .expect("sending to a valid channel"); + } + + // Verify the semantics + + let subscribed_at = fixtures::now(); + let events = app + .channels() + .events(&channel.id, &subscribed_at, None) + .await + .expect("subscribing to a valid channel") + .take(requests.len()); + + let events = events.collect::>().immediately().await; + + for ((sent_at, request), event) in requests.into_iter().zip(events) { + assert_eq!(request.message, event.body); + assert_eq!(sender, event.sender); + assert_eq!(*sent_at, event.sent_at); + } +} + +#[tokio::test] +async fn nonexistent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = routes::SendRequest { + message: fixtures::message::propose(), + }; + let routes::ErrorResponse(error) = routes::on_send( + State(app), + Path(channel.clone()), + sent_at, + login, + Json(request), + ) + .await + .expect_err("sending to a nonexistent channel"); + + // Verify the structure of the response + + fixtures::error::expected!( + error, + app::EventsError::ChannelNotFound(error_channel), + assert_eq!(channel, error_channel) + ); +} diff --git a/src/clock.rs b/src/clock.rs index f7e728f..d162fc0 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -32,13 +32,27 @@ where // This is purely for ergonomics: it allows `RequestedAt` to be extracted // without having to wrap it in `Extension<>`. Callers _can_ still do that, // but they aren't forced to. - let Extension(requested_at) = - Extension::::from_request_parts(parts, state).await?; + let Extension(requested_at) = Extension::::from_request_parts(parts, state).await?; Ok(requested_at) } } +impl From for RequestedAt { + fn from(timestamp: DateTime) -> Self { + Self(timestamp) + } +} + +impl std::ops::Deref for RequestedAt { + type Target = DateTime; + + fn deref(&self) -> &Self::Target { + let Self(timestamp) = self; + timestamp + } +} + /// Computes a canonical "requested at" time for each request it wraps. This /// time can be recovered using the [RequestedAt] extractor. pub async fn middleware(mut req: Request, next: Next) -> Result { diff --git a/src/error.rs b/src/error.rs index 2a6555f..e2128d3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,7 @@ type BoxedError = Box; // Returns a 500 Internal Server Error to the client. Meant to be used via the // `?` operator; _does not_ return the originating error to the client. +#[derive(Debug)] pub struct InternalError(Id, BoxedError); impl From for InternalError @@ -40,6 +41,7 @@ impl IntoResponse for InternalError { } /// Transient identifier for an InternalError. Prefixed with `E`. +#[derive(Debug)] pub struct Id(BaseId); impl From for Id { diff --git a/src/events/app.rs b/src/events/app.rs index c3a027d..99e849e 100644 --- a/src/events/app.rs +++ b/src/events/app.rs @@ -69,7 +69,10 @@ impl Broadcaster { // panic: if ``channel`` has not been previously registered, and was not // part of the initial set of channels. - pub fn listen(&self, channel: &channel::Id) -> impl Stream { + pub fn listen( + &self, + channel: &channel::Id, + ) -> impl Stream + std::fmt::Debug { let rx = self.sender(channel).subscribe(); BroadcastStream::from(rx) diff --git a/src/events/repo/broadcast.rs b/src/events/repo/broadcast.rs index 182203a..bffe991 100644 --- a/src/events/repo/broadcast.rs +++ b/src/events/repo/broadcast.rs @@ -21,7 +21,7 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Broadcast<'t>(&'t mut SqliteConnection); -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Message { pub id: message::Id, pub sender: Login, diff --git a/src/events/routes.rs b/src/events/routes.rs index ce5b778..a6bf5d9 100644 --- a/src/events/routes.rs +++ b/src/events/routes.rs @@ -22,11 +22,14 @@ use crate::{ repo::{channel, login::Login}, }; +#[cfg(test)] +mod test; + pub fn router() -> Router { Router::new().route("/api/events", get(events)) } -#[derive(serde::Deserialize)] +#[derive(Clone, serde::Deserialize)] struct EventsQuery { #[serde(default, rename = "channel")] channels: Vec, @@ -38,7 +41,7 @@ async fn events( _: Login, // requires auth, but doesn't actually care who you are last_event_id: Option, Query(query): Query, -) -> Result>, ErrorResponse> { +) -> Result + std::fmt::Debug>, ErrorResponse> { let resume_at = last_event_id.as_deref(); let streams = stream::iter(query.channels) @@ -64,6 +67,7 @@ async fn events( Ok(Events(stream)) } +#[derive(Debug)] struct Events(S); impl IntoResponse for Events @@ -79,6 +83,7 @@ where } } +#[derive(Debug)] struct ErrorResponse(EventsError); impl IntoResponse for ErrorResponse { @@ -96,7 +101,7 @@ impl IntoResponse for ErrorResponse { } } -#[derive(serde::Serialize)] +#[derive(Debug, serde::Serialize)] struct ChannelEvent { channel: channel::Id, #[serde(flatten)] diff --git a/src/events/routes/test.rs b/src/events/routes/test.rs new file mode 100644 index 0000000..df2d5f6 --- /dev/null +++ b/src/events/routes/test.rs @@ -0,0 +1,368 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{ + future, + stream::{self, StreamExt as _}, +}; + +use crate::{ + channel::app, + events::routes, + repo::channel::{self}, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn no_subscriptions() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let subscriber = fixtures::login::create(&app).await; + + // Call the endpoint + + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { channels: vec![] }; + let routes::Events(mut events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("empty subscription"); + + // Verify the structure of the response. + + assert!(events.next().immediately().await.is_none()); +} + +#[tokio::test] +async fn includes_historical_message() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + let message = fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.id.clone()], + }; + let routes::Events(mut events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("subscribed to valid channel"); + + // Verify the structure of the response. + + let event = events + .next() + .immediately() + .await + .expect("delivered stored message"); + + assert_eq!(channel.id, event.channel); + assert_eq!(message, event.message); +} + +#[tokio::test] +async fn includes_live_message() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app).await; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.id.clone()], + }; + let routes::Events(mut events) = routes::events( + State(app.clone()), + subscribed_at, + subscriber, + None, + Query(query), + ) + .await + .expect("subscribed to a valid channel"); + + // Verify the semantics + + let sender = fixtures::login::create(&app).await; + let message = fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await; + + let event = events + .next() + .immediately() + .await + .expect("delivered live message"); + + assert_eq!(channel.id, event.channel); + assert_eq!(message, event.message); +} + +#[tokio::test] +async fn excludes_other_channels() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let subscribed = fixtures::channel::create(&app).await; + let unsubscribed = fixtures::channel::create(&app).await; + let sender = fixtures::login::create(&app).await; + let message = fixtures::message::send(&app, &sender, &subscribed, &fixtures::now()).await; + fixtures::message::send(&app, &sender, &unsubscribed, &fixtures::now()).await; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![subscribed.id.clone()], + }; + let routes::Events(mut events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("subscribed to a valid channel"); + + // Verify the semantics + + let event = events + .next() + .immediately() + .await + .expect("delivered at least one message"); + + assert_eq!(subscribed.id, event.channel); + assert_eq!(message, event.message); +} + +#[tokio::test] +async fn includes_multiple_channels() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app).await; + + let channels = [ + fixtures::channel::create(&app).await, + fixtures::channel::create(&app).await, + ]; + + let messages = stream::iter(channels) + .then(|channel| async { + let message = fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await; + + (channel, message) + }) + .collect::>() + .await; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: messages + .iter() + .map(|(channel, _)| &channel.id) + .cloned() + .collect(), + }; + let routes::Events(events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("subscribed to valid channels"); + + // Verify the structure of the response. + + let events = events + .take(messages.len()) + .collect::>() + .immediately() + .await; + + for (channel, message) in messages { + assert!(events + .iter() + .any(|event| { event.channel == channel.id && event.message == message })); + } +} + +#[tokio::test] +async fn nonexitent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = channel::Id::generate(); + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.clone()], + }; + let routes::ErrorResponse(error) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect_err("subscribed to nonexistent channel"); + + // Verify the structure of the response. + + fixtures::error::expected!( + error, + app::EventsError::ChannelNotFound(error_channel), + assert_eq!(channel, error_channel) + ); +} + +#[tokio::test] +async fn sequential_messages() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app).await; + let sender = fixtures::login::create(&app).await; + + let messages = vec![ + fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await, + fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await, + fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await, + ]; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.id.clone()], + }; + let routes::Events(events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("subscribed to a valid channel"); + + // Verify the structure of the response. + + let mut events = events.filter(|event| future::ready(messages.contains(&event.message))); + + // Verify delivery in order + for message in &messages { + let event = events + .next() + .immediately() + .await + .expect("undelivered messages remaining"); + + assert_eq!(channel.id, event.channel); + assert_eq!(message, &event.message); + } +} + +#[tokio::test] +async fn resumes_from() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app).await; + let sender = fixtures::login::create(&app).await; + + let initial_message = fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await; + + let later_messages = vec![ + fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await, + fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await, + ]; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.id.clone()], + }; + + let resume_at = { + // First subscription + let routes::Events(mut events) = routes::events( + State(app.clone()), + subscribed_at, + subscriber.clone(), + None, + Query(query.clone()), + ) + .await + .expect("subscribed to a valid channel"); + + let event = events.next().immediately().await.expect("delivered events"); + + assert_eq!(channel.id, event.channel); + assert_eq!(initial_message, event.message); + + event.event_id() + }; + + // Resume after disconnect + let resumed_at = fixtures::now(); + let routes::Events(resumed) = routes::events( + State(app), + resumed_at, + subscriber, + Some(resume_at.into()), + Query(query), + ) + .await + .expect("subscribed to a valid channel"); + + // Verify the structure of the response. + + let events = resumed + .take(later_messages.len()) + .collect::>() + .immediately() + .await; + + for message in later_messages { + assert!(events + .iter() + .any(|event| event.channel == channel.id && event.message == message)); + } +} + +#[tokio::test] +async fn removes_expired_messages() { + // Set up the environment + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app).await; + let channel = fixtures::channel::create(&app).await; + + fixtures::message::send(&app, &sender, &channel, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &sender, &channel, &fixtures::now()).await; + + // Call the endpoint + + let subscriber = fixtures::login::create(&app).await; + let subscribed_at = fixtures::now(); + let query = routes::EventsQuery { + channels: vec![channel.id.clone()], + }; + let routes::Events(mut events) = + routes::events(State(app), subscribed_at, subscriber, None, Query(query)) + .await + .expect("subscribed to valid channel"); + + // Verify the semantics + + let event = events + .next() + .immediately() + .await + .expect("delivered messages"); + + assert_eq!(channel.id, event.channel); + assert_eq!(message, event.message); +} diff --git a/src/id.rs b/src/id.rs index ce7f13b..22add08 100644 --- a/src/id.rs +++ b/src/id.rs @@ -27,7 +27,7 @@ pub const ID_SIZE: usize = 15; // // By convention, the prefix should be UPPERCASE - note that the alphabet for this // is entirely lowercase. -#[derive(Clone, Debug, Hash, PartialEq, Eq, sqlx::Type, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, Hash, Eq, PartialEq, sqlx::Type, serde::Deserialize, serde::Serialize)] #[sqlx(transparent)] #[serde(transparent)] pub struct Id(String); diff --git a/src/lib.rs b/src/lib.rs index 609142a..09bfac4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,5 @@ mod id; mod login; mod password; mod repo; +#[cfg(test)] +mod test; diff --git a/src/login/app.rs b/src/login/app.rs index 292a564..10609c6 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -48,6 +48,17 @@ impl<'a> Logins<'a> { Ok(token) } + #[cfg(test)] + pub async fn create(&self, name: &str, password: &str) -> Result { + let password_hash = StoredHash::new(password)?; + + let mut tx = self.db.begin().await?; + let login = tx.logins().create(name, &password_hash).await?; + tx.commit().await?; + + Ok(login) + } + pub async fn validate(&self, secret: &str, used_at: &DateTime) -> Result { // Somewhat arbitrarily, expire after 7 days. let expire_at = used_at.to_owned() - TimeDelta::days(7); @@ -87,6 +98,14 @@ pub enum LoginError { PasswordHashError(#[from] password_hash::Error), } +#[cfg(test)] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum CreateError { + DatabaseError(#[from] sqlx::Error), + PasswordHashError(#[from] password_hash::Error), +} + #[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] diff --git a/src/login/extract.rs b/src/login/extract.rs index 735bc22..bda55cd 100644 --- a/src/login/extract.rs +++ b/src/login/extract.rs @@ -7,11 +7,20 @@ use axum_extra::extract::cookie::{Cookie, CookieJar}; // The usage pattern here - receive the extractor as an argument, return it in // the response - is heavily modelled after CookieJar's own intended usage. +#[derive(Clone, Debug)] pub struct IdentityToken { cookies: CookieJar, } impl IdentityToken { + /// Creates a new, unpopulated identity token store. + #[cfg(test)] + pub fn new() -> Self { + Self { + cookies: CookieJar::new(), + } + } + /// Get the identity secret sent in the request, if any. If the identity /// was not sent, or if it has previously been [clear]ed, then this will /// return [None]. If the identity has previously been [set], then this diff --git a/src/login/routes.rs b/src/login/routes.rs index 41554dd..06e5853 100644 --- a/src/login/routes.rs +++ b/src/login/routes.rs @@ -10,6 +10,9 @@ use crate::{app::App, clock::RequestedAt, error::InternalError, repo::login::Log use super::{app, extract::IdentityToken}; +#[cfg(test)] +mod test; + pub fn router() -> Router { Router::new() .route("/api/boot", get(boot)) @@ -53,6 +56,7 @@ async fn on_login( Ok((identity, StatusCode::NO_CONTENT)) } +#[derive(Debug)] struct LoginError(app::LoginError); impl IntoResponse for LoginError { @@ -85,6 +89,7 @@ async fn on_logout( Ok((identity, StatusCode::NO_CONTENT)) } +#[derive(Debug)] struct LogoutError(app::ValidateError); impl IntoResponse for LogoutError { diff --git a/src/login/routes/test/boot.rs b/src/login/routes/test/boot.rs new file mode 100644 index 0000000..dee554f --- /dev/null +++ b/src/login/routes/test/boot.rs @@ -0,0 +1,9 @@ +use crate::{login::routes, test::fixtures}; + +#[tokio::test] +async fn returns_identity() { + let login = fixtures::login::fictitious(); + let response = routes::boot(login.clone()).await; + + assert_eq!(login, response.login); +} diff --git a/src/login/routes/test/login.rs b/src/login/routes/test/login.rs new file mode 100644 index 0000000..4fa491a --- /dev/null +++ b/src/login/routes/test/login.rs @@ -0,0 +1,137 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use crate::{ + login::{app, routes}, + test::fixtures, +}; + +#[tokio::test] +async fn new_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::login::propose(); + let request = routes::LoginRequest { + name: name.clone(), + password, + }; + let (identity, status) = + routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + assert_eq!(StatusCode::NO_CONTENT, status); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let validated_at = fixtures::now(); + let validated = app + .logins() + .validate(secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated.name); +} + +#[tokio::test] +async fn existing_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_for_login(&app).await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = routes::LoginRequest { + name: name.clone(), + password, + }; + let (identity, status) = + routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + assert_eq!(StatusCode::NO_CONTENT, status); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let validated_at = fixtures::now(); + let validated_login = app + .logins() + .validate(secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated_login.name); +} + +#[tokio::test] +async fn authentication_failed() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::identity::not_logged_in(); + let request = routes::LoginRequest { + name: login.name, + password: fixtures::login::propose_password(), + }; + let routes::LoginError(error) = + routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + fixtures::error::expected!(error, app::LoginError::Rejected); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_for_login(&app).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::identity::not_logged_in(); + let request = routes::LoginRequest { name, password }; + let (identity, _) = routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let token = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let verified_at = fixtures::now(); + let error = app + .logins() + .validate(token, &verified_at) + .await + .expect_err("validating an expired token"); + + fixtures::error::expected!(error, app::ValidateError::InvalidToken); +} diff --git a/src/login/routes/test/logout.rs b/src/login/routes/test/logout.rs new file mode 100644 index 0000000..003bc8e --- /dev/null +++ b/src/login/routes/test/logout.rs @@ -0,0 +1,86 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use crate::{ + login::{app, routes}, + test::fixtures, +}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let login = fixtures::login::create_for_login(&app).await; + let identity = fixtures::identity::logged_in(&app, &login, &now).await; + let secret = fixtures::identity::secret(&identity); + + // Call the endpoint + + let (response_identity, response_status) = routes::on_logout( + State(app.clone()), + identity.clone(), + Json(routes::LogoutRequest {}), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + + assert!(response_identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, response_status); + + // Verify the semantics + + let error = app + .logins() + .validate(secret, &now) + .await + .expect_err("secret is invalid"); + match error { + app::ValidateError::InvalidToken => (), // should be invalid + other => panic!("expected ValidateError::InvalidToken, got {other:#}"), + } +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let (identity, status) = + routes::on_logout(State(app), identity, Json(routes::LogoutRequest {})) + .await + .expect("logged out with no token"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, status); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::fictitious(); + let routes::LogoutError(error) = + routes::on_logout(State(app), identity, Json(routes::LogoutRequest {})) + .await + .expect_err("logged out with an invalid token"); + + // Verify the return value's basic structure + + fixtures::error::expected!(error, app::ValidateError::InvalidToken); +} diff --git a/src/login/routes/test/mod.rs b/src/login/routes/test/mod.rs new file mode 100644 index 0000000..7693755 --- /dev/null +++ b/src/login/routes/test/mod.rs @@ -0,0 +1,3 @@ +mod boot; +mod login; +mod logout; diff --git a/src/repo/channel.rs b/src/repo/channel.rs index 95516d2..8f089e8 100644 --- a/src/repo/channel.rs +++ b/src/repo/channel.rs @@ -16,7 +16,7 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Channels<'t>(&'t mut SqliteConnection); -#[derive(Debug, serde::Serialize)] +#[derive(Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { pub id: Id, pub name: String, diff --git a/src/repo/login/store.rs b/src/repo/login/store.rs index d979579..2f922d7 100644 --- a/src/repo/login/store.rs +++ b/src/repo/login/store.rs @@ -18,7 +18,7 @@ pub struct Logins<'t>(&'t mut SqliteConnection); // can be used as an extractor for endpoints that want to require login, or for // endpoints that need to behave differently depending on whether the client is // or is not logged in. -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Login { pub id: Id, pub name: String, diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs new file mode 100644 index 0000000..0558395 --- /dev/null +++ b/src/test/fixtures/channel.rs @@ -0,0 +1,24 @@ +use faker_rand::{ + en_us::{addresses::CityName, names::FullName}, + faker_impl_from_templates, +}; +use rand; + +use crate::{app::App, repo::channel::Channel}; + +pub async fn create(app: &App) -> Channel { + let name = propose(); + app.channels() + .create(&name) + .await + .expect("should always succeed if the channel is actually new") +} + +pub fn propose() -> String { + rand::random::().to_string() +} + +struct Name(String); +faker_impl_from_templates! { + Name; "{} {}", CityName, FullName; +} diff --git a/src/test/fixtures/error.rs b/src/test/fixtures/error.rs new file mode 100644 index 0000000..559afee --- /dev/null +++ b/src/test/fixtures/error.rs @@ -0,0 +1,14 @@ +macro_rules! expected { + ($expr:expr, $expect:pat $(,)?) => { + $crate::test::fixtures::error::expected!($expr, $expect, ()) + }; + + ($expr:expr, $expect:pat, $body:expr $(,)?) => { + match $expr { + $expect => $body, + other => panic!("expected {}, found {other:#?}", stringify!($expect)), + } + }; +} + +pub(crate) use expected; diff --git a/src/test/fixtures/future.rs b/src/test/fixtures/future.rs new file mode 100644 index 0000000..bbdc9f8 --- /dev/null +++ b/src/test/fixtures/future.rs @@ -0,0 +1,55 @@ +use std::{future::IntoFuture, time::Duration}; + +use futures::{stream, Stream}; +use tokio::time::timeout; + +async fn immediately(fut: F) -> F::Output +where + F: IntoFuture, +{ + // I haven't been particularly rigorous here. Zero delay _seems to work_, + // but this can be set higher; it makes tests that fail to meet the + // "immediate" expectation take longer, but gives slow tests time to + // succeed, as well. + let duration = Duration::from_nanos(0); + timeout(duration, fut) + .await + .expect("expected result immediately") +} + +// This is only intended for streams, since their `next()`, `collect()`, and +// so on can all block indefinitely on an empty stream. There's no need to +// force immediacy on futures that "can't" block forever, and it can hide logic +// errors if you do that. +// +// The impls below _could_ be replaced with a blanket impl for all future +// types, otherwise. The choice to restrict impls to stream futures is +// deliberate. +pub trait Immediately { + type Output; + + async fn immediately(self) -> Self::Output; +} + +impl<'a, St> Immediately for stream::Next<'a, St> +where + St: Stream + Unpin + ?Sized, +{ + type Output = Option<::Item>; + + async fn immediately(self) -> Self::Output { + immediately(self).await + } +} + +impl Immediately for stream::Collect +where + St: Stream, + C: Default + Extend<::Item>, +{ + type Output = C; + + async fn immediately(self) -> Self::Output { + immediately(self).await + } +} diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs new file mode 100644 index 0000000..16463aa --- /dev/null +++ b/src/test/fixtures/identity.rs @@ -0,0 +1,27 @@ +use uuid::Uuid; + +use crate::{app::App, clock::RequestedAt, login::extract::IdentityToken}; + +pub fn not_logged_in() -> IdentityToken { + IdentityToken::new() +} + +pub async fn logged_in(app: &App, login: &(String, String), now: &RequestedAt) -> IdentityToken { + let (name, password) = login; + let token = app + .logins() + .login(name, password, now) + .await + .expect("should succeed given known-valid credentials"); + + IdentityToken::new().set(&token) +} + +pub fn secret(identity: &IdentityToken) -> &str { + identity.secret().expect("identity contained a secret") +} + +pub fn fictitious() -> IdentityToken { + let token = Uuid::new_v4().to_string(); + IdentityToken::new().set(&token) +} diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs new file mode 100644 index 0000000..b2a4292 --- /dev/null +++ b/src/test/fixtures/login.rs @@ -0,0 +1,44 @@ +use faker_rand::en_us::internet; +use uuid::Uuid; + +use crate::{ + app::App, + repo::login::{self, Login}, +}; + +pub async fn create_for_login(app: &App) -> (String, String) { + let (name, password) = propose(); + app.logins() + .create(&name, &password) + .await + .expect("should always succeed if the login is actually new"); + + (name, password) +} + +pub async fn create(app: &App) -> Login { + let (name, password) = propose(); + app.logins() + .create(&name, &password) + .await + .expect("should always succeed if the login is actually new") +} + +pub fn fictitious() -> Login { + Login { + id: login::Id::generate(), + name: name(), + } +} + +pub fn propose() -> (String, String) { + (name(), propose_password()) +} + +fn name() -> String { + rand::random::().to_string() +} + +pub fn propose_password() -> String { + Uuid::new_v4().to_string() +} diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs new file mode 100644 index 0000000..7fe3cb9 --- /dev/null +++ b/src/test/fixtures/message.rs @@ -0,0 +1,26 @@ +use faker_rand::lorem::Paragraphs; + +use crate::{ + app::App, + clock::RequestedAt, + events::repo::broadcast, + repo::{channel::Channel, login::Login}, +}; + +pub async fn send( + app: &App, + login: &Login, + channel: &Channel, + sent_at: &RequestedAt, +) -> broadcast::Message { + let body = propose(); + + app.channels() + .send(login, &channel.id, &body, sent_at) + .await + .expect("should succeed if the channel exists") +} + +pub fn propose() -> String { + rand::random::().to_string() +} diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs new file mode 100644 index 0000000..05e3f3f --- /dev/null +++ b/src/test/fixtures/mod.rs @@ -0,0 +1,28 @@ +use chrono::{TimeDelta, Utc}; + +use crate::{app::App, clock::RequestedAt, repo::pool}; + +pub mod channel; +pub mod error; +pub mod future; +pub mod identity; +pub mod login; +pub mod message; + +pub async fn scratch_app() -> App { + let pool = pool::prepare("sqlite::memory:") + .await + .expect("setting up in-memory sqlite database"); + App::from(pool) + .await + .expect("creating an app from a fresh, in-memory database") +} + +pub fn now() -> RequestedAt { + Utc::now().into() +} + +pub fn ancient() -> RequestedAt { + let timestamp = Utc::now() - TimeDelta::days(365); + timestamp.into() +} diff --git a/src/test/mod.rs b/src/test/mod.rs new file mode 100644 index 0000000..d066349 --- /dev/null +++ b/src/test/mod.rs @@ -0,0 +1 @@ +pub mod fixtures; -- cgit v1.2.3