From 2d05e6fb933d8c33078232b3bdfbc2aa05106d80 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:07:53 -0400 Subject: Make `Boot` a freestanding app type, rather than a view of `crate::app::App`'s internals. In the course of working on web push, I determined that we probably need to make `App` generic over the web push client we're using, so that tests can use a dummy client while the real app uses a client created at startup and maintained over the life of the program's execution. The most direct implementation of that is to render App as `App

`, where the parameter is occupied by the specific web push client type in use. However, doing this requires refactoring at _every_ site that mentions `App`, including every handler, even though the vast majority of those sites will not be concerned with web push. I reviewed a few options with @wlonk: * Accept the type parameter and apply it everywhere, as the cost of supporting web push. * Hard-code the use of a specific web push client. * Insulate handlers &c from `App` via provider traits, mimicing what we do for repository provider traits today. * Treat each app type as a freestanding state in its own right, so that only push-related components need to consider push clients (as far as is feasible). This is a prototype towards that last point, using a simple app component (boot) as a testbed. `FromRef` allows handlers that take a `Boot` to be used in routes that provide an `App`, so this is a contained change. However, the structure of `FromRef` prevents `Boot` from carrying any lifetime narrower than `'static`, so it now holds clones of the state fields it acquires from App, instead of references. This is fine - that's just a database pool, and sqlx's pool type is designed to be shared via cloning. From : > Cloning Pool is cheap as it is simply a reference-counted handle to the inner pool state. --- src/test/fixtures/boot.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/test/fixtures/boot.rs b/src/test/fixtures/boot.rs index 120726f..7421512 100644 --- a/src/test/fixtures/boot.rs +++ b/src/test/fixtures/boot.rs @@ -1,7 +1,12 @@ -use crate::{app::App, event::Sequence}; +use axum::extract::FromRef; -pub async fn resume_point(app: &App) -> Sequence { - app.boot() +use crate::{boot::app::Boot, event::Sequence}; + +pub async fn resume_point(app: &App) -> Sequence +where + Boot: FromRef, +{ + Boot::from_ref(app) .snapshot() .await .expect("boot always succeeds") -- cgit v1.2.3 From 398bc79a3f1f4316e6cc33b6e6bce133c1db3e4c Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:29:36 -0400 Subject: Convert the `Conversations` component into a freestanding struct. Unlike the previous example, this involves cloning an event broadcaster, as well. This is, per the documentation, how the type may be used. From : > The Sender can be cloned to send to the same channel from multiple points in the process or it can be used concurrently from an `Arc`. The language is less firm than the language sqlx uses for its pool, but the intent is clear enough, and it works in practice. --- src/app.rs | 10 ++++- src/conversation/app.rs | 10 ++--- src/conversation/handlers/create/mod.rs | 8 ++-- src/conversation/handlers/create/test.rs | 66 +++++++++++++++++++++----------- src/conversation/handlers/delete/mod.rs | 9 ++--- src/conversation/handlers/delete/test.rs | 12 +++--- src/test/fixtures/conversation.rs | 11 ++++-- src/ui/handlers/conversation.rs | 7 ++-- 8 files changed, 78 insertions(+), 55 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/app.rs b/src/app.rs index e33ee39..3f3885a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,8 +38,8 @@ impl App { Boot::new(self.db.clone()) } - pub const fn conversations(&self) -> Conversations<'_> { - Conversations::new(&self.db, &self.events) + pub fn conversations(&self) -> Conversations { + Conversations::new(self.db.clone(), self.events.clone()) } pub const fn events(&self) -> Events<'_> { @@ -77,3 +77,9 @@ impl FromRef for Boot { app.boot() } } + +impl FromRef for Conversations { + fn from_ref(app: &App) -> Self { + app.conversations() + } +} diff --git a/src/conversation/app.rs b/src/conversation/app.rs index 26886af..2b62e77 100644 --- a/src/conversation/app.rs +++ b/src/conversation/app.rs @@ -15,13 +15,13 @@ use crate::{ name::{self, Name}, }; -pub struct Conversations<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Conversations { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Conversations<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Conversations { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } diff --git a/src/conversation/handlers/create/mod.rs b/src/conversation/handlers/create/mod.rs index 18eca1f..2b7fa39 100644 --- a/src/conversation/handlers/create/mod.rs +++ b/src/conversation/handlers/create/mod.rs @@ -5,9 +5,8 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, - conversation::{Conversation, app}, + conversation::{Conversation, app, app::Conversations}, error::Internal, name::Name, token::extract::Identity, @@ -17,13 +16,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(conversations): State, _: Identity, // requires auth, but doesn't actually care who you are RequestedAt(created_at): RequestedAt, Json(request): Json, ) -> Result { - let conversation = app - .conversations() + let conversation = conversations .create(&request.name, &created_at) .await .map_err(Error)?; diff --git a/src/conversation/handlers/create/test.rs b/src/conversation/handlers/create/test.rs index bc05b00..380bb13 100644 --- a/src/conversation/handlers/create/test.rs +++ b/src/conversation/handlers/create/test.rs @@ -22,10 +22,14 @@ async fn new_conversation() { let name = fixtures::conversation::propose(); let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creating a conversation in an empty app succeeds"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creating a conversation in an empty app succeeds"); // Verify the structure of the response @@ -77,10 +81,14 @@ async fn duplicate_name() { let request = super::Request { name: conversation.name.clone(), }; - let super::Error(error) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate conversation name should fail the request"); + let super::Error(error) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response @@ -110,10 +118,14 @@ async fn conflicting_canonical_name() { let request = super::Request { name: conflicting_name.clone(), }; - let super::Error(error) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate conversation name should fail the request"); + let super::Error(error) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response @@ -135,7 +147,7 @@ async fn invalid_name() { let name = fixtures::conversation::propose_invalid_name(); let request = super::Request { name: name.clone() }; let super::Error(error) = crate::conversation::handlers::create::handler( - State(app.clone()), + State(app.conversations()), creator, fixtures::now(), Json(request), @@ -163,7 +175,7 @@ async fn name_reusable_after_delete() { let request = super::Request { name: name.clone() }; let super::Response(response) = super::handler( - State(app.clone()), + State(app.conversations()), creator.clone(), fixtures::now(), Json(request), @@ -181,10 +193,14 @@ async fn name_reusable_after_delete() { // Call the endpoint (second time) let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original conversation deleted"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creation succeeds after original conversation deleted"); // Verify the structure of the response @@ -212,7 +228,7 @@ async fn name_reusable_after_expiry() { let request = super::Request { name: name.clone() }; let super::Response(_) = super::handler( - State(app.clone()), + State(app.conversations()), creator.clone(), fixtures::ancient(), Json(request), @@ -230,10 +246,14 @@ async fn name_reusable_after_expiry() { // Call the endpoint (second time) let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original conversation expired"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creation succeeds after original conversation expired"); // Verify the structure of the response diff --git a/src/conversation/handlers/delete/mod.rs b/src/conversation/handlers/delete/mod.rs index 272165a..231e433 100644 --- a/src/conversation/handlers/delete/mod.rs +++ b/src/conversation/handlers/delete/mod.rs @@ -5,9 +5,8 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, - conversation::{self, app, handlers::PathInfo}, + conversation::{self, app, app::Conversations, handlers::PathInfo}, error::{Internal, NotFound}, token::extract::Identity, }; @@ -16,14 +15,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(conversations): State, Path(conversation): Path, RequestedAt(deleted_at): RequestedAt, _: Identity, ) -> Result { - app.conversations() - .delete(&conversation, &deleted_at) - .await?; + conversations.delete(&conversation, &deleted_at).await?; Ok(Response { id: conversation }) } diff --git a/src/conversation/handlers/delete/test.rs b/src/conversation/handlers/delete/test.rs index 2718d3b..e9e882a 100644 --- a/src/conversation/handlers/delete/test.rs +++ b/src/conversation/handlers/delete/test.rs @@ -14,7 +14,7 @@ pub async fn valid_conversation() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let response = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -52,7 +52,7 @@ pub async fn invalid_conversation_id() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let conversation = fixtures::conversation::fictitious(); let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.clone()), fixtures::now(), deleter, @@ -81,7 +81,7 @@ pub async fn conversation_deleted() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -110,7 +110,7 @@ pub async fn conversation_expired() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -144,7 +144,7 @@ pub async fn conversation_purged() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -170,7 +170,7 @@ pub async fn conversation_not_empty() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, diff --git a/src/test/fixtures/conversation.rs b/src/test/fixtures/conversation.rs index fb2f58d..f0d8c8c 100644 --- a/src/test/fixtures/conversation.rs +++ b/src/test/fixtures/conversation.rs @@ -1,3 +1,4 @@ +use axum::extract::FromRef; use faker_rand::{ en_us::{addresses::CityName, names::FullName}, faker_impl_from_templates, @@ -6,15 +7,17 @@ use faker_rand::{ use rand; use crate::{ - app::App, clock::RequestedAt, - conversation::{self, Conversation}, + conversation::{self, Conversation, app::Conversations}, name::Name, }; -pub async fn create(app: &App, created_at: &RequestedAt) -> Conversation { +pub async fn create(app: &App, created_at: &RequestedAt) -> Conversation +where + Conversations: FromRef, +{ let name = propose(); - app.conversations() + Conversations::from_ref(app) .create(&name, created_at) .await .expect("should always succeed if the conversation is actually new") diff --git a/src/ui/handlers/conversation.rs b/src/ui/handlers/conversation.rs index f1bb319..2ff090c 100644 --- a/src/ui/handlers/conversation.rs +++ b/src/ui/handlers/conversation.rs @@ -4,8 +4,7 @@ use axum::{ }; use crate::{ - app::App, - conversation::{self, app}, + conversation::{self, app, app::Conversations}, error::Internal, token::extract::Identity, ui::{ @@ -15,12 +14,12 @@ use crate::{ }; pub async fn handler( - State(app): State, + State(conversations): State, identity: Option, Path(conversation): Path, ) -> Result { let _ = identity.ok_or(Error::NotLoggedIn)?; - app.conversations() + conversations .get(&conversation) .await .map_err(Error::from)?; -- cgit v1.2.3 From f305e487d619f1d993d11d728c8cf7261bf3b371 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:41:22 -0400 Subject: Convert `Invites` into a freestanding component. --- src/app.rs | 10 ++++++++-- src/invite/app.rs | 12 ++++++------ src/invite/handlers/accept/mod.rs | 8 +++----- src/invite/handlers/accept/test.rs | 12 ++++++------ src/invite/handlers/get/mod.rs | 7 +++---- src/invite/handlers/get/test.rs | 6 +++--- src/invite/handlers/issue/mod.rs | 9 ++++++--- src/invite/handlers/issue/test.rs | 2 +- src/test/fixtures/invite.rs | 12 ++++++++---- src/ui/handlers/invite.rs | 6 +++--- 10 files changed, 47 insertions(+), 37 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/app.rs b/src/app.rs index 0b560dd..2d6d62b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,8 +46,8 @@ impl App { Events::new(self.db.clone(), self.events.clone()) } - pub const fn invites(&self) -> Invites<'_> { - Invites::new(&self.db, &self.events) + pub fn invites(&self) -> Invites { + Invites::new(self.db.clone(), self.events.clone()) } pub const fn logins(&self) -> Logins<'_> { @@ -83,3 +83,9 @@ impl FromRef for Conversations { app.conversations() } } + +impl FromRef for Invites { + fn from_ref(app: &App) -> Self { + app.invites() + } +} diff --git a/src/invite/app.rs b/src/invite/app.rs index 6684d03..6f58d0a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -17,13 +17,13 @@ use crate::{ }, }; -pub struct Invites<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Invites { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Invites<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Invites { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } @@ -88,7 +88,7 @@ impl<'a> Invites<'a> { tx.tokens().create(&token, &secret).await?; tx.commit().await?; - stored.publish(self.events); + stored.publish(&self.events); Ok(secret) } diff --git a/src/invite/handlers/accept/mod.rs b/src/invite/handlers/accept/mod.rs index cdf385f..8bdaa51 100644 --- a/src/invite/handlers/accept/mod.rs +++ b/src/invite/handlers/accept/mod.rs @@ -5,11 +5,10 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::{Internal, NotFound}, - invite::{app, handlers::PathInfo}, + invite::{app, app::Invites, handlers::PathInfo}, name::Name, password::Password, token::extract::IdentityCookie, @@ -19,14 +18,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(invites): State, RequestedAt(accepted_at): RequestedAt, identity: IdentityCookie, Path(invite): Path, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .invites() + let secret = invites .accept(&invite, &request.name, &request.password, &accepted_at) .await .map_err(Error)?; diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index 283ec76..446dbf9 100644 --- a/src/invite/handlers/accept/test.rs +++ b/src/invite/handlers/accept/test.rs @@ -24,7 +24,7 @@ async fn valid_invite() { password: password.clone(), }; let (identity, Empty) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id), @@ -67,7 +67,7 @@ async fn nonexistent_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.clone()), @@ -103,7 +103,7 @@ async fn expired_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -140,7 +140,7 @@ async fn accepted_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -183,7 +183,7 @@ async fn conflicting_name() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -217,7 +217,7 @@ async fn invalid_name() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id), diff --git a/src/invite/handlers/get/mod.rs b/src/invite/handlers/get/mod.rs index bb72586..d5fd9c2 100644 --- a/src/invite/handlers/get/mod.rs +++ b/src/invite/handlers/get/mod.rs @@ -4,19 +4,18 @@ use axum::{ }; use crate::{ - app::App, error::{Internal, NotFound}, - invite::{Id, Summary, handlers::PathInfo}, + invite::{Id, Summary, app::Invites, handlers::PathInfo}, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(invites): State, Path(invite): Path, ) -> Result, Error> { - app.invites() + invites .get(&invite) .await? .map(Json) diff --git a/src/invite/handlers/get/test.rs b/src/invite/handlers/get/test.rs index 0f2f725..a08c510 100644 --- a/src/invite/handlers/get/test.rs +++ b/src/invite/handlers/get/test.rs @@ -12,7 +12,7 @@ async fn valid_invite() { // Call endpoint - let Json(response) = super::handler(State(app), Path(invite.id)) + let Json(response) = super::handler(State(app.invites()), Path(invite.id)) .await .expect("get for an existing invite succeeds"); @@ -31,7 +31,7 @@ async fn nonexistent_invite() { // Call endpoint let invite = fixtures::invite::fictitious(); - let error = super::handler(State(app), Path(invite.clone())) + let error = super::handler(State(app.invites()), Path(invite.clone())) .await .expect_err("get for a nonexistent invite fails"); @@ -55,7 +55,7 @@ async fn expired_invite() { // Call endpoint - let error = super::handler(State(app), Path(invite.id.clone())) + let error = super::handler(State(app.invites()), Path(invite.id.clone())) .await .expect_err("get for an expired invite fails"); diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs index 4ac74cc..0549c78 100644 --- a/src/invite/handlers/issue/mod.rs +++ b/src/invite/handlers/issue/mod.rs @@ -1,19 +1,22 @@ use axum::extract::{Json, State}; use crate::{ - app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, + clock::RequestedAt, + error::Internal, + invite::{Invite, app::Invites}, + token::extract::Identity, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(invites): State, RequestedAt(issued_at): RequestedAt, identity: Identity, _: Json, ) -> Result, Internal> { - let invite = app.invites().issue(&identity.login, &issued_at).await?; + let invite = invites.issue(&identity.login, &issued_at).await?; Ok(Json(invite)) } diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs index 4421705..dc89243 100644 --- a/src/invite/handlers/issue/test.rs +++ b/src/invite/handlers/issue/test.rs @@ -13,7 +13,7 @@ async fn create_invite() { // Call the endpoint let Json(invite) = super::handler( - State(app), + State(app.invites()), issued_at.clone(), issuer.clone(), Json(super::Request {}), diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs index 654d1b4..5a5d4d0 100644 --- a/src/test/fixtures/invite.rs +++ b/src/test/fixtures/invite.rs @@ -1,12 +1,16 @@ +use axum::extract::FromRef; + use crate::{ - app::App, clock::DateTime, - invite::{self, Invite}, + invite::{self, Invite, app::Invites}, login::Login, }; -pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite { - app.invites() +pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite +where + Invites: FromRef, +{ + Invites::from_ref(app) .issue(issuer, issued_at) .await .expect("issuing invites never fails") diff --git a/src/ui/handlers/invite.rs b/src/ui/handlers/invite.rs index 0f9580a..edd6dc1 100644 --- a/src/ui/handlers/invite.rs +++ b/src/ui/handlers/invite.rs @@ -4,9 +4,9 @@ use axum::{ }; use crate::{ - app::App, error::Internal, invite, + invite::app::Invites, ui::{ assets::{Asset, Assets}, error::NotFound, @@ -14,10 +14,10 @@ use crate::{ }; pub async fn handler( - State(app): State, + State(invites): State, Path(invite): Path, ) -> Result { - app.invites() + invites .get(&invite) .await .map_err(Error::internal)? -- cgit v1.2.3 From 38ac83aef9667f1a4fe86e03e53565376081179f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:47:30 -0400 Subject: Convert `Logins` into a freestanding component. --- src/app.rs | 10 ++++++++-- src/login/app.rs | 10 +++++----- src/login/handlers/login/mod.rs | 14 +++++++++----- src/login/handlers/login/test.rs | 8 ++++---- src/login/handlers/password/mod.rs | 8 +++----- src/login/handlers/password/test.rs | 2 +- src/test/fixtures/cookie.rs | 14 +++++++++----- 7 files changed, 39 insertions(+), 27 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/app.rs b/src/app.rs index 2d6d62b..793bdab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,8 +50,8 @@ impl App { Invites::new(self.db.clone(), self.events.clone()) } - pub const fn logins(&self) -> Logins<'_> { - Logins::new(&self.db, &self.token_events) + pub fn logins(&self) -> Logins { + Logins::new(self.db.clone(), self.token_events.clone()) } pub const fn messages(&self) -> Messages<'_> { @@ -89,3 +89,9 @@ impl FromRef for Invites { app.invites() } } + +impl FromRef for Logins { + fn from_ref(app: &App) -> Self { + app.logins() + } +} diff --git a/src/login/app.rs b/src/login/app.rs index e471000..a2f9636 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -9,13 +9,13 @@ use crate::{ token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _}, }; -pub struct Logins<'a> { - db: &'a SqlitePool, - token_events: &'a Broadcaster, +pub struct Logins { + db: SqlitePool, + token_events: Broadcaster, } -impl<'a> Logins<'a> { - pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { +impl Logins { + pub const fn new(db: SqlitePool, token_events: Broadcaster) -> Self { Self { db, token_events } } diff --git a/src/login/handlers/login/mod.rs b/src/login/handlers/login/mod.rs index 6591984..2ce8a67 100644 --- a/src/login/handlers/login/mod.rs +++ b/src/login/handlers/login/mod.rs @@ -5,21 +5,25 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, - password::Password, token::extract::IdentityCookie, + clock::RequestedAt, + empty::Empty, + error::Internal, + login::{app, app::Logins}, + name::Name, + password::Password, + token::extract::IdentityCookie, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(logins): State, RequestedAt(now): RequestedAt, identity: IdentityCookie, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .logins() + let secret = logins .with_password(&request.name, &request.password, &now) .await .map_err(Error)?; diff --git a/src/login/handlers/login/test.rs b/src/login/handlers/login/test.rs index f3911d0..7bb56b6 100644 --- a/src/login/handlers/login/test.rs +++ b/src/login/handlers/login/test.rs @@ -22,7 +22,7 @@ async fn correct_credentials() { password, }; let (identity, Empty) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); @@ -52,7 +52,7 @@ async fn invalid_name() { password, }; let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password fails"); @@ -77,7 +77,7 @@ async fn incorrect_password() { password: fixtures::user::propose_password(), }; let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password"); @@ -98,7 +98,7 @@ async fn token_expires() { let logged_in_at = fixtures::ancient(); let identity = fixtures::cookie::not_logged_in(); let request = super::Request { name, password }; - let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + let (identity, _) = super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); let secret = identity.secret().expect("logged in with valid credentials"); diff --git a/src/login/handlers/password/mod.rs b/src/login/handlers/password/mod.rs index 94c7fb4..8b82605 100644 --- a/src/login/handlers/password/mod.rs +++ b/src/login/handlers/password/mod.rs @@ -5,11 +5,10 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::Internal, - login::app, + login::{app, app::Logins}, password::Password, token::extract::{Identity, IdentityCookie}, }; @@ -18,14 +17,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(logins): State, RequestedAt(now): RequestedAt, identity: Identity, cookie: IdentityCookie, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .logins() + let secret = logins .change_password(&identity.login, &request.password, &request.to, &now) .await .map_err(Error)?; diff --git a/src/login/handlers/password/test.rs b/src/login/handlers/password/test.rs index ba2f28f..61d5b5a 100644 --- a/src/login/handlers/password/test.rs +++ b/src/login/handlers/password/test.rs @@ -21,7 +21,7 @@ async fn password_change() { to: to.clone(), }; let (new_cookie, Empty) = super::handler( - State(app.clone()), + State(app.logins()), fixtures::now(), identity.clone(), cookie.clone(), diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs index 7dc5083..0b5ec9b 100644 --- a/src/test/fixtures/cookie.rs +++ b/src/test/fixtures/cookie.rs @@ -1,21 +1,25 @@ +use axum::extract::FromRef; use uuid::Uuid; use crate::{ - app::App, clock::RequestedAt, name::Name, password::Password, token::extract::IdentityCookie, + clock::RequestedAt, login::app::Logins, name::Name, password::Password, + token::extract::IdentityCookie, }; pub fn not_logged_in() -> IdentityCookie { IdentityCookie::new() } -pub async fn logged_in( +pub async fn logged_in( app: &App, credentials: &(Name, Password), now: &RequestedAt, -) -> IdentityCookie { +) -> IdentityCookie +where + Logins: FromRef, +{ let (name, password) = credentials; - let secret = app - .logins() + let secret = Logins::from_ref(app) .with_password(name, password, now) .await .expect("should succeed given known-valid credentials"); -- cgit v1.2.3 From c2a3a010c67776b9a459d7ba0930630ff25a3a51 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 18:03:36 -0400 Subject: Convert the `Messages` component to a freestanding struct. --- src/app.rs | 10 ++++++++-- src/conversation/handlers/send/mod.rs | 11 ++++++----- src/conversation/handlers/send/test.rs | 6 +++--- src/message/app.rs | 10 +++++----- src/message/handlers/delete/mod.rs | 10 ++++++---- src/message/handlers/delete/test.rs | 12 ++++++------ src/test/fixtures/message.rs | 13 ++++++++----- 7 files changed, 42 insertions(+), 30 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/app.rs b/src/app.rs index 793bdab..74e1070 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,8 +54,8 @@ impl App { Logins::new(self.db.clone(), self.token_events.clone()) } - pub const fn messages(&self) -> Messages<'_> { - Messages::new(&self.db, &self.events) + pub fn messages(&self) -> Messages { + Messages::new(self.db.clone(), self.events.clone()) } pub const fn setup(&self) -> Setup<'_> { @@ -95,3 +95,9 @@ impl FromRef for Logins { app.logins() } } + +impl FromRef for Messages { + fn from_ref(app: &App) -> Self { + app.messages() + } +} diff --git a/src/conversation/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs index c8be59c..ff63652 100644 --- a/src/conversation/handlers/send/mod.rs +++ b/src/conversation/handlers/send/mod.rs @@ -5,11 +5,13 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, conversation::handlers::PathInfo, error::{Internal, NotFound}, - message::{Body, Message, app::SendError}, + message::{ + Body, Message, + app::{Messages, SendError}, + }, token::extract::Identity, }; @@ -17,14 +19,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(messages): State, Path(conversation): Path, RequestedAt(sent_at): RequestedAt, identity: Identity, Json(request): Json, ) -> Result { - let message = app - .messages() + let message = messages .send(&conversation, &identity.login, &sent_at, &request.body) .await?; diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index 8863090..013aaa4 100644 --- a/src/conversation/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -28,7 +28,7 @@ async fn messages_in_order() { let request = super::Request { body: body.clone() }; let _ = super::handler( - State(app.clone()), + State(app.messages()), Path(conversation.id.clone()), sent_at.clone(), sender.clone(), @@ -75,7 +75,7 @@ async fn nonexistent_conversation() { body: fixtures::message::propose(), }; let super::Error(error) = super::handler( - State(app), + State(app.messages()), Path(conversation.clone()), sent_at, sender, @@ -112,7 +112,7 @@ async fn deleted_conversation() { body: fixtures::message::propose(), }; let super::Error(error) = super::handler( - State(app), + State(app.messages()), Path(conversation.id.clone()), sent_at, sender, diff --git a/src/message/app.rs b/src/message/app.rs index 647152e..cbcbff9 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -13,13 +13,13 @@ use crate::{ user::{self, repo::Provider as _}, }; -pub struct Messages<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Messages { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Messages<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Messages { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs index 3e9a212..c09a752 100644 --- a/src/message/handlers/delete/mod.rs +++ b/src/message/handlers/delete/mod.rs @@ -5,10 +5,12 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, error::{Internal, NotFound}, - message::{self, app::DeleteError}, + message::{ + self, + app::{DeleteError, Messages}, + }, token::extract::Identity, }; @@ -16,12 +18,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(messages): State, Path(message): Path, RequestedAt(deleted_at): RequestedAt, identity: Identity, ) -> Result { - app.messages() + messages .delete(&identity.login, &message, &deleted_at) .await?; diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index 05d9344..198728b 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -16,7 +16,7 @@ pub async fn delete_message() { // Send the request let response = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -52,7 +52,7 @@ pub async fn delete_invalid_message_id() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let message = fixtures::message::fictitious(); let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.clone()), fixtures::now(), deleter, @@ -83,7 +83,7 @@ pub async fn delete_deleted() { // Send the request let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -114,7 +114,7 @@ pub async fn delete_expired() { // Send the request let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -150,7 +150,7 @@ pub async fn delete_purged() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), deleter, @@ -176,7 +176,7 @@ pub async fn delete_not_sender() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), deleter.clone(), diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 92ac1f5..0bd0b7a 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -1,22 +1,25 @@ +use axum::extract::FromRef; use faker_rand::lorem::Paragraphs; use crate::{ - app::App, clock::RequestedAt, conversation::Conversation, login::Login, - message::{self, Body, Message}, + message::{self, Body, Message, app::Messages}, }; -pub async fn send( +pub async fn send( app: &App, conversation: &Conversation, sender: &Login, sent_at: &RequestedAt, -) -> Message { +) -> Message +where + Messages: FromRef, +{ let body = propose(); - app.messages() + Messages::from_ref(app) .send(&conversation.id, sender, sent_at, &body) .await .expect("should succeed if the conversation exists") -- cgit v1.2.3 From dc7074bbf39aff895ba52abd5e7b7e9bb643cf27 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 28 Oct 2025 01:41:13 -0400 Subject: Convert the last stray tests to be generic over components deriveable from an App. There are a few places in the test fixtures that still call `App` methods directly, as they call `app.users()` (which, as per previous commits, has no `FromRef` impl). --- src/test/fixtures/identity.rs | 21 ++++++++++++++++----- src/test/verify/login.rs | 23 +++++++++++++++-------- 2 files changed, 31 insertions(+), 13 deletions(-) (limited to 'src/test/fixtures') diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 93e4a38..20929f9 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -1,11 +1,15 @@ +use axum::extract::FromRef; + use crate::{ app::App, clock::RequestedAt, + login::app::Logins, name::Name, password::Password, test::fixtures, token::{ Token, + app::Tokens, extract::{Identity, IdentityCookie}, }, }; @@ -15,23 +19,30 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Identity { logged_in(app, &credentials, created_at).await } -pub async fn from_cookie( +pub async fn from_cookie( app: &App, cookie: &IdentityCookie, validated_at: &RequestedAt, -) -> Identity { +) -> Identity +where + Tokens: FromRef, +{ let secret = cookie.secret().expect("identity token has a secret"); - app.tokens() + Tokens::from_ref(app) .validate(&secret, validated_at) .await .expect("always validates newly-issued secret") } -pub async fn logged_in( +pub async fn logged_in( app: &App, credentials: &(Name, Password), issued_at: &RequestedAt, -) -> Identity { +) -> Identity +where + Tokens: FromRef, + Logins: FromRef, +{ let secret = fixtures::cookie::logged_in(app, credentials, issued_at).await; from_cookie(app, &secret, issued_at).await } diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs index ae2e91e..aad01bc 100644 --- a/src/test/verify/login.rs +++ b/src/test/verify/login.rs @@ -1,23 +1,30 @@ +use axum::extract::FromRef; + use crate::{ - app::App, - login::app::LoginError, + login::app::{LoginError, Logins}, name::Name, password::Password, test::{fixtures, verify}, + token::app::Tokens, }; -pub async fn valid_login(app: &App, name: &Name, password: &Password) { - let secret = app - .logins() +pub async fn valid_login(app: &App, name: &Name, password: &Password) +where + Logins: FromRef, + Tokens: FromRef, +{ + let secret = Logins::from_ref(app) .with_password(name, password, &fixtures::now()) .await .expect("login credentials expected to be valid"); verify::token::valid_for_name(&app, &secret, &name).await; } -pub async fn invalid_login(app: &App, name: &Name, password: &Password) { - let error = app - .logins() +pub async fn invalid_login(app: &App, name: &Name, password: &Password) +where + Logins: FromRef, +{ + let error = Logins::from_ref(app) .with_password(name, password, &fixtures::now()) .await .expect_err("login credentials expected not to be valid"); -- cgit v1.2.3