diff --git a/docs/backend.md b/docs/backend.md index bc5a8bdde..01a883278 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -307,20 +307,27 @@ surface per layer: recipients, `template_data` content, and the tracking record. Callers, including handler tests, assert only kind, recipients, and identifiers. - Error contract (`handlers/error/tests.rs`): no mocks; asserts each - `HandlerError` variant's status and body. + `HandlerError` variant's status and body, and the status and body each + typed manager error kind (`OCG01` rejection, `Rejected`, internal failure) + maps to through its `From` impl. - Contract (`db/contract_tests`, `just db-contract-tests`): real database; - asserts SQL JSON to Rust DTO shapes, the `PgUnitOfWork` lifecycle - (rollback on drop, explicit rollback, failing deferred constraint on - commit), and manager compositions that mocks cannot prove. + asserts SQL JSON to Rust DTO shapes, lock ordering, the `PgUnitOfWork` + lifecycle (rollback on drop, explicit rollback, failing deferred + constraint on commit), that an `OCG01` rejection reaches Rust as a + user-facing error, and manager compositions that mocks cannot prove. A + test that only re-asserts a pgTAP `throws_ok` rejection is not repeated + here; a rejection is asserted only as the outcome of a race, lifecycle, or + composition the test exists to prove, or to pin how a wrapper passes its + arguments. Rules that follow from the layering: - Handler tests build the router with `TestRouterBuilder` and the shared session and permission helpers. A handler behind a manager has one or two - manager expectations per test and no workflow `MockDB` expectations. Every - typed manager error is proven at the handler with three tests: an `OCG01` - rejection returns 422 with its message, a `Rejected` returns 422 with its - message, and an internal failure returns 500 with an empty body. + manager expectations per test and no workflow `MockDB` expectations. The + mapping of typed manager errors to responses is proven once by the error + contract tests; each route that calls a manager has one test showing that + a manager rejection returns 422 with its message. - Handler DB-failure tests exist only when they prove something the error contract tests cannot: a failure mapped to a non-500 status, a suppressed later side effect (asserted with `.never()` on the side effect and its @@ -330,7 +337,11 @@ Rules that follow from the layering: - Shared sample builders for domain types live in `types/tests.rs`; `handlers/tests.rs` re-exports them, and `services` or `templates` tests import them from `types::tests`, so test code follows the same dependency - direction as production code. + direction as production code. Other shared helpers live next to what they + build: `MockDB` expectation helpers in `db::mock`, configuration samples in + `config::tests`, router, request, session, and permission helpers in + `handlers/tests.rs`, and helpers that belong to no layer (byte fixtures, + text normalization, polling) in `test_util`. - A test file's local helpers share what is identical and incidental across its tests and keep visible what each test exists to prove. Request builders for a route and the fixed set of context reads a page always @@ -338,8 +349,12 @@ Rules that follow from the layering: is passed to the helper as an explicit argument, never selected by a flag, and is written inline when only one test needs it. Assertions on the response (status, headers, body) stay inline in every test. +- Pure functions and value logic are tested with tables: one test iterates + labeled cases and every assertion message names the case. Handler and + manager tests keep one test per scenario. - A test is removed only when the PR description names the test that proves - the same behavior at another layer. A test with no replacement stays. + the same behavior, at the same or another layer. A test with no + replacement stays. ## Bounded operations and worker health diff --git a/docs/database.md b/docs/database.md index 5d576b919..ee1f29020 100644 --- a/docs/database.md +++ b/docs/database.md @@ -418,9 +418,12 @@ assertions, so the seeded rows also prove they survive later migrations. `ocg-server/src/db/contract_tests/` runs the ignored `db_contracts` tests against a real database migrated and seeded with `tests/data/contract.sql`. They guard the JSON boundary between functions and Rust DTOs, lock ordering, -and the error SQLSTATEs, with one module per database trait. A change to a -JSON-returning function, a DTO, a wrapper, or `contract.sql` runs -`just db-contract-tests`. +and that an `OCG01` rejection reaches Rust as a user-facing error, with one +module per database trait. A test that only re-asserts a pgTAP `throws_ok` +rejection is not repeated in Rust; a rejection is asserted here only as the +outcome of a race, lifecycle, or composition the test exists to prove, or to +pin how a wrapper passes its arguments. A change to a JSON-returning +function, a DTO, a wrapper, or `contract.sql` runs `just db-contract-tests`. ### Commands diff --git a/ocg-common/src/config.rs b/ocg-common/src/config.rs index f42b9f2c1..7a0522f2f 100644 --- a/ocg-common/src/config.rs +++ b/ocg-common/src/config.rs @@ -105,31 +105,6 @@ mod tests { use super::*; - #[test] - fn test_database_config_accepts_default_tls_configuration() { - let db = sample_db_config(); - - assert!(db.validate().is_ok()); - } - - #[test] - fn test_database_config_accepts_verification_with_required_tls() { - // Setup full certificate verification over required TLS - let mut db = sample_db_config(); - db.connection.ssl_mode = Some(DeadpoolSslMode::Require); - db.tls = Some(DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: Some("trusted CA certificate".to_string()), - }); - - // Validate the database transport configuration - let result = db.validate(); - - // Check secure TLS configuration is accepted - assert!(result.is_ok()); - } - #[test] fn test_database_config_deserializes_tls_fields() { // Setup the chart-compatible flattened database configuration @@ -153,70 +128,66 @@ tls: } #[test] - fn test_database_config_rejects_ca_certificate_without_verification() { - // Setup CA material that certificate verification would ignore - let mut db = sample_db_config(); - db.tls = Some(DbTlsConfig { - mode: DbTlsMode::None, - - ca_cert: Some("unused CA certificate".to_string()), - }); - - // Validate the database transport configuration - let result = db.validate(); - - // Check ignored trust material is rejected - assert_eq!( - result.unwrap_err().to_string(), - "db.tls.ca_cert requires db.tls.mode verify-ca or verify-full" - ); - } - - #[test] - fn test_database_config_rejects_verification_without_ca_certificate() { - // Setup certificate verification without trust roots - let mut db = sample_db_config(); - db.connection.ssl_mode = Some(DeadpoolSslMode::Require); - db.tls = Some(DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: None, - }); - - // Validate the database transport configuration - let result = db.validate(); - - // Check missing trust roots are rejected - assert_eq!( - result.unwrap_err().to_string(), - "db.tls.ca_cert is required when db.tls.mode enables certificate verification" - ); - } - - #[test] - fn test_database_config_rejects_verification_without_required_tls() { - for ssl_mode in [ - None, - Some(DeadpoolSslMode::Disable), - Some(DeadpoolSslMode::Prefer), - ] { - // Setup certificate verification with a plaintext-capable SSL mode + fn test_database_config_validate_checks_tls_policy() { + let require = Some(DeadpoolSslMode::Require); + let ca_cert = Some("trusted CA certificate"); + let verification_without_tls = + "db.ssl_mode must be Require when db.tls.mode enables certificate verification"; + // (case, ssl_mode, tls mode and CA certificate, expected error) + let cases = [ + ("default configuration", None, None, None), + ( + "full verification over required TLS", + require, + Some((DbTlsMode::VerifyFull, ca_cert)), + None, + ), + ( + "CA certificate without verification", + None, + Some((DbTlsMode::None, Some("unused CA certificate"))), + Some("db.tls.ca_cert requires db.tls.mode verify-ca or verify-full"), + ), + ( + "verification without CA certificate", + require, + Some((DbTlsMode::VerifyFull, None)), + Some( + "db.tls.ca_cert is required when db.tls.mode enables certificate verification", + ), + ), + ( + "verification without ssl mode", + None, + Some((DbTlsMode::VerifyCa, ca_cert)), + Some(verification_without_tls), + ), + ( + "verification with disabled ssl", + Some(DeadpoolSslMode::Disable), + Some((DbTlsMode::VerifyCa, ca_cert)), + Some(verification_without_tls), + ), + ( + "verification with preferred ssl", + Some(DeadpoolSslMode::Prefer), + Some((DbTlsMode::VerifyCa, ca_cert)), + Some(verification_without_tls), + ), + ]; + + for (case, ssl_mode, tls, expected) in cases { let mut db = sample_db_config(); db.connection.ssl_mode = ssl_mode; - db.tls = Some(DbTlsConfig { - mode: DbTlsMode::VerifyCa, + db.tls = tls.map(|(mode, ca_cert)| DbTlsConfig { + mode, - ca_cert: Some("trusted CA certificate".to_string()), + ca_cert: ca_cert.map(str::to_string), }); - // Validate the database transport configuration - let result = db.validate(); + let result = db.validate().map_err(|err| err.to_string()); - // Check every plaintext-capable mode is rejected - assert_eq!( - result.unwrap_err().to_string(), - "db.ssl_mode must be Require when db.tls.mode enables certificate verification" - ); + assert_eq!(result.err().as_deref(), expected, "{case}"); } } diff --git a/ocg-common/src/db.rs b/ocg-common/src/db.rs index 4b3948ae9..60662194c 100644 --- a/ocg-common/src/db.rs +++ b/ocg-common/src/db.rs @@ -83,200 +83,125 @@ mod tests { use super::*; - #[test] - fn test_tls_connector_accepts_custom_ca_certificate() { - // Setup a generated CA certificate and full verification - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: Some(test_ca_certificate()), - }; - - // Build the database TLS connector - let result = tls_connector(Some(&tls)); - - // Check the custom trust root is accepted - assert!(result.is_ok()); - } - - #[test] - fn test_tls_connector_accepts_disabled_verification() { - assert!(tls_connector(None).is_ok()); - } - #[tokio::test] - async fn test_tls_connector_disabled_verification_accepts_untrusted_chain() { - // Setup a server whose certificate chains to an unknown CA - let ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&ca)); - let connector = tls_connector(None).unwrap(); - - // Perform the TLS handshake against the test server - let result = tls_handshake(connector, address, "localhost").await; - - // Check disabled verification accepts the untrusted chain - result.expect("handshake should succeed"); - server.join().unwrap(); - } - - #[test] - fn test_tls_connector_rejects_empty_ca_certificate() { - // Setup an empty trust bundle - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyCa, - - ca_cert: Some(String::new()), - }; - - // Build the database TLS connector - let error = tls_connector(Some(&tls)) - .err() - .expect("empty CA certificate should fail"); - - // Check the startup error identifies the missing certificate - assert_eq!( - error.to_string(), - "db.tls.ca_cert does not contain a certificate" - ); - } - - #[test] - fn test_tls_connector_rejects_invalid_ca_certificate() { - // Setup invalid trust material - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyCa, - - ca_cert: Some("not a certificate".to_string()), - }; - - // Build the database TLS connector - let error = tls_connector(Some(&tls)) - .err() - .expect("invalid CA certificate should fail"); - - // Check the startup error identifies the invalid field - assert!(error.to_string().contains("db.tls.ca_cert")); + async fn test_tls_connector_handshake_follows_verification_mode() { + // Setup a trusted CA and server certificates chaining to it or to an unknown CA + let trusted_ca = test_certificate_authority(); + let trusted_server = test_server_certificate(&trusted_ca); + let untrusted_server = test_server_certificate(&test_certificate_authority()); + + // (case, mode, server chains to the trusted CA, hostname, handshake succeeds) + let cases = [ + ( + "disabled verification, untrusted chain", + None, + false, + "localhost", + true, + ), + ( + "verify-ca, hostname mismatch", + Some(DbTlsMode::VerifyCa), + true, + "db.example.test", + true, + ), + ( + "verify-ca, untrusted chain", + Some(DbTlsMode::VerifyCa), + false, + "localhost", + false, + ), + ( + "verify-full, trusted chain and hostname", + Some(DbTlsMode::VerifyFull), + true, + "localhost", + true, + ), + ( + "verify-full, hostname mismatch", + Some(DbTlsMode::VerifyFull), + true, + "db.example.test", + false, + ), + ( + "verify-full, untrusted chain", + Some(DbTlsMode::VerifyFull), + false, + "localhost", + false, + ), + ]; + + for (case, mode, trusted_chain, hostname, succeeds) in cases { + // Setup the server and the connector for the verification mode + let server_certificate = if trusted_chain { + &trusted_server + } else { + &untrusted_server + }; + let (address, server) = spawn_tls_server(server_certificate); + let tls = mode.map(|mode| DbTlsConfig { + mode, + + ca_cert: Some(certificate_pem(&trusted_ca)), + }); + let connector = tls_connector(tls.as_ref()).unwrap(); + + // Perform the TLS handshake against the test server + let result = tls_handshake(connector, address, hostname).await; + + // Check the handshake outcome matches the verification policy + assert_eq!(result.is_ok(), succeeds, "{case}: {result:?}"); + server.join().unwrap(); + } } #[test] - fn test_tls_connector_rejects_verification_without_ca_certificate() { - // Setup certificate verification without trust roots - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: None, - }; - - // Build the database TLS connector - let error = tls_connector(Some(&tls)) - .err() - .expect("missing CA certificate should fail"); - - // Check the startup error identifies the missing trust roots - assert_eq!( - error.to_string(), - "db.tls.ca_cert is required when db.tls.mode enables certificate verification" - ); - } - - #[tokio::test] - async fn test_tls_connector_verify_ca_accepts_hostname_mismatch() { - // Setup a trusted chain with a certificate for another hostname - let ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&ca)); - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyCa, - - ca_cert: Some(certificate_pem(&ca)), - }; - let connector = tls_connector(Some(&tls)).unwrap(); - - // Perform the TLS handshake with a non-matching hostname - let result = tls_handshake(connector, address, "db.example.test").await; - - // Check verify-ca keeps the chain check but skips hostname verification - result.expect("handshake should succeed"); - server.join().unwrap(); - } - - #[tokio::test] - async fn test_tls_connector_verify_ca_rejects_untrusted_chain() { - // Setup a server whose certificate chains to an unknown CA - let untrusted_ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&untrusted_ca)); - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyCa, - - ca_cert: Some(certificate_pem(&test_certificate_authority())), - }; - let connector = tls_connector(Some(&tls)).unwrap(); - - // Perform the TLS handshake against the test server - let result = tls_handshake(connector, address, "localhost").await; - - // Check chain verification rejects the handshake - assert!(result.is_err()); - server.join().unwrap(); - } - - #[tokio::test] - async fn test_tls_connector_verify_full_accepts_trusted_chain_and_hostname() { - // Setup a server whose certificate chains to the trusted CA - let ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&ca)); - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: Some(certificate_pem(&ca)), - }; - let connector = tls_connector(Some(&tls)).unwrap(); - - // Perform the TLS handshake against the test server - let result = tls_handshake(connector, address, "localhost").await; - - // Check the trusted chain and matching hostname are accepted - result.expect("handshake should succeed"); - server.join().unwrap(); - } - - #[tokio::test] - async fn test_tls_connector_verify_full_rejects_hostname_mismatch() { - // Setup a trusted chain with a certificate for another hostname - let ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&ca)); - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: Some(certificate_pem(&ca)), - }; - let connector = tls_connector(Some(&tls)).unwrap(); - - // Perform the TLS handshake with a non-matching hostname - let result = tls_handshake(connector, address, "db.example.test").await; - - // Check hostname verification rejects the handshake - assert!(result.is_err()); - server.join().unwrap(); - } - - #[tokio::test] - async fn test_tls_connector_verify_full_rejects_untrusted_chain() { - // Setup a server whose certificate chains to an unknown CA - let untrusted_ca = test_certificate_authority(); - let (address, server) = spawn_tls_server(test_server_certificate(&untrusted_ca)); - let tls = DbTlsConfig { - mode: DbTlsMode::VerifyFull, - - ca_cert: Some(certificate_pem(&test_certificate_authority())), - }; - let connector = tls_connector(Some(&tls)).unwrap(); - - // Perform the TLS handshake against the test server - let result = tls_handshake(connector, address, "localhost").await; - - // Check chain verification rejects the handshake - assert!(result.is_err()); - server.join().unwrap(); + fn test_tls_connector_rejects_invalid_trust_configuration() { + let invalid_pem = "-----BEGIN CERTIFICATE-----\nnot base64!\n-----END CERTIFICATE-----\n"; + // (case, mode, ca_cert, expected error) + let cases = [ + ( + "empty bundle", + DbTlsMode::VerifyCa, + Some(""), + "db.tls.ca_cert does not contain a certificate", + ), + ( + "text without PEM markers", + DbTlsMode::VerifyCa, + Some("not a certificate"), + "db.tls.ca_cert does not contain a certificate", + ), + ( + "malformed certificate", + DbTlsMode::VerifyCa, + Some(invalid_pem), + "db.tls.ca_cert is not valid PEM", + ), + ( + "verification without trust roots", + DbTlsMode::VerifyFull, + None, + "db.tls.ca_cert is required when db.tls.mode enables certificate verification", + ), + ]; + + for (case, mode, ca_cert, expected) in cases { + let tls = DbTlsConfig { + mode, + + ca_cert: ca_cert.map(str::to_string), + }; + + let error = tls_connector(Some(&tls)).err().expect(case); + + assert_eq!(error.to_string(), expected, "{case}"); + } } // Helpers. @@ -293,15 +218,16 @@ mod tests { } /// Spawns a TLS server that accepts a single handshake attempt. - fn spawn_tls_server(server: TestCertificate) -> (SocketAddr, JoinHandle<()>) { + fn spawn_tls_server(server: &TestCertificate) -> (SocketAddr, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); + let (certificate, key) = (server.certificate.clone(), server.key.clone()); let handle = thread::spawn(move || { // Setup the acceptor with the server certificate and key let mut acceptor = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); - acceptor.set_certificate(&server.certificate).unwrap(); - acceptor.set_private_key(&server.key).unwrap(); + acceptor.set_certificate(&certificate).unwrap(); + acceptor.set_private_key(&key).unwrap(); let acceptor = acceptor.build(); // Serve a single handshake; failures are asserted client-side @@ -312,11 +238,6 @@ mod tests { (address, handle) } - /// Generates a self-signed CA certificate for TLS connector tests. - fn test_ca_certificate() -> String { - certificate_pem(&test_certificate_authority()) - } - /// Generates a self-signed certificate authority for handshake tests. fn test_certificate_authority() -> TestCertificate { // Setup deterministic certificate identity and validity diff --git a/ocg-redirector/src/config.rs b/ocg-redirector/src/config.rs index c7e4d2f2e..2cd9c0a9d 100644 --- a/ocg-redirector/src/config.rs +++ b/ocg-redirector/src/config.rs @@ -122,6 +122,59 @@ mod tests { assert!(!output.contains("database-password-sensitive-value")); } + #[test] + fn test_config_validate_checks_base_redirect_url() { + // (case, base_redirect_url, expected error) + let cases = [ + ("https URL", "https://ocg.example.test", None), + ("http URL with port", "http://localhost:9000", None), + ( + "relative URL", + "/community", + Some("server.base_redirect_url must be an absolute http(s) URL"), + ), + ( + "unsupported scheme", + "ftp://ocg.example.test", + Some("server.base_redirect_url must be an absolute http(s) URL"), + ), + ( + "unparseable URL", + "https://ocg example", + Some("server.base_redirect_url is invalid: invalid uri character"), + ), + ]; + + for (case, base_redirect_url, expected) in cases { + let mut cfg = sample_config(); + cfg.server.base_redirect_url = base_redirect_url.to_string(); + + let result = cfg.validate().map_err(|err| err.to_string()); + + assert_eq!(result.err().as_deref(), expected, "{case}"); + } + } + + #[test] + fn test_http_server_config_redirect_host_suffix_uses_base_url_host() { + for (case, base_redirect_url) in [ + ("bare host", "https://ocg.example.test"), + ("trailing slash", "https://ocg.example.test/"), + ("port and path", "http://ocg.example.test:9000/base"), + ] { + let server_cfg = HttpServerConfig { + base_redirect_url: base_redirect_url.to_string(), + ..HttpServerConfig::default() + }; + + assert_eq!( + server_cfg.redirect_host_suffix(), + "redirects.ocg.example.test", + "{case}" + ); + } + } + // Helpers. fn sample_config() -> Config { diff --git a/ocg-redirector/src/router.rs b/ocg-redirector/src/router.rs index 9b01fe6ac..624dcfadc 100644 --- a/ocg-redirector/src/router.rs +++ b/ocg-redirector/src/router.rs @@ -264,10 +264,7 @@ mod tests { #[tokio::test] async fn test_redirect_drops_query_string_from_lookup_and_target() { - let router = test_router(test_community_redirects(HashMap::from([( - "/events/legacy-event".to_string(), - "/community/group/group/event/event".to_string(), - )]))); + let router = legacy_event_router(); let response = router .oneshot(test_request("/events/legacy-event?utm_source=test")) .await @@ -282,10 +279,7 @@ mod tests { #[tokio::test] async fn test_redirect_normalizes_event_trailing_slash_lookup() { - let router = test_router(test_community_redirects(HashMap::from([( - "/events/legacy-event".to_string(), - "/community/group/group/event/event".to_string(), - )]))); + let router = legacy_event_router(); let response = router.oneshot(test_request("/events/legacy-event/")).await.unwrap(); assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); @@ -297,10 +291,7 @@ mod tests { #[tokio::test] async fn test_redirects_event_match_to_canonical_url() { - let router = test_router(test_community_redirects(HashMap::from([( - "/events/legacy-event".to_string(), - "/community/group/group/event/event".to_string(), - )]))); + let router = legacy_event_router(); let response = router.oneshot(test_request("/events/legacy-event")).await.unwrap(); assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); @@ -402,18 +393,6 @@ mod tests { ); } - #[tokio::test] - async fn test_redirects_to_community_page_when_match_is_duplicated() { - let router = test_router(test_community_redirects(HashMap::new())); - let response = router.oneshot(test_request("/duplicate")).await.unwrap(); - - assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); - assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static("https://ocg.example/active-community") - ); - } - #[tokio::test] async fn test_redirects_unknown_community_host_to_not_found() { let router = test_router(test_community_redirects(HashMap::new())); @@ -459,6 +438,14 @@ mod tests { // Helpers. + /// Sets up a router mapping the legacy event path for the active community. + fn legacy_event_router() -> Router { + test_router(test_community_redirects(HashMap::from([( + "/events/legacy-event".to_string(), + "/community/group/group/event/event".to_string(), + )]))) + } + /// Builds test redirects for the active community. fn test_community_redirects(redirects: HashMap) -> Redirects { HashMap::from([("active-community".to_string(), test_redirects(redirects))]) diff --git a/ocg-server/src/activity_tracker/tests.rs b/ocg-server/src/activity_tracker/tests.rs index 93a87898a..7dcb194fb 100644 --- a/ocg-server/src/activity_tracker/tests.rs +++ b/ocg-server/src/activity_tracker/tests.rs @@ -3,11 +3,16 @@ //! These tests verify that activities are flushed both periodically and on //! shutdown, and that no flush occurs if no activities are tracked. +use std::sync::atomic::AtomicUsize; + use anyhow::anyhow; use mockall::predicate::eq; use tokio::time::{sleep, timeout}; -use crate::db::activity_tracker::MockDBActivityTracker; +use crate::{ + db::activity_tracker::MockDBActivityTracker, services::workers::WorkerRegistry, + test_util::wait_until, +}; use super::*; @@ -29,7 +34,7 @@ static GROUP2_ID: LazyLock = #[tokio::test] async fn test_flushes_activities_on_stop() { // Setup mock database - let day = OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap(); + let day = current_day(); let mut mock_db = MockDBActivityTracker::new(); mock_db .expect_update_community_views() @@ -56,7 +61,8 @@ async fn test_flushes_activities_on_stop() { // Setup tracker and track some activities let background_tasks = BackgroundTasks::new(Duration::from_secs(5)); - let tracker = ActivityTrackerDB::new(mock_db, &background_tasks); + let registry = background_tasks.registry(); + let tracker = ActivityTrackerDB::new(mock_db.clone(), &background_tasks); tracker .track(Activity::CommunityView { community_id: *COMMUNITY1_ID, @@ -115,36 +121,52 @@ async fn test_flushes_activities_on_stop() { .await .unwrap(); - // Stop the tracker and wait for the workers to complete + // Stop the tracker and check the shutdown flush wrote every counter background_tasks.shutdown().await; + assert_no_unexpected_exits(®istry); + drop(mock_db); } /// Test that activities are flushed periodically. -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_flushes_activities_periodically() { - // Setup mock database - let day = OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap(); + // Setup mock database counting the writes it receives + let day = current_day(); + let writes = Arc::new(AtomicUsize::new(0)); let mut mock_db = MockDBActivityTracker::new(); + let community_writes = writes.clone(); mock_db .expect_update_community_views() .with(eq(vec![(*COMMUNITY1_ID, day.clone(), 1)])) .times(1) - .returning(|_| Ok(())); + .returning(move |_| { + community_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let event_writes = writes.clone(); mock_db .expect_update_event_views() .with(eq(vec![(*EVENT1_ID, day.clone(), 1)])) .times(1) - .returning(|_| Ok(())); + .returning(move |_| { + event_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let group_writes = writes.clone(); mock_db .expect_update_group_views() .with(eq(vec![(*GROUP1_ID, day, 1)])) .times(1) - .returning(|_| Ok(())); + .returning(move |_| { + group_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); let mock_db = Arc::new(mock_db); // Setup tracker and track some activities let background_tasks = BackgroundTasks::new(Duration::from_secs(5)); - let tracker = ActivityTrackerDB::new(mock_db, &background_tasks); + let registry = background_tasks.registry(); + let tracker = ActivityTrackerDB::new(mock_db.clone(), &background_tasks); tracker .track(Activity::CommunityView { community_id: *COMMUNITY1_ID, @@ -165,33 +187,37 @@ async fn test_flushes_activities_periodically() { .await .unwrap(); - // Wait for the periodic flush to complete - sleep(FLUSH_FREQUENCY * 2).await; + // Check the periodic flush writes every counter before shutdown is requested + wait_until(|| writes.load(Ordering::SeqCst) == 3).await; - // Stop the tracker and wait for the workers to complete + // Stop the tracker and check the workers stopped cleanly with nothing left to flush background_tasks.shutdown().await; + assert_no_unexpected_exits(®istry); + drop(mock_db); } /// Test that nothing is flushed if no activities are tracked. -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_skips_flush_when_no_activities_are_tracked() { - // Setup tracker with no activities tracked - let mock_db = Arc::new(MockDBActivityTracker::new()); + // Setup tracker with no activities tracked and a database expecting no writes let background_tasks = BackgroundTasks::new(Duration::from_secs(5)); - let _tracker = ActivityTrackerDB::new(mock_db, &background_tasks); + let registry = background_tasks.registry(); + let _tracker = + ActivityTrackerDB::new(Arc::new(MockDBActivityTracker::new()), &background_tasks); // Wait long enough for a periodic flush attempt sleep(FLUSH_FREQUENCY * 2).await; - // Stop the tracker and wait for the workers to complete + // Stop the tracker and check no write reached the database background_tasks.shutdown().await; + assert_no_unexpected_exits(®istry); } /// Test that queued activities are drained into the final shutdown batch. #[tokio::test] async fn test_drains_queued_activities_on_stop() { // Setup channels and cancellation state - let day = OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap(); + let day = current_day(); let (activities_tx, activities_rx) = mpsc::channel(10); let (batches_tx, mut batches_rx) = mpsc::channel(1); let cancellation_token = CancellationToken::new(); @@ -393,10 +419,10 @@ fn test_batches_skip_utm_attribution_of_dropped_views() { } /// Test that a busy flusher does not block the aggregator and pending counters keep merging. -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_keeps_aggregating_when_flusher_is_busy() { // Setup a batches queue whose only slot is taken to simulate a busy flusher - let day = OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap(); + let day = current_day(); let (activities_tx, activities_rx) = mpsc::channel(10); let (batches_tx, mut batches_rx) = mpsc::channel(1); batches_tx.send(Batches::new()).await.unwrap(); @@ -454,7 +480,7 @@ async fn test_keeps_aggregating_when_flusher_is_busy() { #[tokio::test] async fn test_flusher_continues_after_database_failure() { // Setup a database that rejects the first batch's community views only - let day = OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap(); + let day = current_day(); let mut mock_db = MockDBActivityTracker::new(); mock_db .expect_update_community_views() @@ -652,6 +678,20 @@ fn test_prepare_utm_batch_data_encodes_missing_values_as_empty_strings() { // Helpers. +/// Asserts that no activity worker exited unexpectedly. +/// +/// Mock panics inside the workers are recorded in the registry instead of +/// failing the test, so this check is what surfaces them. +fn assert_no_unexpected_exits(registry: &WorkerRegistry) { + for (name, status) in registry.snapshot() { + assert!( + status.unexpected_exits.is_empty(), + "{name} exited unexpectedly: {:?}", + status.unexpected_exits + ); + } +} + /// Returns the current day in the tracker date format. fn current_day() -> Day { OffsetDateTime::now_utc().format(&DATE_FORMAT).unwrap() diff --git a/ocg-server/src/auth/tests.rs b/ocg-server/src/auth/tests.rs index 78717b5eb..bace5a7e7 100644 --- a/ocg-server/src/auth/tests.rs +++ b/ocg-server/src/auth/tests.rs @@ -21,18 +21,14 @@ use crate::{ use super::*; #[tokio::test] -async fn authenticate_dispatches_password_credentials() { +async fn test_authenticate_dispatches_password_credentials() { // Setup database mock - let password_hash = generate_hash("correct-password"); + let user = User { + password: Some(generate_hash("correct-password")), + ..sample_user() + }; let mut db = MockDB::new(); - db.expect_get_user_by_username() - .times(1) - .withf(|username| username == "test-user") - .returning(move |_| { - let mut user = sample_user(); - user.password = Some(password_hash.clone()); - Ok(Some(user)) - }); + expect_user_by_username(&mut db, Some(user)); let db: DynDB = Arc::new(db); // Execute authentication @@ -48,13 +44,14 @@ async fn authenticate_dispatches_password_credentials() { .unwrap() .unwrap(); - // Check result + // Check the user is returned without the password hash assert!(user.password.is_none()); + assert_eq!(user.email, "user@example.com"); assert_eq!(user.username, "test-user"); } #[tokio::test] -async fn authenticate_maps_password_backend_error_to_auth_error() { +async fn test_authenticate_maps_password_backend_error_to_auth_error() { // Setup database mock let mut db = MockDB::new(); db.expect_get_user_by_username() @@ -82,7 +79,7 @@ async fn authenticate_maps_password_backend_error_to_auth_error() { } #[tokio::test] -async fn authenticate_maps_oauth2_backend_error_to_auth_error() { +async fn test_authenticate_maps_oauth2_backend_error_to_auth_error() { // Setup backend without configured providers let db: DynDB = Arc::new(MockDB::new()); let backend = authn_backend(db).await; @@ -105,7 +102,7 @@ async fn authenticate_maps_oauth2_backend_error_to_auth_error() { } #[tokio::test] -async fn authenticate_maps_oidc_backend_error_to_auth_error() { +async fn test_authenticate_maps_oidc_backend_error_to_auth_error() { // Setup backend without configured providers let db: DynDB = Arc::new(MockDB::new()); let backend = authn_backend(db).await; @@ -129,57 +126,14 @@ async fn authenticate_maps_oidc_backend_error_to_auth_error() { } #[tokio::test] -async fn authenticate_oauth2_returns_error_when_provider_missing() { - // Setup backend without configured providers - let db: DynDB = Arc::new(MockDB::new()); - let backend = authn_backend(db).await; - - // Execute authentication - let result = backend - .authenticate_oauth2(OAuth2Credentials { - code: "code".to_string(), - provider: OAuth2Provider::GitHub, - }) - .await; - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("oauth2 provider not found")); -} - -#[tokio::test] -async fn authenticate_oidc_returns_error_when_provider_missing() { - // Setup backend without configured providers - let db: DynDB = Arc::new(MockDB::new()); - let backend = authn_backend(db).await; - - // Execute authentication - let result = backend - .authenticate_oidc(OidcCredentials { - code: "code".to_string(), - nonce: oidc::Nonce::new("nonce".to_string()), - provider: OidcProvider::LinuxFoundation, - }) - .await; - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("oidc provider not found")); -} - -#[tokio::test] -async fn authenticate_password_returns_none_when_password_is_incorrect() { +async fn test_authenticate_password_returns_none_when_password_is_incorrect() { // Setup database mock - let password_hash = generate_hash("correct-password"); + let user = User { + password: Some(generate_hash("correct-password")), + ..sample_user() + }; let mut db = MockDB::new(); - db.expect_get_user_by_username() - .times(1) - .withf(|username| username == "test-user") - .returning(move |_| { - let mut user = sample_user(); - user.password = Some(password_hash.clone()); - Ok(Some(user)) - }); + expect_user_by_username(&mut db, Some(user)); let db: DynDB = Arc::new(db); // Execute authentication @@ -197,17 +151,14 @@ async fn authenticate_password_returns_none_when_password_is_incorrect() { } #[tokio::test] -async fn authenticate_password_returns_none_when_password_is_missing() { +async fn test_authenticate_password_returns_none_when_password_is_missing() { // Setup database mock + let user = User { + password: None, + ..sample_user() + }; let mut db = MockDB::new(); - db.expect_get_user_by_username() - .times(1) - .withf(|username| username == "test-user") - .returning(|_| { - let mut user = sample_user(); - user.password = None; - Ok(Some(user)) - }); + expect_user_by_username(&mut db, Some(user)); let db: DynDB = Arc::new(db); // Execute authentication @@ -225,13 +176,10 @@ async fn authenticate_password_returns_none_when_password_is_missing() { } #[tokio::test] -async fn authenticate_password_returns_none_when_user_not_found() { +async fn test_authenticate_password_returns_none_when_user_not_found() { // Setup database mock let mut db = MockDB::new(); - db.expect_get_user_by_username() - .times(1) - .withf(|username| username == "test-user") - .returning(|_| Ok(None)); + expect_user_by_username(&mut db, None); let db: DynDB = Arc::new(db); // Execute authentication @@ -248,39 +196,8 @@ async fn authenticate_password_returns_none_when_user_not_found() { assert!(result.is_none()); } -#[tokio::test] -async fn authenticate_password_returns_user_without_password_when_valid() { - // Setup database mock - let password_hash = generate_hash("correct-password"); - let mut db = MockDB::new(); - db.expect_get_user_by_username() - .times(1) - .withf(|username| username == "test-user") - .returning(move |_| { - let mut user = sample_user(); - user.password = Some(password_hash.clone()); - Ok(Some(user)) - }); - let db: DynDB = Arc::new(db); - - // Execute authentication - let backend = authn_backend(db).await; - let user = backend - .authenticate_password(PasswordCredentials { - password: "correct-password".to_string(), - username: "test-user".to_string(), - }) - .await - .unwrap() - .unwrap(); - - // Check result - assert!(user.password.is_none()); - assert_eq!(user.email, "user@example.com"); -} - #[test] -fn from_user_to_external_user_profile_drops_password() { +fn test_from_user_to_external_user_profile_drops_password() { // Setup input user let mut user = sample_user(); user.has_password = Some(true); @@ -296,7 +213,7 @@ fn from_user_to_external_user_profile_drops_password() { } #[tokio::test] -async fn get_or_sign_up_external_user_merges_provider_into_existing_user() { +async fn test_get_or_sign_up_external_user_merges_provider_into_existing_user() { // Setup database mock let mut db = MockDB::new(); let existing_user = sample_user(); @@ -304,16 +221,8 @@ async fn get_or_sign_up_external_user_merges_provider_into_existing_user() { let incoming_provider = sample_linuxfoundation_user_provider(); let profile = sample_external_user_profile(Some(incoming_provider.clone())); - db.expect_get_user_by_linuxfoundation_identity_for_external_auth() - .times(1) - .withf(|issuer, subject| { - issuer == "https://issuer.example.com" && subject == "auth0|test-user" - }) - .returning(|_, _| Ok(None)); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_lf_identity_lookup(&mut db, None); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider() .times(1) .withf(move |user_id, provider| { @@ -345,7 +254,7 @@ async fn get_or_sign_up_external_user_merges_provider_into_existing_user() { } #[tokio::test] -async fn get_or_sign_up_external_user_rejects_linuxfoundation_identity_relink() { +async fn test_get_or_sign_up_external_user_rejects_linuxfoundation_identity_relink() { // Setup database mock let mut db = MockDB::new(); let mut existing_provider = sample_user_provider(); @@ -361,16 +270,8 @@ async fn get_or_sign_up_external_user_rejects_linuxfoundation_identity_relink() }; let profile = sample_external_user_profile(Some(sample_linuxfoundation_user_provider())); - db.expect_get_user_by_linuxfoundation_identity_for_external_auth() - .times(1) - .withf(|issuer, subject| { - issuer == "https://issuer.example.com" && subject == "auth0|test-user" - }) - .returning(|_, _| Ok(None)); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_lf_identity_lookup(&mut db, None); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider().times(0); db.expect_sign_up_user().times(0); let db: DynDB = Arc::new(db); @@ -384,7 +285,8 @@ async fn get_or_sign_up_external_user_rejects_linuxfoundation_identity_relink() } #[tokio::test] -async fn get_or_sign_up_external_user_ignores_linuxfoundation_username_mismatch_without_identity() { +async fn test_get_or_sign_up_external_user_ignores_linuxfoundation_username_mismatch_without_identity() + { // Setup database mock let mut db = MockDB::new(); let existing_user = User { @@ -397,16 +299,8 @@ async fn get_or_sign_up_external_user_ignores_linuxfoundation_username_mismatch_ let incoming_provider = sample_linuxfoundation_user_provider(); let profile = sample_external_user_profile(Some(incoming_provider.clone())); - db.expect_get_user_by_linuxfoundation_identity_for_external_auth() - .times(1) - .withf(|issuer, subject| { - issuer == "https://issuer.example.com" && subject == "auth0|test-user" - }) - .returning(|_, _| Ok(None)); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_lf_identity_lookup(&mut db, None); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider() .times(1) .withf(move |user_id, provider| { @@ -425,7 +319,7 @@ async fn get_or_sign_up_external_user_ignores_linuxfoundation_username_mismatch_ } #[tokio::test] -async fn get_or_sign_up_external_user_reconciles_linuxfoundation_identity_before_email() { +async fn test_get_or_sign_up_external_user_reconciles_linuxfoundation_identity_before_email() { // Setup database mock let mut db = MockDB::new(); let existing_user = User { @@ -440,12 +334,7 @@ async fn get_or_sign_up_external_user_reconciles_linuxfoundation_identity_before profile.email = "new@example.com".to_string(); profile.name = "New Test User".to_string(); - db.expect_get_user_by_linuxfoundation_identity_for_external_auth() - .times(1) - .withf(|issuer, subject| { - issuer == "https://issuer.example.com" && subject == "auth0|test-user" - }) - .returning(move |_, _| Ok(Some(existing_user.clone()))); + expect_lf_identity_lookup(&mut db, Some(existing_user)); db.expect_update_user_external_auth() .times(1) .withf(move |user_id, summary| { @@ -471,7 +360,7 @@ async fn get_or_sign_up_external_user_reconciles_linuxfoundation_identity_before } #[tokio::test] -async fn get_or_sign_up_external_user_sets_provider_for_existing_user_without_provider() { +async fn test_get_or_sign_up_external_user_sets_provider_for_existing_user_without_provider() { // Setup database mock let mut db = MockDB::new(); let existing_user = sample_user_without_provider(); @@ -479,10 +368,7 @@ async fn get_or_sign_up_external_user_sets_provider_for_existing_user_without_pr let incoming_provider = sample_user_provider(); let profile = sample_external_user_profile(Some(incoming_provider.clone())); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider() .times(1) .withf(move |user_id, provider| { @@ -501,16 +387,13 @@ async fn get_or_sign_up_external_user_sets_provider_for_existing_user_without_pr } #[tokio::test] -async fn get_or_sign_up_external_user_skips_provider_update_when_unchanged() { +async fn test_get_or_sign_up_external_user_skips_provider_update_when_unchanged() { // Setup database mock let mut db = MockDB::new(); let existing_user = sample_user(); let profile = sample_external_user_profile(Some(sample_user_provider())); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider().times(0); db.expect_sign_up_user().times(0); let db: DynDB = Arc::new(db); @@ -524,17 +407,14 @@ async fn get_or_sign_up_external_user_skips_provider_update_when_unchanged() { } #[tokio::test] -async fn get_or_sign_up_external_user_signs_up_new_user() { +async fn test_get_or_sign_up_external_user_signs_up_new_user() { // Setup database mock let mut db = MockDB::new(); let provider = sample_user_provider(); let profile = sample_external_user_profile(Some(provider.clone())); let signed_up_user = sample_user(); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(|_| Ok(None)); + expect_email_lookup(&mut db, None); db.expect_update_user_provider().times(0); db.expect_sign_up_user() .times(1) @@ -558,7 +438,7 @@ async fn get_or_sign_up_external_user_signs_up_new_user() { } #[tokio::test] -async fn get_or_sign_up_external_user_activates_pre_registered_user() { +async fn test_get_or_sign_up_external_user_activates_pre_registered_user() { // Setup database mock let mut db = MockDB::new(); let mut pre_registered_user = sample_user(); @@ -567,16 +447,8 @@ async fn get_or_sign_up_external_user_activates_pre_registered_user() { let activated_user = sample_user(); let profile = sample_external_user_profile(Some(sample_linuxfoundation_user_provider())); - db.expect_get_user_by_linuxfoundation_identity_for_external_auth() - .times(1) - .withf(|issuer, subject| { - issuer == "https://issuer.example.com" && subject == "auth0|test-user" - }) - .returning(|_, _| Ok(None)); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(pre_registered_user.clone()))); + expect_lf_identity_lookup(&mut db, None); + expect_email_lookup(&mut db, Some(pre_registered_user)); db.expect_activate_pre_registered_user_external_provider() .times(1) .withf(move |user_id, summary| { @@ -598,16 +470,13 @@ async fn get_or_sign_up_external_user_activates_pre_registered_user() { } #[tokio::test] -async fn get_or_sign_up_external_user_skips_provider_update_when_missing() { +async fn test_get_or_sign_up_external_user_skips_provider_update_when_missing() { // Setup database mock let mut db = MockDB::new(); let existing_user = sample_user(); let profile = sample_external_user_profile(None); - db.expect_get_user_by_email_for_external_auth() - .times(1) - .withf(|email| email == "user@example.com") - .returning(move |_| Ok(Some(existing_user.clone()))); + expect_email_lookup(&mut db, Some(existing_user)); db.expect_update_user_provider().times(0); db.expect_sign_up_user().times(0); let db: DynDB = Arc::new(db); @@ -621,7 +490,7 @@ async fn get_or_sign_up_external_user_skips_provider_update_when_missing() { } #[tokio::test] -async fn get_user_maps_db_error_to_auth_error() { +async fn test_get_user_maps_db_error_to_auth_error() { // Setup database mock let user_id = Uuid::new_v4(); let mut db = MockDB::new(); @@ -643,7 +512,7 @@ async fn get_user_maps_db_error_to_auth_error() { } #[tokio::test] -async fn get_user_returns_none_when_user_not_found() { +async fn test_get_user_returns_none_when_user_not_found() { // Setup database mock let user_id = Uuid::new_v4(); let mut db = MockDB::new(); @@ -662,7 +531,7 @@ async fn get_user_returns_none_when_user_not_found() { } #[tokio::test] -async fn setup_oidc_providers_rejects_invalid_issuer_url() { +async fn test_setup_oidc_providers_rejects_invalid_issuer_url() { // Setup invalid OIDC configuration let mut oidc_cfg: OidcConfig = HashMap::new(); let mut provider_cfg = sample_oidc_provider_config(); @@ -678,7 +547,7 @@ async fn setup_oidc_providers_rejects_invalid_issuer_url() { } #[test] -fn setup_oauth2_providers_builds_provider_map_when_config_is_valid() { +fn test_setup_oauth2_providers_builds_provider_map_when_config_is_valid() { // Setup valid OAuth2 configuration let mut oauth2_cfg: OAuth2Config = HashMap::new(); let provider_cfg = sample_oauth2_provider_config(); @@ -697,52 +566,46 @@ fn setup_oauth2_providers_builds_provider_map_when_config_is_valid() { } #[test] -fn setup_oauth2_providers_rejects_invalid_auth_url() { - // Setup invalid OAuth2 configuration - let mut oauth2_cfg: OAuth2Config = HashMap::new(); - let mut provider_cfg = sample_oauth2_provider_config(); - provider_cfg.auth_url = "invalid-url".to_string(); - oauth2_cfg.insert(OAuth2Provider::GitHub, provider_cfg); - - // Execute provider setup - let result = AuthnBackend::setup_oauth2_providers(&oauth2_cfg); - - // Check result - assert!(result.is_err()); -} - -#[test] -fn setup_oauth2_providers_rejects_invalid_redirect_uri() { - // Setup invalid OAuth2 configuration - let mut oauth2_cfg: OAuth2Config = HashMap::new(); - let mut provider_cfg = sample_oauth2_provider_config(); - provider_cfg.redirect_uri = "invalid-url".to_string(); - oauth2_cfg.insert(OAuth2Provider::GitHub, provider_cfg); - - // Execute provider setup - let result = AuthnBackend::setup_oauth2_providers(&oauth2_cfg); - - // Check result - assert!(result.is_err()); -} - -#[test] -fn setup_oauth2_providers_rejects_invalid_token_url() { - // Setup invalid OAuth2 configuration - let mut oauth2_cfg: OAuth2Config = HashMap::new(); - let mut provider_cfg = sample_oauth2_provider_config(); - provider_cfg.token_url = "invalid-url".to_string(); - oauth2_cfg.insert(OAuth2Provider::GitHub, provider_cfg); - - // Execute provider setup - let result = AuthnBackend::setup_oauth2_providers(&oauth2_cfg); - - // Check result - assert!(result.is_err()); +fn test_setup_oauth2_providers_rejects_invalid_urls() { + // Setup configurations with one invalid URL each + let valid_cfg = sample_oauth2_provider_config(); + let cases = [ + ( + "invalid auth url", + OAuth2ProviderConfig { + auth_url: "invalid-url".to_string(), + ..valid_cfg.clone() + }, + ), + ( + "invalid redirect uri", + OAuth2ProviderConfig { + redirect_uri: "invalid-url".to_string(), + ..valid_cfg.clone() + }, + ), + ( + "invalid token url", + OAuth2ProviderConfig { + token_url: "invalid-url".to_string(), + ..valid_cfg + }, + ), + ]; + + for (case, provider_cfg) in cases { + let oauth2_cfg: OAuth2Config = HashMap::from([(OAuth2Provider::GitHub, provider_cfg)]); + + // Execute provider setup + let result = AuthnBackend::setup_oauth2_providers(&oauth2_cfg); + + // Check result + assert!(result.is_err(), "{case}"); + } } #[test] -fn user_debug_does_not_expose_sensitive_fields() { +fn test_user_debug_does_not_expose_sensitive_fields() { // Setup input user let mut user = sample_user(); user.auth_hash = "private-auth-hash".to_string(); @@ -758,64 +621,40 @@ fn user_debug_does_not_expose_sensitive_fields() { } #[test] -fn user_is_profile_complete_returns_false_when_bio_is_missing() { - // Setup user missing bio - let mut user = sample_user(); - user.github_url = Some("https://github.com/test-user".to_string()); - user.title = Some("Software Engineer".to_string()); - - // Check profile completion result - assert!(!user.is_profile_complete()); -} - -#[test] -fn user_is_profile_complete_returns_false_when_social_link_is_missing() { - // Setup user missing profile links - let mut user = sample_user(); - user.bio = Some("Builds community tools.".to_string()); - user.title = Some("Software Engineer".to_string()); - - // Check profile completion result - assert!(!user.is_profile_complete()); -} - -#[test] -fn user_is_profile_complete_returns_false_when_title_is_missing() { - // Setup user missing title - let mut user = sample_user(); - user.bio = Some("Builds community tools.".to_string()); - user.github_url = Some("https://github.com/test-user".to_string()); - - // Check profile completion result - assert!(!user.is_profile_complete()); -} - -#[test] -fn user_is_profile_complete_returns_false_when_values_are_whitespace_only() { - // Setup user with whitespace-only profile fields - let mut user = sample_user(); - user.bio = Some(" ".to_string()); - user.github_url = Some("\n\t".to_string()); - user.title = Some(" ".to_string()); - - // Check profile completion result - assert!(!user.is_profile_complete()); -} - -#[test] -fn user_is_profile_complete_returns_true_when_required_fields_are_present() { - // Setup complete profile - let mut user = sample_user(); - user.bio = Some("Builds community tools.".to_string()); - user.github_url = Some("https://github.com/test-user".to_string()); - user.title = Some("Software Engineer".to_string()); - - // Check profile completion result - assert!(user.is_profile_complete()); +fn test_user_is_profile_complete_requires_title_bio_and_profile_link() { + // Setup profile field combinations: (case, bio, github_url, title, expected) + let bio = Some("Builds community tools."); + let github_url = Some("https://github.com/test-user"); + let title = Some("Software Engineer"); + let cases = [ + ("bio missing", None, github_url, title, false), + ("profile link missing", bio, None, title, false), + ("title missing", bio, github_url, None, false), + ( + "whitespace-only values", + Some(" "), + Some("\n\t"), + Some(" "), + false, + ), + ("required fields present", bio, github_url, title, true), + ]; + + for (case, bio, github_url, title, expected) in cases { + let user = User { + bio: bio.map(String::from), + github_url: github_url.map(String::from), + title: title.map(String::from), + ..sample_user() + }; + + // Check profile completion result + assert_eq!(user.is_profile_complete(), expected, "{case}"); + } } #[test] -fn external_user_profile_debug_does_not_expose_password() { +fn test_external_user_profile_debug_does_not_expose_password() { // Setup input summary let summary = ExternalUserProfile { email: "user@example.com".to_string(), @@ -835,7 +674,7 @@ fn external_user_profile_debug_does_not_expose_password() { } #[test] -fn external_user_profile_from_oidc_id_token_claims_extracts_verified_user() { +fn test_external_user_profile_from_oidc_id_token_claims_extracts_verified_user() { // Setup valid claims let claims = sample_oidc_claims( Some("user@example.com"), @@ -868,92 +707,68 @@ fn external_user_profile_from_oidc_id_token_claims_extracts_verified_user() { } #[test] -fn external_user_profile_from_oidc_id_token_claims_rejects_missing_email() { - // Setup invalid claims - let claims = sample_oidc_claims(None, Some(true), Some("Test User"), Some("test-user")); - - // Execute conversion - let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("email missing")); -} - -#[test] -fn external_user_profile_from_oidc_id_token_claims_rejects_missing_name() { - // Setup invalid claims - let claims = sample_oidc_claims( - Some("user@example.com"), - Some(true), - None, - Some("test-user"), - ); - - // Execute conversion - let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("name missing")); -} - -#[test] -fn external_user_profile_from_oidc_id_token_claims_rejects_missing_nickname() { - // Setup invalid claims - let claims = sample_oidc_claims( - Some("user@example.com"), - Some(true), - Some("Test User"), - None, - ); - - // Execute conversion - let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("nickname missing")); -} - -#[test] -fn external_user_profile_from_oidc_id_token_claims_rejects_missing_email_verified() { - // Setup invalid claims - let claims = sample_oidc_claims( - Some("user@example.com"), - None, - Some("Test User"), - Some("test-user"), - ); - - // Execute conversion - let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("email not verified")); -} - -#[test] -fn external_user_profile_from_oidc_id_token_claims_rejects_unverified_email() { - // Setup invalid claims - let claims = sample_oidc_claims( - Some("user@example.com"), - Some(false), - Some("Test User"), - Some("test-user"), - ); - - // Execute conversion - let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); - - // Check result - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("email not verified")); +fn test_external_user_profile_from_oidc_id_token_claims_rejects_incomplete_claims() { + // Setup claims: (case, email, email_verified, name, nickname, expected error) + let email = Some("user@example.com"); + let name = Some("Test User"); + let nickname = Some("test-user"); + let cases = [ + ( + "email missing", + None, + Some(true), + name, + nickname, + "email missing", + ), + ( + "name missing", + email, + Some(true), + None, + nickname, + "name missing", + ), + ( + "nickname missing", + email, + Some(true), + name, + None, + "nickname missing", + ), + ( + "email verification missing", + email, + None, + name, + nickname, + "email not verified", + ), + ( + "email unverified", + email, + Some(false), + name, + nickname, + "email not verified", + ), + ]; + + for (case, email, email_verified, name, nickname, expected_error) in cases { + let claims = sample_oidc_claims(email, email_verified, name, nickname); + + // Execute conversion + let result = ExternalUserProfile::from_oidc_id_token_claims(&claims); + + // Check result + let err = result.expect_err(case); + assert!(err.to_string().contains(expected_error), "{case}: {err}"); + } } #[test] -fn user_session_auth_hash_matches_auth_hash_bytes() { +fn test_user_session_auth_hash_matches_auth_hash_bytes() { // Setup input user let mut user = sample_user(); user.auth_hash = "private-auth-hash".to_string(); @@ -967,6 +782,32 @@ fn user_session_auth_hash_matches_auth_hash_bytes() { // Helpers. +/// Expect the email lookup for the sample profile to return the user. +fn expect_email_lookup(db: &mut MockDB, user: Option) { + db.expect_get_user_by_email_for_external_auth() + .times(1) + .withf(|email| email == "user@example.com") + .returning(move |_| Ok(user.clone())); +} + +/// Expect the LF identity lookup for the sample profile to return the user. +fn expect_lf_identity_lookup(db: &mut MockDB, user: Option) { + db.expect_get_user_by_linuxfoundation_identity_for_external_auth() + .times(1) + .withf(|issuer, subject| { + issuer == "https://issuer.example.com" && subject == "auth0|test-user" + }) + .returning(move |_, _| Ok(user.clone())); +} + +/// Expect the username lookup for the sample credentials to return the user. +fn expect_user_by_username(db: &mut MockDB, user: Option) { + db.expect_get_user_by_username() + .times(1) + .withf(|username| username == "test-user") + .returning(move |_| Ok(user.clone())); +} + async fn authn_backend(db: DynDB) -> AuthnBackend { let http_client_cfg = HttpClientConfig::default(); let oidc_cfg: OidcConfig = HashMap::new(); diff --git a/ocg-server/src/config.rs b/ocg-server/src/config.rs index 1e0cb0dc6..e020e5b8b 100644 --- a/ocg-server/src/config.rs +++ b/ocg-server/src/config.rs @@ -32,7 +32,7 @@ use crate::types::{ }; #[cfg(test)] -mod tests; +pub(crate) mod tests; /// Default connection deadline in seconds for outbound clients. const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10; diff --git a/ocg-server/src/config/tests.rs b/ocg-server/src/config/tests.rs index d108852b3..a9ac6578b 100644 --- a/ocg-server/src/config/tests.rs +++ b/ocg-server/src/config/tests.rs @@ -5,48 +5,67 @@ use super::*; #[test] fn test_badges_config_rejects_invalid_keys() { - // Setup invalid identifiers, mismatched material, and duplicate identifiers + // Setup a signing key, a public key of another key pair, and mismatched material let private_jwk = JWK::generate_ed25519().unwrap(); - let mut mismatched = private_jwk.clone(); - let Params::OKP(params) = &mut mismatched.params else { + let other_private_jwk = JWK::generate_ed25519().unwrap(); + let mut mismatched_jwk = private_jwk.clone(); + let (Params::OKP(mismatched), Params::OKP(other)) = + (&mut mismatched_jwk.params, &other_private_jwk.params) + else { unreachable!(); }; - params.public_key.0[0] ^= 1; - let public_jwk = private_jwk.to_public(); - - let invalid_id = BadgesConfig { - signing_key: BadgeSigningKeyConfig { - key_id: "Not Stable".to_string(), - private_jwk: private_jwk.clone(), - }, - verification_keys: vec![], + mismatched.public_key = other.public_key.clone(); + let signing_key = |key_id: &str, private_jwk: &JWK| BadgeSigningKeyConfig { + key_id: key_id.to_string(), + private_jwk: private_jwk.clone(), }; - let mismatched_key = BadgesConfig { - signing_key: BadgeSigningKeyConfig { - key_id: "active-2026".to_string(), - private_jwk: mismatched, - }, - verification_keys: vec![], + let verification_key = |key_id: &str, public_jwk: &JWK| BadgeVerificationKeyConfig { + key_id: key_id.to_string(), + public_jwk: public_jwk.clone(), }; - let duplicate = BadgesConfig { - signing_key: BadgeSigningKeyConfig { - key_id: "active-2026".to_string(), - private_jwk, - }, - verification_keys: vec![BadgeVerificationKeyConfig { - key_id: "active-2026".to_string(), - public_jwk, - }], - }; - // Validate each inconsistent key configuration - let duplicate_result = duplicate.validate(); - let invalid_id_result = invalid_id.validate(); - let mismatched_key_result = mismatched_key.validate(); - - // Check every inconsistent configuration is rejected - assert!(duplicate_result.is_err()); - assert!(invalid_id_result.is_err()); - assert!(mismatched_key_result.is_err()); + + // (case, signing key, verification keys, expected error) + let cases = [ + ( + "unstable key identifier", + signing_key("Not Stable", &private_jwk), + vec![], + "server.badges key identifiers must be 1-64 lowercase URL-safe characters and start and end with a letter or digit", + ), + ( + "signing key without private material", + signing_key("active-2026", &private_jwk.to_public()), + vec![], + "server.badges signing key must contain an Ed25519 private key", + ), + ( + "mismatched key material", + signing_key("active-2026", &mismatched_jwk), + vec![], + "server.badges signing key public and private material must match", + ), + ( + "verification key with private material", + signing_key("active-2026", &private_jwk), + vec![verification_key("retired-2025", &other_private_jwk)], + "server.badges verification keys must contain public material only", + ), + ( + "duplicate key identifiers", + signing_key("active-2026", &private_jwk), + vec![verification_key("active-2026", &private_jwk.to_public())], + "server.badges contains duplicate key identifiers", + ), + ]; + + for (case, signing_key, verification_keys, expected) in cases { + let cfg = BadgesConfig { + signing_key, + verification_keys, + }; + + assert_eq!(cfg.validate().unwrap_err().to_string(), expected, "{case}"); + } } #[test] @@ -130,98 +149,71 @@ fn test_external_payments_config_defaults_window_hours() { } #[test] -fn test_external_payments_config_rejects_default_window_above_max() { - // Setup a default window longer than the configured maximum - let cfg = ExternalPaymentsConfig { - allowed_countries: vec!["KR".to_string()], - default_payment_window_hours: 48, - max_payment_window_hours: 24, - }; - - // Validate the inconsistent window limits - let result = cfg.validate(); - - // Check startup rejects a default that cannot be enforced - assert_eq!( - result.unwrap_err().to_string(), - "external_payments.default_payment_window_hours cannot exceed max_payment_window_hours" - ); -} - -#[test] -fn test_external_payments_config_rejects_duplicate_country_codes() { - // Setup an allowlist with the same country in different case - let cfg = ExternalPaymentsConfig { - allowed_countries: vec!["KR".to_string(), "kr".to_string()], - default_payment_window_hours: DEFAULT_EXTERNAL_PAYMENT_WINDOW_HOURS, - max_payment_window_hours: DEFAULT_MAX_EXTERNAL_PAYMENT_WINDOW_HOURS, - }; - - // Validate the duplicated country codes - let result = cfg.validate(); - - // Check case-insensitive duplicates are rejected - assert_eq!( - result.unwrap_err().to_string(), - "external_payments.allowed_countries contains duplicate country code 'kr'" - ); -} - -#[test] -fn test_external_payments_config_rejects_empty_allowlist() { - // Setup a present config section with no countries - let cfg = ExternalPaymentsConfig { - allowed_countries: vec![], - default_payment_window_hours: DEFAULT_EXTERNAL_PAYMENT_WINDOW_HOURS, - max_payment_window_hours: DEFAULT_MAX_EXTERNAL_PAYMENT_WINDOW_HOURS, - }; - - // Validate the empty allowlist - let result = cfg.validate(); - - // Check startup rejects a configured-but-empty allowlist - assert_eq!( - result.unwrap_err().to_string(), - "external_payments.allowed_countries cannot be empty" - ); -} - -#[test] -fn test_external_payments_config_rejects_invalid_country_code() { - // Setup an allowlist entry that is not an ISO 3166-1 alpha-2 code - let cfg = ExternalPaymentsConfig { - allowed_countries: vec!["KOR".to_string()], - default_payment_window_hours: DEFAULT_EXTERNAL_PAYMENT_WINDOW_HOURS, - max_payment_window_hours: DEFAULT_MAX_EXTERNAL_PAYMENT_WINDOW_HOURS, - }; - - // Validate the invalid country code - let result = cfg.validate(); - - // Check startup rejects codes that cannot match group.country_code - assert_eq!( - result.unwrap_err().to_string(), - "external_payments.allowed_countries contains invalid country code 'KOR'" - ); -} - -#[test] -fn test_external_payments_config_rejects_non_positive_window_hours() { - // Setup a non-positive default window - let cfg = ExternalPaymentsConfig { - allowed_countries: vec!["KR".to_string()], - default_payment_window_hours: 0, - max_payment_window_hours: DEFAULT_MAX_EXTERNAL_PAYMENT_WINDOW_HOURS, - }; - - // Validate the non-positive window - let result = cfg.validate(); +fn test_external_payments_config_rejects_invalid_settings() { + let default_hours = DEFAULT_EXTERNAL_PAYMENT_WINDOW_HOURS; + let max_hours = DEFAULT_MAX_EXTERNAL_PAYMENT_WINDOW_HOURS; + // (case, allowed countries, default window hours, max window hours, expected error) + let cases = [ + ( + "empty allowlist", + vec![], + default_hours, + max_hours, + "external_payments.allowed_countries cannot be empty", + ), + ( + "invalid country code", + vec!["KOR"], + default_hours, + max_hours, + "external_payments.allowed_countries contains invalid country code 'KOR'", + ), + ( + "duplicate country code in different case", + vec!["KR", "kr"], + default_hours, + max_hours, + "external_payments.allowed_countries contains duplicate country code 'kr'", + ), + ( + "non-positive default window", + vec!["KR"], + 0, + max_hours, + "external_payments.default_payment_window_hours must be at least 1", + ), + ( + "non-positive max window", + vec!["KR"], + 1, + 0, + "external_payments.max_payment_window_hours must be at least 1", + ), + ( + "default window above max", + vec!["KR"], + 48, + 24, + "external_payments.default_payment_window_hours cannot exceed max_payment_window_hours", + ), + ]; - // Check startup rejects a window that cannot schedule a hold - assert_eq!( - result.unwrap_err().to_string(), - "external_payments.default_payment_window_hours must be at least 1" - ); + for ( + case, + allowed_countries, + default_payment_window_hours, + max_payment_window_hours, + expected, + ) in cases + { + let cfg = ExternalPaymentsConfig { + allowed_countries: allowed_countries.into_iter().map(str::to_string).collect(), + default_payment_window_hours, + max_payment_window_hours, + }; + + assert_eq!(cfg.validate().unwrap_err().to_string(), expected, "{case}"); + } } #[test] @@ -235,33 +227,38 @@ fn test_http_client_config_defaults_deadlines() { } #[test] -fn test_http_client_config_rejects_zero_connect_timeout() { - // Setup a section with a zero connection deadline - let cfg = HttpClientConfig { - connect_timeout_secs: 0, - request_timeout_secs: 30, - }; - - // Check the section name is carried in the rejection - assert_eq!( - cfg.validate("payments.http_client").unwrap_err().to_string(), - "payments.http_client.connect_timeout_secs must be >= 1" - ); -} - -#[test] -fn test_http_client_config_rejects_zero_request_timeout() { - // Setup a section with a zero request deadline - let cfg = HttpClientConfig { - connect_timeout_secs: 10, - request_timeout_secs: 0, - }; +fn test_http_client_config_rejects_zero_timeouts() { + // (case, connect timeout, request timeout, section, expected error) + let cases = [ + ( + "zero connect timeout", + 0, + 30, + "payments.http_client", + "payments.http_client.connect_timeout_secs must be >= 1", + ), + ( + "zero request timeout", + 10, + 0, + "server.http_client", + "server.http_client.request_timeout_secs must be >= 1", + ), + ]; - // Check the section name is carried in the rejection - assert_eq!( - cfg.validate("server.http_client").unwrap_err().to_string(), - "server.http_client.request_timeout_secs must be >= 1" - ); + for (case, connect_timeout_secs, request_timeout_secs, section, expected) in cases { + let cfg = HttpClientConfig { + connect_timeout_secs, + request_timeout_secs, + }; + + // Check the section name is carried in the rejection + assert_eq!( + cfg.validate(section).unwrap_err().to_string(), + expected, + "{case}" + ); + } } #[test] @@ -274,13 +271,6 @@ fn test_http_server_config_defaults_deadlines_and_grace_period() { assert_eq!(cfg.shutdown_grace_period(), Duration::from_secs(30)); } -#[test] -fn test_http_server_config_defaults_image_hotlinking_to_false() { - let cfg: HttpServerConfig = serde_json::from_value(sample_http_server_config_value()).unwrap(); - - assert!(!cfg.allow_image_hotlinking); -} - #[test] fn test_http_server_config_defaults_max_blocking_concurrency_to_parallelism() { // Setup a server section that omits the blocking bound @@ -295,13 +285,28 @@ fn test_http_server_config_defaults_max_blocking_concurrency_to_parallelism() { } #[test] -fn test_http_server_config_ignores_removed_referer_setting() { - let mut value = sample_http_server_config_value(); - value["disable_referer_checks"] = serde_json::Value::Bool(true); +fn test_http_server_config_deserializes_image_hotlinking() { + // (case, extra setting, expected allow_image_hotlinking) + let cases = [ + ("omitted", None, false), + ("enabled", Some("allow_image_hotlinking"), true), + ( + "removed referer setting", + Some("disable_referer_checks"), + false, + ), + ]; - let cfg: HttpServerConfig = serde_json::from_value(value).unwrap(); + for (case, setting, expected) in cases { + let mut value = sample_http_server_config_value(); + if let Some(setting) = setting { + value[setting] = serde_json::Value::Bool(true); + } + + let cfg: HttpServerConfig = serde_json::from_value(value).expect(case); - assert!(!cfg.allow_image_hotlinking); + assert_eq!(cfg.allow_image_hotlinking, expected, "{case}"); + } } #[test] @@ -317,16 +322,6 @@ fn test_http_server_config_rejects_zero_max_blocking_concurrency() { ); } -#[test] -fn test_http_server_config_uses_image_hotlinking_setting() { - let mut value = sample_http_server_config_value(); - value["allow_image_hotlinking"] = serde_json::Value::Bool(true); - - let cfg: HttpServerConfig = serde_json::from_value(value).unwrap(); - - assert!(cfg.allow_image_hotlinking); -} - #[test] fn test_meetings_config_meeting_limits_by_provider_includes_zoom_limits() { let meetings_cfg = sample_config().meetings.unwrap(); @@ -367,117 +362,74 @@ fn test_payments_config_accepts_maximum_platform_fee_bps() { #[test] fn test_payments_config_defaults_platform_fee_bps_to_zero() { // Setup a Stripe configuration without a platform fee entry - let cfg: PaymentsConfig = serde_json::from_value(serde_json::json!({ - "provider": "stripe", - "connected_webhook_secret": "whsec_connect_secret", - "mode": "test", - "secret_key": "sk_test_secret", - "ticket_tax_api_version": "2026-07-29.preview", - "webhook_secret": "whsec_secret", - })) - .unwrap(); + let cfg: PaymentsConfig = serde_json::from_value(sample_stripe_config_value()).unwrap(); // Check the platform fee defaults to zero (no fee) assert_eq!(cfg.platform_fee_bps(), 0); } #[test] -fn test_payments_config_rejects_zero_http_client_deadline() { - // Setup a Stripe configuration with a zero request deadline - let Some(PaymentsConfig::Stripe(mut cfg)) = sample_config().payments else { - unreachable!(); - }; - cfg.http_client.request_timeout_secs = 0; - - // Check the Stripe section rejects the deadline - assert_eq!( - cfg.validate().unwrap_err().to_string(), - "payments.http_client.request_timeout_secs must be >= 1" - ); -} - -#[test] -fn test_payments_config_rejects_platform_fee_bps_above_maximum() { - // Setup a Stripe configuration with an out-of-range platform fee - let Some(PaymentsConfig::Stripe(mut cfg)) = sample_config().payments else { - unreachable!(); - }; - cfg.platform_fee_bps = MAX_PLATFORM_FEE_BPS + 1; - - // Validate the Stripe payments configuration - let result = cfg.validate(); - - // Check the out-of-range fee is rejected - assert_eq!( - result.unwrap_err().to_string(), - "payments.platform_fee_bps cannot exceed 9999" - ); -} - -#[test] -fn test_payments_config_rejects_blank_connected_webhook_secret() { - // Setup a Stripe configuration with a blank Connect webhook secret - let Some(PaymentsConfig::Stripe(mut cfg)) = sample_config().payments else { - unreachable!(); - }; - cfg.connected_webhook_secret = " ".to_string(); - - // Validate the Stripe payments configuration - let result = cfg.validate(); - - // Check startup rejects a secret that cannot verify Connect events - assert_eq!( - result.unwrap_err().to_string(), - "payments.connected_webhook_secret cannot be empty" - ); -} - -#[test] -fn test_payments_config_rejects_blank_ticket_tax_api_version() { - // Setup a Stripe configuration with a blank ticket-tax API version - let Some(PaymentsConfig::Stripe(mut cfg)) = sample_config().payments else { - unreachable!(); - }; - cfg.ticket_tax_api_version = " ".to_string(); - - // Validate the Stripe payments configuration - let result = cfg.validate(); - - // Check startup rejects a version that cannot be sent to Stripe - assert_eq!( - result.unwrap_err().to_string(), - "payments.ticket_tax_api_version cannot be empty" - ); -} +fn test_payments_config_rejects_invalid_stripe_settings() { + type Change = fn(&mut PaymentsStripeConfig); + + // (case, setting change, expected error) + let cases: [(&str, Change, &str); 6] = [ + ( + "zero http client deadline", + |cfg| cfg.http_client.request_timeout_secs = 0, + "payments.http_client.request_timeout_secs must be >= 1", + ), + ( + "platform fee above maximum", + |cfg| cfg.platform_fee_bps = MAX_PLATFORM_FEE_BPS + 1, + "payments.platform_fee_bps cannot exceed 9999", + ), + ( + "blank connected webhook secret", + |cfg| cfg.connected_webhook_secret = " ".to_string(), + "payments.connected_webhook_secret cannot be empty", + ), + ( + "blank secret key", + |cfg| cfg.secret_key = " ".to_string(), + "payments.secret_key cannot be empty", + ), + ( + "blank ticket tax API version", + |cfg| cfg.ticket_tax_api_version = " ".to_string(), + "payments.ticket_tax_api_version cannot be empty", + ), + ( + "blank webhook secret", + |cfg| cfg.webhook_secret = " ".to_string(), + "payments.webhook_secret cannot be empty", + ), + ]; -#[test] -fn test_payments_config_rejects_missing_connected_webhook_secret() { - // Setup serialized Stripe configuration without a Connect webhook secret - let result = serde_json::from_value::(serde_json::json!({ - "provider": "stripe", - "mode": "test", - "secret_key": "sk_test_secret", - "ticket_tax_api_version": "2026-07-29.preview", - "webhook_secret": "whsec_secret", - })); + for (case, change, expected) in cases { + let Some(PaymentsConfig::Stripe(mut cfg)) = sample_config().payments else { + unreachable!(); + }; + change(&mut cfg); - // Check deserialization rejects an unverifiable Connect configuration - assert!(result.is_err()); + assert_eq!(cfg.validate().unwrap_err().to_string(), expected, "{case}"); + } } #[test] -fn test_payments_config_rejects_missing_ticket_tax_api_version() { - // Setup serialized Stripe configuration without the ticket-tax API version - let result = serde_json::from_value::(serde_json::json!({ - "provider": "stripe", - "connected_webhook_secret": "whsec_connect_secret", - "mode": "test", - "secret_key": "sk_test_secret", - "webhook_secret": "whsec_secret", - })); - - // Check deserialization rejects an incomplete tax and credit-note configuration - assert!(result.is_err()); +fn test_payments_config_requires_stripe_fields() { + for field in ["connected_webhook_secret", "ticket_tax_api_version"] { + // Setup serialized Stripe configuration without the field + let mut value = sample_stripe_config_value(); + value.as_object_mut().unwrap().remove(field); + + // Check deserialization names the missing field + let error = serde_json::from_value::(value).unwrap_err(); + assert!( + error.to_string().contains(&format!("missing field `{field}`")), + "{field}: {error}" + ); + } } #[test] @@ -509,6 +461,65 @@ fn test_smtp_config_rejects_zero_send_timeout() { ); } +// Shared configuration samples. + +/// Sample badge signing configuration with a freshly generated key. +pub(crate) fn sample_badges_cfg() -> BadgesConfig { + BadgesConfig { + signing_key: BadgeSigningKeyConfig { + key_id: "test-key".to_string(), + private_jwk: JWK::generate_ed25519().expect("test signing key generation to succeed"), + }, + verification_keys: vec![], + } +} + +/// Sample Stripe payments configuration. +pub(crate) fn sample_payments_cfg() -> PaymentsConfig { + PaymentsConfig::Stripe(PaymentsStripeConfig { + connected_webhook_secret: "whsec_connect_test".to_string(), + mode: PaymentMode::Test, + secret_key: "sk_test".to_string(), + ticket_tax_api_version: "2026-07-29.preview".to_string(), + webhook_secret: "whsec_test".to_string(), + + http_client: HttpClientConfig::default(), + platform_fee_bps: 0, + }) +} + +/// Sample HTTP server configuration with the given public base URL. +pub(crate) fn sample_server_cfg(base_url: &str) -> HttpServerConfig { + HttpServerConfig { + base_url: base_url.to_string(), + ..Default::default() + } +} + +/// Sample enabled Zoom configuration with the given webhook secret token. +pub(crate) fn sample_zoom_cfg(webhook_secret_token: &str) -> MeetingsZoomConfig { + MeetingsZoomConfig { + account_id: "account-id".to_string(), + client_id: "client-id".to_string(), + client_secret: "client-secret".to_string(), + enabled: true, + host_pool_users: vec!["host@example.com".to_string()], + max_participants: 100, + max_simultaneous_meetings_per_host: 1, + webhook_secret_token: webhook_secret_token.to_string(), + + http_client: HttpClientConfig::default(), + } +} + +/// Sample meetings configuration with Zoom enabled and the given webhook +/// secret token. +pub(crate) fn sample_zoom_meetings_cfg(webhook_secret_token: &str) -> MeetingsConfig { + MeetingsConfig { + zoom: Some(sample_zoom_cfg(webhook_secret_token)), + } +} + // Helpers. fn sample_config() -> Config { @@ -645,6 +656,17 @@ fn sample_http_server_config_value() -> serde_json::Value { }) } +fn sample_stripe_config_value() -> serde_json::Value { + serde_json::json!({ + "provider": "stripe", + "connected_webhook_secret": "whsec_connect_secret", + "mode": "test", + "secret_key": "sk_test_secret", + "ticket_tax_api_version": "2026-07-29.preview", + "webhook_secret": "whsec_secret", + }) +} + fn sensitive_values() -> [&'static str; 11] { [ "db-password-sensitive-value", diff --git a/ocg-server/src/db.rs b/ocg-server/src/db.rs index 4563572c3..546af8d6c 100644 --- a/ocg-server/src/db.rs +++ b/ocg-server/src/db.rs @@ -7,7 +7,10 @@ use async_trait::async_trait; use cached::{Cached, async_sync::RwLock}; use deadpool_postgres::{Client, Pool}; use serde::de::DeserializeOwned; -use tokio_postgres::types::{FromSql, Json, ToSql}; +use tokio_postgres::{ + error::SqlState, + types::{FromSql, Json, ToSql}, +}; use crate::db::{ activity_tracker::DBActivityTracker, auth::DBAuth, badges::DBBadges, common::DBCommon, @@ -77,6 +80,20 @@ pub(crate) mod site; /// `P0001` of `raise exception`, is an internal failure. pub(crate) const USER_FACING_DB_ERROR_CODE: &str = "OCG01"; +/// Returns the message of a database error raised with the user-facing SQLSTATE. +/// +/// Every other error, including database errors with another SQLSTATE, yields +/// `None` and stays internal. +pub(crate) fn user_facing_message(err: &anyhow::Error) -> Option { + let db_err = err.downcast_ref::()?.as_db_error()?; + user_facing_db_message(db_err.code(), db_err.message()) +} + +/// Returns the message of a database error when its SQLSTATE is user-facing. +fn user_facing_db_message(code: &SqlState, message: &str) -> Option { + (code.code() == USER_FACING_DB_ERROR_CODE).then(|| message.to_string()) +} + /// Database operations supported by root and transaction-scoped handles. pub(crate) trait DBOperations: DBActivityTracker @@ -420,11 +437,15 @@ mod tests { use anyhow::{Result, anyhow}; use cached::cached; use tokio::{sync::Notify, time::timeout}; + use tokio_postgres::error::SqlState; - use crate::db::{DBExt, cache_probe, mock::MockDB}; + use crate::db::{ + DBExt, USER_FACING_DB_ERROR_CODE, cache_probe, mock::MockDB, user_facing_db_message, + user_facing_message, + }; #[tokio::test] - async fn cache_probe_returns_without_waiting_for_in_progress_fill() { + async fn test_cache_probe_returns_without_waiting_for_in_progress_fill() { // Setup a cached fill that holds its key lock until released #[cached( ttl = 60, @@ -462,8 +483,34 @@ mod tests { assert_eq!(cache_probe(&FILL.0, "key").await, Some("KEY".to_string())); } + #[test] + fn test_user_facing_db_message_keeps_only_user_facing_sqlstate() { + let message = "event has reached capacity"; + let cases = [ + ( + "OCG01 user-facing rejection", + SqlState::from_code(USER_FACING_DB_ERROR_CODE), + Some(message.to_string()), + ), + ("P0001 raise exception", SqlState::RAISE_EXCEPTION, None), + ("23505 unique violation", SqlState::UNIQUE_VIOLATION, None), + ]; + + for (case, code, expected) in cases { + assert_eq!(user_facing_db_message(&code, message), expected, "{case}"); + } + } + + #[test] + fn test_user_facing_message_ignores_non_database_errors() { + assert_eq!( + user_facing_message(&anyhow!("event has reached capacity")), + None + ); + } + #[tokio::test] - async fn transaction_commits_successful_work() { + async fn test_transaction_commits_successful_work() { // Setup transaction-scoped database mock let mut uow = MockDB::new(); uow.expect_list_timezones() @@ -487,7 +534,7 @@ mod tests { } #[tokio::test] - async fn transaction_rolls_back_failed_work() { + async fn test_transaction_rolls_back_failed_work() { // Setup transaction-scoped database mock let mut uow = MockDB::new(); uow.expect_list_timezones() diff --git a/ocg-server/src/db/contract_tests/auth.rs b/ocg-server/src/db/contract_tests/auth.rs index 27a68a9d1..6c7c662ba 100644 --- a/ocg-server/src/db/contract_tests/auth.rs +++ b/ocg-server/src/db/contract_tests/auth.rs @@ -20,8 +20,8 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_activate_pre_registered_user_email_password_deserializes() -> Result<()> { - // Setup the activation password profile and verification notification - let db = contract_tests_db()?; + // Setup a rolled-back unit of work, the password profile and verification + let uow = contract_unit_of_work().await?; let profile = ExternalUserProfile { email: "activation-password.contract@example.com".to_string(), name: "Contract Password Activation".to_string(), @@ -39,7 +39,7 @@ async fn db_contracts_activate_pre_registered_user_email_password_deserializes() }; // Activate the pre-registered user through the Rust contract - let (user, verification_code) = db + let (user, verification_code) = uow .activate_pre_registered_user_email_password(&profile, &verification) .await? .expect("contract password activation user should exist"); @@ -59,8 +59,8 @@ async fn db_contracts_activate_pre_registered_user_email_password_deserializes() #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_activate_pre_registered_user_external_provider_deserializes() -> Result<()> { - // Setup the activation identity and external profile - let db = contract_tests_db()?; + // Setup a rolled-back unit of work, the activation identity and external profile + let uow = contract_unit_of_work().await?; let profile = ExternalUserProfile { email: "activation.contract@example.com".to_string(), name: "Contract Activation".to_string(), @@ -74,7 +74,7 @@ async fn db_contracts_activate_pre_registered_user_external_provider_deserialize }; // Activate the pre-registered user through the Rust contract - let user = db + let user = uow .activate_pre_registered_user_external_provider(&activation_id(), &profile) .await?; @@ -243,8 +243,8 @@ async fn db_contracts_get_user_by_username_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_sign_up_user_deserializes() -> Result<()> { - // Setup a verified sign-up profile without a password - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and a verified sign-up profile without a password + let uow = contract_unit_of_work().await?; let profile = ExternalUserProfile { email: "sign-up.contract@example.com".to_string(), name: "Contract Sign Up".to_string(), @@ -256,7 +256,7 @@ async fn db_contracts_sign_up_user_deserializes() -> Result<()> { }; // Sign up the user through the Rust contract - let (user, verification_code) = db.sign_up_user(&profile, true, None).await?; + let (user, verification_code) = uow.sign_up_user(&profile, true, None).await?; // Check the signed up user fields and computed membership flags assert!(user.email_verified); @@ -275,8 +275,8 @@ async fn db_contracts_sign_up_user_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_update_user_external_auth_deserializes() -> Result<()> { - // Setup the contract database and external profile update - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the external profile update + let uow = contract_unit_of_work().await?; let profile = ExternalUserProfile { email: "external-update-new.contract@example.com".to_string(), name: "Contract External Update".to_string(), @@ -292,7 +292,7 @@ async fn db_contracts_update_user_external_auth_deserializes() -> Result<()> { }; // Update external authentication through the Rust contract - let user = db.update_user_external_auth(&external_update_id(), &profile).await?; + let user = uow.update_user_external_auth(&external_update_id(), &profile).await?; // Check account identity and verification fields assert_eq!(user.email, "external-update-new.contract@example.com"); diff --git a/ocg-server/src/db/contract_tests/badges.rs b/ocg-server/src/db/contract_tests/badges.rs index 99185b4e4..1a0e68941 100644 --- a/ocg-server/src/db/contract_tests/badges.rs +++ b/ocg-server/src/db/contract_tests/badges.rs @@ -9,15 +9,15 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_badge_award_worker_deserializes() -> Result<()> { - // Setup the contract database wrapper - let db = contract_tests_db()?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; // Claim and process the seeded pending award job - let claim = db + let claim = uow .claim_badge_award_job() .await? .context("contract badge award job should be claimable")?; - let outcome = db + let outcome = uow .process_badge_award_job_batch(claim.badge_award_job_id, claim.claim_id, 25, 500) .await?; diff --git a/ocg-server/src/db/contract_tests/common.rs b/ocg-server/src/db/contract_tests/common.rs index ce20e04b0..00ae21798 100644 --- a/ocg-server/src/db/contract_tests/common.rs +++ b/ocg-server/src/db/contract_tests/common.rs @@ -5,10 +5,9 @@ use chrono::{DateTime, NaiveDate}; use tokio_postgres::error::{DbError, SqlState}; use crate::{ - db::common::DBCommon, + db::{PgExecutor, common::DBCommon, user_facing_message}, types::{ event::EventKind, - payments::EventTicketType, search::{SearchEventsFilters, SearchGroupsFilters}, }, }; @@ -166,8 +165,11 @@ async fn db_contracts_get_event_summary_deserializes() -> Result<()> { // Setup the contract database and event fixture let db = contract_tests_db()?; - // Load the event summary through the Rust contract + // Load the event summaries through the Rust contract let event = db.get_event_summary(community_id(), group_id(), event_id()).await?; + let external_event = db + .get_event_summary(community_id(), group_id(), external_event_id()) + .await?; // Check required and computed event fields assert_eq!(event.event_id, event_id()); @@ -178,12 +180,6 @@ async fn db_contracts_get_event_summary_deserializes() -> Result<()> { ); assert!(!event.has_external_payment); assert!(event.has_registration_questions); - assert!( - event - .ticket_types - .as_ref() - .is_some_and(|ticket_types| !ticket_types.is_empty()) - ); assert_contract_ticket_type( &event.ticket_types.as_ref().expect("event should have tickets")[0], ); @@ -193,23 +189,9 @@ async fn db_contracts_get_event_summary_deserializes() -> Result<()> { // Check the venue zip code key assert_eq!(event.venue_zip_code.as_deref(), Some("94105")); - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_get_event_summary_external_payment_deserializes() -> Result<()> { - // Setup the contract database and external-payments event - let db = contract_tests_db()?; - - // Load the external-payments event summary through the Rust contract - let event = db - .get_event_summary(community_id(), group_id(), external_event_id()) - .await?; - - // Check the summary marks off-platform payment collection - assert_eq!(event.event_id, external_event_id()); - assert!(event.has_external_payment); + // Check the external-payments summary marks off-platform payment collection + assert_eq!(external_event.event_id, external_event_id()); + assert!(external_event.has_external_payment); Ok(()) } @@ -276,7 +258,10 @@ async fn db_contracts_get_group_summary_deserializes() -> Result<()> { // Check group identity and community fields assert_eq!(group.group_id, group_id()); assert_eq!(group.community_name, "contract-community"); - assert!(group.region.is_some()); + assert_eq!( + group.region.as_ref().map(|region| region.name.as_str()), + Some("North America") + ); Ok(()) } @@ -284,23 +269,45 @@ async fn db_contracts_get_group_summary_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_worker_queue_health_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the queue health before any change + let uow = contract_unit_of_work().await?; + let before = uow.get_worker_queue_health().await?; + + // Queue one old pending and one processing notification + uow.execute( + " + insert into notification (created_at, delivery_status, kind, user_id) + values + (current_timestamp - interval '100 years', 'pending', 'event-welcome', $1::uuid), + (current_timestamp, 'processing', 'event-welcome', $1::uuid) + ", + &[&organizer_id()], + ) + .await?; // Load the queue health through the Rust contract - let health = db.get_worker_queue_health().await?; - - // Check every queue decodes with an age only while something is pending - for queue in [ - &health.badge_award_jobs, - &health.notifications, - &health.payment_jobs, - ] { - assert!(queue.pending >= 0); - assert!(queue.processing >= 0); - assert_eq!(queue.pending > 0, queue.oldest_pending_age_secs.is_some()); - assert!(queue.oldest_pending_age_secs.is_none_or(|age| age >= 0)); - } + let after = uow.get_worker_queue_health().await?; + + // Check only the notification queue counts the new jobs and reports the + // oldest pending age in seconds + assert_eq!( + after.notifications.pending, + before.notifications.pending + 1 + ); + assert_eq!( + after.notifications.processing, + before.notifications.processing + 1 + ); + assert!( + after + .notifications + .oldest_pending_age_secs + .is_some_and(|age| age >= 100 * 365 * 24 * 60 * 60), + "unexpected oldest pending age: {:?}", + after.notifications.oldest_pending_age_secs + ); + assert_eq!(after.badge_award_jobs, before.badge_award_jobs); + assert_eq!(after.payment_jobs, before.payment_jobs); Ok(()) } @@ -322,6 +329,10 @@ async fn db_contracts_internal_raise_keeps_default_sqlstate() -> Result<()> { internal_err.as_db_error().map(DbError::message), Some("badge award job retention must be positive") ); + assert_eq!( + user_facing_message(&anyhow::Error::from(internal_err)), + None + ); Ok(()) } @@ -378,55 +389,6 @@ async fn db_contracts_list_group_organizers_deserializes() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_list_event_ticket_types_deserializes() -> Result<()> { - // Query the normalized ticket inventory through its SQL boundary - let pool = contract_tests_pool()?; - let client = pool.get().await?; - let row = client - .query_one("select list_event_ticket_types($1)", &[&paid_event_id()]) - .await?; - - // Deserialize the exact JSON result into the production DTO - let payload: serde_json::Value = row.try_get(0)?; - let ticket_types: Vec = serde_json::from_value(payload)?; - assert_eq!(ticket_types.len(), 2); - let paid_ticket_type = ticket_types - .iter() - .find(|ticket_type| ticket_type.event_ticket_type_id == paid_ticket_type_id()) - .expect("paid ticket type to be returned"); - assert_contract_paid_ticket_type(paid_ticket_type); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_list_public_event_ticket_types_deserializes() -> Result<()> { - // Query the attendee-facing ticket inventory through its SQL boundary - let pool = contract_tests_pool()?; - let client = pool.get().await?; - let row = client - .query_one( - "select list_public_event_ticket_types($1)", - &[&paid_event_id()], - ) - .await?; - - // Deserialize the exact JSON result into the production DTO - let payload: serde_json::Value = row.try_get(0)?; - let ticket_types: Vec = serde_json::from_value(payload)?; - assert_eq!(ticket_types.len(), 2); - let paid_ticket_type = ticket_types - .iter() - .find(|ticket_type| ticket_type.event_ticket_type_id == paid_ticket_type_id()) - .expect("public paid ticket type to be returned"); - assert_contract_paid_ticket_type(paid_ticket_type); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_search_events_deserializes() -> Result<()> { @@ -446,9 +408,11 @@ async fn db_contracts_search_events_deserializes() -> Result<()> { // Search events through the Rust contract let output = db.search_events(&filters).await?; - // Check result totals + // Check result totals and the returned event assert_eq!(output.total, 1); assert_eq!(output.events.len(), 1); + assert_eq!(output.events[0].event_id, event_id()); + assert_eq!(output.events[0].name, "Future Contract Event"); Ok(()) } @@ -470,9 +434,19 @@ async fn db_contracts_search_groups_deserializes() -> Result<()> { // Search groups through the Rust contract let output = db.search_groups(&filters).await?; - // Check result totals + // Check result totals and the returned groups in name order assert_eq!(output.total, 2); - assert_eq!(output.groups.len(), 2); + assert_eq!( + output + .groups + .iter() + .map(|group| (group.group_id, group.name.as_str())) + .collect::>(), + vec![ + (group_id(), "Contract Group"), + (subgroup_id(), "Contract Subgroup") + ] + ); Ok(()) } diff --git a/ocg-server/src/db/contract_tests/dashboard_community.rs b/ocg-server/src/db/contract_tests/dashboard_community.rs index 1753cbfcd..68056e4be 100644 --- a/ocg-server/src/db/contract_tests/dashboard_community.rs +++ b/ocg-server/src/db/contract_tests/dashboard_community.rs @@ -125,55 +125,6 @@ async fn db_contracts_get_community_contact_recipients_summary_lists_unmatched_g Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_get_community_contact_recipients_summary_rejects_foreign_filters() --> Result<()> { - // Setup the contract database and a category of another community - let db = contract_tests_db()?; - let filters = CommunityContactFilters { - group_category_ids: vec![group_category_id()], - ..Default::default() - }; - - // Summarize recipients with the foreign category - let err = db - .get_community_contact_recipients_summary(contact_community_id(), &filters) - .await - .expect_err("foreign category should be rejected"); - - // Check the user-facing rejection is raised - assert_eq!( - ocg01_message(&err).as_deref(), - Some("group category not found") - ); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_get_community_contact_recipients_summary_rejects_foreign_groups() -> Result<()> -{ - // Setup the contract database and a group of another community - let db = contract_tests_db()?; - let filters = CommunityContactFilters { - group_ids: vec![contact_group_a_id(), group_id()], - ..Default::default() - }; - - // Summarize recipients with the foreign group - let err = db - .get_community_contact_recipients_summary(contact_community_id(), &filters) - .await - .expect_err("foreign group should be rejected"); - - // Check the user-facing rejection is raised - assert_eq!(ocg01_message(&err).as_deref(), Some("group not found")); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_community_stats_deserializes() -> Result<()> { diff --git a/ocg-server/src/db/contract_tests/dashboard_group.rs b/ocg-server/src/db/contract_tests/dashboard_group.rs index 3edfa1270..9ea531bed 100644 --- a/ocg-server/src/db/contract_tests/dashboard_group.rs +++ b/ocg-server/src/db/contract_tests/dashboard_group.rs @@ -1,18 +1,18 @@ //! Contract tests for the `DBDashboardGroup` functions. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use anyhow::{Context, Result}; use chrono::{DateTime, Duration, NaiveDate, Utc}; use tokio_postgres::{ - error::{DbError, SqlState}, + error::SqlState, types::{Json, ToSql}, }; use uuid::Uuid; use crate::{ db::{ - DB, DBUnitOfWork, PgExecutor, PgUnitOfWork, USER_FACING_DB_ERROR_CODE, + DB, DBUnitOfWork, PgExecutor, PgUnitOfWork, common::DBCommon, dashboard::{ group::{ @@ -69,11 +69,11 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_accept_event_invitation_request_deserializes() -> Result<()> { - // Setup the contract database and pending RSVP request fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the pending RSVP request fixture + let uow = contract_unit_of_work().await?; // Accept the pending request through the Rust JSON contract - let result = db + let result = uow .accept_event_invitation_request( organizer_id(), subgroup_id(), @@ -106,7 +106,7 @@ async fn db_contracts_add_event_stores_submitted_organizers() -> Result<()> { let event = EventInput { organizers: Some(vec![organizer_id()]), organizers_present: Some(true), - ..organizers_event_input("Contract Organizers Event") + ..contract_event_input("Contract Organizers Event", 1) }; // Add the event through the Rust contract @@ -128,7 +128,7 @@ async fn db_contracts_add_event_stores_submitted_organizers() -> Result<()> { let event = EventInput { organizers: Some(vec![attendee_id()]), organizers_present: Some(true), - ..organizers_event_input("Contract Outside Organizer Event") + ..contract_event_input("Contract Outside Organizer Event", 1) }; let err = uow .add_event( @@ -140,7 +140,7 @@ async fn db_contracts_add_event_stores_submitted_organizers() -> Result<()> { ) .await .expect_err("organizers outside the group team should be rejected"); - assert_user_facing_error(&err, "organizer is not a group team member"); + assert_ocg01(&err, "organizer is not a group team member"); // Roll back the created event Box::new(uow).rollback().await?; @@ -179,7 +179,7 @@ async fn db_contracts_approve_event_cohost_deserializes() -> Result<()> { ) .await .expect_err("replayed approval should be rejected"); - assert_user_facing_error(&err, "co-hosting invitation is no longer pending"); + assert_ocg01(&err, "co-hosting invitation is no longer pending"); Ok(()) } @@ -192,10 +192,8 @@ async fn db_contracts_approve_event_cohost_rejects_concurrently_reinvited_invita let editor_uow = contract_unit_of_work().await?; let approval_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let editor_backend_pid: i32 = - editor_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let approval_backend_pid: i32 = - approval_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let editor_backend_pid = uow_backend_pid(&editor_uow).await?; + let approval_backend_pid = uow_backend_pid(&approval_uow).await?; // Remove and re-invite the pending co-host while retaining the event lock let removal = editor_uow @@ -239,7 +237,7 @@ async fn db_contracts_approve_event_cohost_rejects_concurrently_reinvited_invita .expect_err("the rotated invitation should not be approved"); // Check the approval rechecked the invitation under the event lock - assert_user_facing_error(&err, "co-hosting invitation not found"); + assert_ocg01(&err, "co-hosting invitation not found"); let cohost = probe_client .query_one( "select approved_at, event_cohost_status_id, invitation_id from event_cohost where event_id = $1::uuid and group_id = $2::uuid", @@ -256,11 +254,11 @@ async fn db_contracts_approve_event_cohost_rejects_concurrently_reinvited_invita #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_award_badge_deserializes() -> Result<()> { - // Setup the contract database and badge recipient - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the badge recipient + let uow = contract_unit_of_work().await?; // Queue the badge award through the Rust contract - let outcome = db + let outcome = uow .award_badge( organizer_id(), community_id(), @@ -461,11 +459,11 @@ async fn db_contracts_badge_json_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_cancel_event_admission_offer_deserializes() -> Result<()> { - // Setup the contract database and dedicated RSVP offer fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the dedicated RSVP offer fixture + let uow = contract_unit_of_work().await?; // Cancel the offer through the Rust JSON contract - let outcome = db + let outcome = uow .cancel_event_admission_offer(organizer_id(), group_id(), mutation_offer_id(), None) .await?; @@ -480,11 +478,11 @@ async fn db_contracts_cancel_event_admission_offer_deserializes() -> Result<()> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_cancel_event_attendee_attendance_deserializes() -> Result<()> { - // Setup the contract database and attendance fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the attendance fixture + let uow = contract_unit_of_work().await?; // Cancel the attendee through the Rust contract - let outcome = db + let outcome = uow .cancel_event_attendee_attendance( organizer_id(), group_id(), @@ -507,11 +505,11 @@ async fn db_contracts_cancel_event_attendee_attendance_deserializes() -> Result< #[ignore = "requires the contract test database"] async fn db_contracts_cancel_event_attendee_attendance_queues_paid_refund_deserializes() -> Result<()> { - // Setup the contract database and paid attendance fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the paid attendance fixture + let uow = contract_unit_of_work().await?; // Queue cancellation through the real paid-attendance database contract - let outcome = db + let outcome = uow .cancel_event_attendee_attendance( organizer_id(), group_id(), @@ -526,7 +524,7 @@ async fn db_contracts_cancel_event_attendee_attendance_queues_paid_refund_deseri outcome.cancellation_status, EventAttendeeCancellationStatus::RefundQueued ); - let refund = db.get_event_purchase_refund(paid_cancellation_purchase_id()).await?; + let refund = uow.get_event_purchase_refund(paid_cancellation_purchase_id()).await?; assert_eq!(refund.event_purchase_id, paid_cancellation_purchase_id()); assert_eq!(refund.kind, EventPurchaseRefundKind::AttendanceCancellation); assert_eq!(refund.status, EventPurchaseRefundStatus::ProviderPending); @@ -562,7 +560,7 @@ async fn db_contracts_cancel_event_cohost_requires_approval() -> Result<()> { ) .await .expect_err("pending co-hosting should not be withdrawn"); - assert_user_facing_error(&err, "co-hosting is not approved"); + assert_ocg01(&err, "co-hosting is not approved"); Ok(()) } @@ -604,24 +602,17 @@ async fn db_contracts_cfs_session_link_serializes_submission_rejection() -> Resu "select update_cfs_submission($1::uuid, $2::uuid, $3::uuid, $4::uuid, '{\"label_ids\": [], \"status_id\": \"rejected\"}'::jsonb)", &[&organizer_id(), &group_id(), &event_id(), &cfs_submission_id()], ) - .await - .expect_err("linked submissions should remain approved"); - assert_eq!( - linked_err.as_db_error().map(DbError::message), - Some("linked submissions must remain approved") - ); - assert_eq!( - linked_err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + .await; - // Restore the shared submission fixture for later contract tests + // Restore the shared submission fixture before any check can fail review_client .execute( "delete from session where session_id = $1::uuid", &[&cfs_lock_session_id()], ) .await?; + let linked_err = linked_err.expect_err("linked submissions should remain approved"); + assert_pg_ocg01(linked_err, "linked submissions must remain approved"); Ok(()) } @@ -717,53 +708,7 @@ async fn db_contracts_check_in_attendee_by_code_rejects_concurrently_revoked_cod .await?; let scan_err = scan_result.expect_err("the revoked credential should not pass the attendee lock"); - assert_eq!( - scan_err.as_db_error().map(DbError::message), - Some("check-in credential not found") - ); - assert_eq!( - scan_err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_check_in_event_rejects_event_outside_group() -> Result<()> { - // Setup the contract database and a user without attendance, so neither - // call can mutate state - let db = contract_tests_db()?; - let non_attendee_user_id = Uuid::new_v4(); - - // Check the event passes the scope gate through its own group - let owner_err = db - .check_in_event( - organizer_id(), - community_id(), - event_id(), - group_id(), - non_attendee_user_id, - ) - .await - .expect_err("users without attendance should be rejected"); - assert_user_facing_error(&owner_err, "attendance is not confirmed"); - - // Check in through another group of the same community - let err = db - .check_in_event( - organizer_id(), - community_id(), - event_id(), - subgroup_id(), - non_attendee_user_id, - ) - .await - .expect_err("events outside the selected group should be rejected"); - - // Check the rejection is user-facing - assert_user_facing_error(&err, "event unavailable for check-in"); + assert_pg_ocg01(scan_err, "check-in credential not found"); Ok(()) } @@ -876,12 +821,7 @@ async fn db_contracts_event_ticketing_configuration_changed_deserializes() -> Re async fn db_contracts_event_ticketing_configuration_changed_ignores_dated_editor_roundtrip() -> Result<()> { // Setup a unit of work under a non-UTC session time zone - let pool = contract_tests_pool()?; - let client = pool.get().await?; - client.batch_execute("begin").await?; - let uow = PgUnitOfWork { - client: Some(client), - }; + let uow = contract_unit_of_work().await?; uow.execute("set local time zone 'Europe/Madrid'", &[]).await?; // Date the paid price window and add discount codes with live inventory @@ -980,38 +920,6 @@ async fn db_contracts_event_ticketing_configuration_changed_ignores_dated_editor Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_event_ticketing_configuration_changed_rejects_foreign_event() -> Result<()> { - // Setup a direct connection to observe the SQLSTATE of the scope check - let pool = contract_tests_pool()?; - let client = pool.get().await?; - - // An event outside the group scope is rejected with the user-facing SQLSTATE - let scope_err = client - .query_one( - "select event_ticketing_configuration_changed($1::uuid, $2::uuid, $3::uuid, $4::jsonb)", - &[ - &community_id(), - &subgroup_id(), - &paid_event_id(), - &Json(serde_json::json!({ "kind_id": "in-person" })), - ], - ) - .await - .expect_err("events outside the group scope should be rejected"); - assert_eq!( - scope_err.as_db_error().map(DbError::message), - Some("event not found or inactive") - ); - assert_eq!( - scope_err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_cfs_submission_notification_data_deserializes() -> Result<()> { @@ -1286,12 +1194,6 @@ async fn db_contracts_get_event_summary_dashboard_deserializes() -> Result<()> { // Check shared summary fields assert_eq!(event.event_id, event_id()); assert!(event.has_registration_questions); - assert!( - event - .ticket_types - .as_ref() - .is_some_and(|ticket_types| !ticket_types.is_empty()) - ); assert_eq!(event.kind, EventKind::Hybrid); // Check dashboard-only fields @@ -1356,47 +1258,48 @@ async fn db_contracts_get_group_external_payments_eligibility_deserializes() -> // Setup the contract database and group fixture (stored country US, allowlisted) let db = contract_tests_db()?; - // Check an omitted country resolves to the stored allowlisted country - assert!( - db.get_group_external_payments_eligibility(community_id(), group_id(), None) - .await? - ); - - // Check a submitted non-allowlisted country overrides the stored one - assert!( - !db.get_group_external_payments_eligibility( - community_id(), + // Check how the submitted country is encoded and matched for each case + for (case, target_group_id, country_code, expected) in [ + ( + "an omitted country resolves to the stored one", group_id(), - Some("FR".to_string()) - ) - .await? - ); - - // Check a submitted lowercase country is normalized before matching - assert!( - db.get_group_external_payments_eligibility( - community_id(), + None, + true, + ), + ( + "a non-allowlisted country overrides the stored one", group_id(), - Some("us".to_string()) - ) - .await? - ); - - // Check an explicit empty country clears the stored one - assert!( - !db.get_group_external_payments_eligibility( - community_id(), + Some("FR"), + false, + ), + ( + "a lowercase country is normalized before matching", group_id(), - Some(String::new()) - ) - .await? - ); - - // Check a missing group is reported as not allowlisted instead of null - assert!( - !db.get_group_external_payments_eligibility(community_id(), Uuid::new_v4(), None) - .await? - ); + Some("us"), + true, + ), + ( + "an explicit empty country clears the stored one", + group_id(), + Some(""), + false, + ), + ( + "a missing group is not allowlisted instead of null", + Uuid::new_v4(), + None, + false, + ), + ] { + let eligible = db + .get_group_external_payments_eligibility( + community_id(), + target_group_id, + country_code.map(str::to_string), + ) + .await?; + assert_eq!(eligible, expected, "{case}"); + } Ok(()) } @@ -1502,11 +1405,11 @@ async fn db_contracts_group_has_event_checks_ownership() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_invite_event_attendee_deserializes() -> Result<()> { - // Setup the contract database and dedicated invitee fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the dedicated invitee fixture + let uow = contract_unit_of_work().await?; // Invite the registered user through the Rust JSON contract - let result = db + let result = uow .invite_event_attendee( organizer_id(), subgroup_id(), @@ -1537,11 +1440,11 @@ async fn db_contracts_invite_event_attendee_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_invite_event_attendee_queue_offer_deserializes() -> Result<()> { - // Setup the contract database and queued invitee fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the queued invitee fixture + let uow = contract_unit_of_work().await?; // Invite the queue head so reconciliation allocates the released seat - let result = db + let result = uow .invite_event_attendee( organizer_id(), subgroup_id(), @@ -1600,10 +1503,7 @@ async fn db_contracts_list_cohost_group_options_deserializes() -> Result<()> { .find(|option| option.group_id == subgroup_id()) .expect("subgroup to be offered as a co-host"); assert_eq!(subgroup.community_name, "contract-community"); - assert!( - !subgroup.logo_url.is_empty(), - "subgroup logo url should be set" - ); + assert_eq!(subgroup.logo_url, "https://example.com/community-logo.png"); // Check inactive communities offer no options let inactive_options = db @@ -1693,12 +1593,17 @@ async fn db_contracts_list_event_cohosts_deserializes() -> Result<()> { .cohosts .iter() .map(|cohost| cohost.group_id) - .collect::>(); - assert_eq!(group_ids.len(), 4); - assert!(group_ids.contains(&cohost_approved_group_id())); - assert!(group_ids.contains(&cohost_cross_community_group_id())); - assert!(group_ids.contains(&cohost_pending_group_id())); - assert!(group_ids.contains(&cohost_reinvited_group_id())); + .collect::>(); + assert_eq!(editor.cohosts.len(), 4); + assert_eq!( + group_ids, + HashSet::from([ + cohost_approved_group_id(), + cohost_cross_community_group_id(), + cohost_pending_group_id(), + cohost_reinvited_group_id(), + ]) + ); // Check the pending invitation fields let pending = editor @@ -1862,24 +1767,33 @@ async fn db_contracts_list_group_cohosted_events_deserializes() -> Result<()> { assert!(event.canceled); assert_eq!(event.event_id, cohost_canceled_event_id()); assert_eq!(event.event_kind, EventKind::Virtual); - assert!( - !event.event_logo_url.is_empty(), - "event logo url should be set" + assert_eq!( + event.event_logo_url, + "https://example.com/community-logo.png" ); assert_eq!(event.event_name, "Contract Co-host Canceled Event"); assert_eq!(event.event_slug, "contract-cohost-canceled-event"); assert_eq!(event.owner_community_display_name, "Contract Community"); assert_eq!(event.owner_community_name, "contract-community"); - assert!( - !event.owner_group_logo_url.is_empty(), - "owner group logo url should be set" + assert_eq!( + event.owner_group_logo_url, + "https://example.com/community-logo.png" ); assert!(!event.published); assert_eq!(event.status, EventCohostStatus::EventCanceled); assert_eq!(event.timezone.to_string(), "UTC"); - assert!(event.ends_at.is_some()); - assert!(event.responded_at.is_some()); - assert!(event.starts_at.is_some()); + assert_eq!( + event.ends_at, + Some(DateTime::parse_from_rfc3339("2099-10-02T12:00:00Z")?.with_timezone(&Utc)) + ); + assert_eq!( + event.responded_at, + Some(DateTime::parse_from_rfc3339("2024-02-03T10:00:00Z")?.with_timezone(&Utc)) + ); + assert_eq!( + event.starts_at, + Some(DateTime::parse_from_rfc3339("2099-10-02T10:00:00Z")?.with_timezone(&Utc)) + ); // Check deleted events are not listed let deleted_output = db @@ -1961,12 +1875,19 @@ async fn db_contracts_list_group_refunds_deserializes() -> Result<()> { // Load the filtered group refunds through the Rust contract let output = db.list_group_refunds(group_id(), &filters).await?; - // Check event options and refund pagination - assert_eq!(output.events.len(), 3); + // Check event options include every seeded refund event; the durable + // enrollment manager contracts may add the mutation event + let event_options = output + .events + .iter() + .map(|event| (event.event_id, event.name.as_str())) + .collect::>(); assert!( - output.events.iter().any(|event| { - event.event_id == paid_event_id() && event.name == "Contract Paid Event" - }) + event_options.is_superset(&HashSet::from([ + (external_event_id(), "Contract External Event"), + (paid_event_id(), "Contract Paid Event"), + ])), + "unexpected refund event options: {event_options:?}" ); assert_eq!(output.refunds.len(), 1); assert_eq!(output.total, 2); @@ -2161,13 +2082,13 @@ async fn db_contracts_list_user_groups_deserializes() -> Result<()> { .iter() .all(|group| group.community_name == "contract-community") ); - assert!(output[0].groups.iter().any(|group| group.group_id == group_id())); - assert!(output[0].groups.iter().any(|group| group.group_id == subgroup_id())); - assert!( + assert_eq!( output[0] .groups .iter() - .any(|group| group.group_id == claim_group_id()) + .map(|group| group.group_id) + .collect::>(), + HashSet::from([claim_group_id(), group_id(), subgroup_id()]) ); Ok(()) @@ -2181,11 +2102,8 @@ async fn db_contracts_lock_event_cohost_groups_locks_in_group_id_order() -> Resu let blocker_client = pool.get().await?; let probe_client = pool.get().await?; let lock_uow = contract_unit_of_work().await?; - let blocker_backend_pid = blocker_client - .query_one("select pg_backend_pid()", &[]) - .await? - .get::<_, i32>(0); - let lock_backend_pid: i32 = lock_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let blocker_backend_pid = backend_pid(&blocker_client).await?; + let lock_backend_pid = uow_backend_pid(&lock_uow).await?; let high_group_id = cohost_lock_order_high_group_id(); let low_group_id = cohost_lock_order_low_group_id(); @@ -2208,24 +2126,23 @@ async fn db_contracts_lock_event_cohost_groups_locks_in_group_id_order() -> Resu wait_for_backend_blocker(&probe_client, blocker_backend_pid, lock_backend_pid).await?; // Probe whether the helper already holds the lower group - probe_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let probe_result = probe_client - .query_one( - r#"select group_id from "group" where group_id = $1::uuid for update"#, - &[&low_group_id], - ) - .await; + let probe_code = probe_lock( + &probe_client, + r#"select group_id from "group" where group_id = $1::uuid for update"#, + &[&low_group_id], + ) + .await?; - // Release the probe and the blocker so the helper can finish - probe_client.batch_execute("rollback").await?; + // Release the blocker so the helper can finish blocker_client.batch_execute("rollback").await?; lock_task.await??; // Check the lower group is locked first, so crossed invitations cannot deadlock - let probe_err = probe_result.expect_err("the lower group should already be locked"); - assert_eq!(probe_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "the lower group should already be locked" + ); Ok(()) } @@ -2239,7 +2156,7 @@ async fn db_contracts_lock_group_events_serializes_update() -> Result<()> { let racing_client = racing_pool.get().await?; let event_id = group_lock_first_event_id(); let event_payload = - Json(group_lock_event_update("Contract Group Lock Event One Updated", 3).to_db_payload()?); + Json(contract_event_input("Contract Group Lock Event One Updated", 3).to_db_payload()?); racing_client.batch_execute("set lock_timeout = '250ms'").await?; // Lock the group and event through the Rust database contract @@ -2264,7 +2181,9 @@ async fn db_contracts_lock_group_events_serializes_update() -> Result<()> { // Release the helper locks before retrying the update uow.commit().await?; - // Check the serialized update succeeds after the locks are released + // Check the serialized update succeeds after the locks are released, then + // roll it back so the fixture event keeps its seeded state + racing_client.batch_execute("begin").await?; let updated = racing_client .query_one( "select update_event($1::uuid, $2::uuid, $3::uuid, $4::jsonb)", @@ -2276,6 +2195,7 @@ async fn db_contracts_lock_group_events_serializes_update() -> Result<()> { ], ) .await?; + racing_client.batch_execute("rollback").await?; assert!(!updated.get::<_, bool>(0)); Ok(()) @@ -2289,12 +2209,8 @@ async fn db_contracts_publish_event_reads_group_payment_state_after_group_lock() let blocker_client = pool.get().await?; let probe_client = pool.get().await?; let publish_uow = contract_unit_of_work().await?; - let blocker_backend_pid = blocker_client - .query_one("select pg_backend_pid()", &[]) - .await? - .get::<_, i32>(0); - let publish_backend_pid: i32 = - publish_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let blocker_backend_pid = backend_pid(&blocker_client).await?; + let publish_backend_pid = uow_backend_pid(&publish_uow).await?; // Clear the seller name while holding the group row lock blocker_client.batch_execute("begin").await?; @@ -2326,7 +2242,7 @@ async fn db_contracts_publish_event_reads_group_payment_state_after_group_lock() .expect_err("the committed missing seller name should block publication"); // Check the readiness helpers read the group state after the group lock - assert_user_facing_error( + assert_ocg01( &err, "external payments require the legal name of the organization collecting payments", ); @@ -2349,10 +2265,8 @@ async fn db_contracts_publish_event_rechecks_cohosts_after_concurrent_invitation let editor_uow = contract_unit_of_work().await?; let publish_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let editor_backend_pid: i32 = - editor_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let publish_backend_pid: i32 = - publish_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let editor_backend_pid = uow_backend_pid(&editor_uow).await?; + let publish_backend_pid = uow_backend_pid(&publish_uow).await?; // Invite a co-host while retaining the event lock let sync = editor_uow @@ -2387,7 +2301,7 @@ async fn db_contracts_publish_event_rechecks_cohosts_after_concurrent_invitation .expect_err("the committed pending co-host should block publication"); // Check the publish gate rechecked the co-hosts under the event lock - assert_user_facing_error( + assert_ocg01( &err, "co-hosts must respond before the event can be published", ); @@ -2403,31 +2317,6 @@ async fn db_contracts_publish_event_rechecks_cohosts_after_concurrent_invitation Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_publish_event_requires_cohost_responses() -> Result<()> { - // Setup a rolled-back unit of work for the publish attempt - let uow = contract_unit_of_work().await?; - - // Check the pending co-hosts block publication - let err = uow - .publish_event( - organizer_id(), - subgroup_id(), - cohost_matrix_event_id(), - None, - None, - ) - .await - .expect_err("pending co-hosts should block publication"); - assert_user_facing_error( - &err, - "co-hosts must respond before the event can be published", - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_reject_event_cohost_deserializes() -> Result<()> { @@ -2456,14 +2345,10 @@ async fn db_contracts_search_event_attendees_deserializes() -> Result<()> { // Setup the contract database and attendee filters let db = contract_tests_db()?; let filters = AttendeesFilters { - checked_in: None, - event_ticket_type_ids: None, limit: Some(10), offset: Some(0), - sort: None, - status: None, - title: None, - ts_query: None, + + ..Default::default() }; // Search event attendees through the Rust contract @@ -2543,14 +2428,11 @@ async fn db_contracts_search_event_attendees_external_payment_deserializes() -> // Setup the contract database and unfiltered external-event search let db = contract_tests_db()?; let filters = AttendeesFilters { - checked_in: None, - event_ticket_type_ids: None, limit: Some(10), offset: Some(0), - sort: None, status: Some(AttendeeEnrollmentStatusFilter::All), - title: None, - ts_query: None, + + ..Default::default() }; // Search external-payment attendees through the Rust contract @@ -2559,7 +2441,7 @@ async fn db_contracts_search_event_attendees_external_payment_deserializes() -> .await?; // Check pending and completed external purchase encodings - assert!(output.total >= 2); + assert_eq!(output.total, 4); let pending = output .attendees .iter() @@ -2636,14 +2518,11 @@ async fn db_contracts_search_event_attendees_terminal_offer_statuses_deserialize // Setup the contract database and unfiltered status event search let db = contract_tests_db()?; let filters = AttendeesFilters { - checked_in: None, - event_ticket_type_ids: None, limit: Some(10), offset: Some(0), - sort: None, status: Some(AttendeeEnrollmentStatusFilter::All), - title: None, - ts_query: None, + + ..Default::default() }; // Search terminal organizer offers through the Rust contract @@ -2688,12 +2567,12 @@ async fn db_contracts_search_event_invitation_requests_deserializes() -> Result< // Setup the contract database and invitation request filters let db = contract_tests_db()?; let filters = InvitationRequestsFilters { + status: InvitationRequestsStatusFilter::All, + limit: Some(10), offset: Some(0), - sort: None, - status: InvitationRequestsStatusFilter::All, - title: None, - ts_query: None, + + ..Default::default() }; // Search invitation requests through the Rust contract @@ -2742,13 +2621,10 @@ async fn db_contracts_search_event_invitation_requests_deserializes() -> Result< .as_ref() .expect("contract invitation request should include registration answers"); assert_eq!(answers.answers.len(), 1); - assert_eq!( - answers.answers[0].question_id, - parse_uuid("00000000-0000-0000-0000-00000000c071") - ); + assert_eq!(answers.answers[0].question_id, registration_question_id()); match &answers.answers[0].value { QuestionnaireAnswerValue::One(value) => { - assert_eq!(value, "00000000-0000-0000-0000-00000000c072"); + assert_eq!(*value, registration_question_option_id().to_string()); } QuestionnaireAnswerValue::Many(_) => panic!("expected single-select answer"), } @@ -2764,9 +2640,8 @@ async fn db_contracts_search_event_waitlist_deserializes() -> Result<()> { let filters = WaitlistFilters { limit: Some(10), offset: Some(0), - sort: None, - title: None, - ts_query: None, + + ..Default::default() }; // Search the event waitlist through the Rust contract @@ -2825,11 +2700,16 @@ async fn db_contracts_sync_event_cohosts_deserializes() -> Result<()> { .removed .iter() .map(|reference| reference.cohost_group_id) - .collect::>(); - assert_eq!(removed_group_ids.len(), 3); - assert!(removed_group_ids.contains(&cohost_cross_community_group_id())); - assert!(removed_group_ids.contains(&cohost_pending_group_id())); - assert!(removed_group_ids.contains(&cohost_reinvited_group_id())); + .collect::>(); + assert_eq!(sync.removed.len(), 3); + assert_eq!( + removed_group_ids, + HashSet::from([ + cohost_cross_community_group_id(), + cohost_pending_group_id(), + cohost_reinvited_group_id(), + ]) + ); assert!( sync.removed .iter() @@ -2846,7 +2726,7 @@ async fn db_contracts_sync_event_cohosts_deserializes() -> Result<()> { ) .await .expect_err("removed invitation should not be approved"); - assert_user_facing_error(&err, "co-hosting invitation is no longer pending"); + assert_ocg01(&err, "co-hosting invitation is no longer pending"); uow.execute("rollback to savepoint removed_invitation", &[]).await?; // Check a second editor on the old revision is rejected @@ -2860,7 +2740,7 @@ async fn db_contracts_sync_event_cohosts_deserializes() -> Result<()> { ) .await .expect_err("stale co-hosts revision should be rejected"); - assert_user_facing_error( + assert_ocg01( &err, "co-hosts changed since this page was loaded; reload to continue", ); @@ -2868,69 +2748,18 @@ async fn db_contracts_sync_event_cohosts_deserializes() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_sync_event_cohosts_rejects_published_changes() -> Result<()> { - // Setup a rolled-back unit of work with the matrix event published - let uow = contract_unit_of_work().await?; - uow.execute( - "update event set published = true where event_id = $1::uuid", - &[&cohost_matrix_event_id()], - ) - .await?; - - // Check changing the co-hosts of a published event is rejected - let err = uow - .sync_event_cohosts( - organizer_id(), - subgroup_id(), - cohost_matrix_event_id(), - &[cohost_approved_group_id()], - 9, - ) - .await - .expect_err("published co-hosts should be locked"); - assert_user_facing_error( - &err, - "co-hosts cannot be changed while the event is published", - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_update_event_deserializes() -> Result<()> { - // Setup the contract database and event schedule - let db = contract_tests_db()?; - let starts_at = NaiveDate::from_ymd_opt(2099, 8, 1) - .expect("date should be valid") - .and_hms_opt(10, 0, 0) - .expect("time should be valid"); - let ends_at = NaiveDate::from_ymd_opt(2099, 8, 1) - .expect("date should be valid") - .and_hms_opt(11, 0, 0) - .expect("time should be valid"); - let event = EventInput { - category_id: event_category_id(), - description: "A mutation event updated by Rust database contract tests".to_string(), - kind_id: "virtual".to_string(), - name: "Contract Mutation Event".to_string(), - timezone: "UTC".to_string(), - - capacity: Some(100), - ends_at: Some(ends_at), - starts_at: Some(starts_at), - test_event: Some(true), - - ..Default::default() - }; + // Setup a rolled-back unit of work and the mutation event update + let uow = contract_unit_of_work().await?; + let event = contract_event_input("Contract Mutation Event", 1); // Serialize the event update for the database contract let payload = event.to_db_payload()?; // Update the event through the Rust contract - let requires_paid_notification = db + let requires_paid_notification = uow .update_event( organizer_id(), group_id(), @@ -2952,7 +2781,7 @@ async fn db_contracts_update_event_deserializes() -> Result<()> { async fn db_contracts_update_event_syncs_submitted_organizers() -> Result<()> { // Setup a rolled-back unit of work and the mutation event update let uow = contract_unit_of_work().await?; - let base_event = organizers_event_input("Contract Mutation Event"); + let base_event = contract_event_input("Contract Mutation Event", 1); // Submit the organizers with their marker let event = EventInput { @@ -3010,10 +2839,10 @@ async fn db_contracts_update_event_serializes_same_group_mutations() -> Result<( let racing_client = racing_pool.get().await?; let first_event_id = group_lock_first_event_id(); let first_payload = - group_lock_event_update("Contract Group Lock Event One Updated", 3).to_db_payload()?; + contract_event_input("Contract Group Lock Event One Updated", 3).to_db_payload()?; let second_event_id = group_lock_second_event_id(); let second_payload = - Json(group_lock_event_update("Contract Group Lock Event Two Updated", 4).to_db_payload()?); + Json(contract_event_input("Contract Group Lock Event Two Updated", 4).to_db_payload()?); racing_client.batch_execute("set lock_timeout = '250ms'").await?; // Update the first event while retaining the owning group lock @@ -3045,10 +2874,12 @@ async fn db_contracts_update_event_serializes_same_group_mutations() -> Result<( .expect_err("same-group event update should wait for the group lock"); assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); - // Commit the first update before retrying the second mutation - uow.commit().await?; + // Roll back the first update to release the group lock + uow.rollback().await?; - // Check the second update succeeds after observing the committed group state + // Check the second update succeeds once the group lock is released, then + // roll it back so the fixture event keeps its seeded state + racing_client.batch_execute("begin").await?; let updated = racing_client .query_one( "select update_event($1::uuid, $2::uuid, $3::uuid, $4::jsonb)", @@ -3060,6 +2891,7 @@ async fn db_contracts_update_event_serializes_same_group_mutations() -> Result<( ], ) .await?; + racing_client.batch_execute("rollback").await?; assert!(!updated.get::<_, bool>(0)); Ok(()) @@ -3067,18 +2899,6 @@ async fn db_contracts_update_event_serializes_same_group_mutations() -> Result<( // Helpers. -/// Asserts a database error is a user-facing rejection with the given message. -fn assert_user_facing_error(err: &anyhow::Error, message: &str) { - let db_err = err - .downcast_ref::() - .expect("error to come from the database"); - assert_eq!(db_err.as_db_error().map(DbError::message), Some(message)); - assert_eq!( - db_err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); -} - /// Builds the dashboard editor input that echoes a stored paid event: dated /// windows as `DateTime`, discount codes in reversed order without their /// live inventory count, and the seeded venue and tax context. @@ -3162,33 +2982,6 @@ fn organizer_user_ids(event: &EventFull) -> Vec { event.organizers.iter().map(|user| user.user_id).collect() } -/// Builds a minimal future virtual event input used by organizer contracts. -fn organizers_event_input(name: &str) -> EventInput { - let starts_at = NaiveDate::from_ymd_opt(2099, 8, 1) - .expect("date should be valid") - .and_hms_opt(10, 0, 0) - .expect("time should be valid"); - let ends_at = NaiveDate::from_ymd_opt(2099, 8, 1) - .expect("date should be valid") - .and_hms_opt(11, 0, 0) - .expect("time should be valid"); - - EventInput { - category_id: event_category_id(), - description: "An event used by organizer Rust database contract tests".to_string(), - kind_id: "virtual".to_string(), - name: name.to_string(), - timezone: "UTC".to_string(), - - capacity: Some(100), - ends_at: Some(ends_at), - starts_at: Some(starts_at), - test_event: Some(true), - - ..Default::default() - } -} - /// Updates the mutation event with the given input inside the unit of work. async fn update_mutation_event(uow: &PgUnitOfWork, event: &EventInput) -> Result<()> { uow.update_event( diff --git a/ocg-server/src/db/contract_tests/dashboard_group_surveys.rs b/ocg-server/src/db/contract_tests/dashboard_group_surveys.rs index a27dd6739..2cf9e2292 100644 --- a/ocg-server/src/db/contract_tests/dashboard_group_surveys.rs +++ b/ocg-server/src/db/contract_tests/dashboard_group_surveys.rs @@ -11,11 +11,13 @@ use crate::{ db::{ DBUnitOfWork, PgDB, PgExecutor, dashboard::{group::DBDashboardGroup, user::DBDashboardUser}, + user_facing_message, }, types::{ dashboard::group::surveys::{ - EventSurveyInput, EventSurveyResponseNavigationFilters, EventSurveyResponsesFilters, - EventSurveyStatusAction, EventSurveyStatusForm, EventSurveyTextAnswersFilters, + EventSurveyFull, EventSurveyInput, EventSurveyResponseNavigationFilters, + EventSurveyResponsesFilters, EventSurveyStatusAction, EventSurveyStatusForm, + EventSurveyTextAnswersFilters, }, event_survey::{EventSurveyAudience, EventSurveyState}, questionnaire::{ @@ -46,10 +48,7 @@ async fn db_contracts_add_event_survey_creates_draft() -> Result<()> { .await?; // Check the draft settings, with dates interpreted in the event timezone - let survey = uow - .get_event_survey(subgroup_id(), survey_event_id(), event_survey_id) - .await? - .context("added survey should be returned")?; + let survey = load_survey(&uow, survey_event_id(), event_survey_id).await?; assert!(!survey.summary.anonymous); assert_eq!(survey.summary.audience, EventSurveyAudience::Confirmed); assert_eq!(survey.summary.questions_count, 1); @@ -82,45 +81,17 @@ async fn db_contracts_add_event_survey_creates_draft() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_add_event_survey_rejects_survey_limit() -> Result<()> { - // Setup a rolled-back unit of work for an event with three surveys - let uow = contract_unit_of_work().await?; - let input = identified_survey_input(); - - // Fill the event up to the survey limit - for _ in 0..7 { - uow.add_event_survey(organizer_id(), subgroup_id(), survey_event_id(), &input) - .await?; - } - - // Check one more survey is rejected under the event lock - let err = uow - .add_event_survey(organizer_id(), subgroup_id(), survey_event_id(), &input) - .await - .expect_err("the eleventh survey should be rejected"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("events can have up to 10 surveys") - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_change_event_survey_status_applies_transitions() -> Result<()> { // Setup a rolled-back unit of work and the draft opening the editor shows let uow = contract_unit_of_work().await?; - let draft = uow - .get_event_survey( - subgroup_id(), - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - ) - .await? - .context("draft survey should be returned")?; + let draft = load_survey( + &uow, + survey_mark_ready_event_id(), + survey_mark_ready_race_id(), + ) + .await?; // Mark the draft ready with the opening time it showed uow.change_event_survey_status( @@ -136,14 +107,12 @@ async fn db_contracts_change_event_survey_status_applies_transitions() -> Result }, ) .await?; - let ready = uow - .get_event_survey( - subgroup_id(), - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - ) - .await? - .context("ready survey should be returned")?; + let ready = load_survey( + &uow, + survey_mark_ready_event_id(), + survey_mark_ready_race_id(), + ) + .await?; assert_eq!(ready.summary.revision, draft.summary.revision + 1); assert_eq!(ready.summary.state, EventSurveyState::Scheduled); @@ -156,14 +125,12 @@ async fn db_contracts_change_event_survey_status_applies_transitions() -> Result &status_form(EventSurveyStatusAction::BackToDraft, ready.summary.revision), ) .await?; - let draft_again = uow - .get_event_survey( - subgroup_id(), - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - ) - .await? - .context("draft survey should be returned")?; + let draft_again = load_survey( + &uow, + survey_mark_ready_event_id(), + survey_mark_ready_race_id(), + ) + .await?; assert_eq!(draft_again.summary.state, EventSurveyState::Draft); // Close and reopen the open identified survey @@ -175,10 +142,7 @@ async fn db_contracts_change_event_survey_status_applies_transitions() -> Result &status_form(EventSurveyStatusAction::Close, 2), ) .await?; - let closed = uow - .get_event_survey(subgroup_id(), survey_event_id(), survey_identified_id()) - .await? - .context("closed survey should be returned")?; + let closed = load_survey(&uow, survey_event_id(), survey_identified_id()).await?; assert!(closed.summary.closed_manually); assert!(closed.closed_at.is_some()); assert_eq!(closed.summary.state, EventSurveyState::Closed); @@ -190,10 +154,7 @@ async fn db_contracts_change_event_survey_status_applies_transitions() -> Result &status_form(EventSurveyStatusAction::Reopen, 3), ) .await?; - let reopened = uow - .get_event_survey(subgroup_id(), survey_event_id(), survey_identified_id()) - .await? - .context("reopened survey should be returned")?; + let reopened = load_survey(&uow, survey_event_id(), survey_identified_id()).await?; assert!(!reopened.summary.closed_manually); assert_eq!(reopened.summary.revision, 4); assert_eq!(reopened.summary.state, EventSurveyState::Open); @@ -223,96 +184,6 @@ async fn db_contracts_change_event_survey_status_applies_transitions() -> Result Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_change_event_survey_status_rejects_invalid_requests() -> Result<()> { - // Setup a rolled-back unit of work and the draft opening the editor shows - let uow = contract_unit_of_work().await?; - let draft = uow - .get_event_survey( - subgroup_id(), - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - ) - .await? - .context("draft survey should be returned")?; - let scenarios = [ - ( - "a draft without questions", - survey_event_id(), - survey_draft_id(), - EventSurveyStatusForm { - action: EventSurveyStatusAction::MarkReady, - revision: 1, - - expected_effective_opens_at: Utc.with_ymd_and_hms(2025, 4, 1, 19, 0, 0).single(), - }, - "event survey must have at least one question", - ), - ( - "a confirmation showing another opening time", - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - EventSurveyStatusForm { - action: EventSurveyStatusAction::MarkReady, - revision: draft.summary.revision, - - expected_effective_opens_at: Utc.with_ymd_and_hms(2099, 12, 1, 0, 0, 0).single(), - }, - "the survey opening time changed; review and try again", - ), - ( - "a draft close", - survey_event_id(), - survey_draft_id(), - status_form(EventSurveyStatusAction::Close, 1), - "only open surveys can be closed", - ), - ( - "a stale revision", - survey_event_id(), - survey_identified_id(), - status_form(EventSurveyStatusAction::Close, 1), - "the survey changed; review and try again", - ), - ( - "an open survey moved back to draft", - survey_event_id(), - survey_identified_id(), - status_form(EventSurveyStatusAction::BackToDraft, 2), - "only scheduled surveys not yet sent or answered can move back to draft", - ), - ( - "an open survey reopened", - survey_event_id(), - survey_identified_id(), - status_form(EventSurveyStatusAction::Reopen, 2), - "only closed surveys can be reopened", - ), - ]; - - for (scenario, event_id, event_survey_id, status, message) in scenarios { - // Request the transition inside its own savepoint - uow.execute("savepoint status_change", &[]).await?; - let err = uow - .change_event_survey_status( - organizer_id(), - subgroup_id(), - event_id, - event_survey_id, - &status, - ) - .await - .expect_err(scenario); - - // Check the transition was rejected with its message - assert_eq!(ocg01_message(&err).as_deref(), Some(message), "{scenario}"); - uow.execute("rollback to savepoint status_change", &[]).await?; - } - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_change_event_survey_status_rejects_mark_ready_racing_reschedule() -> Result<()> @@ -321,25 +192,19 @@ async fn db_contracts_change_event_survey_status_rejects_mark_ready_racing_resch let reschedule_uow = contract_unit_of_work().await?; let status_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let reschedule_backend_pid: i32 = reschedule_uow - .fetch_scalar_one("select pg_backend_pid()", &[]) - .await?; - let status_backend_pid: i32 = - status_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let reschedule_backend_pid = uow_backend_pid(&reschedule_uow).await?; + let status_backend_pid = uow_backend_pid(&status_uow).await?; // Load the opening time the Mark ready confirmation shows - let draft = status_uow - .get_event_survey( - subgroup_id(), - survey_mark_ready_event_id(), - survey_mark_ready_race_id(), - ) - .await? - .context("draft survey should be returned")?; + let draft = load_survey( + &status_uow, + survey_mark_ready_event_id(), + survey_mark_ready_race_id(), + ) + .await?; // Reschedule the event while retaining its lock - let payload = - group_lock_event_update("Contract Survey Mark Ready Event", 10).to_db_payload()?; + let payload = contract_event_input("Contract Survey Mark Ready Event", 10).to_db_payload()?; reschedule_uow .update_event( organizer_id(), @@ -377,9 +242,9 @@ async fn db_contracts_change_event_survey_status_rejects_mark_ready_racing_resch .expect_err("a confirmation of the old opening time should be rejected"); // Check the survey stayed a draft - assert_eq!( - ocg01_message(&err).as_deref(), - Some("the survey opening time changed; review and try again") + assert_ocg01( + &err, + "the survey opening time changed; review and try again", ); let status: String = probe_client .query_one( @@ -443,10 +308,8 @@ async fn db_contracts_delete_event_survey_waits_for_concurrent_submission() -> R let submit_uow = contract_unit_of_work().await?; let delete_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let submit_backend_pid: i32 = - submit_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let delete_backend_pid: i32 = - delete_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let submit_backend_pid = uow_backend_pid(&submit_uow).await?; + let delete_backend_pid = uow_backend_pid(&delete_uow).await?; // Submit answers while retaining the event and survey locks submit_uow @@ -501,124 +364,6 @@ async fn db_contracts_delete_event_survey_waits_for_concurrent_submission() -> R Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_event_survey_mutations_reject_unmanageable_events() -> Result<()> { - // Setup a rolled-back unit of work and the changes that block management - let uow = contract_unit_of_work().await?; - let scenarios = [ - ( - "canceled event", - "update event set canceled = true where event_id = $1::uuid", - ), - ( - "deleted event", - "update event set deleted = true, published = false where event_id = $1::uuid", - ), - ( - "deleted group", - r#"update "group" set active = false, deleted = true where group_id = (select group_id from event where event_id = $1::uuid)"#, - ), - ( - "inactive group", - r#"update "group" set active = false where group_id = (select group_id from event where event_id = $1::uuid)"#, - ), - ]; - - for (scenario, change) in scenarios { - // Apply the change inside its own savepoint - uow.execute("savepoint unmanageable_event", &[]).await?; - uow.execute(change, &[&survey_event_id()]).await?; - - // Check adding a survey is rejected - let err = uow - .add_event_survey( - organizer_id(), - subgroup_id(), - survey_event_id(), - &identified_survey_input(), - ) - .await - .expect_err(scenario); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event not found or inactive"), - "{scenario}" - ); - uow.execute("rollback to savepoint unmanageable_event", &[]).await?; - } - - // Check a survey cannot be managed through another group - uow.execute("savepoint invalid_request", &[]).await?; - let err = uow - .delete_event_survey( - organizer_id(), - group_id(), - survey_event_id(), - survey_identified_id(), - ) - .await - .expect_err("a foreign group should not manage the survey"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event not found or inactive") - ); - uow.execute("rollback to savepoint invalid_request", &[]).await?; - - // Check a survey cannot be added to the event of another group - let err = uow - .add_event_survey( - organizer_id(), - group_id(), - survey_event_id(), - &identified_survey_input(), - ) - .await - .expect_err("a foreign group should not add surveys to the event"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event not found or inactive") - ); - uow.execute("rollback to savepoint invalid_request", &[]).await?; - - // Check a survey closing before it opens is rejected - let err = uow - .add_event_survey( - organizer_id(), - subgroup_id(), - survey_event_id(), - &EventSurveyInput { - opens_at: NaiveDate::from_ymd_opt(2099, 1, 2) - .and_then(|day| day.and_hms_opt(1, 0, 0)), - ..identified_survey_input() - }, - ) - .await - .expect_err("surveys closing before they open should be rejected"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event survey closing date must be after the opening date") - ); - uow.execute("rollback to savepoint invalid_request", &[]).await?; - - // Check a missing survey cannot be deleted - let err = uow - .delete_event_survey( - organizer_id(), - subgroup_id(), - survey_event_id(), - Uuid::new_v4(), - ) - .await - .expect_err("missing surveys should not be deleted"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event survey not found") - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_event_analytics_deserializes_survey_summary() -> Result<()> { @@ -809,11 +554,11 @@ async fn db_contracts_get_event_survey_export_rejects_oversized_export() -> Resu ) .await .expect_err("export above the row bound should be rejected"); - assert_eq!( - ocg01_message(&err), - Some(format!( + assert_ocg01( + &err, + &format!( "survey export supports up to 2 responses and 1000000 bytes of answers; this survey has 3 responses and {answers_bytes} bytes" - )) + ), ); // Check exports above the byte bound are rejected with both bounds and sizes @@ -828,11 +573,11 @@ async fn db_contracts_get_event_survey_export_rejects_oversized_export() -> Resu ) .await .expect_err("export above the byte bound should be rejected"); - assert_eq!( - ocg01_message(&err), - Some(format!( + assert_ocg01( + &err, + &format!( "survey export supports up to 100 responses and {max_bytes} bytes of answers; this survey has 3 responses and {answers_bytes} bytes" - )) + ), ); Ok(()) @@ -919,7 +664,7 @@ async fn db_contracts_get_event_survey_export_stays_consistent_while_responses_g async fn db_contracts_get_event_survey_response_deserializes() -> Result<()> { // Setup the contract database let db = contract_tests_db()?; - let second_respondent_id = parse_uuid("00000000-0000-0000-0000-00000000f303"); + let second_respondent_id = survey_second_respondent_id(); // Load the second response among all respondents let response = db @@ -971,33 +716,6 @@ async fn db_contracts_get_event_survey_response_deserializes() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_get_event_survey_response_rejects_anonymous_survey() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - - // Load a response of the anonymous survey - let err = db - .get_event_survey_response( - subgroup_id(), - survey_event_id(), - survey_anonymous_id(), - survey_respondent_id(), - &EventSurveyResponseNavigationFilters::default(), - ) - .await - .expect_err("anonymous respondents should not be viewed"); - - // Check the respondents stay hidden - assert_eq!( - ocg01_message(&err).as_deref(), - Some("responses of anonymous surveys cannot be viewed") - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_event_survey_results_deserializes() -> Result<()> { @@ -1127,10 +845,7 @@ async fn db_contracts_list_event_survey_responses_deserializes() -> Result<()> { // Check the respondent without a ticket whose blank text is unanswered let second = &second_page.responses[0]; assert_eq!(second.answered_questions_count, 2); - assert_eq!( - second.user.user_id, - parse_uuid("00000000-0000-0000-0000-00000000f303") - ); + assert_eq!(second.user.user_id, survey_second_respondent_id()); assert_eq!(second.user.username, "survey-second-respondent"); assert_eq!( second.user.name.as_deref(), @@ -1162,32 +877,6 @@ async fn db_contracts_list_event_survey_responses_deserializes() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_list_event_survey_responses_rejects_anonymous_survey() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - - // List the responses of the anonymous survey - let err = db - .list_event_survey_responses( - subgroup_id(), - survey_event_id(), - survey_anonymous_id(), - &EventSurveyResponsesFilters::default(), - ) - .await - .expect_err("anonymous respondents should not be listed"); - - // Check the respondents stay hidden - assert_eq!( - ocg01_message(&err).as_deref(), - Some("responses of anonymous surveys cannot be listed") - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_list_event_survey_text_answers_deserializes() -> Result<()> { @@ -1322,10 +1011,8 @@ async fn db_contracts_submit_event_survey_response_locks_settings_against_anonym let submit_uow = contract_unit_of_work().await?; let update_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let submit_backend_pid: i32 = - submit_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let update_backend_pid: i32 = - update_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let submit_backend_pid = uow_backend_pid(&submit_uow).await?; + let update_backend_pid = uow_backend_pid(&update_uow).await?; // Submit the first response while retaining the event and survey locks submit_uow @@ -1359,9 +1046,9 @@ async fn db_contracts_submit_event_survey_response_locks_settings_against_anonym .expect_err("answered surveys should keep their anonymity"); // Check the response locked the settings it was given under - assert_eq!( - ocg01_message(&err).as_deref(), - Some("survey questions, audience and anonymity cannot change once it has responses") + assert_ocg01( + &err, + "survey questions, audience and anonymity cannot change once it has responses", ); let row = probe_client .query_one( @@ -1383,10 +1070,8 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrent_reconfigur let update_uow = contract_unit_of_work().await?; let submit_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let update_backend_pid: i32 = - update_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let submit_backend_pid: i32 = - submit_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let update_backend_pid = uow_backend_pid(&update_uow).await?; + let submit_backend_pid = uow_backend_pid(&submit_uow).await?; // Make the survey anonymous while retaining the event and survey locks update_uow @@ -1420,10 +1105,7 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrent_reconfigur .expect_err("answers to an older revision should be rejected"); // Check no response was stored - assert_eq!( - ocg01_message(&err).as_deref(), - Some("the survey changed; review and try again") - ); + assert_ocg01(&err, "the survey changed; review and try again"); assert_survey_has_no_responses(&probe_client, survey_stale_race_id()).await?; Ok(()) @@ -1437,9 +1119,8 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrently_closed_s let close_uow = contract_unit_of_work().await?; let submit_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let close_backend_pid: i32 = close_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let submit_backend_pid: i32 = - submit_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let close_backend_pid = uow_backend_pid(&close_uow).await?; + let submit_backend_pid = uow_backend_pid(&submit_uow).await?; // Close the survey while retaining the event and survey locks close_uow @@ -1472,10 +1153,7 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrently_closed_s .expect_err("answers to a closed survey should be rejected"); // Check no response was stored - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event survey is not open") - ); + assert_ocg01(&err, "event survey is not open"); assert_survey_has_no_responses(&probe_client, survey_close_race_id()).await?; Ok(()) @@ -1489,10 +1167,8 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrently_deleted_ let delete_uow = contract_unit_of_work().await?; let submit_uow = contract_unit_of_work().await?; let probe_client = contract_tests_pool()?.get().await?; - let delete_backend_pid: i32 = - delete_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; - let submit_backend_pid: i32 = - submit_uow.fetch_scalar_one("select pg_backend_pid()", &[]).await?; + let delete_backend_pid = uow_backend_pid(&delete_uow).await?; + let submit_backend_pid = uow_backend_pid(&submit_uow).await?; // Delete the survey while retaining the event lock delete_uow @@ -1524,10 +1200,7 @@ async fn db_contracts_submit_event_survey_response_rejects_concurrently_deleted_ .expect_err("answers to a deleted survey should be rejected"); // Check no response was stored - assert_eq!( - ocg01_message(&err).as_deref(), - Some("event survey not found") - ); + assert_ocg01(&err, "event survey not found"); assert_survey_has_no_responses(&probe_client, survey_delete_race_id()).await?; Ok(()) @@ -1558,10 +1231,7 @@ async fn db_contracts_update_event_survey_bumps_revision_for_configuration_chang .await?; // Check the configuration change increased the revision - let survey = uow - .get_event_survey(subgroup_id(), survey_event_id(), survey_draft_id()) - .await? - .context("draft survey should be returned")?; + let survey = load_survey(&uow, survey_event_id(), survey_draft_id()).await?; assert_eq!(survey.summary.questions_count, 1); assert_eq!(survey.summary.revision, 2); assert_eq!( @@ -1573,56 +1243,12 @@ async fn db_contracts_update_event_survey_bumps_revision_for_configuration_chang Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_update_event_survey_keeps_anonymity_once_opened() -> Result<()> { - // Setup a rolled-back unit of work - let uow = contract_unit_of_work().await?; - - // Check the opened anonymous survey reports its anonymity as locked - let survey = uow - .get_event_survey( - subgroup_id(), - survey_race_event_id(), - survey_opened_anonymous_id(), - ) - .await? - .context("opened anonymous survey should be returned")?; - assert!(survey.summary.anonymous); - assert!(survey.summary.anonymous_locked); - assert!(!survey.summary.locked); - - // Try to make the opened anonymous survey identified - let err = uow - .update_event_survey( - organizer_id(), - subgroup_id(), - survey_race_event_id(), - survey_opened_anonymous_id(), - 1, - &race_survey_input(false, "Contract opened anonymous survey"), - ) - .await - .expect_err("opened anonymous surveys should stay anonymous"); - - // Check the anonymity change was rejected - assert_eq!( - ocg01_message(&err).as_deref(), - Some("anonymity cannot be turned off after the survey has opened") - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_update_event_survey_keeps_locked_settings() -> Result<()> { // Setup a rolled-back unit of work and the editor input of the answered survey let uow = contract_unit_of_work().await?; - let survey = uow - .get_event_survey(subgroup_id(), survey_event_id(), survey_identified_id()) - .await? - .context("identified survey should be returned")?; + let survey = load_survey(&uow, survey_event_id(), survey_identified_id()).await?; let input = EventSurveyInput { description: Some("Contract renamed introduction".to_string()), questions: survey.questions, @@ -1642,10 +1268,7 @@ async fn db_contracts_update_event_survey_keeps_locked_settings() -> Result<()> .await?; // Check the rename kept the revision and both stored dates - let renamed = uow - .get_event_survey(subgroup_id(), survey_event_id(), survey_identified_id()) - .await? - .context("renamed survey should be returned")?; + let renamed = load_survey(&uow, survey_event_id(), survey_identified_id()).await?; assert_eq!(renamed.summary.revision, 2); assert_eq!(renamed.summary.title, "Contract renamed survey"); assert_eq!( @@ -1671,9 +1294,9 @@ async fn db_contracts_update_event_survey_keeps_locked_settings() -> Result<()> ) .await .expect_err("answered surveys should keep their anonymity"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("survey questions, audience and anonymity cannot change once it has responses") + assert_ocg01( + &err, + "survey questions, audience and anonymity cannot change once it has responses", ); uow.execute("rollback to savepoint locked_settings", &[]).await?; @@ -1689,10 +1312,7 @@ async fn db_contracts_update_event_survey_keeps_locked_settings() -> Result<()> ) .await .expect_err("stale forms should be rejected"); - assert_eq!( - ocg01_message(&err).as_deref(), - Some("the survey changed; review and try again") - ); + assert_ocg01(&err, "the survey changed; review and try again"); Ok(()) } @@ -1751,7 +1371,7 @@ async fn check_survey_export_snapshot( } // Rejected exports measured one snapshot above the bounds Err(err) => { - let message = ocg01_message(&err) + let message = user_facing_message(&err) .with_context(|| format!("export should only fail by size: {err:#}"))?; let measured = message .split("this survey has ") @@ -1791,6 +1411,17 @@ async fn assert_survey_has_no_responses( Ok(()) } +/// Loads a survey of the given subgroup event, failing when it is missing. +async fn load_survey( + db: &(impl DBDashboardGroup + Sync), + event_id: Uuid, + event_survey_id: Uuid, +) -> Result { + db.get_event_survey(subgroup_id(), event_id, event_survey_id) + .await? + .context("survey should be returned") +} + /// Builds the editor input that echoes the identified survey settings. fn identified_survey_input() -> EventSurveyInput { EventSurveyInput { diff --git a/ocg-server/src/db/contract_tests/dashboard_user.rs b/ocg-server/src/db/contract_tests/dashboard_user.rs index fe277494c..ebf8a5f2e 100644 --- a/ocg-server/src/db/contract_tests/dashboard_user.rs +++ b/ocg-server/src/db/contract_tests/dashboard_user.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, TimeZone, Utc}; use tokio_postgres::{error::SqlState, types::Json}; use crate::{ - db::{PgExecutor, dashboard::user::DBDashboardUser}, + db::{PgExecutor, dashboard::user::DBDashboardUser, user_facing_message}, types::{ community::CommunityRole, dashboard::{ @@ -59,14 +59,8 @@ async fn db_contracts_delete_session_proposal_locks_before_submission_check() -> let barrier_client = pool.get().await?; let delete_client = pool.get().await?; let probe_client = pool.get().await?; - let barrier_backend_pid = barrier_client - .query_one("select pg_backend_pid()", &[]) - .await? - .get::<_, i32>(0); - let delete_backend_pid = delete_client - .query_one("select pg_backend_pid()", &[]) - .await? - .get::<_, i32>(0); + let barrier_backend_pid = backend_pid(&barrier_client).await?; + let delete_backend_pid = backend_pid(&delete_client).await?; // Block the dependent submission read after the proposal lock is acquired barrier_client @@ -91,24 +85,23 @@ async fn db_contracts_delete_session_proposal_locks_before_submission_check() -> wait_for_backend_blocker(&probe_client, barrier_backend_pid, delete_backend_pid).await?; // Probe whether deletion already holds the proposal update lock - probe_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let lock_result = probe_client - .query_one( - "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for key share", - &[&cfs_delete_lock_proposal_id()], - ) - .await; + let probe_code = probe_lock( + &probe_client, + "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for key share", + &[&cfs_delete_lock_proposal_id()], + ) + .await?; - // Release the probes and let the deletion roll back its mutation - probe_client.batch_execute("rollback").await?; + // Release the barrier and let the deletion roll back its mutation barrier_client.batch_execute("rollback").await?; delete_task.await??; // Check deletion locks the proposal before inspecting submissions - let lock_err = lock_result.expect_err("proposal read should wait for the deletion lock"); - assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "proposal read should wait for the deletion lock" + ); Ok(()) } @@ -116,11 +109,11 @@ async fn db_contracts_delete_session_proposal_locks_before_submission_check() -> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_decline_event_admission_offer_deserializes() -> Result<()> { - // Setup the contract database and dedicated RSVP offer fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the dedicated RSVP offer fixture + let uow = contract_unit_of_work().await?; // Decline the owned offer through the Rust JSON contract - let outcome = db + let outcome = uow .decline_event_admission_offer(offer_decliner_id(), offer_decline_offer_id(), None) .await?; @@ -223,10 +216,10 @@ async fn db_contracts_get_user_event_survey_hides_unavailable_surveys() -> Resul assert!(closed.is_none()); uow.execute("rollback to savepoint survey_scope", &[]).await?; - for (scenario, change) in survey_scope_changes() { + for (scenario, change, id) in survey_scope_changes() { // Apply the change inside its own savepoint uow.execute("savepoint survey_scope", &[]).await?; - uow.execute(change, &[]).await?; + uow.execute(change, &[&id]).await?; // Check the survey is no longer returned let survey = uow @@ -509,10 +502,10 @@ async fn db_contracts_list_user_event_surveys_hides_unreachable_surveys() -> Res // Setup a rolled-back unit of work let uow = contract_unit_of_work().await?; - for (scenario, change) in survey_scope_changes() { + for (scenario, change, id) in survey_scope_changes() { // Apply the change inside its own savepoint uow.execute("savepoint survey_scope", &[]).await?; - uow.execute(change, &[]).await?; + uow.execute(change, &[&id]).await?; // Check the surveys are no longer listed let output = uow @@ -682,7 +675,7 @@ async fn db_contracts_list_user_events_deserializes() -> Result<()> { assert_eq!(answers.answers.len(), 1); match &answers.answers[0].value { QuestionnaireAnswerValue::One(value) => { - assert_eq!(value, "00000000-0000-0000-0000-00000000c072"); + assert_eq!(*value, registration_question_option_id().to_string()); } QuestionnaireAnswerValue::Many(_) => panic!("expected single-select answer"), } @@ -931,18 +924,18 @@ async fn db_contracts_list_user_session_proposals_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_refresh_user_badge_identity_deserializes() -> Result<()> { - // Setup the contract database and stale badge identity - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the stale badge identity + let uow = contract_unit_of_work().await?; // Rebind the stale seeded identity, repeat the current binding, and read // the persisted award through its production wrapper - let rebound = db + let rebound = uow .refresh_user_badge_identity(organizer_id(), rebind_user_badge_id()) .await?; - let repeated = db + let repeated = uow .refresh_user_badge_identity(organizer_id(), rebind_user_badge_id()) .await?; - let award = db + let award = uow .get_user_badge(organizer_id(), rebind_user_badge_id()) .await? .context("rebind contract badge should exist")?; @@ -966,72 +959,6 @@ async fn db_contracts_refresh_user_badge_identity_deserializes() -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_submit_event_survey_response_rejects_invalid_requests() -> Result<()> { - // Setup a rolled-back unit of work and the requests to reject - let uow = contract_unit_of_work().await?; - let rating = rating_answers(); - let scenarios = [ - ( - "a closed survey", - Some( - "update event_survey set closed_at = current_timestamp where event_survey_id = '00000000-0000-0000-0000-00000000f305'", - ), - survey_identified_id(), - survey_pending_respondent_id(), - 2, - rating.clone(), - "event survey is not open", - ), - ( - "a missing required answer", - None, - survey_identified_id(), - survey_pending_respondent_id(), - 2, - QuestionnaireAnswers::default(), - "required questionnaire answer is missing", - ), - ( - "a stale revision", - None, - survey_identified_id(), - survey_pending_respondent_id(), - 1, - rating.clone(), - "the survey changed; review and try again", - ), - ( - "an attendee outside the audience", - None, - survey_anonymous_id(), - survey_pending_respondent_id(), - 3, - QuestionnaireAnswers::default(), - "you are not eligible to answer this survey", - ), - ]; - - for (scenario, change, event_survey_id, user_id, revision, answers, message) in scenarios { - // Apply the change inside its own savepoint - uow.execute("savepoint survey_submission", &[]).await?; - if let Some(change) = change { - uow.execute(change, &[]).await?; - } - - // Check the submission is rejected with its message - let err = uow - .submit_event_survey_response(event_survey_id, user_id, revision, &answers) - .await - .expect_err(scenario); - assert_eq!(ocg01_message(&err).as_deref(), Some(message), "{scenario}"); - uow.execute("rollback to savepoint survey_submission", &[]).await?; - } - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_submit_event_survey_response_rejects_unreachable_surveys() -> Result<()> { @@ -1057,17 +984,17 @@ async fn db_contracts_submit_event_survey_response_rejects_unreachable_surveys() // Check the survey is not found assert_eq!( - ocg01_message(&err).as_deref(), + user_facing_message(&err).as_deref(), Some("event survey not found"), - "{scenario}" + "{scenario}: {err:#}" ); uow.execute("rollback to savepoint survey_scope", &[]).await?; } - for (scenario, change) in survey_scope_changes() { + for (scenario, change, id) in survey_scope_changes() { // Apply the change inside its own savepoint uow.execute("savepoint survey_scope", &[]).await?; - uow.execute(change, &[]).await?; + uow.execute(change, &[&id]).await?; // Check the survey is no longer found let err = uow @@ -1080,9 +1007,9 @@ async fn db_contracts_submit_event_survey_response_rejects_unreachable_surveys() .await .expect_err(scenario); assert_eq!( - ocg01_message(&err).as_deref(), + user_facing_message(&err).as_deref(), Some("event survey not found"), - "{scenario}" + "{scenario}: {err:#}" ); uow.execute("rollback to savepoint survey_scope", &[]).await?; } @@ -1175,23 +1102,22 @@ async fn db_contracts_update_session_proposal_blocks_key_share() -> Result<()> { .await?; // Probe with a lock mode compatible with an ordinary non-key update - probe_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let lock_result = probe_client - .query_one( - "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for key share", - &[&cfs_update_lock_proposal_id()], - ) - .await; + let probe_code = probe_lock( + &probe_client, + "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for key share", + &[&cfs_update_lock_proposal_id()], + ) + .await?; - // Roll back the update and lock probe before checking the outcome - probe_client.batch_execute("rollback").await?; + // Roll back the update before checking the outcome update_client.batch_execute("rollback").await?; // Check the explicit update lock blocks a competing key-share reader - let lock_err = lock_result.expect_err("proposal read should wait for the update lock"); - assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "proposal read should wait for the update lock" + ); Ok(()) } diff --git a/ocg-server/src/db/contract_tests/dashboard_user_notifications.rs b/ocg-server/src/db/contract_tests/dashboard_user_notifications.rs index ea071253f..dc4db144d 100644 --- a/ocg-server/src/db/contract_tests/dashboard_user_notifications.rs +++ b/ocg-server/src/db/contract_tests/dashboard_user_notifications.rs @@ -5,13 +5,11 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use chrono::{DateTime, Utc}; use strum::VariantArray; -use tokio_postgres::{ - error::{DbError, SqlState}, - types::Json, -}; +use tokio_postgres::types::Json; +use uuid::Uuid; use crate::{ - db::{DB, USER_FACING_DB_ERROR_CODE, dashboard::user::DBDashboardUser}, + db::{DBUnitOfWork, PgExecutor, dashboard::user::DBDashboardUser}, types::dashboard::user::notifications::{NotificationCategory, NotificationPreferencesInput}, }; @@ -63,7 +61,7 @@ async fn db_contracts_get_user_notification_preferences_deserializes() -> Result let unavailable_group = preferences .muted_groups .iter() - .find(|group| group.group_id == notification_unavailable_group_id()) + .find(|group| group.group_id == cohost_reinvited_group_id()) .expect("unavailable muted group should be present"); assert!(!unavailable_group.available); assert_eq!( @@ -118,112 +116,43 @@ async fn db_contracts_list_user_notification_group_options_deserializes() -> Res Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_mute_user_group_notifications_rejects_disconnected_group() -> Result<()> { - // Setup a disconnected user and active group - let client = contract_tests_pool()?.get().await?; - - // Try to mute a group the user cannot select - let err = client - .query_one( - "select mute_user_group_notifications($1::uuid, $2::uuid)", - &[¬ification_update_user_id(), &subgroup_id()], - ) - .await - .expect_err("disconnected groups should be rejected"); - - // Check the rejection maps to the user-facing SQLSTATE - assert_eq!( - err.as_db_error().map(DbError::message), - Some("group not available to mute") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_mute_user_group_notifications_serializes_concurrent_requests() -> Result<()> { - // Setup a clean dedicated mute target - let pool = contract_tests_pool()?; - let cleanup_client = pool.get().await?; - cleanup_client - .execute( - "delete from user_group_notification_mute - where user_id = $1::uuid - and group_id = $2::uuid", - &[¬ification_concurrency_user_id(), &subgroup_id()], - ) - .await?; - let test_started_at: DateTime = cleanup_client - .query_one("select clock_timestamp()", &[]) - .await? - .get(0); - - // Mute the same connected group through two independent transactions - let db = contract_tests_db()?; - let first = db.begin().await?; - let second = db.begin().await?; - let first_result = async move { - first - .mute_user_group_notifications(notification_concurrency_user_id(), subgroup_id()) - .await?; - first.commit().await - }; - let second_result = async move { - second - .mute_user_group_notifications(notification_concurrency_user_id(), subgroup_id()) - .await?; - second.commit().await - }; - let (first_result, second_result) = tokio::join!(first_result, second_result); - first_result?; - second_result?; + // Setup a clean dedicated mute target and two racing units of work + let user_id = notification_concurrency_user_id(); + let probe_client = contract_tests_pool()?.get().await?; + delete_mute(&probe_client, user_id).await?; + let test_started_at = clock_timestamp(&probe_client).await?; + let first = contract_unit_of_work().await?; + let second = contract_unit_of_work().await?; + let first_backend_pid = uow_backend_pid(&first).await?; + let second_backend_pid = uow_backend_pid(&second).await?; + + // Mute the group while retaining the uncommitted mute row + first.mute_user_group_notifications(user_id, subgroup_id()).await?; + + // Mute the same group concurrently from the second transaction + let second_mute = tokio::spawn(async move { + second.mute_user_group_notifications(user_id, subgroup_id()).await?; + Box::new(second).commit().await + }); + wait_for_backend_blocker(&probe_client, first_backend_pid, second_backend_pid).await?; + + // Commit the first mute so the waiting request observes it + Box::new(first).commit().await?; + second_mute.await??; // Check one durable mute and one audit row survived the race - let row = cleanup_client - .query_one( - "select - ( - select count(*) - from user_group_notification_mute - where user_id = $1::uuid - and group_id = $2::uuid - ), - ( - select count(*) - from audit_log - where actor_user_id = $1::uuid - and action = 'group_notifications_muted' - and resource_id = $1::uuid - and resource_type = 'user' - and details->>'group_id' = $2::uuid::text - and created_at >= $3::timestamptz - )", - &[ - ¬ification_concurrency_user_id(), - &subgroup_id(), - &test_started_at, - ], - ) - .await?; - assert_eq!(row.get::<_, i64>(0), 1); - assert_eq!(row.get::<_, i64>(1), 1); - - // Clean up the mutable row so repeated focused runs start from the same state - cleanup_client - .execute( - "delete from user_group_notification_mute - where user_id = $1::uuid - and group_id = $2::uuid", - &[¬ification_concurrency_user_id(), &subgroup_id()], - ) - .await?; + let counts = mute_and_audit_counts( + &probe_client, + user_id, + "group_notifications_muted", + test_started_at, + ) + .await?; + delete_mute(&probe_client, user_id).await?; + assert_eq!(counts, (1, 1)); Ok(()) } @@ -260,114 +189,46 @@ async fn db_contracts_notification_categories_match_enum() -> Result<()> { #[ignore = "requires the contract test database"] async fn db_contracts_unmute_user_group_notifications_serializes_concurrent_requests() -> Result<()> { - // Setup one durable mute row for the concurrent unmute target - let pool = contract_tests_pool()?; - let cleanup_client = pool.get().await?; - cleanup_client - .execute( - "delete from user_group_notification_mute - where user_id = $1::uuid - and group_id = $2::uuid", - &[¬ification_unmute_concurrency_user_id(), &subgroup_id()], - ) - .await?; - cleanup_client + // Setup one durable mute row and two racing units of work + let user_id = notification_unmute_concurrency_user_id(); + let probe_client = contract_tests_pool()?.get().await?; + delete_mute(&probe_client, user_id).await?; + probe_client .execute( "insert into user_group_notification_mute (group_id, user_id) values ($1::uuid, $2::uuid)", - &[&subgroup_id(), ¬ification_unmute_concurrency_user_id()], + &[&subgroup_id(), &user_id], ) .await?; - let test_started_at: DateTime = cleanup_client - .query_one("select clock_timestamp()", &[]) - .await? - .get(0); - - // Unmute the same group through two independent transactions - let db = contract_tests_db()?; - let first = db.begin().await?; - let second = db.begin().await?; - let first_result = async move { - first - .unmute_user_group_notifications( - notification_unmute_concurrency_user_id(), - subgroup_id(), - ) - .await?; - first.commit().await - }; - let second_result = async move { - second - .unmute_user_group_notifications( - notification_unmute_concurrency_user_id(), - subgroup_id(), - ) - .await?; - second.commit().await - }; - let (first_result, second_result) = tokio::join!(first_result, second_result); - first_result?; - second_result?; + let test_started_at = clock_timestamp(&probe_client).await?; + let first = contract_unit_of_work().await?; + let second = contract_unit_of_work().await?; + let first_backend_pid = uow_backend_pid(&first).await?; + let second_backend_pid = uow_backend_pid(&second).await?; + + // Unmute the group while retaining the lock on the deleted mute row + first.unmute_user_group_notifications(user_id, subgroup_id()).await?; + + // Unmute the same group concurrently from the second transaction + let second_unmute = tokio::spawn(async move { + second.unmute_user_group_notifications(user_id, subgroup_id()).await?; + Box::new(second).commit().await + }); + wait_for_backend_blocker(&probe_client, first_backend_pid, second_backend_pid).await?; + + // Commit the first unmute so the waiting request observes it + Box::new(first).commit().await?; + second_unmute.await??; // Check the race removed the mute once and audited exactly one unmute - let row = cleanup_client - .query_one( - "select - ( - select count(*) - from user_group_notification_mute - where user_id = $1::uuid - and group_id = $2::uuid - ), - ( - select count(*) - from audit_log - where actor_user_id = $1::uuid - and action = 'group_notifications_unmuted' - and resource_id = $1::uuid - and resource_type = 'user' - and details->>'group_id' = $2::uuid::text - and created_at >= $3::timestamptz - )", - &[ - ¬ification_unmute_concurrency_user_id(), - &subgroup_id(), - &test_started_at, - ], - ) - .await?; - assert_eq!(row.get::<_, i64>(0), 0); - assert_eq!(row.get::<_, i64>(1), 1); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_update_user_notification_preferences_rejects_unknown_category() -> Result<()> -{ - // Setup a raw invalid preferences payload - let client = contract_tests_pool()?.get().await?; - let payload = Json(serde_json::json!({ "unknown-category": false })); - - // Submit the unknown category directly to the database function - let err = client - .query_one( - "select update_user_notification_preferences($1::uuid, $2::jsonb)", - &[¬ification_update_user_id(), &payload], - ) - .await - .expect_err("unknown categories should be rejected"); - - // Check the rejection maps to the user-facing SQLSTATE - assert_eq!( - err.as_db_error().map(DbError::message), - Some("unknown notification category") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + let counts = mute_and_audit_counts( + &probe_client, + user_id, + "group_notifications_unmuted", + test_started_at, + ) + .await?; + assert_eq!(counts, (0, 1)); Ok(()) } @@ -375,17 +236,13 @@ async fn db_contracts_update_user_notification_preferences_rejects_unknown_categ #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_update_user_notification_preferences_saves_categories() -> Result<()> { - // Setup a clean dedicated preference mutation target - let db = contract_tests_db()?; - let client = contract_tests_pool()?.get().await?; - client - .execute( - "delete from user_notification_opt_out where user_id = $1::uuid", - &[¬ification_update_user_id()], - ) - .await?; - let test_started_at: DateTime = - client.query_one("select clock_timestamp()", &[]).await?.get(0); + // Setup a rolled-back unit of work and a clean preference mutation target + let uow = contract_unit_of_work().await?; + uow.execute( + "delete from user_notification_opt_out where user_id = $1::uuid", + &[¬ification_update_user_id()], + ) + .await?; // Save one disabled category and one explicitly enabled category let input = NotificationPreferencesInput { @@ -394,25 +251,24 @@ async fn db_contracts_update_user_notification_preferences_saves_categories() -> (NotificationCategory::NewEvents, true), ]), }; - db.update_user_notification_preferences(notification_update_user_id(), &input) + uow.update_user_notification_preferences(notification_update_user_id(), &input) .await?; // Check the persisted preferences and audit snapshot - let preferences = db + let preferences = uow .get_user_notification_preferences(notification_update_user_id()) .await?; - let audit_count: i64 = client - .query_one( + let audit_count: i64 = uow + .fetch_scalar_one( "select count(*) from audit_log where actor_user_id = $1::uuid and action = 'user_notification_preferences_updated' and details = '{\"opted_out_categories\":[\"badges\"]}'::jsonb - and created_at >= $2::timestamptz", - &[¬ification_update_user_id(), &test_started_at], + and created_at >= current_timestamp", + &[¬ification_update_user_id()], ) - .await? - .get(0); + .await?; assert_eq!( preferences.opted_out_categories, vec![NotificationCategory::Badges] @@ -421,3 +277,58 @@ async fn db_contracts_update_user_notification_preferences_saves_categories() -> Ok(()) } + +// Helpers. + +/// Returns the current database clock time. +async fn clock_timestamp(client: &tokio_postgres::Client) -> Result> { + Ok(client.query_one("select clock_timestamp()", &[]).await?.get(0)) +} + +/// Deletes the mute of the subgroup for the given user. +async fn delete_mute(client: &tokio_postgres::Client, user_id: Uuid) -> Result<()> { + client + .execute( + "delete from user_group_notification_mute + where user_id = $1::uuid + and group_id = $2::uuid", + &[&user_id, &subgroup_id()], + ) + .await?; + + Ok(()) +} + +/// Returns the subgroup mute rows of the user and the audit rows of the given +/// action recorded since `since`. +async fn mute_and_audit_counts( + client: &tokio_postgres::Client, + user_id: Uuid, + action: &str, + since: DateTime, +) -> Result<(i64, i64)> { + let row = client + .query_one( + "select + ( + select count(*) + from user_group_notification_mute + where user_id = $1::uuid + and group_id = $2::uuid + ), + ( + select count(*) + from audit_log + where actor_user_id = $1::uuid + and action = $3::text + and resource_id = $1::uuid + and resource_type = 'user' + and details->>'group_id' = $2::uuid::text + and created_at >= $4::timestamptz + )", + &[&user_id, &subgroup_id(), &action, &since], + ) + .await?; + + Ok((row.get(0), row.get(1))) +} diff --git a/ocg-server/src/db/contract_tests/event.rs b/ocg-server/src/db/contract_tests/event.rs index 9726be42f..1b83dc513 100644 --- a/ocg-server/src/db/contract_tests/event.rs +++ b/ocg-server/src/db/contract_tests/event.rs @@ -2,11 +2,11 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use tokio_postgres::error::{DbError, SqlState}; +use tokio_postgres::error::SqlState; use crate::{ db::{ - DB, USER_FACING_DB_ERROR_CODE, + DB, dashboard::user::DBDashboardUser, event::{AttendEventResult, DBEvent}, }, @@ -45,23 +45,22 @@ async fn db_contracts_add_cfs_submission_blocks_no_key_updates() -> Result<()> { .await?; // Probe with a lock mode that does not conflict with the foreign-key lock - proposal_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let lock_result = proposal_client - .query_one( - "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for no key update", - &[&cfs_add_lock_proposal_id()], - ) - .await; - - // Release both transactions before checking the lock outcome - proposal_client.batch_execute("rollback").await?; + let probe_code = probe_lock( + &proposal_client, + "select session_proposal_id from session_proposal where session_proposal_id = $1::uuid for no key update", + &[&cfs_add_lock_proposal_id()], + ) + .await?; + + // Release the submission before checking the lock outcome submission_client.batch_execute("rollback").await?; // Check the explicit share lock blocks a competing non-key update - let lock_err = lock_result.expect_err("proposal update should wait for the submission lock"); - assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "proposal update should wait for the submission lock" + ); Ok(()) } @@ -70,7 +69,9 @@ async fn db_contracts_add_cfs_submission_blocks_no_key_updates() -> Result<()> { #[ignore = "requires the contract test database"] async fn db_contracts_attend_event_and_decline_event_admission_offer_complete_concurrently() -> Result<()> { - // Setup the contract database and a client to read the race outcome + // Setup the contract database and a client to read the race outcome; the + // two single-statement calls cannot be interleaved deterministically, so + // this race is best effort and only proves neither call deadlocks or fails let db = contract_tests_db()?; let state_client = contract_tests_pool()?.get().await?; @@ -131,21 +132,13 @@ async fn db_contracts_get_event_enrollment_deserializes() -> Result<()> { .get_event_enrollment(community_id(), event_id(), pre_registered_id()) .await?; let refund_offer_enrollment = db - .get_event_enrollment(community_id(), refund_event_id(), refund_offer_user_id()) + .get_event_enrollment(community_id(), paid_event_id(), refund_offer_user_id()) .await?; let rejected_refund_enrollment = db - .get_event_enrollment( - community_id(), - refund_event_id(), - refund_rejected_buyer_id(), - ) + .get_event_enrollment(community_id(), paid_event_id(), refund_rejected_buyer_id()) .await?; let rejected_request_enrollment = db - .get_event_enrollment( - community_id(), - refund_event_id(), - rejected_request_user_id(), - ) + .get_event_enrollment(community_id(), paid_event_id(), rejected_request_user_id()) .await?; let expired_offer_enrollment = db .get_event_enrollment(community_id(), status_event_id(), status_expired_user_id()) @@ -323,13 +316,10 @@ async fn db_contracts_get_event_summary_by_id_deserializes() -> Result<()> { // Load the event summary by identifier let event = db.get_event_summary_by_id(community_id(), event_id()).await?; - // Check identity, kind, and name fields + // Check identity, kind, name, and ticket type fields assert_eq!(event.event_id, event_id()); - assert!( - event - .ticket_types - .as_ref() - .is_some_and(|ticket_types| !ticket_types.is_empty()) + assert_contract_ticket_type( + &event.ticket_types.as_ref().expect("event should have tickets")[0], ); assert_eq!(event.kind, EventKind::Hybrid); assert_eq!(event.name, "Future Contract Event"); @@ -376,11 +366,11 @@ async fn db_contracts_get_event_utm_allowlist_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_leave_event_deserializes() -> Result<()> { - // Setup the contract database and attendee fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and the attendee fixture + let uow = contract_unit_of_work().await?; // Leave the event through the Rust contract - let outcome = db + let outcome = uow .leave_event( community_id(), mutation_event_id(), @@ -389,7 +379,7 @@ async fn db_contracts_leave_event_deserializes() -> Result<()> { ) .await?; - // Check the prior status and waitlist outcome + // Check the prior enrollment status assert_eq!(outcome.left_status, EventEnrollmentStatus::Attendee); Ok(()) @@ -464,14 +454,7 @@ async fn db_contracts_lock_events_for_cancellation_serializes_rsvp() -> Result<( ) .await .expect_err("RSVP should fail after cancellation commits"); - assert_eq!( - canceled_err.as_db_error().map(DbError::message), - Some("event not found or inactive") - ); - assert_eq!( - canceled_err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + assert_pg_ocg01(canceled_err, "event not found or inactive"); Ok(()) } diff --git a/ocg-server/src/db/contract_tests/helpers.rs b/ocg-server/src/db/contract_tests/helpers.rs index 76cb87938..fb9fe1e28 100644 --- a/ocg-server/src/db/contract_tests/helpers.rs +++ b/ocg-server/src/db/contract_tests/helpers.rs @@ -5,11 +5,11 @@ use std::{env, time::Duration}; use anyhow::{Context, Result}; use chrono::{DateTime, NaiveDate}; use deadpool_postgres::{Config as DeadpoolDbConfig, Pool, Runtime}; -use tokio_postgres::NoTls; -use uuid::Uuid; +use tokio_postgres::{NoTls, error::SqlState, types::ToSql}; +use uuid::{Uuid, uuid}; use crate::{ - db::PgDB, + db::{PgDB, PgExecutor, PgUnitOfWork, user_facing_message}, types::{ badges::{BadgeSnapshot, BadgeSnapshotIssuer, UserBadge}, dashboard::group::events::EventInput, @@ -18,377 +18,42 @@ use crate::{ }, }; -const ACTIVATION_ID: &str = "00000000-0000-0000-0000-00000000c045"; -/// Pre-registered user activated by the email/password contract. -const ACTIVATION_PASSWORD_ID: &str = "00000000-0000-0000-0000-00000000c048"; -const ACTIVE_USER_BADGE_ID: &str = "00000000-0000-0000-0000-00000000c0bd"; -/// Past paid event used by the event analytics contracts. -const ANALYTICS_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f205"; -/// Group owning the event analytics fixture. -const ANALYTICS_GROUP_ID: &str = "00000000-0000-0000-0000-00000000f203"; -/// Select registration question of the event analytics fixture. -const ANALYTICS_QUESTION_ID: &str = "00000000-0000-0000-0000-00000000f20b"; -/// Paid ticket type of the event analytics fixture. -const ANALYTICS_TICKET_TYPE_ID: &str = "00000000-0000-0000-0000-00000000f208"; -const ATTENDEE_ID: &str = "00000000-0000-0000-0000-00000000c042"; -const AUTO_END_MEETING_ID: &str = "00000000-0000-0000-0000-00000000c0a3"; -const BADGE_ARTWORK_ID: &str = "00000000-0000-0000-0000-00000000c0ba"; -const BADGE_AWARD_JOB_ID: &str = "00000000-0000-0000-0000-00000000c0bf"; -const BADGE_ID: &str = "00000000-0000-0000-0000-00000000c0bb"; -const BADGE_STATUS_LIST_ID: &str = "00000000-0000-0000-0000-00000000c0bc"; -const CANCELEE_ID: &str = "00000000-0000-0000-0000-00000000c0e9"; -/// Confirmed attendee canceled by the enrollment manager lifecycle contract. -const LIFECYCLE_CANCELEE_ID: &str = "00000000-0000-0000-0000-00000000c1f0"; -/// Confirmed attendee whose manager cancellation is rolled back by the lifecycle contract. -const LIFECYCLE_ROLLBACK_CANCELEE_ID: &str = "00000000-0000-0000-0000-00000000c1f1"; -/// User fixture that races an RSVP against event cancellation. -const CANCELLATION_LOCK_ATTENDEE_ID: &str = "00000000-0000-0000-0000-00000000c0ec"; -/// Event fixture used to verify cancellation lock ownership. -const CANCELLATION_LOCK_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d6"; -/// Proposal fixture used to verify submission share locking. -const CFS_ADD_LOCK_PROPOSAL_ID: &str = "00000000-0000-0000-0000-00000000c124"; -/// Proposal fixture used to verify deletion lock ordering. -const CFS_DELETE_LOCK_PROPOSAL_ID: &str = "00000000-0000-0000-0000-00000000c125"; -/// Session fixture used to verify CFS submission locking. -const CFS_LOCK_SESSION_ID: &str = "00000000-0000-0000-0000-00000000c123"; -const CFS_SUBMISSION_ID: &str = "00000000-0000-0000-0000-00000000c0c5"; -/// Proposal fixture used to verify update lock strength. -const CFS_UPDATE_LOCK_PROPOSAL_ID: &str = "00000000-0000-0000-0000-00000000c126"; -const CHECK_IN_CODE: &str = "00000000-0000-0000-0000-00000000c084"; -const CHECKOUT_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c0e1"; -const CLAIM_GROUP_ID: &str = "00000000-0000-0000-0000-00000000c0a0"; -const CO_SPEAKER_PROPOSAL_ID: &str = "00000000-0000-0000-0000-00000000c0c2"; -/// Group admin notified about co-hosting lifecycle changes. -const COHOST_ADMIN_ID: &str = "00000000-0000-0000-0000-00000000d401"; -/// Group with an approved co-hosting of the matrix event. -const COHOST_APPROVED_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d022"; -/// Canceled event with an event-canceled co-hosting. -const COHOST_CANCELED_EVENT_ID: &str = "00000000-0000-0000-0000-00000000d202"; -/// Group that withdrew its co-hosting of the matrix event. -const COHOST_CANCELED_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d023"; -/// Inactive community holding every co-host group fixture. -const COHOST_COMMUNITY_ID: &str = "00000000-0000-0000-0000-00000000d101"; -/// Active cross-community group with an approved co-hosting. -const COHOST_CROSS_COMMUNITY_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d121"; -/// Approved invitation of the cross-community co-host. -const COHOST_CROSS_COMMUNITY_INVITATION_ID: &str = "00000000-0000-0000-0000-00000000d309"; -/// Deleted event with an event-deleted co-hosting. -const COHOST_DELETED_EVENT_ID: &str = "00000000-0000-0000-0000-00000000d203"; -/// Group whose co-hosting ended with the event cancellation. -const COHOST_EVENT_CANCELED_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d024"; -/// Group whose co-hosting ended with the event deletion. -const COHOST_EVENT_DELETED_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d025"; -/// Invitation closed by the event deletion. -const COHOST_EVENT_DELETED_INVITATION_ID: &str = "00000000-0000-0000-0000-00000000d305"; -/// Group sorting after the low lock-order group. -const COHOST_LOCK_ORDER_HIGH_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d02a"; -/// Group sorting before the high lock-order group. -const COHOST_LOCK_ORDER_LOW_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d029"; -/// Unpublished event holding one co-hosting per editable status. -const COHOST_MATRIX_EVENT_ID: &str = "00000000-0000-0000-0000-00000000d201"; -/// Group with a pending co-hosting invitation. -const COHOST_PENDING_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d021"; -/// Pending invitation of the matrix event. -const COHOST_PENDING_INVITATION_ID: &str = "00000000-0000-0000-0000-00000000d301"; -/// Unpublished event without co-hosts used to race publication against an invitation. -const COHOST_PUBLISH_RACE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000d204"; -/// Inactive group re-invited to co-host the matrix event. -const COHOST_REINVITED_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d028"; -/// Unpublished event whose pending co-host is re-invited while an approval waits. -const COHOST_REINVITE_RACE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000d205"; -/// Pending invitation rotated by the re-invite race. -const COHOST_REINVITE_RACE_INVITATION_ID: &str = "00000000-0000-0000-0000-00000000d310"; -const COMMUNITY_ID: &str = "00000000-0000-0000-0000-00000000c001"; -/// Verified contact user who is admin of one group and viewer of another. -const CONTACT_ADMIN_ID: &str = "00000000-0000-0000-0000-00000000e141"; -/// Contact group category holding the active and inactive groups. -const CONTACT_CATEGORY_A_ID: &str = "00000000-0000-0000-0000-00000000e112"; -/// Contact group category holding the group without a region. -const CONTACT_CATEGORY_B_ID: &str = "00000000-0000-0000-0000-00000000e113"; -/// Inactive community used by the community contact contracts. -const CONTACT_COMMUNITY_ID: &str = "00000000-0000-0000-0000-00000000e101"; -/// Active contact group with a region. -const CONTACT_GROUP_A_ID: &str = "00000000-0000-0000-0000-00000000e121"; -/// Active contact group without a region whose only seat is a viewer. -const CONTACT_GROUP_B_ID: &str = "00000000-0000-0000-0000-00000000e122"; -/// Inactive contact group excluded from contact. -const CONTACT_INACTIVE_GROUP_ID: &str = "00000000-0000-0000-0000-00000000e123"; -/// Verified contact user who is events manager of one active group. -const CONTACT_MANAGER_ID: &str = "00000000-0000-0000-0000-00000000e142"; -/// Region of the contact community. -const CONTACT_REGION_ID: &str = "00000000-0000-0000-0000-00000000e111"; -const DOCUMENT_ADJUSTMENT_ID: &str = "00000000-0000-0000-0000-00000000c11d"; -const DOCUMENT_ADJUSTMENT_JOB_ID: &str = "00000000-0000-0000-0000-00000000c134"; -const DOCUMENT_CREDIT_NOTE_ID: &str = "00000000-0000-0000-0000-00000000c11e"; -const DOCUMENT_CREDIT_NOTE_JOB_ID: &str = "00000000-0000-0000-0000-00000000c135"; -const DOCUMENT_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c11b"; -const DOCUMENT_REFUND_ID: &str = "00000000-0000-0000-0000-00000000c11c"; -const DOCUMENT_REFUND_JOB_ID: &str = "00000000-0000-0000-0000-00000000c136"; -/// User who attends the event raced against an offer decline. -const ENROLLMENT_RACE_ATTENDEE_ID: &str = "00000000-0000-0000-0000-00000000f370"; -/// User who declines the offer raced against another user's attendance. -const ENROLLMENT_RACE_DECLINER_ID: &str = "00000000-0000-0000-0000-00000000f371"; -/// Free one-seat event used by the enrollment lock order race. -const ENROLLMENT_RACE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f372"; -/// Pending approval offer holding the only seat of the enrollment race event. -const ENROLLMENT_RACE_OFFER_ID: &str = "00000000-0000-0000-0000-00000000f375"; -const EVENT_CATEGORY_ID: &str = "00000000-0000-0000-0000-00000000c013"; -const EVENT_ID: &str = "00000000-0000-0000-0000-00000000c031"; -/// Label assigned to the contract event session and submission. -const EVENT_LABEL_ID: &str = "00000000-0000-0000-0000-00000000c0c8"; -/// Buyer fixture used to prepare a new external checkout hold. -const EXTERNAL_CHECKOUT_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c12a"; -/// Pending external purchase dedicated to the completion mutation contract. -const EXTERNAL_COMPLETE_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c133"; -/// Buyer fixture dedicated to the external completion mutation contract. -const EXTERNAL_COMPLETE_USER_ID: &str = "00000000-0000-0000-0000-00000000c132"; -/// Completed externally managed purchase used by document and attendee contracts. -const EXTERNAL_COMPLETED_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c12f"; -/// Buyer fixture with a completed externally managed purchase. -const EXTERNAL_COMPLETED_USER_ID: &str = "00000000-0000-0000-0000-00000000c128"; -/// Event fixture dedicated to external-payments contracts. -const EXTERNAL_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c12b"; -const EXTERNAL_LOOKUP_ID: &str = "00000000-0000-0000-0000-00000000c046"; -/// Pending external purchase awaiting organizer confirmation. -const EXTERNAL_PENDING_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c12e"; -/// Buyer fixture with a pending external purchase. -const EXTERNAL_PENDING_USER_ID: &str = "00000000-0000-0000-0000-00000000c127"; -/// External purchase waiting for local refund approval. -const EXTERNAL_REFUND_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c130"; -/// Buyer fixture whose external refund request is ready for approval. -const EXTERNAL_REFUND_USER_ID: &str = "00000000-0000-0000-0000-00000000c129"; -/// Ticket fixture used by the external-payments event. -const EXTERNAL_TICKET_TYPE_ID: &str = "00000000-0000-0000-0000-00000000c12c"; -const EXTERNAL_UPDATE_ID: &str = "00000000-0000-0000-0000-00000000c047"; -const FINANCIAL_RECOVERY_ADJUSTMENT_JOB_ID: &str = "00000000-0000-0000-0000-00000000c137"; -const FINANCIAL_RECOVERY_CREDIT_NOTE_JOB_ID: &str = "00000000-0000-0000-0000-00000000c138"; -const FREE_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c0e4"; -const FREE_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0f3"; -/// Group category of the contract community. -const GROUP_CATEGORY_ID: &str = "00000000-0000-0000-0000-00000000c012"; -const GROUP_ID: &str = "00000000-0000-0000-0000-00000000c021"; -/// First event fixture used to verify group-level mutation locks. -const GROUP_LOCK_FIRST_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c121"; -/// Second event fixture used to verify group-level mutation locks. -const GROUP_LOCK_SECOND_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c122"; -const GROUP_SPONSOR_ID: &str = "00000000-0000-0000-0000-00000000c061"; -const INBOX_OPT_OUT_ADMIN_ID: &str = "00000000-0000-0000-0000-00000000e02c"; -const INBOX_OPT_OUT_EVENT_ID: &str = "00000000-0000-0000-0000-00000000e034"; -const INBOX_OPT_OUT_EVENTS_MANAGER_ID: &str = "00000000-0000-0000-0000-00000000e02d"; -const INBOX_OPT_OUT_GROUP_ID: &str = "00000000-0000-0000-0000-00000000e014"; -const INBOX_OPT_OUT_USER_ID: &str = "00000000-0000-0000-0000-00000000e02e"; -const INVITATION_OFFER_ID: &str = "00000000-0000-0000-0000-00000000c083"; -const INVITATION_TICKET_TYPE_ID: &str = "00000000-0000-0000-0000-00000000c081"; -const INVITE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d8"; -const INVITEE_ID: &str = "00000000-0000-0000-0000-00000000c0ed"; -const LEAVER_ID: &str = "00000000-0000-0000-0000-00000000c0e8"; -const MUTATION_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d5"; -const MUTATION_OFFER_ID: &str = "00000000-0000-0000-0000-00000000c0d7"; -const NOTIFICATION_CONCURRENCY_USER_ID: &str = "00000000-0000-0000-0000-00000000f004"; -const NOTIFICATION_ELIGIBLE_USER_ID: &str = "00000000-0000-0000-0000-00000000f002"; -const NOTIFICATION_PREFERENCES_USER_ID: &str = "00000000-0000-0000-0000-00000000f001"; -const NOTIFICATION_UNAVAILABLE_GROUP_ID: &str = "00000000-0000-0000-0000-00000000d028"; -const NOTIFICATION_UNMUTE_CONCURRENCY_USER_ID: &str = "00000000-0000-0000-0000-00000000f005"; -const NOTIFICATION_UPDATE_USER_ID: &str = "00000000-0000-0000-0000-00000000f003"; -const OFFER_DECLINE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0dc"; -const OFFER_DECLINE_OFFER_ID: &str = "00000000-0000-0000-0000-00000000c0dd"; -const OFFER_DECLINER_ID: &str = "00000000-0000-0000-0000-00000000c0f0"; -const ORGANIZER_ID: &str = "00000000-0000-0000-0000-00000000c041"; -const PAID_CANCELLATION_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c118"; -const PAID_CANCELLATION_USER_ID: &str = "00000000-0000-0000-0000-00000000c117"; -const PAID_TICKET_PRICE_WINDOW_ID: &str = "00000000-0000-0000-0000-00000000c0d2"; -const PAID_TICKET_TYPE_ID: &str = "00000000-0000-0000-0000-00000000c0d1"; -const PAST_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c032"; -/// Admin of the group payment lock order fixture group. -const PAYMENT_LOCK_ADMIN_ID: &str = "00000000-0000-0000-0000-00000000f408"; -/// Unpublished paid event of the group payment lock order fixture. -const PAYMENT_LOCK_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f405"; -/// External-payments group of the group payment lock order fixture. -const PAYMENT_LOCK_GROUP_ID: &str = "00000000-0000-0000-0000-00000000f404"; -const PRE_REGISTERED_ID: &str = "00000000-0000-0000-0000-00000000c044"; -/// Event fixture whose full capacity sends organizer invitations to the queue. -const QUEUE_INVITE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c105"; -/// User fixture queued by the organizer invitation contract. -const QUEUE_INVITEE_ID: &str = "00000000-0000-0000-0000-00000000c103"; -const REBIND_USER_BADGE_ID: &str = "00000000-0000-0000-0000-00000000c0b9"; -const RECONCILE_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c0e3"; -const RECONCILE_DUE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0de"; -const REFUND_OFFER_USER_ID: &str = "00000000-0000-0000-0000-00000000c101"; -/// Purchase fixture whose provider refund is ready for local finalization. -const REFUND_APPROVE_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0f6"; -const REFUND_APPROVE_JOB_ID: &str = "00000000-0000-0000-0000-00000000c139"; -const REFUND_BEGIN_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0f4"; -const REFUND_LIFECYCLE_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0fb"; -/// Paid event containing the refund contract fixtures. -const REFUND_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d0"; -/// Purchase fixture whose locally finalized refund requires recovery. -const REFUND_RECOVERY_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0fd"; -/// Durable refund fixture preserving post-finalization recovery state. -const REFUND_RECOVERY_REFUND_ID: &str = "00000000-0000-0000-0000-00000000c0fe"; -const REFUND_RECOVERY_JOB_ID: &str = "00000000-0000-0000-0000-00000000c13a"; -const REFUND_REJECT_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c0e7"; -/// Purchase fixture whose refund request is ready for rejection. -const REFUND_REJECT_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0f8"; -/// Buyer fixture with a rejected refund reason for attendee-facing contracts. -const REFUND_REJECTED_BUYER_ID: &str = "00000000-0000-0000-0000-00000000c114"; -const REJECTED_REQUEST_USER_ID: &str = "00000000-0000-0000-0000-00000000c102"; -const REQUEST_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d9"; -const REQUESTER_ID: &str = "00000000-0000-0000-0000-00000000c0ee"; -const REVOKED_USER_BADGE_ID: &str = "00000000-0000-0000-0000-00000000c0be"; -/// Contract event session with one label. -const SESSION_ID: &str = "00000000-0000-0000-0000-00000000c051"; -const SESSION_PROPOSAL_ID: &str = "00000000-0000-0000-0000-00000000c0c1"; -const SITE_ID: &str = "00000000-0000-0000-0000-00000000c0b1"; -/// Inactive community whose cached public statistics are verified. -const STATS_CACHE_COMMUNITY_ID: &str = "00000000-0000-0000-0000-00000000f101"; -/// Active group counted by the stats cache community statistics. -const STATS_CACHE_GROUP_ID: &str = "00000000-0000-0000-0000-00000000f103"; -/// User added to the stats cache group after its statistics are cached. -const STATS_CACHE_JOINER_ID: &str = "00000000-0000-0000-0000-00000000f105"; -/// User fixture with a canceled organizer invitation. -const STATUS_CANCELED_USER_ID: &str = "00000000-0000-0000-0000-00000000c10e"; -/// User fixture with a declined organizer invitation. -const STATUS_DECLINED_USER_ID: &str = "00000000-0000-0000-0000-00000000c10f"; -/// Event fixture dedicated to enrollment status contracts. -const STATUS_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c109"; -/// User fixture with an expired organizer invitation. -const STATUS_EXPIRED_USER_ID: &str = "00000000-0000-0000-0000-00000000c10d"; -/// User fixture with a resumable pending payment. -const STATUS_PENDING_PAYMENT_USER_ID: &str = "00000000-0000-0000-0000-00000000c10c"; -/// Ticket fixture used by the pending payment. -const STATUS_TICKET_TYPE_ID: &str = "00000000-0000-0000-0000-00000000c10a"; -const SUBGROUP_ID: &str = "00000000-0000-0000-0000-00000000c022"; -const SUMMARY_PURCHASE_ID: &str = "00000000-0000-0000-0000-00000000c0f1"; -/// Open anonymous survey for checked-in attendees of the survey event. -const SURVEY_ANONYMOUS_ID: &str = "00000000-0000-0000-0000-00000000f306"; -/// Open survey closed while an attendee submits answers. -const SURVEY_CLOSE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f323"; -/// Open survey deleted while an attendee's submission holds its lock. -const SURVEY_DELETE_AFTER_SUBMISSION_RACE_ID: &str = "00000000-0000-0000-0000-00000000f327"; -/// Open survey deleted while an attendee submits answers. -const SURVEY_DELETE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f324"; -/// Draft survey without questions of the survey event. -const SURVEY_DRAFT_ID: &str = "00000000-0000-0000-0000-00000000f307"; -/// Open survey closed while the opening email job runs. -const SURVEY_EMAIL_CLOSE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f344"; -/// Past event whose survey opening emails reach its verified attendees. -const SURVEY_EMAIL_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f340"; -/// Open survey waiting for the opening email of its verified attendees. -const SURVEY_EMAIL_ID: &str = "00000000-0000-0000-0000-00000000f343"; -/// Verified attendee of the survey email event. -const SURVEY_EMAIL_RECIPIENT_ID: &str = "00000000-0000-0000-0000-00000000f313"; -/// Open survey whose event is rescheduled while the opening email job runs. -const SURVEY_EMAIL_RESCHEDULE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f351"; -/// Past test event holding the identified, anonymous and draft surveys. -const SURVEY_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f301"; -/// Open identified survey without responses answered while it is exported. -const SURVEY_EXPORT_GROWTH_ID: &str = "00000000-0000-0000-0000-00000000f325"; /// Number of attendees who answer the export growth survey. pub(super) const SURVEY_EXPORT_RESPONDENTS: u8 = 12; -/// Former attendee who answered the identified survey. -const SURVEY_FORMER_ATTENDEE_ID: &str = "00000000-0000-0000-0000-00000000f317"; -/// Open survey answered while its anonymity changes. -const SURVEY_FIRST_RESPONSE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f321"; -/// Free-text question of the identified survey. -const SURVEY_FREE_TEXT_QUESTION_ID: &str = "00000000-0000-0000-0000-00000000f308"; -/// Open identified survey with every question kind and three responses, one -/// of them from a former attendee. -const SURVEY_IDENTIFIED_ID: &str = "00000000-0000-0000-0000-00000000f305"; -/// Upcoming test event rescheduled while a survey is marked ready. -const SURVEY_MARK_READY_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f360"; -/// Draft survey marked ready while its event is rescheduled. -const SURVEY_MARK_READY_RACE_ID: &str = "00000000-0000-0000-0000-00000000f361"; -/// Open anonymous survey without responses whose opening email was handled. -const SURVEY_OPENED_ANONYMOUS_ID: &str = "00000000-0000-0000-0000-00000000f326"; -/// Confirmed attendee of the survey event who did not check in or answer. -const SURVEY_PENDING_RESPONDENT_ID: &str = "00000000-0000-0000-0000-00000000f304"; -/// Past test event holding the surveys used by the concurrency contracts. -const SURVEY_RACE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f320"; -/// Free-text question shared by the concurrency contract surveys. -const SURVEY_RACE_QUESTION_ID: &str = "00000000-0000-0000-0000-00000000f330"; -/// Attendee answering the concurrency contract surveys. -const SURVEY_RACER_ID: &str = "00000000-0000-0000-0000-00000000f316"; -/// Past test event rescheduled while the opening email job runs. -const SURVEY_RESCHEDULE_EVENT_ID: &str = "00000000-0000-0000-0000-00000000f350"; -/// Checked-in attendee who answered both open surveys of the survey event. -const SURVEY_RESPONDENT_ID: &str = "00000000-0000-0000-0000-00000000f302"; -/// Single-select question of the identified survey. -const SURVEY_SINGLE_SELECT_QUESTION_ID: &str = "00000000-0000-0000-0000-00000000f309"; -/// Open survey reconfigured while an attendee submits answers. -const SURVEY_STALE_RACE_ID: &str = "00000000-0000-0000-0000-00000000f322"; -/// Unverified attendee of the survey email event. -const SURVEY_UNVERIFIED_ATTENDEE_ID: &str = "00000000-0000-0000-0000-00000000f314"; -const SYNC_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0a1"; -const TICKETED_EVENT_ID: &str = "00000000-0000-0000-0000-00000000c0d0"; -/// Contract event session without labels. -const UNLABELED_SESSION_ID: &str = "00000000-0000-0000-0000-00000000c052"; -const WAITLIST_ID: &str = "00000000-0000-0000-0000-00000000c043"; /// Returns the activation identifier used by the contract fixture. -pub(super) fn activation_id() -> Uuid { - parse_uuid(ACTIVATION_ID) +pub(super) const fn activation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c045") } /// Returns the pre-registered user activated by the email/password contract. -pub(super) fn activation_password_id() -> Uuid { - parse_uuid(ACTIVATION_PASSWORD_ID) +pub(super) const fn activation_password_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c048") } /// Returns the active user badge identifier used by the contract fixture. -pub(super) fn active_user_badge_id() -> Uuid { - parse_uuid(ACTIVE_USER_BADGE_ID) +pub(super) const fn active_user_badge_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0bd") } /// Returns the past paid event used by the event analytics contracts. -pub(super) fn analytics_event_id() -> Uuid { - parse_uuid(ANALYTICS_EVENT_ID) +pub(super) const fn analytics_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f205") } /// Returns the group owning the event analytics fixture. -pub(super) fn analytics_group_id() -> Uuid { - parse_uuid(ANALYTICS_GROUP_ID) +pub(super) const fn analytics_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f203") } /// Returns the select registration question of the event analytics fixture. -pub(super) fn analytics_question_id() -> Uuid { - parse_uuid(ANALYTICS_QUESTION_ID) +pub(super) const fn analytics_question_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f20b") } /// Returns the paid ticket type of the event analytics fixture. -pub(super) fn analytics_ticket_type_id() -> Uuid { - parse_uuid(ANALYTICS_TICKET_TYPE_ID) -} - -/// Checks the complete paid ticket type JSON contract. -pub(super) fn assert_contract_paid_ticket_type(ticket_type: &EventTicketType) { - assert!(ticket_type.active); - assert_eq!( - ticket_type.availability, - EventTicketTypeAvailability::Public - ); - assert_eq!(ticket_type.event_ticket_type_id, paid_ticket_type_id()); - assert_eq!(ticket_type.order, 1); - assert_eq!(ticket_type.title, "Contract Paid Ticket"); - - let current_price = ticket_type - .current_price - .as_ref() - .expect("paid contract ticket to have a current price"); - assert_eq!(current_price.amount_minor, 2_500); - assert_eq!(current_price.ends_at, None); - assert_eq!(current_price.starts_at, None); - assert_eq!(ticket_type.description, None); - assert_eq!(ticket_type.price_windows.len(), 1); - assert_eq!(ticket_type.price_windows[0].amount_minor, 2_500); - assert_eq!( - ticket_type.price_windows[0].event_ticket_price_window_id, - paid_ticket_price_window_id() - ); - assert_eq!(ticket_type.price_windows[0].ends_at, None); - assert_eq!(ticket_type.price_windows[0].starts_at, None); - assert!(ticket_type.remaining_seats.is_some()); - assert_eq!(ticket_type.seats_total, Some(50)); - assert!(!ticket_type.sold_out); +pub(super) const fn analytics_ticket_type_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f208") } /// Checks the complete ticket type JSON contract shared by event projections. @@ -417,7 +82,7 @@ pub(super) fn assert_contract_ticket_type(ticket_type: &EventTicketType) { assert_eq!(ticket_type.price_windows[0].amount_minor, 2_500); assert_eq!( ticket_type.price_windows[0].event_ticket_price_window_id, - parse_uuid("00000000-0000-0000-0000-00000000c082") + invitation_ticket_price_window_id() ); assert_eq!(ticket_type.price_windows[0].ends_at, None); assert_eq!(ticket_type.price_windows[0].starts_at, None); @@ -426,327 +91,343 @@ pub(super) fn assert_contract_ticket_type(ticket_type: &EventTicketType) { assert!(!ticket_type.sold_out); } -/// Returns the attendee identifier used by the contract fixture. -pub(super) fn attendee_id() -> Uuid { - parse_uuid(ATTENDEE_ID) -} - -/// Returns the automatically ended meeting identifier used by the contract fixture. -pub(super) fn auto_end_meeting_id() -> Uuid { - parse_uuid(AUTO_END_MEETING_ID) -} - -/// Returns the badge artwork identifier used by the contract fixture. -pub(super) fn badge_artwork_id() -> Uuid { - parse_uuid(BADGE_ARTWORK_ID) -} - -/// Returns the badge award job identifier used by the contract fixture. -pub(super) fn badge_award_job_id() -> Uuid { - parse_uuid(BADGE_AWARD_JOB_ID) -} - -/// Returns the badge identifier used by the contract fixture. -pub(super) fn badge_id() -> Uuid { - parse_uuid(BADGE_ID) -} - -/// Returns the badge status list identifier used by the contract fixture. -pub(super) fn badge_status_list_id() -> Uuid { - parse_uuid(BADGE_STATUS_LIST_ID) +/// Asserts an error is a user-facing `OCG01` database rejection with the +/// given message, classified through the production [`user_facing_message`]. +#[track_caller] +pub(super) fn assert_ocg01(err: &anyhow::Error, message: &str) { + assert_eq!( + user_facing_message(err).as_deref(), + Some(message), + "expected an OCG01 rejection, got {err:#}" + ); } -/// Builds an active user badge from the supplied contract snapshot and identity fields. -pub(super) fn contract_active_user_badge( - snapshot: BadgeSnapshot, - event_name: Option, - recipient_name: Option, - recipient_username: Option, -) -> UserBadge { - UserBadge { - awarded_at: DateTime::from_timestamp(1_705_053_600, 0).unwrap(), - badge_status_list_id: badge_status_list_id(), - display_order: 0, - group_id: group_id(), - is_listed: true, - snapshot, - status_list_index: 7, - user_badge_id: active_user_badge_id(), - - badge_id: Some(badge_id()), - event_id: Some(event_id()), - event_name, - identity_bound_at: None, - identity_hash: None, - identity_salt: None, - recipient_name, - recipient_username, - revocation_reason: None, - revoked_at: None, - revoked_by_user_id: None, - user_id: Some(attendee_id()), - } +/// Asserts a raw database error is a user-facing `OCG01` rejection with the +/// given message. +#[track_caller] +pub(super) fn assert_pg_ocg01(err: tokio_postgres::Error, message: &str) { + assert_ocg01(&err.into(), message); } -/// Builds the badge snapshot used by contract fixtures. -pub(super) fn contract_badge_snapshot() -> BadgeSnapshot { - BadgeSnapshot { - criteria: "Attend the contract event".to_string(), - description: "Recognizes contract event participation".to_string(), - image_file_name: "contract-badge.png".to_string(), - issuer: BadgeSnapshotIssuer { - community_id: community_id(), - community_name: "Contract Community".to_string(), - group_id: group_id(), - group_name: "Contract Group".to_string(), - }, - name: "Contract Participant".to_string(), - } -} - -/// Returns the pending application-fee adjustment used by worker contracts. -pub(super) fn document_adjustment_id() -> Uuid { - parse_uuid(DOCUMENT_ADJUSTMENT_ID) +/// Returns the attendee identifier used by the contract fixture. +pub(super) const fn attendee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c042") } -/// Returns the pending application-fee adjustment job used by worker contracts. -pub(super) fn document_adjustment_job_id() -> Uuid { - parse_uuid(DOCUMENT_ADJUSTMENT_JOB_ID) +/// Returns the automatically ended meeting identifier used by the contract fixture. +pub(super) const fn auto_end_meeting_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0a3") } -/// Returns the credit note used by worker and attendee document contracts. -pub(super) fn document_credit_note_id() -> Uuid { - parse_uuid(DOCUMENT_CREDIT_NOTE_ID) +/// Returns the backend process ID of a raw contract-test connection. +pub(super) async fn backend_pid(client: &tokio_postgres::Client) -> Result { + Ok(client.query_one("select pg_backend_pid()", &[]).await?.get(0)) } -/// Returns the credit-note job used by worker and attendee document contracts. -pub(super) fn document_credit_note_job_id() -> Uuid { - parse_uuid(DOCUMENT_CREDIT_NOTE_JOB_ID) +/// Returns the badge artwork identifier used by the contract fixture. +pub(super) const fn badge_artwork_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0ba") } -/// Returns the provider-backed purchase used by attendee document contracts. -pub(super) fn document_purchase_id() -> Uuid { - parse_uuid(DOCUMENT_PURCHASE_ID) +/// Returns the badge award job identifier used by the contract fixture. +pub(super) const fn badge_award_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0bf") } -/// Returns the provider refund used by credit-note worker contracts. -pub(super) fn document_refund_id() -> Uuid { - parse_uuid(DOCUMENT_REFUND_ID) +/// Returns the badge identifier used by the contract fixture. +pub(super) const fn badge_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0bb") } -/// Returns the provider refund job used by credit-note worker contracts. -pub(super) fn document_refund_job_id() -> Uuid { - parse_uuid(DOCUMENT_REFUND_JOB_ID) +/// Returns the badge status list identifier used by the contract fixture. +pub(super) const fn badge_status_list_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0bc") } /// Returns the event cancellation target identifier used by the contract fixture. -pub(super) fn cancelee_id() -> Uuid { - parse_uuid(CANCELEE_ID) +pub(super) const fn cancelee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e9") } /// Returns the attendee fixture that races event cancellation. -pub(super) fn cancellation_lock_attendee_id() -> Uuid { - parse_uuid(CANCELLATION_LOCK_ATTENDEE_ID) +pub(super) const fn cancellation_lock_attendee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0ec") } /// Returns the event fixture used to verify cancellation locking. -pub(super) fn cancellation_lock_event_id() -> Uuid { - parse_uuid(CANCELLATION_LOCK_EVENT_ID) +pub(super) const fn cancellation_lock_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d6") } /// Returns the proposal used to verify submission share locking. -pub(super) fn cfs_add_lock_proposal_id() -> Uuid { - parse_uuid(CFS_ADD_LOCK_PROPOSAL_ID) +pub(super) const fn cfs_add_lock_proposal_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c124") } /// Returns the proposal used to verify deletion lock ordering. -pub(super) fn cfs_delete_lock_proposal_id() -> Uuid { - parse_uuid(CFS_DELETE_LOCK_PROPOSAL_ID) +pub(super) const fn cfs_delete_lock_proposal_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c125") } /// Returns the session identifier used to verify CFS submission locking. -pub(super) fn cfs_lock_session_id() -> Uuid { - parse_uuid(CFS_LOCK_SESSION_ID) +pub(super) const fn cfs_lock_session_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c123") } /// Returns the call-for-speakers submission identifier used by the contract fixture. -pub(super) fn cfs_submission_id() -> Uuid { - parse_uuid(CFS_SUBMISSION_ID) +pub(super) const fn cfs_submission_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0c5") } /// Returns the proposal used to verify update lock strength. -pub(super) fn cfs_update_lock_proposal_id() -> Uuid { - parse_uuid(CFS_UPDATE_LOCK_PROPOSAL_ID) +pub(super) const fn cfs_update_lock_proposal_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c126") } /// Returns the attendee check-in code used by the contract fixture. -pub(super) fn check_in_code() -> Uuid { - parse_uuid(CHECK_IN_CODE) +pub(super) const fn check_in_code() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c084") } /// Returns the checkout buyer identifier used by the contract fixture. -pub(super) fn checkout_buyer_id() -> Uuid { - parse_uuid(CHECKOUT_BUYER_ID) +pub(super) const fn checkout_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e1") } /// Returns the claim group identifier used by the contract fixture. -pub(super) fn claim_group_id() -> Uuid { - parse_uuid(CLAIM_GROUP_ID) +pub(super) const fn claim_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0a0") } /// Returns the co-speaker proposal identifier used by the contract fixture. -pub(super) fn co_speaker_proposal_id() -> Uuid { - parse_uuid(CO_SPEAKER_PROPOSAL_ID) +pub(super) const fn co_speaker_proposal_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0c2") } /// Returns the group admin notified about co-hosting lifecycle changes. -pub(super) fn cohost_admin_id() -> Uuid { - parse_uuid(COHOST_ADMIN_ID) +pub(super) const fn cohost_admin_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d401") } /// Returns the group with an approved co-hosting of the matrix event. -pub(super) fn cohost_approved_group_id() -> Uuid { - parse_uuid(COHOST_APPROVED_GROUP_ID) +pub(super) const fn cohost_approved_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d022") } /// Returns the canceled event with an event-canceled co-hosting. -pub(super) fn cohost_canceled_event_id() -> Uuid { - parse_uuid(COHOST_CANCELED_EVENT_ID) +pub(super) const fn cohost_canceled_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d202") } /// Returns the group that withdrew its co-hosting of the matrix event. -pub(super) fn cohost_canceled_group_id() -> Uuid { - parse_uuid(COHOST_CANCELED_GROUP_ID) +pub(super) const fn cohost_canceled_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d023") } /// Returns the inactive community holding every co-host group fixture. -pub(super) fn cohost_community_id() -> Uuid { - parse_uuid(COHOST_COMMUNITY_ID) +pub(super) const fn cohost_community_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d101") } /// Returns the active cross-community group with an approved co-hosting. -pub(super) fn cohost_cross_community_group_id() -> Uuid { - parse_uuid(COHOST_CROSS_COMMUNITY_GROUP_ID) +pub(super) const fn cohost_cross_community_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d121") } /// Returns the approved invitation of the cross-community co-host. -pub(super) fn cohost_cross_community_invitation_id() -> Uuid { - parse_uuid(COHOST_CROSS_COMMUNITY_INVITATION_ID) +pub(super) const fn cohost_cross_community_invitation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d309") } /// Returns the deleted event with an event-deleted co-hosting. -pub(super) fn cohost_deleted_event_id() -> Uuid { - parse_uuid(COHOST_DELETED_EVENT_ID) +pub(super) const fn cohost_deleted_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d203") } /// Returns the group whose co-hosting ended with the event cancellation. -pub(super) fn cohost_event_canceled_group_id() -> Uuid { - parse_uuid(COHOST_EVENT_CANCELED_GROUP_ID) +pub(super) const fn cohost_event_canceled_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d024") } /// Returns the group whose co-hosting ended with the event deletion. -pub(super) fn cohost_event_deleted_group_id() -> Uuid { - parse_uuid(COHOST_EVENT_DELETED_GROUP_ID) +pub(super) const fn cohost_event_deleted_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d025") } /// Returns the invitation closed by the event deletion. -pub(super) fn cohost_event_deleted_invitation_id() -> Uuid { - parse_uuid(COHOST_EVENT_DELETED_INVITATION_ID) +pub(super) const fn cohost_event_deleted_invitation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d305") } /// Returns the group sorting after the low lock-order group. -pub(super) fn cohost_lock_order_high_group_id() -> Uuid { - parse_uuid(COHOST_LOCK_ORDER_HIGH_GROUP_ID) +pub(super) const fn cohost_lock_order_high_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d02a") } /// Returns the group sorting before the high lock-order group. -pub(super) fn cohost_lock_order_low_group_id() -> Uuid { - parse_uuid(COHOST_LOCK_ORDER_LOW_GROUP_ID) +pub(super) const fn cohost_lock_order_low_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d029") } /// Returns the unpublished event holding one co-hosting per editable status. -pub(super) fn cohost_matrix_event_id() -> Uuid { - parse_uuid(COHOST_MATRIX_EVENT_ID) +pub(super) const fn cohost_matrix_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d201") } /// Returns the group with a pending co-hosting invitation. -pub(super) fn cohost_pending_group_id() -> Uuid { - parse_uuid(COHOST_PENDING_GROUP_ID) +pub(super) const fn cohost_pending_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d021") } /// Returns the pending invitation of the matrix event. -pub(super) fn cohost_pending_invitation_id() -> Uuid { - parse_uuid(COHOST_PENDING_INVITATION_ID) +pub(super) const fn cohost_pending_invitation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d301") } /// Returns the unpublished event used to race publication against an invitation. -pub(super) fn cohost_publish_race_event_id() -> Uuid { - parse_uuid(COHOST_PUBLISH_RACE_EVENT_ID) -} - -/// Returns the inactive group re-invited to co-host the matrix event. -pub(super) fn cohost_reinvited_group_id() -> Uuid { - parse_uuid(COHOST_REINVITED_GROUP_ID) +pub(super) const fn cohost_publish_race_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d204") } /// Returns the event whose pending co-host is re-invited while an approval waits. -pub(super) fn cohost_reinvite_race_event_id() -> Uuid { - parse_uuid(COHOST_REINVITE_RACE_EVENT_ID) +pub(super) const fn cohost_reinvite_race_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d205") } /// Returns the pending invitation rotated by the re-invite race. -pub(super) fn cohost_reinvite_race_invitation_id() -> Uuid { - parse_uuid(COHOST_REINVITE_RACE_INVITATION_ID) +pub(super) const fn cohost_reinvite_race_invitation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d310") +} + +/// Returns the inactive group re-invited to co-host the matrix event, which the +/// notification preferences fixture also mutes as an unavailable group. +pub(super) const fn cohost_reinvited_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000d028") } /// Returns the community identifier used by the contract fixture. -pub(crate) fn community_id() -> Uuid { - parse_uuid(COMMUNITY_ID) +pub(crate) const fn community_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c001") } /// Returns the verified contact user who is admin of one group and viewer of another. -pub(super) fn contact_admin_id() -> Uuid { - parse_uuid(CONTACT_ADMIN_ID) +pub(super) const fn contact_admin_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e141") } /// Returns the contact group category holding the active and inactive groups. -pub(super) fn contact_category_a_id() -> Uuid { - parse_uuid(CONTACT_CATEGORY_A_ID) +pub(super) const fn contact_category_a_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e112") } /// Returns the contact group category holding the group without a region. -pub(super) fn contact_category_b_id() -> Uuid { - parse_uuid(CONTACT_CATEGORY_B_ID) +pub(super) const fn contact_category_b_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e113") } /// Returns the inactive community used by the community contact contracts. -pub(super) fn contact_community_id() -> Uuid { - parse_uuid(CONTACT_COMMUNITY_ID) +pub(super) const fn contact_community_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e101") } /// Returns the active contact group with a region. -pub(super) fn contact_group_a_id() -> Uuid { - parse_uuid(CONTACT_GROUP_A_ID) +pub(super) const fn contact_group_a_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e121") } /// Returns the active contact group without a region whose only seat is a viewer. -pub(super) fn contact_group_b_id() -> Uuid { - parse_uuid(CONTACT_GROUP_B_ID) +pub(super) const fn contact_group_b_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e122") } /// Returns the inactive contact group excluded from contact. -pub(super) fn contact_inactive_group_id() -> Uuid { - parse_uuid(CONTACT_INACTIVE_GROUP_ID) +pub(super) const fn contact_inactive_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e123") } /// Returns the verified contact user who is events manager of one active group. -pub(super) fn contact_manager_id() -> Uuid { - parse_uuid(CONTACT_MANAGER_ID) +pub(super) const fn contact_manager_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e142") } /// Returns the region of the contact community. -pub(super) fn contact_region_id() -> Uuid { - parse_uuid(CONTACT_REGION_ID) +pub(super) const fn contact_region_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e111") +} + +/// Builds an active user badge from the supplied contract snapshot and identity fields. +pub(super) fn contract_active_user_badge( + snapshot: BadgeSnapshot, + event_name: Option, + recipient_name: Option, + recipient_username: Option, +) -> UserBadge { + UserBadge { + awarded_at: DateTime::from_timestamp(1_705_053_600, 0).unwrap(), + badge_status_list_id: badge_status_list_id(), + display_order: 0, + group_id: group_id(), + is_listed: true, + snapshot, + status_list_index: 7, + user_badge_id: active_user_badge_id(), + + badge_id: Some(badge_id()), + event_id: Some(event_id()), + event_name, + identity_bound_at: None, + identity_hash: None, + identity_salt: None, + recipient_name, + recipient_username, + revocation_reason: None, + revoked_at: None, + revoked_by_user_id: None, + user_id: Some(attendee_id()), + } +} + +/// Builds the badge snapshot used by contract fixtures. +pub(super) fn contract_badge_snapshot() -> BadgeSnapshot { + BadgeSnapshot { + criteria: "Attend the contract event".to_string(), + description: "Recognizes contract event participation".to_string(), + image_file_name: "contract-badge.png".to_string(), + issuer: BadgeSnapshotIssuer { + community_id: community_id(), + community_name: "Contract Community".to_string(), + group_id: group_id(), + group_name: "Contract Group".to_string(), + }, + name: "Contract Participant".to_string(), + } +} + +/// Builds a future virtual test event input on the given August 2099 day. +pub(super) fn contract_event_input(name: &str, day: u32) -> EventInput { + let starts_at = NaiveDate::from_ymd_opt(2099, 8, day) + .expect("date should be valid") + .and_hms_opt(10, 0, 0) + .expect("time should be valid"); + let ends_at = NaiveDate::from_ymd_opt(2099, 8, day) + .expect("date should be valid") + .and_hms_opt(11, 0, 0) + .expect("time should be valid"); + + EventInput { + category_id: event_category_id(), + description: "An event used by Rust database contract tests".to_string(), + kind_id: "virtual".to_string(), + name: name.to_string(), + timezone: "UTC".to_string(), + + capacity: Some(100), + ends_at: Some(ends_at), + starts_at: Some(starts_at), + test_event: Some(true), + + ..Default::default() + } } /// Returns the label assigned to the contract session and submission. @@ -793,742 +474,874 @@ pub(crate) fn contract_tests_pool() -> Result { } /// Begins a unit of work that rolls back when dropped. -pub(super) async fn contract_unit_of_work() -> Result { +pub(super) async fn contract_unit_of_work() -> Result { let client = contract_tests_pool()?.get().await?; client.batch_execute("begin").await?; - Ok(crate::db::PgUnitOfWork { + Ok(PgUnitOfWork { client: Some(client), }) } +/// Returns the pending application-fee adjustment used by worker contracts. +pub(super) const fn document_adjustment_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c11d") +} + +/// Returns the pending application-fee adjustment job used by worker contracts. +pub(super) const fn document_adjustment_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c134") +} + +/// Returns the credit note used by worker and attendee document contracts. +pub(super) const fn document_credit_note_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c11e") +} + +/// Returns the credit-note job used by worker and attendee document contracts. +pub(super) const fn document_credit_note_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c135") +} + +/// Returns the provider-backed purchase used by attendee document contracts. +pub(super) const fn document_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c11b") +} + +/// Returns the provider refund used by credit-note worker contracts. +pub(super) const fn document_refund_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c11c") +} + +/// Returns the provider refund job used by credit-note worker contracts. +pub(super) const fn document_refund_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c136") +} + /// Returns the user who attends the enrollment race event. -pub(super) fn enrollment_race_attendee_id() -> Uuid { - parse_uuid(ENROLLMENT_RACE_ATTENDEE_ID) +pub(super) const fn enrollment_race_attendee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f370") } /// Returns the user who declines the enrollment race offer. -pub(super) fn enrollment_race_decliner_id() -> Uuid { - parse_uuid(ENROLLMENT_RACE_DECLINER_ID) +pub(super) const fn enrollment_race_decliner_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f371") } /// Returns the free one-seat event used by the enrollment lock order race. -pub(super) fn enrollment_race_event_id() -> Uuid { - parse_uuid(ENROLLMENT_RACE_EVENT_ID) +pub(super) const fn enrollment_race_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f372") } /// Returns the pending approval offer declined by the enrollment race. -pub(super) fn enrollment_race_offer_id() -> Uuid { - parse_uuid(ENROLLMENT_RACE_OFFER_ID) +pub(super) const fn enrollment_race_offer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f375") } /// Returns an environment value or its contract-test default. -pub(super) fn env_or_default(name: &str, default: &str) -> String { +fn env_or_default(name: &str, default: &str) -> String { env::var(name).unwrap_or_else(|_| default.to_string()) } /// Returns the event category identifier used by the contract fixture. -pub(super) fn event_category_id() -> Uuid { - parse_uuid(EVENT_CATEGORY_ID) +pub(super) const fn event_category_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c013") } /// Returns the event identifier used by the contract fixture. -pub(super) fn event_id() -> Uuid { - parse_uuid(EVENT_ID) +pub(super) const fn event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c031") } /// Returns the label assigned to the contract session and submission. -pub(super) fn event_label_id() -> Uuid { - parse_uuid(EVENT_LABEL_ID) +pub(super) const fn event_label_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0c8") } /// Returns the buyer used to prepare a new external checkout hold. -pub(super) fn external_checkout_buyer_id() -> Uuid { - parse_uuid(EXTERNAL_CHECKOUT_BUYER_ID) +pub(super) const fn external_checkout_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c12a") } /// Returns the pending external purchase dedicated to completion. -pub(super) fn external_complete_purchase_id() -> Uuid { - parse_uuid(EXTERNAL_COMPLETE_PURCHASE_ID) +pub(super) const fn external_complete_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c133") } /// Returns the buyer dedicated to the external completion contract. -pub(super) fn external_complete_user_id() -> Uuid { - parse_uuid(EXTERNAL_COMPLETE_USER_ID) +pub(super) const fn external_complete_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c132") } /// Returns the completed externally managed purchase identifier. -pub(super) fn external_completed_purchase_id() -> Uuid { - parse_uuid(EXTERNAL_COMPLETED_PURCHASE_ID) +pub(super) const fn external_completed_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c12f") } /// Returns the buyer with a completed externally managed purchase. -pub(super) fn external_completed_user_id() -> Uuid { - parse_uuid(EXTERNAL_COMPLETED_USER_ID) +pub(super) const fn external_completed_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c128") } /// Returns the event dedicated to external-payments contracts. -pub(super) fn external_event_id() -> Uuid { - parse_uuid(EXTERNAL_EVENT_ID) +pub(super) const fn external_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c12b") } /// Returns the external identity lookup identifier used by the contract fixture. -pub(super) fn external_lookup_id() -> Uuid { - parse_uuid(EXTERNAL_LOOKUP_ID) +pub(super) const fn external_lookup_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c046") } /// Returns the pending external purchase awaiting organizer confirmation. -pub(super) fn external_pending_purchase_id() -> Uuid { - parse_uuid(EXTERNAL_PENDING_PURCHASE_ID) +pub(super) const fn external_pending_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c12e") } /// Returns the buyer with a pending external purchase. -pub(super) fn external_pending_user_id() -> Uuid { - parse_uuid(EXTERNAL_PENDING_USER_ID) +pub(super) const fn external_pending_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c127") } /// Returns the external purchase waiting for local refund approval. -pub(super) fn external_refund_purchase_id() -> Uuid { - parse_uuid(EXTERNAL_REFUND_PURCHASE_ID) +pub(super) const fn external_refund_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c130") } /// Returns the buyer whose external refund request is ready for approval. -pub(super) fn external_refund_user_id() -> Uuid { - parse_uuid(EXTERNAL_REFUND_USER_ID) +pub(super) const fn external_refund_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c129") } /// Returns the ticket used by the external-payments event. -pub(super) fn external_ticket_type_id() -> Uuid { - parse_uuid(EXTERNAL_TICKET_TYPE_ID) +pub(super) const fn external_ticket_type_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c12c") } /// Returns the external identity update identifier used by the contract fixture. -pub(super) fn external_update_id() -> Uuid { - parse_uuid(EXTERNAL_UPDATE_ID) +pub(super) const fn external_update_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c047") } /// Returns the exhausted application-fee recovery job identifier. -pub(super) fn financial_recovery_adjustment_job_id() -> Uuid { - parse_uuid(FINANCIAL_RECOVERY_ADJUSTMENT_JOB_ID) +pub(super) const fn financial_recovery_adjustment_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c137") } /// Returns the exhausted credit-note recovery job identifier. -pub(super) fn financial_recovery_credit_note_job_id() -> Uuid { - parse_uuid(FINANCIAL_RECOVERY_CREDIT_NOTE_JOB_ID) +pub(super) const fn financial_recovery_credit_note_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c138") } /// Returns the free checkout buyer identifier used by the contract fixture. -pub(super) fn free_buyer_id() -> Uuid { - parse_uuid(FREE_BUYER_ID) +pub(super) const fn free_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e4") } /// Returns the free purchase identifier used by the contract fixture. -pub(super) fn free_purchase_id() -> Uuid { - parse_uuid(FREE_PURCHASE_ID) +pub(super) const fn free_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f3") } -/// Returns the group category of the contract community. -pub(super) fn group_category_id() -> Uuid { - parse_uuid(GROUP_CATEGORY_ID) +/// Returns the group identifier used by the contract fixture. +pub(crate) const fn group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c021") } -/// Returns the group identifier used by the contract fixture. -pub(crate) fn group_id() -> Uuid { - parse_uuid(GROUP_ID) +/// Returns the first event used to verify group-level mutation locks. +pub(super) const fn group_lock_first_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c121") } -/// Returns an event update used by group-level lock contract tests. -pub(super) fn group_lock_event_update(name: &str, day: u32) -> EventInput { - let starts_at = NaiveDate::from_ymd_opt(2099, 8, day) - .expect("date should be valid") - .and_hms_opt(10, 0, 0) - .expect("time should be valid"); - let ends_at = NaiveDate::from_ymd_opt(2099, 8, day) - .expect("date should be valid") - .and_hms_opt(11, 0, 0) - .expect("time should be valid"); +/// Returns the second event used to verify group-level mutation locks. +pub(super) const fn group_lock_second_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c122") +} - EventInput { - category_id: event_category_id(), - description: "An event used by group lock contract tests".to_string(), - kind_id: "virtual".to_string(), - name: name.to_string(), - timezone: "UTC".to_string(), +/// Returns the group sponsor identifier used by the contract fixture. +pub(super) const fn group_sponsor_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c061") +} - capacity: Some(100), - ends_at: Some(ends_at), - starts_at: Some(starts_at), - test_event: Some(true), +/// Returns the group team member with the admin role in the read and write groups. +pub(crate) const fn inbox_admin_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e021") +} - ..Default::default() - } +/// Returns the closed read conversation without event. +pub(super) const fn inbox_closed_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e042") } -/// Returns the first event used to verify group-level mutation locks. -pub(super) fn group_lock_first_event_id() -> Uuid { - parse_uuid(GROUP_LOCK_FIRST_EVENT_ID) +/// Returns the published event owned by the co-host group. +pub(super) const fn inbox_cohost_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e033") } -/// Returns the second event used to verify group-level mutation locks. -pub(super) fn group_lock_second_event_id() -> Uuid { - parse_uuid(GROUP_LOCK_SECOND_EVENT_ID) +/// Returns the inactive community holding every inbox fixture. +pub(crate) const fn inbox_community_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e001") } -/// Returns the group sponsor identifier used by the contract fixture. -pub(super) fn group_sponsor_id() -> Uuid { - parse_uuid(GROUP_SPONSOR_ID) +/// Returns the read conversation whose user account was deleted. +pub(super) const fn inbox_deleted_user_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e043") +} + +/// Returns the group team member with the events-manager role in the read and write groups. +pub(crate) const fn inbox_events_manager_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e022") +} + +/// Returns the open conversation of the follow-up limit user. +pub(super) const fn inbox_follow_up_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e045") +} + +/// Returns the user with nine follow-ups sent today. +pub(super) const fn inbox_follow_up_limit_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e028") +} + +/// Returns the user starting a conversation through the manager. +pub(crate) const fn inbox_manager_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e02a") +} + +/// Returns the open read conversation with three messages. +pub(super) const fn inbox_open_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e041") } /// Returns the opted-out inbox admin used by the contract fixture. -pub(super) fn inbox_opt_out_admin_id() -> Uuid { - parse_uuid(INBOX_OPT_OUT_ADMIN_ID) +pub(crate) const fn inbox_opt_out_admin_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e02c") } /// Returns the event whose inbox recipients opted out of group-inbox emails. -pub(super) fn inbox_opt_out_event_id() -> Uuid { - parse_uuid(INBOX_OPT_OUT_EVENT_ID) +pub(crate) const fn inbox_opt_out_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e034") } /// Returns the opted-out inbox events manager used by the contract fixture. -pub(super) fn inbox_opt_out_events_manager_id() -> Uuid { - parse_uuid(INBOX_OPT_OUT_EVENTS_MANAGER_ID) +pub(crate) const fn inbox_opt_out_events_manager_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e02d") } /// Returns the group whose inbox recipients opted out of group-inbox emails. -pub(super) fn inbox_opt_out_group_id() -> Uuid { - parse_uuid(INBOX_OPT_OUT_GROUP_ID) +pub(crate) const fn inbox_opt_out_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e014") } /// Returns the user who writes to the opted-out inbox fixture. -pub(super) fn inbox_opt_out_user_id() -> Uuid { - parse_uuid(INBOX_OPT_OUT_USER_ID) +pub(crate) const fn inbox_opt_out_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e02e") +} + +/// Returns the open conversation raced by a reply and a close. +pub(super) const fn inbox_race_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e044") +} + +/// Returns the user owning the reply-close race conversation. +pub(super) const fn inbox_race_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e029") +} + +/// Returns the published event owned by the read group and co-hosted by the co-host group. +pub(super) const fn inbox_read_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e031") +} + +/// Returns the group whose conversations are only read by the contracts. +pub(super) const fn inbox_read_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e011") +} + +/// Returns the read conversation marked as spam by the read group. +pub(super) const fn inbox_spam_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e048") +} + +/// Returns the open conversation of the spam user marked and unmarked by the contracts. +pub(super) const fn inbox_spam_round_trip_conversation_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e049") +} + +/// Returns the user whose read group conversation was marked as spam. +pub(super) const fn inbox_spam_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e02b") +} + +/// Returns the user with two conversations started today. +pub(super) const fn inbox_start_limit_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e026") +} + +/// Returns the user starting two conversations with the same group at once. +pub(super) const fn inbox_start_race_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e027") +} + +/// Returns the user owning the open and closed read conversations. +pub(super) const fn inbox_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e024") +} + +/// Returns the group team member with the viewer role in the read and write groups. +pub(super) const fn inbox_viewer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e023") +} + +/// Returns the published event owned by the write group. +pub(crate) const fn inbox_write_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e032") +} + +/// Returns the group receiving conversations written by the contracts. +pub(super) const fn inbox_write_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000e012") } /// Returns the invitation offer identifier used by the contract fixture. -pub(super) fn invitation_offer_id() -> Uuid { - parse_uuid(INVITATION_OFFER_ID) +pub(super) const fn invitation_offer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c083") +} + +/// Returns the price window of the contract event general admission ticket. +pub(super) const fn invitation_ticket_price_window_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c082") } /// Returns the invitation ticket type identifier used by the contract fixture. -pub(super) fn invitation_ticket_type_id() -> Uuid { - parse_uuid(INVITATION_TICKET_TYPE_ID) +pub(super) const fn invitation_ticket_type_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c081") } /// Returns the invited event identifier used by the contract fixture. -pub(super) fn invite_event_id() -> Uuid { - parse_uuid(INVITE_EVENT_ID) +pub(super) const fn invite_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d8") } /// Returns the invitee identifier used by the contract fixture. -pub(super) fn invitee_id() -> Uuid { - parse_uuid(INVITEE_ID) +pub(super) const fn invitee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0ed") } /// Returns the departing attendee identifier used by the contract fixture. -pub(super) fn leaver_id() -> Uuid { - parse_uuid(LEAVER_ID) +pub(super) const fn leaver_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e8") } /// Returns the attendee canceled by the enrollment manager lifecycle contract. -pub(crate) fn lifecycle_cancelee_id() -> Uuid { - parse_uuid(LIFECYCLE_CANCELEE_ID) +pub(crate) const fn lifecycle_cancelee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c1f0") } /// Returns the attendee whose manager cancellation is rolled back by the lifecycle contract. -pub(crate) fn lifecycle_rollback_cancelee_id() -> Uuid { - parse_uuid(LIFECYCLE_ROLLBACK_CANCELEE_ID) +pub(crate) const fn lifecycle_rollback_cancelee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c1f1") } /// Returns the mutation event identifier used by the contract fixture. -pub(crate) fn mutation_event_id() -> Uuid { - parse_uuid(MUTATION_EVENT_ID) +pub(crate) const fn mutation_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d5") } /// Returns the mutation offer identifier used by the contract fixture. -pub(super) fn mutation_offer_id() -> Uuid { - parse_uuid(MUTATION_OFFER_ID) +pub(super) const fn mutation_offer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d7") } /// Returns the user used by notification mute concurrency contracts. -pub(super) fn notification_concurrency_user_id() -> Uuid { - parse_uuid(NOTIFICATION_CONCURRENCY_USER_ID) +pub(super) const fn notification_concurrency_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f004") } /// Returns the recipient that accepts notification filtering contracts. -pub(super) fn notification_eligible_user_id() -> Uuid { - parse_uuid(NOTIFICATION_ELIGIBLE_USER_ID) +pub(super) const fn notification_eligible_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f002") } -/// Returns the user with seeded notification preferences. -pub(super) fn notification_preferences_user_id() -> Uuid { - parse_uuid(NOTIFICATION_PREFERENCES_USER_ID) +/// Returns the notification identifier used by the contract fixture. +/// +/// The purchase summary fixture reuses this UUID in `event_purchase`, a +/// different table. +pub(super) const fn notification_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f1") } -/// Returns the unavailable group muted by the notification preferences fixture. -pub(super) fn notification_unavailable_group_id() -> Uuid { - parse_uuid(NOTIFICATION_UNAVAILABLE_GROUP_ID) +/// Returns the user with seeded notification preferences. +pub(super) const fn notification_preferences_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f001") } /// Returns the user used by notification unmute concurrency contracts. -pub(super) fn notification_unmute_concurrency_user_id() -> Uuid { - parse_uuid(NOTIFICATION_UNMUTE_CONCURRENCY_USER_ID) +pub(super) const fn notification_unmute_concurrency_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f005") } /// Returns the user dedicated to notification preference update contracts. -pub(super) fn notification_update_user_id() -> Uuid { - parse_uuid(NOTIFICATION_UPDATE_USER_ID) -} - -/// Returns the notification identifier used by the contract fixture. -pub(super) fn notification_id() -> Uuid { - parse_uuid("00000000-0000-0000-0000-00000000c0f1") -} - -/// Returns the message of a user-facing `OCG01` database error, if any. -pub(super) fn ocg01_message(err: &anyhow::Error) -> Option { - let db_err = err.downcast_ref::()?.as_db_error()?; - (db_err.code().code() == crate::db::USER_FACING_DB_ERROR_CODE) - .then(|| db_err.message().to_string()) +pub(super) const fn notification_update_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f003") } /// Returns the declined offer event identifier used by the contract fixture. -pub(super) fn offer_decline_event_id() -> Uuid { - parse_uuid(OFFER_DECLINE_EVENT_ID) +pub(super) const fn offer_decline_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0dc") } /// Returns the declined offer identifier used by the contract fixture. -pub(super) fn offer_decline_offer_id() -> Uuid { - parse_uuid(OFFER_DECLINE_OFFER_ID) +pub(super) const fn offer_decline_offer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0dd") } /// Returns the offer decliner identifier used by the contract fixture. -pub(super) fn offer_decliner_id() -> Uuid { - parse_uuid(OFFER_DECLINER_ID) +pub(super) const fn offer_decliner_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f0") } /// Returns the organizer identifier used by the contract fixture. -pub(crate) fn organizer_id() -> Uuid { - parse_uuid(ORGANIZER_ID) +pub(crate) const fn organizer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c041") } /// Returns the purchase used by paid attendance cancellation contracts. -pub(super) fn paid_cancellation_purchase_id() -> Uuid { - parse_uuid(PAID_CANCELLATION_PURCHASE_ID) +pub(super) const fn paid_cancellation_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c118") } /// Returns the attendee used by paid attendance cancellation contracts. -pub(super) fn paid_cancellation_user_id() -> Uuid { - parse_uuid(PAID_CANCELLATION_USER_ID) +pub(super) const fn paid_cancellation_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c117") } -/// Returns the paid ticket price window identifier used by the contract fixture. -pub(super) fn paid_ticket_price_window_id() -> Uuid { - parse_uuid(PAID_TICKET_PRICE_WINDOW_ID) +/// Returns the paid event that also holds the refund fixtures. +pub(super) const fn paid_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d0") } -/// Returns the paid ticket type identifier used by the contract fixture. -pub(super) fn paid_ticket_type_id() -> Uuid { - parse_uuid(PAID_TICKET_TYPE_ID) +/// Returns the paid ticket price window identifier used by the contract fixture. +pub(super) const fn paid_ticket_price_window_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d2") } -/// Parses a UUID stored by the contract fixture. -pub(super) fn parse_uuid(value: &str) -> Uuid { - Uuid::parse_str(value).expect("contract fixture UUID should be valid") +/// Returns the paid ticket type identifier used by the contract fixture. +pub(super) const fn paid_ticket_type_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d1") } /// Returns the past event identifier used by the contract fixture. -pub(super) fn past_event_id() -> Uuid { - parse_uuid(PAST_EVENT_ID) +pub(super) const fn past_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c032") } /// Returns the admin of the group payment lock order fixture group. -pub(super) fn payment_lock_admin_id() -> Uuid { - parse_uuid(PAYMENT_LOCK_ADMIN_ID) +pub(super) const fn payment_lock_admin_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f408") } /// Returns the unpublished paid event of the group payment lock order fixture. -pub(super) fn payment_lock_event_id() -> Uuid { - parse_uuid(PAYMENT_LOCK_EVENT_ID) +pub(super) const fn payment_lock_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f405") } /// Returns the external-payments group of the group payment lock order fixture. -pub(super) fn payment_lock_group_id() -> Uuid { - parse_uuid(PAYMENT_LOCK_GROUP_ID) +pub(super) const fn payment_lock_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f404") } /// Returns the pre-registered attendee identifier used by the contract fixture. -pub(super) fn pre_registered_id() -> Uuid { - parse_uuid(PRE_REGISTERED_ID) +pub(super) const fn pre_registered_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c044") +} + +/// Runs a lock probe in its own transaction with a 250 ms lock timeout, rolls +/// it back, and returns the SQLSTATE the probe failed with, if any. +pub(super) async fn probe_lock( + client: &tokio_postgres::Client, + sql: &str, + params: &[&(dyn ToSql + Sync)], +) -> Result> { + client + .batch_execute("begin; set local lock_timeout = '250ms'") + .await?; + let result = client.query_one(sql, params).await; + client.batch_execute("rollback").await?; + + Ok(result.err().and_then(|err| err.code().cloned())) +} + +/// Returns the event dedicated to queue-offer invitation allocation. +pub(super) const fn queue_invite_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c105") +} + +/// Returns the queued invitation target used by the allocation contract. +pub(super) const fn queue_invitee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c103") } /// Returns the award fixture dedicated to the identity rebind contract. -pub(super) fn rebind_user_badge_id() -> Uuid { - parse_uuid(REBIND_USER_BADGE_ID) +pub(super) const fn rebind_user_badge_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0b9") } /// Returns the reconciliation buyer identifier used by the contract fixture. -pub(super) fn reconcile_buyer_id() -> Uuid { - parse_uuid(RECONCILE_BUYER_ID) +pub(super) const fn reconcile_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e3") } /// Returns the due reconciliation event identifier used by the contract fixture. -pub(super) fn reconcile_due_event_id() -> Uuid { - parse_uuid(RECONCILE_DUE_EVENT_ID) -} - -/// Returns the user whose linked offer is hidden during refund processing. -pub(super) fn refund_offer_user_id() -> Uuid { - parse_uuid(REFUND_OFFER_USER_ID) +pub(super) const fn reconcile_due_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0de") } /// Returns the payment job fixture ready for local refund finalization. -pub(super) fn refund_approve_job_id() -> Uuid { - parse_uuid(REFUND_APPROVE_JOB_ID) +pub(super) const fn refund_approve_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c139") } /// Returns the purchase fixture ready for local refund finalization. -pub(super) fn refund_approve_purchase_id() -> Uuid { - parse_uuid(REFUND_APPROVE_PURCHASE_ID) +pub(super) const fn refund_approve_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f6") } /// Returns the purchase fixture ready to begin refund processing. -pub(super) fn refund_begin_purchase_id() -> Uuid { - parse_uuid(REFUND_BEGIN_PURCHASE_ID) +pub(super) const fn refund_begin_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f4") } -/// Returns the paid event identifier containing the refund fixtures. -pub(super) fn refund_event_id() -> Uuid { - parse_uuid(REFUND_EVENT_ID) +/// Returns the purchase identifier used by the refund lifecycle fixture. +pub(super) const fn refund_lifecycle_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0fb") } -/// Returns the purchase identifier used by the refund lifecycle fixture. -pub(super) fn refund_lifecycle_purchase_id() -> Uuid { - parse_uuid(REFUND_LIFECYCLE_PURCHASE_ID) +/// Returns the user whose linked offer is hidden during refund processing. +pub(super) const fn refund_offer_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c101") } /// Returns the payment job identifier for the refund recovery fixture. -pub(super) fn refund_recovery_job_id() -> Uuid { - parse_uuid(REFUND_RECOVERY_JOB_ID) +pub(super) const fn refund_recovery_job_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c13a") } /// Returns the purchase identifier for the refund recovery fixture. -pub(super) fn refund_recovery_purchase_id() -> Uuid { - parse_uuid(REFUND_RECOVERY_PURCHASE_ID) +pub(super) const fn refund_recovery_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0fd") } /// Returns the durable refund identifier for the recovery fixture. -pub(super) fn refund_recovery_refund_id() -> Uuid { - parse_uuid(REFUND_RECOVERY_REFUND_ID) +pub(super) const fn refund_recovery_refund_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0fe") } /// Returns the refund rejection buyer identifier used by the contract fixture. -pub(super) fn refund_reject_buyer_id() -> Uuid { - parse_uuid(REFUND_REJECT_BUYER_ID) +pub(super) const fn refund_reject_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0e7") } /// Returns the purchase fixture ready for refund rejection. -pub(super) fn refund_reject_purchase_id() -> Uuid { - parse_uuid(REFUND_REJECT_PURCHASE_ID) +pub(super) const fn refund_reject_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f8") } /// Returns the buyer whose rejected refund is visible on attendee surfaces. -pub(super) fn refund_rejected_buyer_id() -> Uuid { - parse_uuid(REFUND_REJECTED_BUYER_ID) +pub(super) const fn refund_rejected_buyer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c114") +} + +/// Returns the single-select meal preference question of the contract event. +pub(super) const fn registration_question_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c071") +} + +/// Returns the vegetarian option of the contract event meal preference question. +pub(super) const fn registration_question_option_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c072") } /// Returns the rejected requester ignored after approval is disabled. -pub(super) fn rejected_request_user_id() -> Uuid { - parse_uuid(REJECTED_REQUEST_USER_ID) +pub(super) const fn rejected_request_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c102") } /// Returns the invitation request event identifier used by the contract fixture. -pub(super) fn request_event_id() -> Uuid { - parse_uuid(REQUEST_EVENT_ID) +pub(super) const fn request_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0d9") } /// Returns the requester identifier used by the contract fixture. -pub(super) fn requester_id() -> Uuid { - parse_uuid(REQUESTER_ID) +pub(super) const fn requester_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0ee") } /// Returns the revoked user badge identifier used by the contract fixture. -pub(super) fn revoked_user_badge_id() -> Uuid { - parse_uuid(REVOKED_USER_BADGE_ID) +pub(super) const fn revoked_user_badge_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0be") } /// Returns the labeled session identifier used by the contract fixture. -pub(super) fn session_id() -> Uuid { - parse_uuid(SESSION_ID) +pub(super) const fn session_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c051") } /// Returns the session proposal identifier used by the contract fixture. -pub(super) fn session_proposal_id() -> Uuid { - parse_uuid(SESSION_PROPOSAL_ID) +pub(super) const fn session_proposal_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0c1") } /// Returns the site identifier used by the contract fixture. -pub(super) fn site_id() -> Uuid { - parse_uuid(SITE_ID) +pub(super) const fn site_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0b1") } /// Returns the community used by the stats cache contract. -pub(super) fn stats_cache_community_id() -> Uuid { - parse_uuid(STATS_CACHE_COMMUNITY_ID) +pub(super) const fn stats_cache_community_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f101") } /// Returns the group used by the stats cache contract. -pub(super) fn stats_cache_group_id() -> Uuid { - parse_uuid(STATS_CACHE_GROUP_ID) +pub(super) const fn stats_cache_group_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f103") } /// Returns the user added by the stats cache contract. -pub(super) fn stats_cache_joiner_id() -> Uuid { - parse_uuid(STATS_CACHE_JOINER_ID) +pub(super) const fn stats_cache_joiner_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f105") } /// Returns the canceled-offer user used by the status contract. -pub(super) fn status_canceled_user_id() -> Uuid { - parse_uuid(STATUS_CANCELED_USER_ID) +pub(super) const fn status_canceled_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c10e") } /// Returns the declined-offer user used by the status contract. -pub(super) fn status_declined_user_id() -> Uuid { - parse_uuid(STATUS_DECLINED_USER_ID) +pub(super) const fn status_declined_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c10f") } /// Returns the event dedicated to enrollment status contracts. -pub(super) fn status_event_id() -> Uuid { - parse_uuid(STATUS_EVENT_ID) +pub(super) const fn status_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c109") } /// Returns the expired-offer user used by the status contract. -pub(super) fn status_expired_user_id() -> Uuid { - parse_uuid(STATUS_EXPIRED_USER_ID) +pub(super) const fn status_expired_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c10d") } /// Returns the pending-purchase user used by the status contract. -pub(super) fn status_pending_payment_user_id() -> Uuid { - parse_uuid(STATUS_PENDING_PAYMENT_USER_ID) +pub(super) const fn status_pending_payment_user_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c10c") } /// Returns the ticket used by the pending payment contract. -pub(super) fn status_ticket_type_id() -> Uuid { - parse_uuid(STATUS_TICKET_TYPE_ID) +pub(super) const fn status_ticket_type_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c10a") } /// Returns the subgroup identifier used by the contract fixture. -pub(super) fn subgroup_id() -> Uuid { - parse_uuid(SUBGROUP_ID) +pub(super) const fn subgroup_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c022") } /// Returns the purchase summary identifier used by the contract fixture. -pub(super) fn summary_purchase_id() -> Uuid { - parse_uuid(SUMMARY_PURCHASE_ID) +/// +/// The notification fixture reuses this UUID in `notification`, a different +/// table. +pub(super) const fn summary_purchase_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0f1") } /// Returns the open anonymous survey of the survey event. -pub(super) fn survey_anonymous_id() -> Uuid { - parse_uuid(SURVEY_ANONYMOUS_ID) +pub(super) const fn survey_anonymous_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f306") } /// Returns the survey closed while an attendee submits answers. -pub(super) fn survey_close_race_id() -> Uuid { - parse_uuid(SURVEY_CLOSE_RACE_ID) +pub(super) const fn survey_close_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f323") } /// Returns the survey deleted while a submission holds its lock. -pub(super) fn survey_delete_after_submission_race_id() -> Uuid { - parse_uuid(SURVEY_DELETE_AFTER_SUBMISSION_RACE_ID) +pub(super) const fn survey_delete_after_submission_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f327") } /// Returns the survey deleted while an attendee submits answers. -pub(super) fn survey_delete_race_id() -> Uuid { - parse_uuid(SURVEY_DELETE_RACE_ID) +pub(super) const fn survey_delete_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f324") } /// Returns the draft survey of the survey event. -pub(super) fn survey_draft_id() -> Uuid { - parse_uuid(SURVEY_DRAFT_ID) +pub(super) const fn survey_draft_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f307") } /// Returns the survey closed while the opening email job runs. -pub(super) fn survey_email_close_race_id() -> Uuid { - parse_uuid(SURVEY_EMAIL_CLOSE_RACE_ID) +pub(super) const fn survey_email_close_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f344") } /// Returns the event whose survey opening emails reach its attendees. -pub(super) fn survey_email_event_id() -> Uuid { - parse_uuid(SURVEY_EMAIL_EVENT_ID) +pub(super) const fn survey_email_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f340") } /// Returns the survey waiting for its opening email. -pub(super) fn survey_email_id() -> Uuid { - parse_uuid(SURVEY_EMAIL_ID) -} - -/// Returns the verified attendee of the survey email event. -pub(super) fn survey_email_recipient_id() -> Uuid { - parse_uuid(SURVEY_EMAIL_RECIPIENT_ID) +pub(super) const fn survey_email_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f343") } /// Returns the survey whose event is rescheduled while the opening email job runs. -pub(super) fn survey_email_reschedule_race_id() -> Uuid { - parse_uuid(SURVEY_EMAIL_RESCHEDULE_RACE_ID) +pub(super) const fn survey_email_reschedule_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f351") } /// Returns the event holding the identified, anonymous and draft surveys. -pub(super) fn survey_event_id() -> Uuid { - parse_uuid(SURVEY_EVENT_ID) +pub(super) const fn survey_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f301") } /// Returns the survey answered while it is exported. -pub(super) fn survey_export_growth_id() -> Uuid { - parse_uuid(SURVEY_EXPORT_GROWTH_ID) +pub(super) const fn survey_export_growth_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f325") } /// Returns the attendee numbered `n` (1 to 12) who answers the export growth /// survey. pub(super) fn survey_export_respondent_id(n: u8) -> Uuid { - parse_uuid(&format!( - "00000000-0000-0000-0000-00000000f3{:02x}", - 127 + n - )) + Uuid::from_u128(0xf37f + u128::from(n)) } /// Returns the survey answered while its anonymity changes. -pub(super) fn survey_first_response_race_id() -> Uuid { - parse_uuid(SURVEY_FIRST_RESPONSE_RACE_ID) +pub(super) const fn survey_first_response_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f321") } /// Returns the former attendee who answered the identified survey. -pub(super) fn survey_former_attendee_id() -> Uuid { - parse_uuid(SURVEY_FORMER_ATTENDEE_ID) +pub(super) const fn survey_former_attendee_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f317") } /// Returns the free-text question of the identified survey. -pub(super) fn survey_free_text_question_id() -> Uuid { - parse_uuid(SURVEY_FREE_TEXT_QUESTION_ID) +pub(super) const fn survey_free_text_question_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f308") } /// Returns the open identified survey of the survey event. -pub(super) fn survey_identified_id() -> Uuid { - parse_uuid(SURVEY_IDENTIFIED_ID) +pub(super) const fn survey_identified_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f305") } /// Returns the upcoming event rescheduled while a survey is marked ready. -pub(super) fn survey_mark_ready_event_id() -> Uuid { - parse_uuid(SURVEY_MARK_READY_EVENT_ID) +pub(super) const fn survey_mark_ready_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f360") } /// Returns the draft survey marked ready while its event is rescheduled. -pub(super) fn survey_mark_ready_race_id() -> Uuid { - parse_uuid(SURVEY_MARK_READY_RACE_ID) -} - -/// Returns the open anonymous survey whose opening email was handled. -pub(super) fn survey_opened_anonymous_id() -> Uuid { - parse_uuid(SURVEY_OPENED_ANONYMOUS_ID) +pub(super) const fn survey_mark_ready_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f361") } /// Returns the survey event attendee who did not check in or answer. -pub(super) fn survey_pending_respondent_id() -> Uuid { - parse_uuid(SURVEY_PENDING_RESPONDENT_ID) +pub(super) const fn survey_pending_respondent_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f304") } /// Returns the event holding the concurrency contract surveys. -pub(super) fn survey_race_event_id() -> Uuid { - parse_uuid(SURVEY_RACE_EVENT_ID) +pub(super) const fn survey_race_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f320") } /// Returns the free-text question of the concurrency contract surveys. -pub(super) fn survey_race_question_id() -> Uuid { - parse_uuid(SURVEY_RACE_QUESTION_ID) +pub(super) const fn survey_race_question_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f330") } /// Returns the attendee answering the concurrency contract surveys. -pub(super) fn survey_racer_id() -> Uuid { - parse_uuid(SURVEY_RACER_ID) +pub(super) const fn survey_racer_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f316") } /// Returns the event rescheduled while the opening email job runs. -pub(super) fn survey_reschedule_event_id() -> Uuid { - parse_uuid(SURVEY_RESCHEDULE_EVENT_ID) +pub(super) const fn survey_reschedule_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f350") } /// Returns the attendee who answered both open surveys of the survey event. -pub(super) fn survey_respondent_id() -> Uuid { - parse_uuid(SURVEY_RESPONDENT_ID) +pub(super) const fn survey_respondent_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f302") } /// Returns the changes that make the open surveys of the survey event -/// unreachable on the public site, each with a short description. -pub(super) fn survey_scope_changes() -> [(&'static str, &'static str); 6] { +/// unreachable on the public site, each with a short description and the +/// identifier bound to its `$1` parameter. +pub(super) fn survey_scope_changes() -> [(&'static str, &'static str, Uuid); 6] { [ ( "canceled event", - "update event set canceled = true where event_id = '00000000-0000-0000-0000-00000000f301'", + "update event set canceled = true where event_id = $1::uuid", + survey_event_id(), ), ( "deleted group", - r#"update "group" set active = false, deleted = true where group_id = '00000000-0000-0000-0000-00000000c022'"#, + r#"update "group" set active = false, deleted = true where group_id = $1::uuid"#, + subgroup_id(), ), ( "draft surveys", - "update event_survey set status = 'draft', closed_at = null, first_response_at = null, open_notification_processed_at = null, open_notification_recipients = null where event_id = '00000000-0000-0000-0000-00000000f301'", + "update event_survey set status = 'draft', closed_at = null, first_response_at = null, open_notification_processed_at = null, open_notification_recipients = null where event_id = $1::uuid", + survey_event_id(), ), ( "inactive community", - "update community set active = false where community_id = '00000000-0000-0000-0000-00000000c001'", + "update community set active = false where community_id = $1::uuid", + community_id(), ), ( "inactive group", - r#"update "group" set active = false where group_id = '00000000-0000-0000-0000-00000000c022'"#, + r#"update "group" set active = false where group_id = $1::uuid"#, + subgroup_id(), ), ( "unpublished event", - "update event set published = false where event_id = '00000000-0000-0000-0000-00000000f301'", + "update event set published = false where event_id = $1::uuid", + survey_event_id(), ), ] } -/// Returns the single-select question of the identified survey. -pub(super) fn survey_single_select_question_id() -> Uuid { - parse_uuid(SURVEY_SINGLE_SELECT_QUESTION_ID) +/// Returns the second attendee who checked in and answered both open surveys. +pub(super) const fn survey_second_respondent_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f303") } -/// Returns the survey reconfigured while an attendee submits answers. -pub(super) fn survey_stale_race_id() -> Uuid { - parse_uuid(SURVEY_STALE_RACE_ID) +/// Returns the single-select question of the identified survey. +pub(super) const fn survey_single_select_question_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f309") } -/// Returns the unverified attendee of the survey email event. -pub(super) fn survey_unverified_attendee_id() -> Uuid { - parse_uuid(SURVEY_UNVERIFIED_ATTENDEE_ID) +/// Returns the survey reconfigured while an attendee submits answers. +pub(super) const fn survey_stale_race_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000f322") } /// Returns the synchronized event identifier used by the contract fixture. -pub(super) fn sync_event_id() -> Uuid { - parse_uuid(SYNC_EVENT_ID) -} - -/// Returns the paid event identifier used by the contract fixture. -pub(super) fn paid_event_id() -> Uuid { - parse_uuid(TICKETED_EVENT_ID) +pub(super) const fn sync_event_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c0a1") } -/// Returns the event dedicated to queue-offer invitation allocation. -pub(super) fn queue_invite_event_id() -> Uuid { - parse_uuid(QUEUE_INVITE_EVENT_ID) -} - -/// Returns the queued invitation target used by the allocation contract. -pub(super) fn queue_invitee_id() -> Uuid { - parse_uuid(QUEUE_INVITEE_ID) +/// Returns the unlabeled session identifier used by the contract fixture. +pub(super) const fn unlabeled_session_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c052") } -/// Returns the unlabeled session identifier used by the contract fixture. -pub(super) fn unlabeled_session_id() -> Uuid { - parse_uuid(UNLABELED_SESSION_ID) +/// Returns the backend process ID of a contract-test unit of work. +pub(super) async fn uow_backend_pid(uow: &PgUnitOfWork) -> Result { + uow.fetch_scalar_one("select pg_backend_pid()", &[]).await } /// Waits until one contract-test backend is blocked by another. @@ -1564,6 +1377,6 @@ pub(super) async fn wait_for_backend_blocker( } /// Returns the waitlist identifier used by the contract fixture. -pub(super) fn waitlist_id() -> Uuid { - parse_uuid(WAITLIST_ID) +pub(super) const fn waitlist_id() -> Uuid { + uuid!("00000000-0000-0000-0000-00000000c043") } diff --git a/ocg-server/src/db/contract_tests/inbox.rs b/ocg-server/src/db/contract_tests/inbox.rs index 466a71483..30ff4c180 100644 --- a/ocg-server/src/db/contract_tests/inbox.rs +++ b/ocg-server/src/db/contract_tests/inbox.rs @@ -1,78 +1,27 @@ //! Contract tests for the `DBInbox` functions. -use anyhow::{Result, bail}; +use anyhow::Result; use chrono::{TimeZone, Utc}; -use tokio_postgres::error::{DbError, SqlState}; use uuid::Uuid; use crate::{ - db::{ - DBExt, USER_FACING_DB_ERROR_CODE, - inbox::{DBInbox, StartInboxConversationConflict, StartInboxConversationResult}, - }, - types::{ - inbox::{InboxConversationStatus, InboxConversationsFilters, InboxMessageKind}, - notifications::{NewNotification, NotificationKind}, - }, + db::inbox::{DBInbox, StartInboxConversationConflict, StartInboxConversationResult}, + types::inbox::{InboxConversationStatus, InboxConversationsFilters, InboxMessageKind}, }; use super::helpers::*; -/// Inactive community holding every inbox fixture. -const INBOX_COMMUNITY_ID: Uuid = Uuid::from_u128(0xe001); -/// Group team member with the admin role in the read and write groups. -const INBOX_ADMIN_ID: Uuid = Uuid::from_u128(0xe021); -/// Group team member with the events-manager role in the read and write groups. -const INBOX_EVENTS_MANAGER_ID: Uuid = Uuid::from_u128(0xe022); -/// Group team member with the viewer role in the read and write groups. -const INBOX_VIEWER_ID: Uuid = Uuid::from_u128(0xe023); -/// Group whose conversations are only read by the contracts. -const INBOX_READ_GROUP_ID: Uuid = Uuid::from_u128(0xe011); -/// Group receiving conversations written by the contracts. -const INBOX_WRITE_GROUP_ID: Uuid = Uuid::from_u128(0xe012); -/// User owning the open and closed read conversations. -const INBOX_USER_ID: Uuid = Uuid::from_u128(0xe024); -/// User with two conversations started today. -const INBOX_START_LIMIT_USER_ID: Uuid = Uuid::from_u128(0xe026); -/// User starting two conversations with the same group at once. -const INBOX_START_RACE_USER_ID: Uuid = Uuid::from_u128(0xe027); -/// User with nine follow-ups sent today. -const INBOX_FOLLOW_UP_LIMIT_USER_ID: Uuid = Uuid::from_u128(0xe028); -/// User owning the reply-close race conversation. -const INBOX_RACE_USER_ID: Uuid = Uuid::from_u128(0xe029); -/// User whose read group conversation was marked as spam. -const INBOX_SPAM_USER_ID: Uuid = Uuid::from_u128(0xe02b); -/// Published event owned by the read group and co-hosted by the co-host group. -const INBOX_READ_EVENT_ID: Uuid = Uuid::from_u128(0xe031); -/// Published event owned by the write group. -const INBOX_WRITE_EVENT_ID: Uuid = Uuid::from_u128(0xe032); -/// Published event owned by the co-host group. -const INBOX_COHOST_EVENT_ID: Uuid = Uuid::from_u128(0xe033); -/// Open read conversation with three messages. -const INBOX_OPEN_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe041); -/// Closed read conversation without event. -const INBOX_CLOSED_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe042); -/// Read conversation whose user account was deleted. -const INBOX_DELETED_USER_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe043); -/// Open conversation raced by a reply and a close. -const INBOX_RACE_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe044); -/// Open conversation of the follow-up limit user. -const INBOX_FOLLOW_UP_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe045); -/// Read conversation marked as spam by the read group. -const INBOX_SPAM_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe048); -/// Open conversation of the spam user marked and unmarked by the contracts. -const INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID: Uuid = Uuid::from_u128(0xe049); - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_add_inbox_user_message_serializes_daily_limit() -> Result<()> { - // Setup two connections and a lock probe - let pool = contract_tests_pool()?; - let first_client = pool.get().await?; - let second_client = pool.get().await?; - let probe_client = pool.get().await?; - let first_backend_pid = backend_pid(&first_client).await?; - let second_backend_pid = backend_pid(&second_client).await?; + // Setup two racing connections and a lock probe + let RaceClients { + first: first_client, + first_backend_pid, + probe: probe_client, + second: second_client, + second_backend_pid, + } = race_clients().await?; // Send the tenth follow-up of the day and keep its transaction open first_client.batch_execute("begin").await?; @@ -80,8 +29,8 @@ async fn db_contracts_add_inbox_user_message_serializes_daily_limit() -> Result< .query_one( "select add_inbox_user_message($1::uuid, $2::uuid, 'Tenth')", &[ - &INBOX_FOLLOW_UP_LIMIT_USER_ID, - &INBOX_FOLLOW_UP_CONVERSATION_ID, + &inbox_follow_up_limit_user_id(), + &inbox_follow_up_conversation_id(), ], ) .await?; @@ -92,8 +41,8 @@ async fn db_contracts_add_inbox_user_message_serializes_daily_limit() -> Result< .query_one( "select add_inbox_user_message($1::uuid, $2::uuid, 'Eleventh')", &[ - &INBOX_FOLLOW_UP_LIMIT_USER_ID, - &INBOX_FOLLOW_UP_CONVERSATION_ID, + &inbox_follow_up_limit_user_id(), + &inbox_follow_up_conversation_id(), ], ) .await @@ -107,14 +56,7 @@ async fn db_contracts_add_inbox_user_message_serializes_daily_limit() -> Result< let err = second_task .await? .expect_err("the second follow-up should reach the daily limit"); - assert_eq!( - err.as_db_error().map(DbError::message), - Some("daily message limit reached") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + assert_pg_ocg01(err, "daily message limit reached"); Ok(()) } @@ -122,13 +64,14 @@ async fn db_contracts_add_inbox_user_message_serializes_daily_limit() -> Result< #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_close_inbox_conversation_serializes_with_reply() -> Result<()> { - // Setup two connections and a lock probe - let pool = contract_tests_pool()?; - let reply_client = pool.get().await?; - let close_client = pool.get().await?; - let probe_client = pool.get().await?; - let reply_backend_pid = backend_pid(&reply_client).await?; - let close_backend_pid = backend_pid(&close_client).await?; + // Setup two racing connections and a lock probe + let RaceClients { + first: reply_client, + first_backend_pid: reply_backend_pid, + probe: probe_client, + second: close_client, + second_backend_pid: close_backend_pid, + } = race_clients().await?; // Start the close transaction before the reply one, so audit times must // follow the lock order rather than the transaction start order @@ -140,9 +83,9 @@ async fn db_contracts_close_inbox_conversation_serializes_with_reply() -> Result .query_one( "select add_inbox_group_reply($1::uuid, $2::uuid, $3::uuid, 'Yes, it will be recorded.')", &[ - &INBOX_ADMIN_ID, - &INBOX_WRITE_GROUP_ID, - &INBOX_RACE_CONVERSATION_ID, + &inbox_admin_id(), + &inbox_write_group_id(), + &inbox_race_conversation_id(), ], ) .await?; @@ -153,9 +96,9 @@ async fn db_contracts_close_inbox_conversation_serializes_with_reply() -> Result .execute( "select close_inbox_conversation($1::uuid, $2::uuid, $3::uuid)", &[ - &INBOX_EVENTS_MANAGER_ID, - &INBOX_WRITE_GROUP_ID, - &INBOX_RACE_CONVERSATION_ID, + &inbox_events_manager_id(), + &inbox_write_group_id(), + &inbox_race_conversation_id(), ], ) .await?; @@ -184,7 +127,7 @@ async fn db_contracts_close_inbox_conversation_serializes_with_reply() -> Result ) from inbox_conversation ic where ic.inbox_conversation_id = $1::uuid", - &[&INBOX_RACE_CONVERSATION_ID], + &[&inbox_race_conversation_id()], ) .await?; assert_eq!(row.get::<_, String>(0), "closed"); @@ -207,7 +150,7 @@ async fn db_contracts_count_group_open_inbox_conversations_counts_open_only() -> let db = contract_tests_db()?; // Count the open conversations of the read group - let count = db.count_group_open_inbox_conversations(INBOX_READ_GROUP_ID).await?; + let count = db.count_group_open_inbox_conversations(inbox_read_group_id()).await?; // Check the open and deleted-user conversations are counted assert_eq!(count, 2); @@ -223,7 +166,7 @@ async fn db_contracts_get_group_inbox_conversation_deserializes() -> Result<()> // Load the open read conversation through the Rust contract let conversation = db - .get_group_inbox_conversation(INBOX_READ_GROUP_ID, INBOX_OPEN_CONVERSATION_ID) + .get_group_inbox_conversation(inbox_read_group_id(), inbox_open_conversation_id()) .await? .expect("read conversation to exist"); @@ -241,7 +184,7 @@ async fn db_contracts_get_group_inbox_conversation_deserializes() -> Result<()> assert_eq!(conversation.group_slug, "contract-inbox-read-group"); assert_eq!( conversation.inbox_conversation_id, - INBOX_OPEN_CONVERSATION_ID + inbox_open_conversation_id() ); assert_eq!( conversation.last_message_at, @@ -288,7 +231,7 @@ async fn db_contracts_get_group_inbox_conversation_deserializes() -> Result<()> Some("inbox-read") ); let user = conversation.user.as_ref().expect("user to exist"); - assert_eq!(user.user_id, INBOX_USER_ID); + assert_eq!(user.user_id, inbox_user_id()); assert_eq!(user.name.as_deref(), Some("Inbox User")); assert_eq!( conversation.event_url().as_deref(), @@ -306,7 +249,7 @@ async fn db_contracts_get_group_inbox_conversation_deserializes_deleted_user() - // Load the conversation whose user account was deleted let conversation = db - .get_group_inbox_conversation(INBOX_READ_GROUP_ID, INBOX_DELETED_USER_CONVERSATION_ID) + .get_group_inbox_conversation(inbox_read_group_id(), inbox_deleted_user_conversation_id()) .await? .expect("deleted-user conversation to exist"); @@ -328,41 +271,45 @@ async fn db_contracts_get_inbox_contact_context_deserializes() -> Result<()> { // Load the contact context for anonymous, team member, read-only member and regular viewers let anonymous = db - .get_inbox_contact_context(INBOX_COMMUNITY_ID, INBOX_READ_EVENT_ID, None) + .get_inbox_contact_context(inbox_community_id(), inbox_read_event_id(), None) .await? .expect("contact context to exist"); let team_member = db .get_inbox_contact_context( - INBOX_COMMUNITY_ID, - INBOX_READ_EVENT_ID, - Some(INBOX_ADMIN_ID), + inbox_community_id(), + inbox_read_event_id(), + Some(inbox_admin_id()), ) .await? .expect("contact context to exist"); let read_only_member = db .get_inbox_contact_context( - INBOX_COMMUNITY_ID, - INBOX_READ_EVENT_ID, - Some(INBOX_VIEWER_ID), + inbox_community_id(), + inbox_read_event_id(), + Some(inbox_viewer_id()), ) .await? .expect("contact context to exist"); let user = db - .get_inbox_contact_context(INBOX_COMMUNITY_ID, INBOX_READ_EVENT_ID, Some(INBOX_USER_ID)) + .get_inbox_contact_context( + inbox_community_id(), + inbox_read_event_id(), + Some(inbox_user_id()), + ) .await? .expect("contact context to exist"); let spam_user = db .get_inbox_contact_context( - INBOX_COMMUNITY_ID, - INBOX_READ_EVENT_ID, - Some(INBOX_SPAM_USER_ID), + inbox_community_id(), + inbox_read_event_id(), + Some(inbox_spam_user_id()), ) .await? .expect("contact context to exist"); // Check the anonymous context assert_eq!(anonymous.community_name, "contract-inbox-community"); - assert_eq!(anonymous.event_id, INBOX_READ_EVENT_ID); + assert_eq!(anonymous.event_id, inbox_read_event_id()); assert_eq!(anonymous.event_name, "Contract Inbox Read Event"); assert_eq!(anonymous.event_slug, "contract-inbox-read-event"); assert_eq!(anonymous.group_name, "Contract Inbox Read Group"); @@ -388,7 +335,7 @@ async fn db_contracts_get_inbox_contact_context_deserializes() -> Result<()> { assert!(!user_viewer.is_group_team_member); assert_eq!( user_viewer.open_inbox_conversation_id, - Some(INBOX_OPEN_CONVERSATION_ID) + Some(inbox_open_conversation_id()) ); let spam_user_viewer = spam_user.viewer.expect("spam user viewer to exist"); assert!(spam_user_viewer.is_blocked); @@ -406,54 +353,22 @@ async fn db_contracts_get_user_inbox_conversation_scopes_by_user() -> Result<()> // Load the conversation as its owner and as another user let owned = db - .get_user_inbox_conversation(INBOX_USER_ID, INBOX_OPEN_CONVERSATION_ID) + .get_user_inbox_conversation(inbox_user_id(), inbox_open_conversation_id()) .await?; let foreign = db - .get_user_inbox_conversation(INBOX_RACE_USER_ID, INBOX_OPEN_CONVERSATION_ID) + .get_user_inbox_conversation(inbox_race_user_id(), inbox_open_conversation_id()) .await?; // Check only the owner can read the thread assert_eq!( owned.map(|conversation| conversation.inbox_conversation_id), - Some(INBOX_OPEN_CONVERSATION_ID) + Some(inbox_open_conversation_id()) ); assert!(foreign.is_none()); Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_inbox_rejections_use_user_facing_sqlstate() -> Result<()> { - // Setup the contract database - let client = contract_tests_pool()?.get().await?; - - // Reply to a conversation whose user account was deleted - let err = client - .query_one( - "select add_inbox_group_reply($1::uuid, $2::uuid, $3::uuid, 'Hi')", - &[ - &INBOX_ADMIN_ID, - &INBOX_READ_GROUP_ID, - &INBOX_DELETED_USER_CONVERSATION_ID, - ], - ) - .await - .expect_err("read-only conversations should reject replies"); - - // Check the rejection is user facing - assert_eq!( - err.as_db_error().map(DbError::message), - Some("conversation is read-only") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_list_group_inbox_conversations_deserializes() -> Result<()> { @@ -475,11 +390,13 @@ async fn db_contracts_list_group_inbox_conversations_deserializes() -> Result<() }; // List the read group conversations - let output = db.list_group_inbox_conversations(INBOX_READ_GROUP_ID, &all).await?; + let output = db.list_group_inbox_conversations(inbox_read_group_id(), &all).await?; let closed_output = db - .list_group_inbox_conversations(INBOX_READ_GROUP_ID, &closed) + .list_group_inbox_conversations(inbox_read_group_id(), &closed) + .await?; + let spam_output = db + .list_group_inbox_conversations(inbox_read_group_id(), &spam) .await?; - let spam_output = db.list_group_inbox_conversations(INBOX_READ_GROUP_ID, &spam).await?; // Check ordering and totals, leaving spam out unless filtered by it let ids: Vec<_> = output @@ -490,21 +407,21 @@ async fn db_contracts_list_group_inbox_conversations_deserializes() -> Result<() assert_eq!( ids, vec![ - INBOX_OPEN_CONVERSATION_ID, - INBOX_DELETED_USER_CONVERSATION_ID, - INBOX_CLOSED_CONVERSATION_ID + inbox_open_conversation_id(), + inbox_deleted_user_conversation_id(), + inbox_closed_conversation_id() ] ); assert_eq!(output.total, 3); assert_eq!(closed_output.total, 1); assert_eq!( closed_output.conversations[0].inbox_conversation_id, - INBOX_CLOSED_CONVERSATION_ID + inbox_closed_conversation_id() ); assert_eq!(spam_output.total, 1); assert_eq!( spam_output.conversations[0].inbox_conversation_id, - INBOX_SPAM_CONVERSATION_ID + inbox_spam_conversation_id() ); assert_eq!( spam_output.conversations[0].status, @@ -523,7 +440,7 @@ async fn db_contracts_list_group_inbox_conversations_deserializes() -> Result<() assert!(event.slug.is_none()); assert_eq!( first.user.as_ref().map(|user| user.user_id), - Some(INBOX_USER_ID) + Some(inbox_user_id()) ); // Check a deleted user and a missing event deserialize as absent @@ -540,10 +457,13 @@ async fn db_contracts_list_inbox_recipient_ids_returns_group_team_members() -> R let db = contract_tests_db()?; // List the group team members emailed by the read group - let recipients = db.list_inbox_recipient_ids(INBOX_READ_GROUP_ID).await?; + let recipients = db.list_inbox_recipient_ids(inbox_read_group_id()).await?; // Check the viewer is excluded - assert_eq!(recipients, vec![INBOX_ADMIN_ID, INBOX_EVENTS_MANAGER_ID]); + assert_eq!( + recipients, + vec![inbox_admin_id(), inbox_events_manager_id()] + ); Ok(()) } @@ -561,7 +481,7 @@ async fn db_contracts_list_user_inbox_conversations_deserializes() -> Result<()> }; // List the conversations of the inbox user - let output = db.list_user_inbox_conversations(INBOX_USER_ID, &filters).await?; + let output = db.list_user_inbox_conversations(inbox_user_id(), &filters).await?; // Check ordering and totals let ids: Vec<_> = output @@ -571,7 +491,7 @@ async fn db_contracts_list_user_inbox_conversations_deserializes() -> Result<()> .collect(); assert_eq!( ids, - vec![INBOX_OPEN_CONVERSATION_ID, INBOX_CLOSED_CONVERSATION_ID] + vec![inbox_open_conversation_id(), inbox_closed_conversation_id()] ); assert_eq!(output.total, 2); @@ -587,13 +507,16 @@ async fn db_contracts_mark_inbox_conversation_as_spam_round_trips() -> Result<() // Mark the conversation as spam db.mark_inbox_conversation_as_spam( - INBOX_ADMIN_ID, - INBOX_WRITE_GROUP_ID, - INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID, + inbox_admin_id(), + inbox_write_group_id(), + inbox_spam_round_trip_conversation_id(), ) .await?; let marked = db - .get_user_inbox_conversation(INBOX_SPAM_USER_ID, INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID) + .get_user_inbox_conversation( + inbox_spam_user_id(), + inbox_spam_round_trip_conversation_id(), + ) .await? .expect("conversation to exist"); @@ -602,28 +525,27 @@ async fn db_contracts_mark_inbox_conversation_as_spam_round_trips() -> Result<() let err = client .query_one( "select add_inbox_user_message($1::uuid, $2::uuid, 'Hello?')", - &[&INBOX_SPAM_USER_ID, &INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID], + &[ + &inbox_spam_user_id(), + &inbox_spam_round_trip_conversation_id(), + ], ) .await .expect_err("conversations marked as spam should reject messages"); - assert_eq!( - err.as_db_error().map(DbError::message), - Some("conversation no longer accepts messages") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + assert_pg_ocg01(err, "conversation no longer accepts messages"); // Unmark the conversation as spam db.unmark_inbox_conversation_as_spam( - INBOX_ADMIN_ID, - INBOX_WRITE_GROUP_ID, - INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID, + inbox_admin_id(), + inbox_write_group_id(), + inbox_spam_round_trip_conversation_id(), ) .await?; let unmarked = db - .get_group_inbox_conversation(INBOX_WRITE_GROUP_ID, INBOX_SPAM_ROUND_TRIP_CONVERSATION_ID) + .get_group_inbox_conversation( + inbox_write_group_id(), + inbox_spam_round_trip_conversation_id(), + ) .await? .expect("conversation to exist"); @@ -633,78 +555,6 @@ async fn db_contracts_mark_inbox_conversation_as_spam_round_trips() -> Result<() Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_start_inbox_conversation_commits_when_group_inbox_recipients_opt_out() --> Result<()> { - // Setup a clean opted-out inbox workflow - let client = contract_tests_pool()?.get().await?; - cleanup_opted_out_inbox_flow(&client).await?; - let db = contract_tests_db()?; - - // Start a conversation and queue its email to recipients who all opted out - let inbox_conversation_id = db - .transaction(|tx| { - Box::pin(async move { - let result = tx - .start_inbox_conversation( - inbox_opt_out_user_id(), - INBOX_COMMUNITY_ID, - inbox_opt_out_event_id(), - "Will the recording be posted?", - ) - .await?; - let StartInboxConversationResult::Started(posted) = result else { - bail!("inbox conversation was not started"); - }; - let recipients = tx.list_inbox_recipient_ids(posted.group_id).await?; - tx.enqueue_notification(&NewNotification { - attachments: vec![], - group_ids: vec![], - kind: NotificationKind::InboxMessageReceived, - recipients, - template_data: Some(serde_json::json!({})), - }) - .await?; - Ok(posted.inbox_conversation_id) - }) - }) - .await?; - - // Load the stored message and any notifications for the opted-out recipients - let body: String = client - .query_one( - "select body - from inbox_message - where inbox_conversation_id = $1::uuid", - &[&inbox_conversation_id], - ) - .await? - .get(0); - let notification_count: i64 = client - .query_one( - "select count(*) - from notification - where kind = 'inbox-message-received' - and user_id = any($1::uuid[])", - &[&vec![ - inbox_opt_out_admin_id(), - inbox_opt_out_events_manager_id(), - ]], - ) - .await? - .get(0); - - // Check preference filtering did not roll back the inbox write - assert_eq!(body, "Will the recording be posted?"); - assert_eq!(notification_count, 0); - - // Clean up durable rows so repeated focused runs start from the same state - cleanup_opted_out_inbox_flow(&client).await?; - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_start_inbox_conversation_returns_open_conversation_conflict() -> Result<()> { @@ -714,9 +564,9 @@ async fn db_contracts_start_inbox_conversation_returns_open_conversation_conflic // Start a second conversation with the read group let result = db .start_inbox_conversation( - INBOX_USER_ID, - INBOX_COMMUNITY_ID, - INBOX_READ_EVENT_ID, + inbox_user_id(), + inbox_community_id(), + inbox_read_event_id(), "Another question", ) .await?; @@ -733,13 +583,14 @@ async fn db_contracts_start_inbox_conversation_returns_open_conversation_conflic #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_start_inbox_conversation_serializes_daily_limit() -> Result<()> { - // Setup two connections and a lock probe - let pool = contract_tests_pool()?; - let first_client = pool.get().await?; - let second_client = pool.get().await?; - let probe_client = pool.get().await?; - let first_backend_pid = backend_pid(&first_client).await?; - let second_backend_pid = backend_pid(&second_client).await?; + // Setup two racing connections and a lock probe + let RaceClients { + first: first_client, + first_backend_pid, + probe: probe_client, + second: second_client, + second_backend_pid, + } = race_clients().await?; // Start the third conversation of the day and keep its transaction open first_client.batch_execute("begin").await?; @@ -747,9 +598,9 @@ async fn db_contracts_start_inbox_conversation_serializes_daily_limit() -> Resul .query_one( "select start_inbox_conversation($1::uuid, $2::uuid, $3::uuid, 'First')", &[ - &INBOX_START_LIMIT_USER_ID, - &INBOX_COMMUNITY_ID, - &INBOX_WRITE_EVENT_ID, + &inbox_start_limit_user_id(), + &inbox_community_id(), + &inbox_write_event_id(), ], ) .await?; @@ -760,9 +611,9 @@ async fn db_contracts_start_inbox_conversation_serializes_daily_limit() -> Resul .query_one( "select start_inbox_conversation($1::uuid, $2::uuid, $3::uuid, 'Second')", &[ - &INBOX_START_LIMIT_USER_ID, - &INBOX_COMMUNITY_ID, - &INBOX_COHOST_EVENT_ID, + &inbox_start_limit_user_id(), + &inbox_community_id(), + &inbox_cohost_event_id(), ], ) .await @@ -776,14 +627,7 @@ async fn db_contracts_start_inbox_conversation_serializes_daily_limit() -> Resul let err = second_task .await? .expect_err("the second start should reach the daily limit"); - assert_eq!( - err.as_db_error().map(DbError::message), - Some("daily limit of new conversations reached") - ); - assert_eq!( - err.code(), - Some(&SqlState::from_code(USER_FACING_DB_ERROR_CODE)) - ); + assert_pg_ocg01(err, "daily limit of new conversations reached"); Ok(()) } @@ -791,13 +635,14 @@ async fn db_contracts_start_inbox_conversation_serializes_daily_limit() -> Resul #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_start_inbox_conversation_serializes_open_conversation() -> Result<()> { - // Setup two connections and a lock probe - let pool = contract_tests_pool()?; - let first_client = pool.get().await?; - let second_client = pool.get().await?; - let probe_client = pool.get().await?; - let first_backend_pid = backend_pid(&first_client).await?; - let second_backend_pid = backend_pid(&second_client).await?; + // Setup two racing connections and a lock probe + let RaceClients { + first: first_client, + first_backend_pid, + probe: probe_client, + second: second_client, + second_backend_pid, + } = race_clients().await?; // Start a conversation with the write group and keep its transaction open first_client.batch_execute("begin").await?; @@ -805,9 +650,9 @@ async fn db_contracts_start_inbox_conversation_serializes_open_conversation() -> .query_one( "select start_inbox_conversation($1::uuid, $2::uuid, $3::uuid, 'First')", &[ - &INBOX_START_RACE_USER_ID, - &INBOX_COMMUNITY_ID, - &INBOX_WRITE_EVENT_ID, + &inbox_start_race_user_id(), + &inbox_community_id(), + &inbox_write_event_id(), ], ) .await?; @@ -818,9 +663,9 @@ async fn db_contracts_start_inbox_conversation_serializes_open_conversation() -> .query_one( "select start_inbox_conversation($1::uuid, $2::uuid, $3::uuid, 'Second')::text", &[ - &INBOX_START_RACE_USER_ID, - &INBOX_COMMUNITY_ID, - &INBOX_WRITE_EVENT_ID, + &inbox_start_race_user_id(), + &inbox_community_id(), + &inbox_write_event_id(), ], ) .await @@ -840,7 +685,7 @@ async fn db_contracts_start_inbox_conversation_serializes_open_conversation() -> let conversations: i64 = probe_client .query_one( "select count(*) from inbox_conversation where user_id = $1::uuid", - &[&INBOX_START_RACE_USER_ID], + &[&inbox_start_race_user_id()], ) .await? .get(0); @@ -851,52 +696,30 @@ async fn db_contracts_start_inbox_conversation_serializes_open_conversation() -> // Helpers. -/// Returns the backend process identifier of a connection. -async fn backend_pid(client: &tokio_postgres::Client) -> Result { - Ok(client - .query_one("select pg_backend_pid()", &[]) - .await? - .get::<_, i32>(0)) +/// Two connections racing for an inbox lock, a probe connection observing +/// the lock graph, and the backend process IDs of the racing connections. +struct RaceClients { + first: deadpool_postgres::Object, + first_backend_pid: i32, + probe: deadpool_postgres::Object, + second: deadpool_postgres::Object, + second_backend_pid: i32, } -/// Removes rows written by the opted-out inbox conversation contract. -async fn cleanup_opted_out_inbox_flow(client: &tokio_postgres::Client) -> Result<()> { - // Remove any emails written by a failed prior focused run - client - .execute( - "delete from notification - where kind = 'inbox-message-received' - and user_id = any($1::uuid[])", - &[&vec![ - inbox_opt_out_admin_id(), - inbox_opt_out_events_manager_id(), - ]], - ) - .await?; - - // Remove messages before their conversations are deleted - client - .execute( - "delete from inbox_message - where inbox_conversation_id in ( - select inbox_conversation_id - from inbox_conversation - where group_id = $1::uuid - and user_id = $2::uuid - )", - &[&inbox_opt_out_group_id(), &inbox_opt_out_user_id()], - ) - .await?; - - // Remove the conversation started by the contract - client - .execute( - "delete from inbox_conversation - where group_id = $1::uuid - and user_id = $2::uuid", - &[&inbox_opt_out_group_id(), &inbox_opt_out_user_id()], - ) - .await?; - - Ok(()) +/// Opens the connections of an inbox lock race. +async fn race_clients() -> Result { + let pool = contract_tests_pool()?; + let first = pool.get().await?; + let second = pool.get().await?; + let probe = pool.get().await?; + let first_backend_pid = backend_pid(&first).await?; + let second_backend_pid = backend_pid(&second).await?; + + Ok(RaceClients { + first, + first_backend_pid, + probe, + second, + second_backend_pid, + }) } diff --git a/ocg-server/src/db/contract_tests/lifecycle.rs b/ocg-server/src/db/contract_tests/lifecycle.rs index 8c9910e78..21f0d98c0 100644 --- a/ocg-server/src/db/contract_tests/lifecycle.rs +++ b/ocg-server/src/db/contract_tests/lifecycle.rs @@ -9,7 +9,7 @@ use tokio::{sync::oneshot, time::timeout}; use tokio_postgres::{NoTls, error::DbError}; use uuid::Uuid; -use crate::db::{DB, DBExt, DBUnitOfWork, PgDB, PgExecutor, PgUnitOfWork, group::DBGroup}; +use crate::db::{DB, DBExt, DBUnitOfWork, PgDB, PgExecutor, group::DBGroup}; use super::helpers::*; @@ -18,12 +18,7 @@ use super::helpers::*; async fn db_contracts_unit_of_work_commit_failure_persists_nothing() -> Result<()> { // Setup a unit of work whose deferred constraint fires at commit let db = contract_tests_db()?; - let pool = contract_tests_pool()?; - let client = pool.get().await?; - client.batch_execute("begin").await?; - let uow = PgUnitOfWork { - client: Some(client), - }; + let uow = contract_unit_of_work().await?; let event_id = Uuid::new_v4(); // Insert an event without the ticket type its deferred constraint requires diff --git a/ocg-server/src/db/contract_tests/meetings.rs b/ocg-server/src/db/contract_tests/meetings.rs index 1fc512cd4..12174e0b7 100644 --- a/ocg-server/src/db/contract_tests/meetings.rs +++ b/ocg-server/src/db/contract_tests/meetings.rs @@ -11,11 +11,11 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_meeting_for_auto_end_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; // Claim the meeting eligible for automatic ending - let candidate = db + let candidate = uow .claim_meeting_for_auto_end() .await? .expect("contract auto-end candidate should exist"); @@ -31,11 +31,11 @@ async fn db_contracts_claim_meeting_for_auto_end_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_meeting_out_of_sync_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; // Claim the meeting requiring provider synchronization - let meeting = db + let meeting = uow .claim_meeting_out_of_sync() .await? .expect("contract meeting sync candidate should exist"); diff --git a/ocg-server/src/db/contract_tests/notifications.rs b/ocg-server/src/db/contract_tests/notifications.rs index 510ce6f38..34b90b277 100644 --- a/ocg-server/src/db/contract_tests/notifications.rs +++ b/ocg-server/src/db/contract_tests/notifications.rs @@ -24,7 +24,6 @@ use crate::{ }, }, event::EventSummary, - group::GroupRole, notifications::{NewNotification, NotificationKind}, }, }; @@ -34,12 +33,12 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_pending_notification_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; // Claim the seeded notification through the production wrapper let claim_started_at = Utc::now(); - let notification = db + let notification = uow .claim_pending_notification() .await? .context("pending contract notification should be claimable")?; @@ -56,7 +55,7 @@ async fn db_contracts_claim_pending_notification_deserializes() -> Result<()> { assert!(notification.template_data.is_none()); // Finalize through the production wrapper to verify the claim identity round trip - db.update_notification(¬ification, None).await?; + uow.update_notification(¬ification, None).await?; Ok(()) } @@ -82,8 +81,7 @@ async fn db_contracts_enqueue_due_event_survey_notifications_enqueues_opening_em // Run the survey opening email job let count = uow.enqueue_due_event_survey_notifications().await?; - // Check the count matches the enqueued notifications and the recipients - // recorded by the surveys it handled + // Check the count matches the enqueued notifications let enqueued: i64 = uow .fetch_scalar_one( "select count(*) from notification @@ -93,82 +91,21 @@ async fn db_contracts_enqueue_due_event_survey_notifications_enqueues_opening_em ) .await?; assert_eq!(count, usize::try_from(enqueued)?); - let recorded: i64 = uow - .fetch_scalar_one( - "select coalesce(sum(open_notification_recipients), 0)::bigint - from event_survey - where open_notification_processed_at = current_timestamp", - &[], - ) - .await?; - assert_eq!(count, usize::try_from(recorded)?); - assert_eq!( - survey_open_notification_recipients(&uow, survey_email_id()).await?, - Some(1) - ); - assert_eq!( - survey_open_notification_recipients(&uow, survey_identified_id()).await?, - Some(0) - ); - // Check only the verified attendee was emailed, with the template data - let notifications = uow - .client() - .await? - .query( - "select n.user_id, ntd.data - from notification n - join notification_template_data ntd using (notification_template_data_id) - where n.kind = 'event-survey-opened' - and ntd.data->>'survey_title' = 'Contract email survey'", - &[], - ) - .await?; - assert_eq!(notifications.len(), 1); - assert_eq!( - notifications[0].get::<_, Uuid>(0), - survey_email_recipient_id() - ); - let data = notifications[0].get::<_, serde_json::Value>(1); - assert_eq!(data["anonymous"], serde_json::json!(false)); - assert_eq!(data["closes_at"], serde_json::json!(4_070_908_800_i64)); - let event: EventSummary = serde_json::from_value(data["event"].clone())?; + // Check both template payloads carry a decodable event and the closing + // date only when the survey has one + let dated = survey_opened_template_data(&uow, "Contract email survey").await?; + let event: EventSummary = serde_json::from_value(dated["event"].clone())?; assert_eq!(event.event_id, survey_email_event_id()); assert_eq!( - data["link"], - format!( - "/dashboard/user?tab=surveys&event_survey_id={}", - survey_email_id() - ) + dated.get("closes_at").and_then(serde_json::Value::as_i64), + Some(4_070_908_800) ); - assert!(data["theme"]["primary_color"].is_string()); - - // Check the closing date is omitted for the survey without one - let open_ended: serde_json::Value = uow - .fetch_scalar_one( - "select ntd.data - from notification n - join notification_template_data ntd using (notification_template_data_id) - where n.kind = 'event-survey-opened' - and ntd.data->>'survey_title' = 'Contract email close race survey'", - &[], - ) - .await?; + let open_ended = survey_opened_template_data(&uow, "Contract email close race survey").await?; + let event: EventSummary = serde_json::from_value(open_ended["event"].clone())?; + assert_eq!(event.event_id, survey_email_event_id()); assert!(open_ended.get("closes_at").is_none()); - // Check unverified attendees are never emailed - let unverified_notifications: i64 = uow - .fetch_scalar_one( - "select count(*) from notification where kind = 'event-survey-opened' and user_id = $1::uuid", - &[&survey_unverified_attendee_id()], - ) - .await?; - assert_eq!(unverified_notifications, 0); - - // Check a second run never emails the handled surveys again - let second_count = uow.enqueue_due_event_survey_notifications().await?; - assert_eq!(second_count, 0); - Ok(()) } @@ -182,7 +119,7 @@ async fn db_contracts_enqueue_due_event_survey_notifications_rechecks_event_resc let payload = EventInput { ends_at: NaiveDate::from_ymd_opt(2025, 3, 31).and_then(|day| day.and_hms_opt(19, 0, 0)), starts_at: NaiveDate::from_ymd_opt(2025, 3, 31).and_then(|day| day.and_hms_opt(17, 0, 0)), - ..group_lock_event_update("Contract Survey Reschedule Event", 1) + ..contract_event_input("Contract Survey Reschedule Event", 1) } .to_db_payload()?; @@ -364,15 +301,14 @@ async fn db_contracts_enqueue_notification_filters_all_group_scoped_recipients() #[tokio::test] #[ignore = "requires the contract test database"] -async fn db_contracts_enqueue_tracked_community_custom_notification_audits_selected_groups() +async fn db_contracts_enqueue_tracked_community_custom_notification_enqueues_and_tracks() -> Result<()> { - // Setup the contract database and filters selecting two groups - let db = contract_tests_db()?; - let client = contract_tests_pool()?.get().await?; + // Setup a rolled-back unit of work and filters selecting two groups + let uow = contract_unit_of_work().await?; let subject = "Contract community contact groups"; // Enqueue the notification for the selected groups only - let count = db + let count = uow .enqueue_tracked_community_custom_notification(CommunityCustomNotificationEnqueue { body: "Hello selected group teams".to_string(), community_id: contact_community_id(), @@ -388,77 +324,9 @@ async fn db_contracts_enqueue_tracked_community_custom_notification_audits_selec // Check only the team member of the active selected group was notified assert_eq!(count, 1); - let recipients: Vec = client - .query( - "select n.user_id - from notification n - join notification_template_data ntd using (notification_template_data_id) - where n.kind = 'community-custom' - and ntd.data->>'subject' = $1::text", - &[&subject], - ) - .await? - .iter() - .map(|row| row.get(0)) - .collect(); - assert_eq!(recipients, vec![contact_admin_id()]); - - // Check the audit row names the selected groups by label - let details: serde_json::Value = client - .query_one( - "select details - from audit_log - where action = 'community_custom_notification_sent' - and details->>'subject' = $1::text", - &[&subject], - ) + let recipients: Vec = uow + .client() .await? - .get(0); - assert_eq!( - details, - serde_json::json!({ - "group_categories": "All", - "groups": [ - "Contact Group B · Contact Category B", - "Contact Inactive Group · Contact Category A", - ], - "recipient_count": 1, - "regions": "All", - "roles": "All", - "subject": subject, - }) - ); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_enqueue_tracked_community_custom_notification_enqueues_and_audits() --> Result<()> { - // Setup the contract database and admin-only filters - let db = contract_tests_db()?; - let client = contract_tests_pool()?.get().await?; - let subject = "Contract community contact"; - - // Enqueue the community notification through the production wrapper - let count = db - .enqueue_tracked_community_custom_notification(CommunityCustomNotificationEnqueue { - body: "Hello group teams".to_string(), - community_id: contact_community_id(), - created_by: contact_manager_id(), - filters: CommunityContactFilters { - roles: vec![GroupRole::Admin], - ..Default::default() - }, - subject: subject.to_string(), - template_data: serde_json::json!({ "subject": subject }), - }) - .await?; - - // Check only the verified admin of an active group was notified - assert_eq!(count, 1); - let recipients: Vec = client .query( "select n.user_id from notification n @@ -474,110 +342,13 @@ async fn db_contracts_enqueue_tracked_community_custom_notification_enqueues_and assert_eq!(recipients, vec![contact_admin_id()]); // Check the custom notification is tracked against the community - let tracked_community_id: Option = client - .query_one( + let tracked_community_id: Option = uow + .fetch_scalar_one( "select community_id from custom_notification where subject = $1::text", &[&subject], ) - .await? - .get(0); - assert_eq!(tracked_community_id, Some(contact_community_id())); - - // Check the audit row describes the recipients and filters by name - let audit = client - .query_one( - "select community_id, details, resource_id, resource_type - from audit_log - where action = 'community_custom_notification_sent' - and details->>'subject' = $1::text", - &[&subject], - ) .await?; - assert_eq!( - audit.get::<_, Option>("community_id"), - Some(contact_community_id()) - ); - assert_eq!( - audit.get::<_, serde_json::Value>("details"), - serde_json::json!({ - "group_categories": "All", - "groups": "All", - "recipient_count": 1, - "regions": "All", - "roles": ["Admin"], - "subject": subject, - }) - ); - assert_eq!( - audit.get::<_, uuid::Uuid>("resource_id"), - contact_community_id() - ); - assert_eq!(audit.get::<_, String>("resource_type"), "community"); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_enqueue_tracked_community_custom_notification_rejects_foreign_filters() --> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - - // Enqueue with a group category of another community - let err = db - .enqueue_tracked_community_custom_notification(CommunityCustomNotificationEnqueue { - body: "Hello".to_string(), - community_id: contact_community_id(), - created_by: contact_manager_id(), - filters: CommunityContactFilters { - group_category_ids: vec![group_category_id()], - ..Default::default() - }, - subject: "Contract foreign contact".to_string(), - template_data: serde_json::json!({}), - }) - .await - .expect_err("foreign category should be rejected"); - - // Check the user-facing rejection is raised - assert_eq!( - ocg01_message(&err).as_deref(), - Some("group category not found") - ); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_enqueue_tracked_community_custom_notification_rejects_zero_recipients() --> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - - // Enqueue with filters no seat matches together - let err = db - .enqueue_tracked_community_custom_notification(CommunityCustomNotificationEnqueue { - body: "Hello".to_string(), - community_id: contact_community_id(), - created_by: contact_manager_id(), - filters: CommunityContactFilters { - group_category_ids: vec![contact_category_b_id()], - roles: vec![GroupRole::Admin], - ..Default::default() - }, - subject: "Contract empty contact".to_string(), - template_data: serde_json::json!({}), - }) - .await - .expect_err("empty audience should be rejected"); - - // Check the user-facing rejection is raised - assert_eq!( - ocg01_message(&err).as_deref(), - Some("no group team members match the selected filters") - ); + assert_eq!(tracked_community_id, Some(contact_community_id())); Ok(()) } @@ -585,13 +356,12 @@ async fn db_contracts_enqueue_tracked_community_custom_notification_rejects_zero #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_enqueue_tracked_custom_notification_tracks_group_scope() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - let client = contract_tests_pool()?.get().await?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; let subject = "Contract group scope"; // Enqueue a group notification through the production wrapper - db.enqueue_tracked_custom_notification( + uow.enqueue_tracked_custom_notification( &NewNotification { attachments: vec![], group_ids: vec![contact_group_a_id()], @@ -613,7 +383,9 @@ async fn db_contracts_enqueue_tracked_custom_notification_tracks_group_scope() - .await?; // Check the custom notification is tracked against the group only - let tracked = client + let tracked = uow + .client() + .await? .query_one( "select community_id, event_id, group_id from custom_notification @@ -621,15 +393,17 @@ async fn db_contracts_enqueue_tracked_custom_notification_tracks_group_scope() - &[&subject], ) .await?; - assert_eq!(tracked.get::<_, Option>("community_id"), None); - assert_eq!(tracked.get::<_, Option>("event_id"), None); + assert_eq!(tracked.get::<_, Option>("community_id"), None); + assert_eq!(tracked.get::<_, Option>("event_id"), None); assert_eq!( - tracked.get::<_, Option>("group_id"), + tracked.get::<_, Option>("group_id"), Some(contact_group_a_id()) ); // Check the audit row carries the community of the group and the recipient count - let audit = client + let audit = uow + .client() + .await? .query_one( "select community_id, group_id, details->>'recipient_count' as recipient_count from audit_log @@ -639,11 +413,11 @@ async fn db_contracts_enqueue_tracked_custom_notification_tracks_group_scope() - ) .await?; assert_eq!( - audit.get::<_, Option>("community_id"), + audit.get::<_, Option>("community_id"), Some(contact_community_id()) ); assert_eq!( - audit.get::<_, Option>("group_id"), + audit.get::<_, Option>("group_id"), Some(contact_group_a_id()) ); assert_eq!( @@ -685,6 +459,23 @@ async fn db_contracts_filter_notification_recipient_ids_preserves_eligible_order // Helpers. +/// Returns the template data of the survey opening email sent for the given +/// survey title. +async fn survey_opened_template_data( + uow: &PgUnitOfWork, + survey_title: &str, +) -> Result { + uow.fetch_scalar_one( + "select ntd.data + from notification n + join notification_template_data ntd using (notification_template_data_id) + where n.kind = 'event-survey-opened' + and ntd.data->>'survey_title' = $1::text", + &[&survey_title], + ) + .await +} + /// Returns the opening email recipients a survey recorded, when it was handled. async fn survey_open_notification_recipients( uow: &PgUnitOfWork, diff --git a/ocg-server/src/db/contract_tests/payments.rs b/ocg-server/src/db/contract_tests/payments.rs index 29f30c82f..0dbe93e9b 100644 --- a/ocg-server/src/db/contract_tests/payments.rs +++ b/ocg-server/src/db/contract_tests/payments.rs @@ -6,7 +6,8 @@ use uuid::Uuid; use crate::{ db::{ - dashboard::group::DBDashboardGroup, + PgExecutor, + dashboard::{group::DBDashboardGroup, user::DBDashboardUser}, payments::{ ClaimedPaymentJobWork, CompletePaymentJobRecoveryInput, DBPayments, EventPurchaseRefundKind, EventPurchaseRefundStatus, PrepareEventCheckoutPurchaseInput, @@ -14,8 +15,11 @@ use crate::{ ReconcileEventPurchaseResult, }, }, - types::payments::{ - EventPurchaseChargeModel, EventPurchaseStatus, PaymentJobKind, PaymentProvider, + types::{ + dashboard::user::purchases::PurchaseDocumentsFilters, + payments::{ + EventPurchaseChargeModel, EventPurchaseStatus, PaymentJobKind, PaymentProvider, + }, }, }; @@ -24,26 +28,23 @@ use super::helpers::*; #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_and_finalize_event_refund_deserializes() -> Result<()> { - // Setup the contract database and make the target refund claim deterministic - let db = contract_tests_db()?; - contract_tests_pool()? - .get() - .await? - .execute( - " - update payment_job - set next_attempt_at = case - when payment_job_id = $1::uuid then current_timestamp - interval '1 minute' - else current_timestamp + interval '1 minute' - end - where kind = 'event-purchase-refund' - ", - &[&refund_approve_job_id()], - ) - .await?; + // Setup a rolled-back unit of work and make the target refund claim deterministic + let uow = contract_unit_of_work().await?; + uow.execute( + " + update payment_job + set next_attempt_at = case + when payment_job_id = $1::uuid then current_timestamp - interval '1 minute' + else current_timestamp + interval '1 minute' + end + where kind = 'event-purchase-refund' + ", + &[&refund_approve_job_id()], + ) + .await?; // Claim the provider-complete durable refund from contract fixtures - let job = db + let job = uow .claim_payment_job(PaymentJobKind::EventPurchaseRefund, PaymentProvider::Stripe) .await? .context("provider-complete contract refund should be claimable")?; @@ -68,7 +69,7 @@ async fn db_contracts_claim_and_finalize_event_refund_deserializes() -> Result<( assert!(refund.provider_refunded_at.is_some()); // Finalize local state with the current worker claim - db.finalize_event_purchase_refund( + uow.finalize_event_purchase_refund( refund.event_purchase_refund_id, job.claim_id, serde_json::json!({"scenario": "contract"}), @@ -82,9 +83,9 @@ async fn db_contracts_claim_and_finalize_event_refund_deserializes() -> Result<( #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_payment_job_application_fee_adjustment_deserializes() -> Result<()> { - // Setup the contract database and claim the pending adjustment fixture - let db = contract_tests_db()?; - let job = db + // Setup a rolled-back unit of work and claim the pending adjustment fixture + let uow = contract_unit_of_work().await?; + let job = uow .claim_payment_job( PaymentJobKind::EventPurchaseApplicationFeeAdjustment, PaymentProvider::Stripe, @@ -120,8 +121,8 @@ async fn db_contracts_claim_payment_job_application_fee_adjustment_deserializes( "fee_contract_documents" ); - // Complete the claim so it cannot interfere with later worker contracts - db.record_event_purchase_application_fee_adjustment_succeeded( + // Record the provider outcome with the current worker claim + uow.record_event_purchase_application_fee_adjustment_succeeded( adjustment.event_purchase_application_fee_adjustment_id, job.claim_id, "fr_contract_documents".to_string(), @@ -134,9 +135,9 @@ async fn db_contracts_claim_payment_job_application_fee_adjustment_deserializes( #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_claim_payment_job_credit_note_deserializes() -> Result<()> { - // Setup the contract database and claim the pending credit-note fixture - let db = contract_tests_db()?; - let job = db + // Setup a rolled-back unit of work and claim the pending credit-note fixture + let uow = contract_unit_of_work().await?; + let job = uow .claim_payment_job( PaymentJobKind::EventPurchaseCreditNote, PaymentProvider::Stripe, @@ -167,8 +168,8 @@ async fn db_contracts_claim_payment_job_credit_note_deserializes() -> Result<()> assert_eq!(credit_note.provider_refund_id, "re_contract_documents"); assert_eq!(credit_note.tax_amount_minor, 0); - // Issue the document so later attendee contracts cover the provider fields - db.record_event_purchase_credit_note_succeeded( + // Issue the document with the current worker claim + uow.record_event_purchase_credit_note_succeeded( credit_note.event_purchase_credit_note_id, job.claim_id, "cn_contract_documents".to_string(), @@ -177,17 +178,38 @@ async fn db_contracts_claim_payment_job_credit_note_deserializes() -> Result<()> ) .await?; + // Check the attendee document history exposes the issued credit note + let documents = uow + .list_user_purchase_documents( + free_buyer_id(), + &PurchaseDocumentsFilters { + limit: Some(10), + offset: Some(0), + }, + ) + .await?; + let issued = &documents.purchases[0].credit_notes[0]; + assert_eq!( + issued.event_purchase_credit_note_id, + document_credit_note_id() + ); + assert_eq!( + issued.provider_credit_note_id.as_deref(), + Some("cn_contract_documents") + ); + assert_eq!(issued.status, "completed"); + Ok(()) } #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_approve_external_event_refund_request_deserializes() -> Result<()> { - // Setup the contract database and pending external refund fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and pending external refund fixture + let uow = contract_unit_of_work().await?; // Approve the external refund request through the Rust contract - let purchase = db + let purchase = uow .approve_external_event_refund_request( organizer_id(), group_id(), @@ -209,11 +231,11 @@ async fn db_contracts_approve_external_event_refund_request_deserializes() -> Re #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_complete_external_event_purchase_deserializes() -> Result<()> { - // Setup the contract database and pending external purchase fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and pending external purchase fixture + let uow = contract_unit_of_work().await?; // Complete the external purchase through the Rust contract - let purchase = db + let purchase = uow .complete_external_event_purchase( organizer_id(), group_id(), @@ -236,61 +258,57 @@ async fn db_contracts_complete_external_event_purchase_deserializes() -> Result< #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_complete_payment_job_recovery_records_evidence() -> Result<()> { - // Setup the contract database and a dedicated exhausted recovery job - let db = contract_tests_db()?; - let pool = contract_tests_pool()?; - let adjustment_id = Uuid::parse_str("00000000-0000-0000-0000-00000000c13c")?; - let payment_job_id = Uuid::parse_str("00000000-0000-0000-0000-00000000c13b")?; - let client = pool.get().await?; - client - .execute( - " - insert into payment_job ( - attempt_count, - event_purchase_id, - failure_message, - idempotency_key, - kind, - payment_job_id, - payment_provider_id, - status - ) values ( - 10, - $1::uuid, - 'contract recovery failure', - 'contract-complete-payment-job-recovery', - 'event-purchase-application-fee-adjustment', - $2::uuid, - 'stripe', - 'failed' - ) - ", - &[&document_purchase_id(), &payment_job_id], + // Setup a rolled-back unit of work and a dedicated exhausted recovery job + let uow = contract_unit_of_work().await?; + let adjustment_id = Uuid::new_v4(); + let payment_job_id = Uuid::new_v4(); + uow.execute( + " + insert into payment_job ( + attempt_count, + event_purchase_id, + failure_message, + idempotency_key, + kind, + payment_job_id, + payment_provider_id, + status + ) values ( + 10, + $1::uuid, + 'contract recovery failure', + 'contract-complete-payment-job-recovery', + 'event-purchase-application-fee-adjustment', + $2::uuid, + 'stripe', + 'failed' ) - .await?; - client - .execute( - " - insert into event_purchase_application_fee_adjustment ( - amount_minor, - event_purchase_application_fee_adjustment_id, - event_purchase_id, - kind, - payment_job_id - ) values ( - 25, - $3::uuid, - $1::uuid, - 'tax-reconciliation', - $2::uuid - ) - ", - &[&document_purchase_id(), &payment_job_id, &adjustment_id], + ", + &[&document_purchase_id(), &payment_job_id], + ) + .await?; + uow.execute( + " + insert into event_purchase_application_fee_adjustment ( + amount_minor, + event_purchase_application_fee_adjustment_id, + event_purchase_id, + kind, + payment_job_id + ) values ( + 25, + $3::uuid, + $1::uuid, + 'tax-reconciliation', + $2::uuid ) - .await?; + ", + &[&document_purchase_id(), &payment_job_id, &adjustment_id], + ) + .await?; // Complete the exhausted job with external provider evidence - db.complete_payment_job_recovery(&CompletePaymentJobRecoveryInput { + uow.complete_payment_job_recovery(&CompletePaymentJobRecoveryInput { actor_user_id: organizer_id(), group_id: group_id(), payment_job_id, @@ -301,8 +319,9 @@ async fn db_contracts_complete_payment_job_recovery_records_evidence() -> Result .await?; // Check the recovery evidence and provider outcome were persisted - let client = pool.get().await?; - let row = client + let row = uow + .client() + .await? .query_one( " select @@ -357,15 +376,15 @@ async fn db_contracts_get_event_purchase_notification_context_deserializes() -> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_complete_free_event_purchase_deserializes() -> Result<()> { - // Setup the contract database and free purchase fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and free purchase fixture + let uow = contract_unit_of_work().await?; // Load the provider-free purchase snapshot before completing it - let summary = db.get_event_purchase_summary(free_purchase_id()).await?; + let summary = uow.get_event_purchase_summary(free_purchase_id()).await?; assert_eq!(summary.currency_code, None); // Complete the free purchase through the Rust contract - let purchase = db.complete_free_event_purchase(free_purchase_id()).await?; + let purchase = uow.complete_free_event_purchase(free_purchase_id()).await?; // Check the completed purchase ownership fields assert_eq!(purchase.community_id, community_id()); @@ -378,24 +397,25 @@ async fn db_contracts_complete_free_event_purchase_deserializes() -> Result<()> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_event_purchase_refund_lifecycle_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; + // Setup a rolled-back unit of work + let uow = contract_unit_of_work().await?; // Queue a durable manual refund from deterministic contract data - db.queue_event_refund_request_approval( + uow.queue_event_refund_request_approval( organizer_id(), group_id(), refund_lifecycle_purchase_id(), Some("Approved for lifecycle contract".to_string()), ) .await?; - let refund = db.get_event_purchase_refund(refund_lifecycle_purchase_id()).await?; + let refund = uow.get_event_purchase_refund(refund_lifecycle_purchase_id()).await?; // Check required fields and initial workflow metadata assert_eq!(refund.amount_minor, 2500); assert_eq!(refund.currency_code, "USD"); assert_eq!(refund.event_purchase_id, refund_lifecycle_purchase_id()); assert_ne!(refund.event_purchase_refund_id, Uuid::nil()); + assert_ne!(refund.payment_job_id, Uuid::nil()); assert_eq!( refund.idempotency_key, format!("event-purchase-refund-{}", refund_lifecycle_purchase_id()) @@ -411,7 +431,7 @@ async fn db_contracts_event_purchase_refund_lifecycle_deserializes() -> Result<( assert!(refund.provider_refunded_at.is_none()); // Record the in-progress provider refund - let refund = db + let refund = uow .record_event_purchase_refund_pending( refund.event_purchase_refund_id, refund.idempotency_key.clone(), @@ -433,7 +453,7 @@ async fn db_contracts_event_purchase_refund_lifecycle_deserializes() -> Result<( assert!(refund.provider_refunded_at.is_none()); // Record provider success for the same durable refund - let refund = db + let refund = uow .record_event_purchase_refund_succeeded( refund.event_purchase_refund_id, refund.idempotency_key.clone(), @@ -457,22 +477,6 @@ async fn db_contracts_event_purchase_refund_lifecycle_deserializes() -> Result<( Ok(()) } -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_get_event_purchase_recovery_summary_deserializes() -> Result<()> { - // Setup the contract database - let db = contract_tests_db()?; - - // Load the deterministic purchase awaiting provider refund recovery - let summary = db.get_event_purchase_summary(refund_recovery_purchase_id()).await?; - - // Check the recovery status crosses the SQL-to-Rust contract - assert_eq!(summary.event_purchase_id, refund_recovery_purchase_id()); - assert_eq!(summary.status, EventPurchaseStatus::RefundRecoveryPending); - - Ok(()) -} - #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_get_event_purchase_refund_deserializes() -> Result<()> { @@ -524,7 +528,7 @@ async fn db_contracts_get_event_purchase_refund_recovery_context_deserializes() // Check the app receives authoritative notification composition context assert_eq!(context.community_id, community_id()); - assert_eq!(context.event_id, refund_event_id()); + assert_eq!(context.event_id, paid_event_id()); assert_eq!( context.event_purchase_refund_id, refund_recovery_refund_id() @@ -540,8 +544,9 @@ async fn db_contracts_get_event_purchase_summary_deserializes() -> Result<()> { // Setup the contract database and purchase fixture let db = contract_tests_db()?; - // Load the purchase summary through the Rust contract + // Load the pending and the refund recovery purchase summaries let summary = db.get_event_purchase_summary(summary_purchase_id()).await?; + let recovery_summary = db.get_event_purchase_summary(refund_recovery_purchase_id()).await?; // Check pricing, hold, status, and ticket fields assert_eq!(summary.amount_minor, 2500); @@ -557,6 +562,16 @@ async fn db_contracts_get_event_purchase_summary_deserializes() -> Result<()> { assert_eq!(summary.status, EventPurchaseStatus::Pending); assert_eq!(summary.ticket_title, "Contract Paid Ticket"); + // Check the recovery status crosses the SQL-to-Rust contract + assert_eq!( + recovery_summary.event_purchase_id, + refund_recovery_purchase_id() + ); + assert_eq!( + recovery_summary.status, + EventPurchaseStatus::RefundRecoveryPending + ); + Ok(()) } @@ -581,8 +596,8 @@ async fn db_contracts_get_user_purchase_document_context_deserializes() -> Resul #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_prepare_event_checkout_purchase_deserializes() -> Result<()> { - // Setup the contract database and checkout input - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and checkout input + let uow = contract_unit_of_work().await?; let input = PrepareEventCheckoutPurchaseInput { event_id: paid_event_id(), event_ticket_type_id: paid_ticket_type_id(), @@ -596,7 +611,7 @@ async fn db_contracts_prepare_event_checkout_purchase_deserializes() -> Result<( }; // Prepare the checkout purchase through the Rust contract - let checkout = match db.prepare_event_checkout_purchase(community_id(), &input).await? { + let checkout = match uow.prepare_event_checkout_purchase(community_id(), &input).await? { PrepareEventCheckoutPurchaseResult::Conflict(conflict) => { return Err(anyhow!("unexpected checkout conflict: {conflict:?}")); } @@ -649,8 +664,8 @@ async fn db_contracts_prepare_event_checkout_purchase_deserializes() -> Result<( #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_prepare_event_checkout_purchase_external_deserializes() -> Result<()> { - // Setup the contract database and external checkout input - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and external checkout input + let uow = contract_unit_of_work().await?; let input = PrepareEventCheckoutPurchaseInput { event_id: external_event_id(), event_ticket_type_id: external_ticket_type_id(), @@ -664,7 +679,7 @@ async fn db_contracts_prepare_event_checkout_purchase_external_deserializes() -> }; // Prepare the external checkout purchase through the Rust contract - let checkout = match db.prepare_event_checkout_purchase(community_id(), &input).await? { + let checkout = match uow.prepare_event_checkout_purchase(community_id(), &input).await? { PrepareEventCheckoutPurchaseResult::Conflict(conflict) => { return Err(anyhow!("unexpected checkout conflict: {conflict:?}")); } @@ -719,53 +734,22 @@ async fn db_contracts_queue_event_refund_request_approval_blocks_event_writers() .await?; // Probe whether a competing writer can lock the owning event - event_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let lock_result = event_client - .query_one( - "select event_id from event where event_id = $1::uuid for update", - &[&refund_event_id()], - ) - .await; - - // Roll back the approval and lock probe before checking the outcome - event_client.batch_execute("rollback").await?; - approval_client.batch_execute("rollback").await?; - - // Check approval holds the event lock before refund workflow rows - let lock_err = lock_result.expect_err("event writer should wait for refund approval"); - assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); - - Ok(()) -} - -#[tokio::test] -#[ignore = "requires the contract test database"] -async fn db_contracts_queue_event_refund_request_approval_deserializes() -> Result<()> { - // Setup the contract database and refund request fixture - let db = contract_tests_db()?; - - // Queue organizer approval using deterministic request fixtures - db.queue_event_refund_request_approval( - organizer_id(), - group_id(), - refund_begin_purchase_id(), - Some("Approved by contract test".to_string()), + let probe_code = probe_lock( + &event_client, + "select event_id from event where event_id = $1::uuid for update", + &[&paid_event_id()], ) .await?; - // Load the queued durable refund through the Rust database contract - let refund = db.get_event_purchase_refund(refund_begin_purchase_id()).await?; + // Roll back the approval before checking the outcome + approval_client.batch_execute("rollback").await?; - // Check required durable refund fields deserialize as expected - assert_eq!(refund.amount_minor, 2500); - assert_eq!(refund.currency_code, "USD"); - assert_eq!(refund.event_purchase_id, refund_begin_purchase_id()); - assert_eq!(refund.kind, EventPurchaseRefundKind::RefundRequestApproval); - assert_ne!(refund.payment_job_id, Uuid::nil()); - assert_eq!(refund.payment_provider, PaymentProvider::Stripe); - assert_eq!(refund.status, EventPurchaseRefundStatus::ProviderPending); + // Check approval holds the event lock before refund workflow rows + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "event writer should wait for refund approval" + ); Ok(()) } @@ -791,42 +775,33 @@ async fn db_contracts_record_event_purchase_refund_pending_blocks_event_writers( ) .await?; - // Probe whether a competing writer can lock the owning event - probe_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let event_lock_result = probe_client - .query_one( - "select event_id from event where event_id = $1::uuid for update", - &[&refund_event_id()], - ) - .await; - probe_client.batch_execute("rollback").await?; - - // Probe whether a competing writer can lock the refunded purchase - probe_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let purchase_lock_result = probe_client - .query_one( - "select event_purchase_id from event_purchase where event_purchase_id = $1::uuid for update", - &[&refund_recovery_purchase_id()], - ) - .await; - probe_client.batch_execute("rollback").await?; + // Probe whether competing writers can lock the owning event and purchase + let event_probe_code = probe_lock( + &probe_client, + "select event_id from event where event_id = $1::uuid for update", + &[&paid_event_id()], + ) + .await?; + let purchase_probe_code = probe_lock( + &probe_client, + "select event_purchase_id from event_purchase where event_purchase_id = $1::uuid for update", + &[&refund_recovery_purchase_id()], + ) + .await?; // Roll back the refund result before checking the outcomes result_client.batch_execute("rollback").await?; // Check the result holds the event and purchase locks - let event_lock_err = - event_lock_result.expect_err("event writer should wait for the refund result"); - assert_eq!(event_lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); - let purchase_lock_err = - purchase_lock_result.expect_err("purchase writer should wait for the refund result"); assert_eq!( - purchase_lock_err.code(), - Some(&SqlState::LOCK_NOT_AVAILABLE) + event_probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "event writer should wait for the refund result" + ); + assert_eq!( + purchase_probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "purchase writer should wait for the refund result" ); Ok(()) @@ -835,28 +810,25 @@ async fn db_contracts_record_event_purchase_refund_pending_blocks_event_writers( #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_record_payment_job_failure_releases_claim() -> Result<()> { - // Setup a processing payment job with a deterministic claim - let db = contract_tests_db()?; - let pool = contract_tests_pool()?; + // Setup a rolled-back unit of work and a processing payment job with a deterministic claim + let uow = contract_unit_of_work().await?; let claim_id = Uuid::new_v4(); - pool.get() - .await? - .execute( - " - update payment_job - set - attempt_count = 1, - claim_id = $2::uuid, - claimed_at = current_timestamp, - status = 'processing' - where payment_job_id = $1::uuid - ", - &[&document_refund_job_id(), &claim_id], - ) - .await?; + uow.execute( + " + update payment_job + set + attempt_count = 1, + claim_id = $2::uuid, + claimed_at = current_timestamp, + status = 'processing' + where payment_job_id = $1::uuid + ", + &[&document_refund_job_id(), &claim_id], + ) + .await?; // Release the claim through the Rust database wrapper - db.record_payment_job_failure( + uow.record_payment_job_failure( document_refund_job_id(), claim_id, "contract retryable failure".to_string(), @@ -864,8 +836,8 @@ async fn db_contracts_record_payment_job_failure_releases_claim() -> Result<()> .await?; // Check the job is failed and unclaimed for a later retry - let row = pool - .get() + let row = uow + .client() .await? .query_one( " @@ -889,11 +861,11 @@ async fn db_contracts_record_payment_job_failure_releases_claim() -> Result<()> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_reconcile_event_purchase_for_checkout_session_deserializes() -> Result<()> { - // Setup the contract database and provider checkout references - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and provider checkout references + let uow = contract_unit_of_work().await?; // Reconcile the provider checkout through the Rust contract - let result = db + let result = uow .reconcile_event_purchase_for_checkout_session( &ReconcileEventPurchaseForCheckoutSessionInput { payment_provider: PaymentProvider::Stripe, @@ -925,33 +897,30 @@ async fn db_contracts_reconcile_event_purchase_for_checkout_session_deserializes #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_requeue_payment_job_resets_exhausted_job() -> Result<()> { - // Setup the contract database and an exhausted refund job - let db = contract_tests_db()?; - let pool = contract_tests_pool()?; - pool.get() - .await? - .execute( - " - update payment_job - set - attempt_count = 10, - claim_id = null, - claimed_at = null, - failure_message = 'contract exhausted', - next_attempt_at = current_timestamp, - status = 'failed' - where payment_job_id = $1::uuid - ", - &[&document_refund_job_id()], - ) - .await?; + // Setup a rolled-back unit of work and an exhausted refund job + let uow = contract_unit_of_work().await?; + uow.execute( + " + update payment_job + set + attempt_count = 10, + claim_id = null, + claimed_at = null, + failure_message = 'contract exhausted', + next_attempt_at = current_timestamp, + status = 'failed' + where payment_job_id = $1::uuid + ", + &[&document_refund_job_id()], + ) + .await?; // Requeue the exhausted job through the Rust wrapper - db.requeue_payment_job(group_id(), document_refund_job_id()).await?; + uow.requeue_payment_job(group_id(), document_refund_job_id()).await?; // Check the job is ready for a new automatic attempt cycle - let row = pool - .get() + let row = uow + .client() .await? .query_one( " @@ -972,32 +941,29 @@ async fn db_contracts_requeue_payment_job_resets_exhausted_job() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_requeue_stale_payment_job_claims_releases_old_claims() -> Result<()> { - // Setup a stale payment job claim beyond the recovery timeout - let db = contract_tests_db()?; - let pool = contract_tests_pool()?; + // Setup a rolled-back unit of work and a stale payment job claim beyond the recovery timeout + let uow = contract_unit_of_work().await?; let claim_id = Uuid::new_v4(); - pool.get() - .await? - .execute( - " - update payment_job - set - attempt_count = 2, - claim_id = $2::uuid, - claimed_at = current_timestamp - interval '20 minutes', - status = 'processing' - where payment_job_id = $1::uuid - ", - &[&document_refund_job_id(), &claim_id], - ) - .await?; + uow.execute( + " + update payment_job + set + attempt_count = 2, + claim_id = $2::uuid, + claimed_at = current_timestamp - interval '20 minutes', + status = 'processing' + where payment_job_id = $1::uuid + ", + &[&document_refund_job_id(), &claim_id], + ) + .await?; // Recover stale claims through the Rust database wrapper - let recovered = db.requeue_stale_payment_job_claims().await?; + let recovered = uow.requeue_stale_payment_job_claims().await?; - // Check the stale claim was released without depending on total sweep count - let row = pool - .get() + // Check the only stale claim was recovered, unclaimed and marked failed + let row = uow + .client() .await? .query_one( " @@ -1008,7 +974,7 @@ async fn db_contracts_requeue_stale_payment_job_claims_releases_old_claims() -> &[&document_refund_job_id()], ) .await?; - assert!(recovered >= 1); + assert_eq!(recovered, 1); assert_eq!(row.get::<_, Option>("claim_id"), None); assert_eq!(row.get::<_, &str>("status"), "failed"); @@ -1018,11 +984,11 @@ async fn db_contracts_requeue_stale_payment_job_claims_releases_old_claims() -> #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_reconcile_next_event_enrollment_deserializes() -> Result<()> { - // Setup the contract database and dedicated due event fixture - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and dedicated due event fixture + let uow = contract_unit_of_work().await?; // Claim and reconcile the due event through the Rust JSON contract - let outcome = db + let outcome = uow .reconcile_next_event_enrollment(None) .await? .context("due contract event should be claimable")?; @@ -1058,23 +1024,22 @@ async fn db_contracts_reject_event_refund_request_blocks_event_writers() -> Resu .await?; // Probe whether a competing writer can lock the owning event - event_client - .batch_execute("begin; set local lock_timeout = '250ms'") - .await?; - let lock_result = event_client - .query_one( - "select event_id from event where event_id = $1::uuid for update", - &[&refund_event_id()], - ) - .await; + let probe_code = probe_lock( + &event_client, + "select event_id from event where event_id = $1::uuid for update", + &[&paid_event_id()], + ) + .await?; - // Roll back the rejection and lock probe before checking the outcome - event_client.batch_execute("rollback").await?; + // Roll back the rejection before checking the outcome rejection_client.batch_execute("rollback").await?; // Check rejection holds the event lock before refund workflow rows - let lock_err = lock_result.expect_err("event writer should wait for refund rejection"); - assert_eq!(lock_err.code(), Some(&SqlState::LOCK_NOT_AVAILABLE)); + assert_eq!( + probe_code, + Some(SqlState::LOCK_NOT_AVAILABLE), + "event writer should wait for refund rejection" + ); Ok(()) } @@ -1082,11 +1047,11 @@ async fn db_contracts_reject_event_refund_request_blocks_event_writers() -> Resu #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_reject_event_refund_request_deserializes() -> Result<()> { - // Setup the contract database and pending refund request - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and pending refund request + let uow = contract_unit_of_work().await?; // Reject the refund request through the Rust contract - let purchase = db + let purchase = uow .reject_event_refund_request( organizer_id(), group_id(), @@ -1106,8 +1071,8 @@ async fn db_contracts_reject_event_refund_request_deserializes() -> Result<()> { #[tokio::test] #[ignore = "requires the contract test database"] async fn db_contracts_sync_external_payments_config_deserializes() -> Result<()> { - // Setup the contract database and operator allowlist payload - let db = contract_tests_db()?; + // Setup a rolled-back unit of work and operator allowlist payload + let uow = contract_unit_of_work().await?; let config = crate::config::ExternalPaymentsConfig { allowed_countries: vec!["US".to_string()], default_payment_window_hours: 72, @@ -1115,10 +1080,10 @@ async fn db_contracts_sync_external_payments_config_deserializes() -> Result<()> }; // Sync the singleton configuration through the Rust contract - db.sync_external_payments_config(Some(config)).await?; + uow.sync_external_payments_config(Some(config)).await?; // Check the group settings context still deserializes after the sync - let context = db + let context = uow .get_group_external_payments_context(community_id(), group_id()) .await?; assert!(context.configured); diff --git a/ocg-server/src/db/inbox.rs b/ocg-server/src/db/inbox.rs index 1b832ac26..995ad2b07 100644 --- a/ocg-server/src/db/inbox.rs +++ b/ocg-server/src/db/inbox.rs @@ -352,47 +352,3 @@ impl From for StartInboxConversationResult { } } } - -#[cfg(test)] -mod tests { - use serde_json::json; - use uuid::Uuid; - - use super::{ - PostedInboxMessage, StartInboxConversationConflict, StartInboxConversationOutput, - StartInboxConversationResult, - }; - - #[test] - fn test_start_inbox_conversation_output_maps_conflicts() { - let output: StartInboxConversationOutput = - serde_json::from_value(json!({ "conflict": "open-conversation" })).unwrap(); - - assert_eq!( - StartInboxConversationResult::from(output), - StartInboxConversationResult::Conflict( - StartInboxConversationConflict::OpenConversation - ) - ); - } - - #[test] - fn test_start_inbox_conversation_output_maps_started_conversations() { - let posted = PostedInboxMessage { - group_id: Uuid::new_v4(), - inbox_conversation_id: Uuid::new_v4(), - inbox_message_id: Uuid::new_v4(), - }; - let output: StartInboxConversationOutput = serde_json::from_value(json!({ - "group_id": posted.group_id, - "inbox_conversation_id": posted.inbox_conversation_id, - "inbox_message_id": posted.inbox_message_id, - })) - .unwrap(); - - assert_eq!( - StartInboxConversationResult::from(output), - StartInboxConversationResult::Started(posted) - ); - } -} diff --git a/ocg-server/src/db/mock.rs b/ocg-server/src/db/mock.rs index 550909854..62ced864d 100644 --- a/ocg-server/src/db/mock.rs +++ b/ocg-server/src/db/mock.rs @@ -7,6 +7,8 @@ use async_trait::async_trait; use mockall::mock; use uuid::Uuid; +use crate::{db::inbox::PostedInboxMessage, types::tests::sample_site_settings}; + mock! { /// Mock `DB` struct for testing purposes, implementing the DB traits. pub(crate) DB {} @@ -1750,3 +1752,33 @@ mock! { >>; } } + +/// Expects one transaction on `db` that rolls back `tx` without committing. +pub(crate) fn expect_rolled_back_transaction(db: &mut MockDB, mut tx: MockDB) { + tx.expect_commit().never(); + tx.expect_rollback().times(1).returning(|| Ok(())); + db.expect_begin().times(1).return_once(|| Ok(Box::new(tx))); +} + +/// Expects one transaction on `db` that commits `tx` without rolling back. +pub(crate) fn expect_successful_transaction(db: &mut MockDB, mut tx: MockDB) { + tx.expect_commit().times(1).returning(|| Ok(())); + tx.expect_rollback().never(); + db.expect_begin().times(1).return_once(|| Ok(Box::new(tx))); +} + +/// Expects one site settings read returning the sample settings. +pub(crate) fn expect_site_settings(db: &mut MockDB) { + db.expect_get_site_settings() + .times(1) + .returning(|| Ok(sample_site_settings())); +} + +/// Returns a posted inbox message with fresh identifiers. +pub(crate) fn sample_posted_inbox_message() -> PostedInboxMessage { + PostedInboxMessage { + group_id: Uuid::new_v4(), + inbox_conversation_id: Uuid::new_v4(), + inbox_message_id: Uuid::new_v4(), + } +} diff --git a/ocg-server/src/db/payments.rs b/ocg-server/src/db/payments.rs index f483f795d..c7769fb95 100644 --- a/ocg-server/src/db/payments.rs +++ b/ocg-server/src/db/payments.rs @@ -1513,145 +1513,14 @@ pub(crate) struct UserPurchaseDocumentContext { #[cfg(test)] mod tests { use serde_json::json; - use uuid::Uuid; use super::{ - ClaimedPaymentJob, ClaimedPaymentJobWork, EventPurchaseRefundStatus, PrepareEventCheckoutPurchaseConflict, PrepareEventCheckoutPurchaseOutput, ReconcileEventPurchaseForCheckoutSessionOutput, ReconcileEventPurchaseResult, }; #[test] - fn claimed_payment_job_deserializes_application_fee_adjustment_work() { - let claim_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - let purchase_id = Uuid::new_v4(); - - // Deserialize the flattened lifecycle fields plus internally tagged work - let job: ClaimedPaymentJob = serde_json::from_value(json!({ - "application_fee_adjustment": { - "amount_minor": 125, - "connected_seller_id": "acct_worker", - "currency_code": "USD", - "event_purchase_application_fee_adjustment_id": Uuid::new_v4(), - "kind": "tax-reconciliation", - "provider_application_fee_id": "fee_worker" - }, - "attempt_count": 1, - "claim_id": claim_id, - "event_purchase_id": purchase_id, - "idempotency_key": "event-purchase-tax-fee-adjustment-test", - "kind": "event-purchase-application-fee-adjustment", - "payment_job_id": payment_job_id, - "payment_provider": "stripe", - "status": "processing" - })) - .unwrap(); - - // Check the job lifecycle and typed work remain distinct - assert_eq!(job.attempt_count, 1); - assert_eq!(job.claim_id, claim_id); - assert_eq!(job.event_purchase_id, purchase_id); - assert_eq!(job.payment_job_id, payment_job_id); - assert!(matches!( - job.work, - ClaimedPaymentJobWork::EventPurchaseApplicationFeeAdjustment { .. } - )); - } - - #[test] - fn claimed_payment_job_deserializes_credit_note_work() { - let claim_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - let purchase_id = Uuid::new_v4(); - - // Deserialize the flattened lifecycle fields plus internally tagged work - let job: ClaimedPaymentJob = serde_json::from_value(json!({ - "attempt_count": 1, - "claim_id": claim_id, - "credit_note": { - "amount_minor": 2500, - "connected_seller_id": "acct_worker", - "event_purchase_credit_note_id": Uuid::new_v4(), - "event_purchase_refund_id": Uuid::new_v4(), - "provider_invoice_id": "in_worker", - "provider_refund_id": "re_worker", - "tax_amount_minor": 200 - }, - "event_purchase_id": purchase_id, - "idempotency_key": "event-purchase-credit-note-test", - "kind": "event-purchase-credit-note", - "payment_job_id": payment_job_id, - "payment_provider": "stripe", - "status": "processing" - })) - .unwrap(); - - // Check the job lifecycle and typed work remain distinct - assert_eq!(job.attempt_count, 1); - assert_eq!(job.claim_id, claim_id); - assert_eq!(job.event_purchase_id, purchase_id); - assert_eq!(job.payment_job_id, payment_job_id); - assert!(matches!( - job.work, - ClaimedPaymentJobWork::EventPurchaseCreditNote { .. } - )); - } - - #[test] - fn claimed_payment_job_deserializes_refund_work() { - let claim_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - let purchase_id = Uuid::new_v4(); - let refund_id = Uuid::new_v4(); - - // Deserialize the flattened lifecycle fields plus internally tagged work - let job: ClaimedPaymentJob = serde_json::from_value(json!({ - "attempt_count": 1, - "claim_id": claim_id, - "event_purchase_id": purchase_id, - "idempotency_key": "event-purchase-refund-test", - "kind": "event-purchase-refund", - "payment_job_id": payment_job_id, - "payment_provider": "stripe", - "refund": { - "amount_minor": 2500, - "claim_id": claim_id, - "community_id": Uuid::new_v4(), - "connected_seller_id": "acct_worker", - "currency_code": "USD", - "event_id": Uuid::new_v4(), - "event_purchase_id": purchase_id, - "event_purchase_refund_id": refund_id, - "idempotency_key": "event-purchase-refund-test", - "kind": "event-cancellation", - "payment_job_id": payment_job_id, - "payment_provider": "stripe", - "provider_payment_reference": "pi_worker", - "status": "provider-pending", - "terminal_failure": false - }, - "status": "processing" - })) - .unwrap(); - - // Check the job lifecycle and nested refund DTO both deserialize - assert_eq!(job.attempt_count, 1); - assert_eq!(job.claim_id, claim_id); - assert_eq!(job.event_purchase_id, purchase_id); - assert_eq!(job.payment_job_id, payment_job_id); - match job.work { - ClaimedPaymentJobWork::EventPurchaseRefund { refund } => { - assert_eq!(refund.event_purchase_refund_id, refund_id); - assert_eq!(refund.payment_job_id, payment_job_id); - assert_eq!(refund.status, EventPurchaseRefundStatus::ProviderPending); - } - _ => panic!("expected refund work"), - } - } - - #[test] - fn prepare_event_checkout_purchase_output_maps_conflicts() { + fn test_prepare_event_checkout_purchase_output_maps_conflicts() { for (value, expected) in [ ( "admission-offer-price-locked", @@ -1693,64 +1562,31 @@ mod tests { let output: PrepareEventCheckoutPurchaseOutput = serde_json::from_value(json!({ "conflict": value })).unwrap(); - assert!(matches!( - output, - PrepareEventCheckoutPurchaseOutput::Conflict { conflict } - if conflict == expected - )); + assert!( + matches!( + output, + PrepareEventCheckoutPurchaseOutput::Conflict { conflict } + if conflict == expected + ), + "{value}" + ); } } #[test] - fn reconcile_event_purchase_for_checkout_session_output_maps_completed() { - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - let output: ReconcileEventPurchaseForCheckoutSessionOutput = - serde_json::from_value(json!({ - "outcome": "completed", - "community_id": community_id, - "event_id": event_id, - "user_id": user_id - })) - .unwrap(); - - match ReconcileEventPurchaseResult::from(output) { - ReconcileEventPurchaseResult::Completed(completed) => { - assert_eq!(completed.community_id, community_id); - assert_eq!(completed.event_id, event_id); - assert_eq!(completed.user_id, user_id); - } - _ => panic!("expected completed result"), + fn test_reconcile_event_purchase_for_checkout_session_output_maps_outcomes_without_ids() { + for (outcome, expected) in [ + ("noop", ReconcileEventPurchaseResult::Noop), + ("refund_queued", ReconcileEventPurchaseResult::RefundQueued), + ] { + let output: ReconcileEventPurchaseForCheckoutSessionOutput = + serde_json::from_value(json!({ "outcome": outcome })).unwrap(); + + assert!( + std::mem::discriminant(&ReconcileEventPurchaseResult::from(output)) + == std::mem::discriminant(&expected), + "{outcome}" + ); } } - - #[test] - fn reconcile_event_purchase_for_checkout_session_output_maps_noop() { - let output: ReconcileEventPurchaseForCheckoutSessionOutput = - serde_json::from_value(json!({ - "outcome": "noop" - })) - .unwrap(); - - assert!(matches!( - ReconcileEventPurchaseResult::from(output), - ReconcileEventPurchaseResult::Noop - )); - } - - #[test] - fn reconcile_event_purchase_for_checkout_session_output_maps_refund_queued() { - let output: ReconcileEventPurchaseForCheckoutSessionOutput = - serde_json::from_value(json!({ - "outcome": "refund_queued" - })) - .unwrap(); - - assert!(matches!( - ReconcileEventPurchaseResult::from(output), - ReconcileEventPurchaseResult::RefundQueued - )); - } } diff --git a/ocg-server/src/db/pool.rs b/ocg-server/src/db/pool.rs index 8fdf01f38..69fdbceb3 100644 --- a/ocg-server/src/db/pool.rs +++ b/ocg-server/src/db/pool.rs @@ -70,7 +70,7 @@ mod tests { fn test_config_with_defaults_preserves_configured_values() { let mut cfg = DeadpoolDbConfig::new(); cfg.manager = Some(ManagerConfig { - recycling_method: RecyclingMethod::Clean, + recycling_method: RecyclingMethod::Fast, }); cfg.options = Some("-c statement_timeout=5000".to_string()); cfg.pool = Some(PoolConfig::new(32)); @@ -83,7 +83,7 @@ mod tests { let cfg = config_with_defaults(&cfg); let manager = cfg.manager.expect("manager config should be set"); - assert_eq!(manager.recycling_method, RecyclingMethod::Clean); + assert_eq!(manager.recycling_method, RecyclingMethod::Fast); assert_eq!(cfg.options.as_deref(), Some("-c statement_timeout=5000")); let pool = cfg.pool.expect("pool config should be set"); diff --git a/ocg-server/src/handlers.rs b/ocg-server/src/handlers.rs index 035200d20..1bbb3e313 100644 --- a/ocg-server/src/handlers.rs +++ b/ocg-server/src/handlers.rs @@ -114,24 +114,25 @@ mod helpers_tests { }; use crate::{ - config::HttpServerConfig, + config::tests::sample_server_cfg, router::{CACHE_CONTROL_PUBLIC_SHARED, PUBLIC_SHARED_CACHE_VARY}, }; use super::*; #[test] - fn test_extend_public_shared_cache_headers_rejects_invalid_extra_header_name() { - let result = extend_public_shared_cache_headers(&[("invalid header", "value")]); - - assert!(result.is_err()); - } - - #[test] - fn test_extend_public_shared_cache_headers_rejects_invalid_extra_header_value() { - let result = extend_public_shared_cache_headers(&[("x-test", "invalid\nvalue")]); - - assert!(result.is_err()); + fn test_extend_public_shared_cache_headers_rejects_invalid_extra_headers() { + // Setup labeled extra headers that cannot be encoded + let cases = [ + ("invalid name", ("invalid header", "value")), + ("invalid value", ("x-test", "invalid\nvalue")), + ]; + + // Check each extra header is rejected + for (case, extra_header) in cases { + let result = extend_public_shared_cache_headers(&[extra_header]); + assert!(result.is_err(), "{case}"); + } } #[test] @@ -147,67 +148,105 @@ mod helpers_tests { } #[test] - fn test_request_headers_match_site_origin_accepts_normalized_default_port() { - let server_cfg = sample_server_cfg("https://example.test"); - let mut headers = HeaderMap::new(); - headers.insert( - REFERER, - HeaderValue::from_static("https://example.test:443/page"), - ); - - assert!(request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_falls_back_to_referer() { - let server_cfg = sample_server_cfg("https://example.test"); - let mut headers = HeaderMap::new(); - headers.insert( - REFERER, - HeaderValue::from_static("https://example.test/page"), - ); - - assert!(request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_matches_host_case_insensitively() { - let server_cfg = sample_server_cfg("https://Example.Test"); - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_static("https://EXAMPLE.TEST")); - - assert!(request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_prefers_origin_over_referer() { - let server_cfg = sample_server_cfg("https://example.test"); - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_static("https://evil.test")); - headers.insert( - REFERER, - HeaderValue::from_static("https://example.test/page"), + fn test_request_headers_match_site_origin() { + // Setup labeled site origins and request headers with the expected match + type Case = ( + &'static str, + &'static str, + Option<&'static [u8]>, + Option<&'static str>, + bool, ); - - assert!(!request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_rejects_mismatched_port() { - let server_cfg = sample_server_cfg("https://example.test:8443"); - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_static("https://example.test")); - - assert!(!request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_rejects_mismatched_scheme() { - let server_cfg = sample_server_cfg("https://example.test"); - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_static("http://example.test")); - - assert!(!request_headers_match_site_origin(&server_cfg, &headers).unwrap()); + let cases: [Case; 11] = [ + ( + "matching origin", + "https://example.test", + Some(b"https://example.test"), + None, + true, + ), + ( + "matching referer without origin", + "https://example.test", + None, + Some("https://example.test/page"), + true, + ), + ( + "referer with normalized default port", + "https://example.test", + None, + Some("https://example.test:443/page"), + true, + ), + ( + "host compared case-insensitively", + "https://Example.Test", + Some(b"https://EXAMPLE.TEST"), + None, + true, + ), + ( + "origin preferred over matching referer", + "https://example.test", + Some(b"https://evil.test"), + Some("https://example.test/page"), + false, + ), + ( + "mismatched referer without origin", + "https://example.test", + None, + Some("https://evil.test/page"), + false, + ), + ( + "mismatched port", + "https://example.test:8443", + Some(b"https://example.test"), + None, + false, + ), + ( + "mismatched scheme", + "https://example.test", + Some(b"http://example.test"), + None, + false, + ), + ( + "non-UTF-8 origin", + "https://example.test", + Some(&[0xFF]), + None, + false, + ), + ( + "unparsable referer", + "https://example.test", + None, + Some("not a url"), + false, + ), + ("missing headers", "https://example.test", None, None, false), + ]; + + // Check each case resolves the expected match + for (case, base_url, origin, referer, expected) in cases { + let server_cfg = sample_server_cfg(base_url); + let mut headers = HeaderMap::new(); + if let Some(origin) = origin { + headers.insert(ORIGIN, HeaderValue::from_bytes(origin).unwrap()); + } + if let Some(referer) = referer { + headers.insert(REFERER, HeaderValue::from_str(referer).unwrap()); + } + assert_eq!( + request_headers_match_site_origin(&server_cfg, &headers).unwrap(), + expected, + "{case}" + ); + } } #[test] @@ -218,67 +257,29 @@ mod helpers_tests { } #[test] - fn test_request_headers_match_site_origin_rejects_non_utf8_origin() { - let server_cfg = sample_server_cfg("https://example.test"); - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_bytes(&[0xFF]).unwrap()); - - assert!(!request_headers_match_site_origin(&server_cfg, &headers).unwrap()); - } - - #[test] - fn test_request_headers_match_site_origin_rejects_when_headers_are_missing() { - let server_cfg = sample_server_cfg("https://example.test"); - - assert!(!request_headers_match_site_origin(&server_cfg, &HeaderMap::new()).unwrap()); - } - - #[test] - fn test_trim_public_gallery_images_keeps_none_unchanged() { - let mut photos_urls = None; - - trim_public_gallery_images(&mut photos_urls); - - assert_eq!(photos_urls, None); - } - - #[test] - fn test_trim_public_gallery_images_keeps_short_vec_unchanged() { - let mut photos_urls = Some(vec![sample_photo_url(0), sample_photo_url(1)]); - - trim_public_gallery_images(&mut photos_urls); - - assert_eq!( - photos_urls, - Some(vec![sample_photo_url(0), sample_photo_url(1)]) - ); - } - - #[test] - fn test_trim_public_gallery_images_truncates_long_vec() { - let mut photos_urls = Some((0..=MAX_PUBLIC_GALLERY_IMAGES).map(sample_photo_url).collect()); - - trim_public_gallery_images(&mut photos_urls); - - assert_eq!( - photos_urls.as_ref().map(Vec::len), - Some(MAX_PUBLIC_GALLERY_IMAGES) - ); - assert_eq!( - photos_urls.as_ref().and_then(|photos_urls| photos_urls.last()), - Some(&sample_photo_url(MAX_PUBLIC_GALLERY_IMAGES - 1)) - ); - } - - // Helpers - - fn sample_server_cfg(base_url: &str) -> HttpServerConfig { - HttpServerConfig { - base_url: base_url.to_string(), - ..Default::default() + fn test_trim_public_gallery_images() { + // Setup labeled galleries with the expected public gallery + let urls = |count: usize| (0..count).map(sample_photo_url).collect::>(); + let cases = [ + ("no gallery", None, None), + ("short gallery", Some(urls(2)), Some(urls(2))), + ( + "long gallery", + Some(urls(MAX_PUBLIC_GALLERY_IMAGES + 1)), + Some(urls(MAX_PUBLIC_GALLERY_IMAGES)), + ), + ]; + + // Check each gallery keeps at most the leading public images + for (case, mut photos_urls, expected) in cases { + trim_public_gallery_images(&mut photos_urls); + assert_eq!(photos_urls, expected, "{case}"); } } + // Helpers. + + /// Builds the URL of the gallery photo at `idx`. fn sample_photo_url(idx: usize) -> String { format!("https://example.test/photo-{idx}.png") } diff --git a/ocg-server/src/handlers/auth/middleware/tests.rs b/ocg-server/src/handlers/auth/middleware/tests.rs index 1de3443fb..2d6cc49fe 100644 --- a/ocg-server/src/handlers/auth/middleware/tests.rs +++ b/ocg-server/src/handlers/auth/middleware/tests.rs @@ -5,22 +5,22 @@ use axum::{ Router, body::{Body, to_bytes}, http::{ - HeaderValue, Request, StatusCode, - header::{CACHE_CONTROL, COOKIE, LOCATION}, + HeaderValue, StatusCode, + header::{CACHE_CONTROL, LOCATION}, + response::Parts, }, middleware, routing::get, }; use axum_login::tower_sessions::session; use serde_json::json; -use tower::ServiceExt; use uuid::Uuid; use crate::{ config::HttpServerConfig, db::{DynDB, mock::MockDB}, handlers::{auth::session_context::SELECTED_GROUP_ID_KEY, tests::*}, - router::CACHE_CONTROL_NO_STORE, + router::{self, CACHE_CONTROL_NO_STORE}, services::{ blocking::BlockingExecutor, images::MockImageStorage, notifications::MockNotificationsManager, @@ -29,1717 +29,514 @@ use crate::{ use super::*; +// Dashboard routes. + #[tokio::test] async fn test_dashboard_community_redirects_to_user_invitations_when_context_is_missing_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_communities() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(|_| Ok(vec![])); - db.expect_list_user_groups().times(0); db.expect_update_session().times(0); - db.expect_user_has_community_permission().times(0); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_dashboard_group_redirects_to_user_invitations_when_context_is_missing_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_groups() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session().times(0); - db.expect_user_has_group_permission().times(0); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } +// Community dashboard middleware. + #[tokio::test] async fn test_user_has_community_dashboard_permission_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_list_user_communities().times(0); - db.expect_list_user_groups().times(0); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_update_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_community_dashboard_router(db).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_community_dashboard_permission_allows_matching_loaded_context() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_update_session().times(0); // Setup router and send request declaring the loaded community let router = setup_community_dashboard_router(db).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) - .header(COOKIE, format!("id={session_id}")) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the protected handler ran - assert_eq!(parts.status, StatusCode::OK); + assert_empty_response(&parts, &bytes, StatusCode::OK); assert!(!parts.headers.contains_key(STALE_DASHBOARD_CONTEXT_HEADER)); - assert!(bytes.is_empty()); } #[tokio::test] -async fn test_user_has_community_dashboard_permission_fetch_redirects_when_selected_context_is_stale() - { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock +async fn test_user_has_community_dashboard_permission_forbidden_when_not_logged_in() { + // Setup an anonymous session with a selected community + let ids = DashboardIds::new(); + let mut session_record = sample_empty_session_record(ids.session_id); + session_record.data.insert( + SELECTED_COMMUNITY_ID_KEY.to_string(), + json!(ids.community_id), + ); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(|_| Ok(vec![])); - db.expect_list_user_groups().times(0); - db.expect_update_session().times(0); - db.expect_delete_session().times(0); + expect_stored_session(&mut db, session_record); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header("X-OCG-Fetch", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_community_dashboard_router(db).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("X-OCG-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), - ); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] -async fn test_user_has_community_dashboard_permission_hx_redirects_when_selected_context_is_stale() -{ - // Setup identifiers and data structures - let inaccessible_community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock +async fn test_user_has_community_dashboard_permission_redirects_when_context_is_missing_and_unavailable() + { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session( - &mut db, - session_id, - user_id, - inaccessible_community_id, - stale_group_id, - ); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == inaccessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); + expect_session(&mut db, &ids); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); - db.expect_list_user_groups().times(0); db.expect_update_session().times(0); - db.expect_delete_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_community_dashboard_router(db).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get("HX-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] -async fn test_user_has_community_dashboard_permission_redirects_when_context_is_missing_and_unavailable() +async fn test_user_has_community_dashboard_permission_redirects_when_selected_context_is_stale_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup a stale selection with no readable replacement community + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_user_has_community_permission().times(0); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, false); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); - db.expect_list_user_groups().times(0); db.expect_update_session().times(0); + db.expect_delete_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_community_dashboard_router(db).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_has_community_dashboard_permission_refreshes_stale_loaded_context_after_repair() { - // Setup identifiers and data structures - let accessible_community_id = Uuid::new_v4(); - let inaccessible_community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let communities = vec![sample_community_summary(accessible_community_id)]; - - // Setup stale selection and repair expectations + // Setup a stale selection and a readable replacement community + let ids = DashboardIds::new(); + let accessible = DashboardIds { + community_id: Uuid::new_v4(), + ..ids + }; + let communities = vec![sample_community_summary(accessible.community_id)]; let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, inaccessible_community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == inaccessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); + expect_community_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, false); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(move |_| Ok(communities.clone())); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(true)); + expect_community_permission_result(&mut db, &accessible, CommunityPermission::Read, true); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(accessible_community_id)) + record.id == ids.session_id && session_selects(record, accessible.community_id, None) }) .returning(|_| Ok(())); // Setup router and send request declaring the stale community let router = setup_community_dashboard_router(db).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header( - SELECTED_COMMUNITY_ID_HEADER, - inaccessible_community_id.to_string(), - ) - .header(COOKIE, format!("id={session_id}")) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the client is told to reload instead of reaching the handler - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_community_dashboard_permission_repairs_missing_context() { - // Setup identifiers and data structures - let accessible_community_id = Uuid::new_v4(); - let accessible_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(accessible_community_id, accessible_group_id); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(move |_| Ok(vec![sample_community_summary(accessible_community_id)])); - db.expect_list_user_groups() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(move |_| Ok(groups.clone())); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(true)); - db.expect_update_session() - .times(1) - .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(accessible_community_id)) - && record - .data - .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(accessible_group_id)) - }) - .returning(|_| Ok(())); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_community_dashboard_permission_repairs_stale_context_without_groups() { - // Setup identifiers and data structures - let accessible_community_id = Uuid::new_v4(); - let inaccessible_community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let communities = vec![sample_community_summary(accessible_community_id)]; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session( - &mut db, - session_id, - user_id, - inaccessible_community_id, - stale_group_id, - ); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == inaccessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(move |_| Ok(communities.clone())); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(true)); - db.expect_list_user_groups() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(|_| Ok(vec![])); - db.expect_update_session() - .times(1) - .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(accessible_community_id)) - && !record.data.contains_key(SELECTED_GROUP_ID_KEY) - }) - .returning(|_| Ok(())); - db.expect_delete_session().times(0); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_community_dashboard_permission_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_user_has_community_permission() .times(1) .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read + *cid == ids.community_id + && *uid == ids.user_id + && permission == CommunityPermission::Read }) .returning(|_, _, _| Err(anyhow!("db error"))); - db.expect_list_user_communities().times(0); - db.expect_list_user_groups().times(0); db.expect_update_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - db.clone(), - user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_path_community_permission_select_route_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/{community_id}/protected", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/{community_id}/protected")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_community_dashboard_router(db).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } -#[tokio::test] -async fn test_user_has_path_community_permission_select_route_forbidden_without_permission() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/{community_id}/protected", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/{community_id}/protected")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); -} +// Path community middleware. #[tokio::test] -async fn test_user_has_path_community_permission_select_route_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock +async fn test_user_has_path_community_permission_allows_request() { + // Setup identifiers and a check for the configured permission + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Err(anyhow!("db error"))); + expect_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, true); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/{community_id}/protected", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/{community_id}/protected")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_community_router(db, CommunityPermission::TeamWrite).await; + let uri = format!("/{}/select", ids.community_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] -async fn test_user_has_path_community_permission_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock +async fn test_user_has_path_community_permission_forbidden_when_not_logged_in() { + // Setup an anonymous session + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_stored_session(&mut db, sample_empty_session_record(ids.session_id)); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/community/{community_id}/select", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_community_router(db, CommunityPermission::Read).await; + let uri = format!("/{}/select", ids.community_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_path_community_permission_forbidden_without_permission() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a denied permission check + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); + expect_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, false); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/community/{community_id}/select", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_community_router(db, CommunityPermission::Read).await; + let uri = format!("/{}/select", ids.community_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_path_community_permission_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a failing permission check + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_user_has_community_permission() .times(1) .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read + *cid == ids.community_id + && *uid == ids.user_id + && permission == CommunityPermission::Read }) .returning(|_, _, _| Err(anyhow!("db error"))); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/community/{community_id}/select", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_path_community_permission_select_route_forbidden_when_not_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - - // Setup database mock - let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_user_by_id().times(0); - db.expect_user_has_community_permission().times(0); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/community/{community_id}/select", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_community_router(db, CommunityPermission::Read).await; + let uri = format!("/{}/select", ids.community_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } -#[tokio::test] -async fn test_user_has_path_community_permission_protected_route_forbidden_when_not_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - - // Setup database mock - let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_user_by_id().times(0); - db.expect_user_has_community_permission().times(0); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route( - "/{community_id}/protected", - get(|| async { StatusCode::OK }), - ) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_path_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/{community_id}/protected")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); -} +// Path group middleware. #[tokio::test] async fn test_user_has_path_group_permission_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a check for the configured permission + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_group_belongs_to_community() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(|_, _| Ok(true)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id - }) - .returning(|_, _, _, _| Ok(true)); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::EventsWrite).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_path_group_permission_fetch_redirects_when_selected_community_is_missing() { - // Setup an authenticated session without selected community context - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database expectations before the missing-context guard - let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_group_belongs_to_community().times(0); - db.expect_user_has_group_permission().times(0); - - // Setup protected path-group route - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute fetch request without selected community context - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header("X-OCG-Fetch", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check fetch navigation uses redirect metadata without a response body - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("X-OCG-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), - ); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_path_group_permission_forbidden_when_group_is_outside_selected_community() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a group outside the selected community + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_group_belongs_to_community() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(|_, _| Ok(false)); - db.expect_user_has_group_permission().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::Read).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_path_group_permission_forbidden_when_not_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(SELECTED_COMMUNITY_ID_KEY.to_string(), json!(community_id)); - - // Setup database mock - let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_user_by_id().times(0); - db.expect_group_belongs_to_community().times(0); - db.expect_user_has_group_permission().times(0); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_path_group_permission_forbidden_without_permission() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_group_belongs_to_community() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(true)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id - }) - .returning(|_, _, _, _| Ok(false)); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup an anonymous session with a selected community + let ids = DashboardIds::new(); + let mut session_record = sample_empty_session_record(ids.session_id); + session_record.data.insert( + SELECTED_COMMUNITY_ID_KEY.to_string(), + json!(ids.community_id), + ); + let mut db = MockDB::new(); + expect_stored_session(&mut db, session_record); + + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::Read).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] -async fn test_user_has_path_group_permission_hx_redirects_when_selected_community_is_missing() { - // Setup an authenticated session without selected community context - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database expectations before the missing-context guard +async fn test_user_has_path_group_permission_forbidden_without_permission() { + // Setup identifiers and a denied check for the configured permission + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_group_belongs_to_community().times(0); - db.expect_user_has_group_permission().times(0); + expect_community_session(&mut db, &ids); + db.expect_group_belongs_to_community() + .times(1) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) + .returning(|_, _| Ok(true)); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, false); - // Setup protected path-group route - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute HTMX request without selected community context - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::EventsWrite).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; - // Check HTMX navigation uses redirect metadata without a response body - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("HX-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), - ); - assert!(bytes.is_empty()); + // Check response matches expectations + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_path_group_permission_redirects_when_selected_community_is_missing() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup an authenticated session without selected community context + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - db.expect_group_belongs_to_community().times(0); - db.expect_user_has_group_permission().times(0); + expect_session(&mut db, &ids); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::Read).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_has_path_group_permission_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a failing permission check + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_group_belongs_to_community() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(|_, _| Ok(true)); db.expect_user_has_group_permission() .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id + .withf(move |cid, gid, uid, permission| { + *cid == ids.community_id + && *gid == ids.group_id + && *uid == ids.user_id + && permission == GroupPermission::Read }) .returning(|_, _, _, _| Err(anyhow!("db error"))); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/groups/{group_id}", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_path_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri(format!("/groups/{group_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::Read).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] -async fn test_user_has_selected_community_permission_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); +async fn test_user_has_path_group_permission_returns_error_when_group_membership_check_fails() { + // Setup identifiers and a failing group membership check + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_community_session(&mut db, &ids); + db.expect_group_belongs_to_community() + .times(1) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) + .returning(|_, _| Err(anyhow!("db error"))); + + // Setup router and send request + let router = setup_path_group_router(db, GroupPermission::Read).await; + let uri = format!("/{}/select", ids.group_id); + let (parts, bytes) = send(router, get_request(&uri, ids.session_id)).await; + + // Check response matches expectations + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); +} + +// Selected community middleware. - // Setup database mock +#[tokio::test] +async fn test_user_has_selected_community_permission_allows_request() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_selected_community_permission_forbidden_when_not_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(SELECTED_COMMUNITY_ID_KEY.to_string(), json!(community_id)); - - // Setup database mock + // Setup an anonymous session with a selected community + let ids = DashboardIds::new(); + let mut session_record = sample_empty_session_record(ids.session_id); + session_record.data.insert( + SELECTED_COMMUNITY_ID_KEY.to_string(), + json!(ids.community_id), + ); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_user_by_id().times(0); - db.expect_user_has_community_permission().times(0); + expect_stored_session(&mut db, session_record); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_community_permission_forbidden_without_permission() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup a readable selection without the write permission the route needs + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_user_has_community_permission() .times(2) .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id + *cid == ids.community_id + && *uid == ids.user_id && matches!( permission, CommunityPermission::Read | CommunityPermission::TeamWrite @@ -1748,668 +545,258 @@ async fn test_user_has_selected_community_permission_forbidden_without_permissio .returning(|_, _, permission| Ok(permission == CommunityPermission::Read)); db.expect_delete_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::TeamWrite), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::TeamWrite).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_community_permission_redirects_when_context_is_missing_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); - db.expect_list_user_groups().times(0); db.expect_update_session().times(0); - db.expect_user_has_community_permission().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_has_selected_community_permission_refreshes_stale_loaded_context_before_forbidding_write() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let loaded_community_id = Uuid::new_v4(); - // Setup a readable selection without the write permission the route needs + let ids = DashboardIds::new(); + let loaded_community_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(|_, _, _| Ok(false)); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, false); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, true); db.expect_update_session().times(0); // Setup router and send an action from a page rendered for another community let router = setup_selected_community_router(db, CommunityPermission::TeamWrite).await; - let request = Request::builder() - .method("GET") - .uri("/protected") + let request = authenticated_request("GET", "/protected", ids.session_id) .header( SELECTED_COMMUNITY_ID_HEADER, loaded_community_id.to_string(), ) - .header(COOKIE, format!("id={session_id}")) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the stale page is refreshed instead of receiving a forbidden error - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_community_permission_refreshes_when_loaded_community_differs() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let loaded_community_id = Uuid::new_v4(); - // Setup a valid selection that differs from the loaded page + let ids = DashboardIds::new(); + let loaded_community_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_update_session().times(0); // Setup router and send request declaring another community let router = setup_selected_community_router(db, CommunityPermission::Read).await; - let request = Request::builder() - .method("GET") - .uri("/protected") + let request = authenticated_request("GET", "/protected", ids.session_id) .header( SELECTED_COMMUNITY_ID_HEADER, loaded_community_id.to_string(), ) - .header(COOKIE, format!("id={session_id}")) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the client is told to reload instead of reaching the handler - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_community_permission_repairs_missing_context() { - // Setup identifiers and data structures - let accessible_community_id = Uuid::new_v4(); - let accessible_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(accessible_community_id, accessible_group_id); - - // Setup database mock + // Setup a session without selection and a readable community and group + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) - .returning(move |_| Ok(vec![sample_community_summary(accessible_community_id)])); + .withf(move |uid| *uid == ids.user_id) + .returning(move |_| Ok(vec![sample_community_summary(ids.community_id)])); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(move |_| Ok(groups.clone())); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(true)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, true); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(accessible_community_id)) - && record - .data - .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(accessible_group_id)) + record.id == ids.session_id + && session_selects(record, ids.community_id, Some(ids.group_id)) }) .returning(|_| Ok(())); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_selected_community_permission_repairs_stale_context_before_forbidding_write() { - // Setup identifiers and stale dashboard context - let accessible_community_id = Uuid::new_v4(); - let inaccessible_community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let stale_group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup current and replacement permission expectations + // Setup a stale selection and a readable replacement without write access + let ids = DashboardIds::new(); + let accessible = DashboardIds { + community_id: Uuid::new_v4(), + ..ids + }; let mut db = MockDB::new(); - expect_authenticated_group_session( - &mut db, - session_id, - user_id, - inaccessible_community_id, - stale_group_id, - ); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == inaccessible_community_id - && *uid == user_id - && permission == CommunityPermission::TeamWrite - }) - .returning(|_, _, _| Ok(false)); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == inaccessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(false)); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, false); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, false); db.expect_list_user_communities() .times(1) - .withf(move |uid| *uid == user_id) - .returning(move |_| Ok(vec![sample_community_summary(accessible_community_id)])); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::Read - }) - .returning(|_, _, _| Ok(true)); + .withf(move |uid| *uid == ids.user_id) + .returning(move |_| Ok(vec![sample_community_summary(accessible.community_id)])); + expect_community_permission_result(&mut db, &accessible, CommunityPermission::Read, true); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); - db.expect_user_has_community_permission() + expect_community_permission_result(&mut db, &accessible, CommunityPermission::TeamWrite, false); + db.expect_update_session() .times(1) - .withf(move |cid, uid, permission| { - *cid == accessible_community_id - && *uid == user_id - && permission == CommunityPermission::TeamWrite + .withf(move |record| { + record.id == ids.session_id && session_selects(record, accessible.community_id, None) }) - .returning(|_, _, _| Ok(false)); - db.expect_update_session().times(1).returning(|_| Ok(())); + .returning(|_| Ok(())); db.expect_delete_session().times(0); - // Setup router with a community write requirement - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::TeamWrite), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); + // Setup router with a community write requirement and send request + let router = setup_selected_community_router(db, CommunityPermission::TeamWrite).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; - // Check repair occurs before write access is denied - assert_eq!(response.status(), StatusCode::FORBIDDEN); + // Check repair is persisted before write access is denied + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_community_permission_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a failing permission check + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_user_has_community_permission() .times(1) .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::Read + *cid == ids.community_id + && *uid == ids.user_id + && permission == CommunityPermission::Read }) .returning(|_, _, _| Err(anyhow!("db error"))); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), CommunityPermission::Read), - user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_community_router(db, CommunityPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } +// Selected group middleware. + #[tokio::test] async fn test_user_has_selected_group_permission_allows_request() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id - }) - .returning(|_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::Read); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_selected_group_permission_allows_matching_loaded_context() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_update_session().times(0); - - // Setup router and send request declaring the loaded community and group - let router = setup_selected_group_router(db, GroupPermission::Read).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) - .header(SELECTED_GROUP_ID_HEADER, group_id.to_string()) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the protected handler ran - assert_eq!(parts.status, StatusCode::OK); - assert!(!parts.headers.contains_key(STALE_DASHBOARD_CONTEXT_HEADER)); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_selected_group_permission_fetch_redirects_when_selected_group_is_stale() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); - db.expect_list_user_groups() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(|_| Ok(vec![])); - db.expect_update_session() - .times(1) - .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) - && !record.data.contains_key(SELECTED_GROUP_ID_KEY) - }) - .returning(|_| Ok(())); - db.expect_delete_session().times(0); + expect_group_access(&mut db, &ids, GroupPermission::Read); + db.expect_update_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header("X-OCG-Fetch", "true") - .header(COOKIE, format!("id={session_id}")) + // Setup router and send request declaring the loaded community and group + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) + .header(SELECTED_GROUP_ID_HEADER, ids.group_id.to_string()) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("X-OCG-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), - ); - assert!(bytes.is_empty()); + // Check the protected handler ran + assert_empty_response(&parts, &bytes, StatusCode::OK); + assert!(!parts.headers.contains_key(STALE_DASHBOARD_CONTEXT_HEADER)); } #[tokio::test] async fn test_user_has_selected_group_permission_forbidden_when_not_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(SELECTED_COMMUNITY_ID_KEY.to_string(), json!(community_id)); + // Setup an anonymous session with a selected community and group + let ids = DashboardIds::new(); + let mut session_record = sample_empty_session_record(ids.session_id); + session_record.data.insert( + SELECTED_COMMUNITY_ID_KEY.to_string(), + json!(ids.community_id), + ); session_record .data - .insert(SELECTED_GROUP_ID_KEY.to_string(), json!(group_id)); - - // Setup database mock + .insert(SELECTED_GROUP_ID_KEY.to_string(), json!(ids.group_id)); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_user_by_id().times(0); - db.expect_user_has_group_permission().times(0); + expect_stored_session(&mut db, session_record); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_group_permission_forbidden_without_permission() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup a readable selection without the write permission the route needs + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); + expect_group_session(&mut db, &ids); db.expect_user_has_group_permission() .times(2) .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id + *cid == ids.community_id + && *gid == ids.group_id + && *uid == ids.user_id && matches!( permission, GroupPermission::Read | GroupPermission::TeamWrite @@ -2418,776 +805,434 @@ async fn test_user_has_selected_group_permission_forbidden_without_permission() .returning(|_, _, _, permission| Ok(permission == GroupPermission::Read)); db.expect_delete_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::TeamWrite), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); -} - -#[tokio::test] -async fn test_user_has_selected_group_permission_hx_redirects_when_selected_group_is_stale() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); - db.expect_list_user_groups() - .times(1) - .withf(move |uid| *uid == user_id) - .returning(|_| Ok(vec![])); - db.expect_update_session() - .times(1) - .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) - && !record.data.contains_key(SELECTED_GROUP_ID_KEY) - }) - .returning(|_| Ok(())); - db.expect_delete_session().times(0); - - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::TeamWrite).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("HX-Redirect").unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), - ); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_group_permission_redirects_when_context_is_missing_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - - // Setup database mock + // Setup a selected community without groups the user can read + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session().times(0); - db.expect_user_has_group_permission().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_has_selected_group_permission_redirects_when_selected_group_is_stale_and_unavailable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); - - // Setup database mock + // Setup a stale group selection with no readable replacement + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); + expect_group_session(&mut db, &ids); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, false); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) - && !record.data.contains_key(SELECTED_GROUP_ID_KEY) + record.id == ids.session_id && session_selects(record, ids.community_id, None) }) .returning(|_| Ok(())); db.expect_delete_session().times(0); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_has_selected_group_permission_refreshes_stale_loaded_context_after_repair() { - // Setup identifiers and stale dashboard context - let community_id = Uuid::new_v4(); - let replacement_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let stale_group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(community_id, replacement_group_id); - - // Setup lost permission and repair expectations + // Setup a stale group selection and a readable replacement group + let ids = DashboardIds::new(); + let replacement = DashboardIds { + group_id: Uuid::new_v4(), + ..ids + }; + let groups = sample_user_groups_by_community(ids.community_id, replacement.group_id); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); + expect_group_session(&mut db, &ids); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, false); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, false); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(move |_| Ok(groups.clone())); - expect_group_permission( - &mut db, - community_id, - replacement_group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - replacement_group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_permission_result(&mut db, &replacement, GroupPermission::Read, true); + expect_group_permission_result(&mut db, &replacement, GroupPermission::EventsWrite, true); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(replacement_group_id)) + record.id == ids.session_id + && session_selects(record, ids.community_id, Some(replacement.group_id)) }) .returning(|_| Ok(())); // Setup router and send an action from the page rendered for the stale group let router = setup_selected_group_router(db, GroupPermission::EventsWrite).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) - .header(SELECTED_GROUP_ID_HEADER, stale_group_id.to_string()) - .header(COOKIE, format!("id={session_id}")) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) + .header(SELECTED_GROUP_ID_HEADER, ids.group_id.to_string()) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the repaired selection is persisted but the action never runs - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_group_permission_refreshes_stale_loaded_context_before_forbidding_write() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let loaded_group_id = Uuid::new_v4(); - // Setup a readable selection without the write permission the route needs + let ids = DashboardIds::new(); + let loaded_group_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_session(&mut db, &ids); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, false); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, true); db.expect_update_session().times(0); // Setup router and send an action from a page rendered for another group let router = setup_selected_group_router(db, GroupPermission::EventsWrite).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) .header(SELECTED_GROUP_ID_HEADER, loaded_group_id.to_string()) - .header(COOKIE, format!("id={session_id}")) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the stale page is refreshed instead of receiving a forbidden error - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_group_permission_refreshes_when_loaded_group_differs() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let loaded_group_id = Uuid::new_v4(); - // Setup a valid selection that differs from the loaded page + let ids = DashboardIds::new(); + let loaded_group_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_update_session().times(0); // Setup router and send request declaring another group let router = setup_selected_group_router(db, GroupPermission::Read).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) .header(SELECTED_GROUP_ID_HEADER, loaded_group_id.to_string()) - .header(COOKIE, format!("id={session_id}")) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the client is told to reload instead of reaching the handler - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_group_permission_refreshes_when_loaded_group_is_unparsable() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_update_session().times(0); // Setup router and send request with an invalid group header let router = setup_selected_group_router(db, GroupPermission::Read).await; - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(SELECTED_COMMUNITY_ID_HEADER, community_id.to_string()) + let request = authenticated_request("GET", "/protected", ids.session_id) + .header(SELECTED_COMMUNITY_ID_HEADER, ids.community_id.to_string()) .header(SELECTED_GROUP_ID_HEADER, "not-a-uuid") - .header(COOKIE, format!("id={session_id}")) .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the client is told to reload instead of reaching the handler - assert_stale_dashboard_context_response(parts.status, &parts.headers); - assert!(bytes.is_empty()); + assert_stale_dashboard_context_response(&parts, &bytes); } #[tokio::test] async fn test_user_has_selected_group_permission_repairs_missing_context() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(community_id, group_id); - - // Setup database mock + // Setup a session without selection and a readable group + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(move |_| Ok(groups.clone())); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, true); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id - && record - .data - .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) - && record - .data - .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(group_id)) + record.id == ids.session_id + && session_selects(record, ids.community_id, Some(ids.group_id)) }) .returning(|_| Ok(())); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_user_has_selected_group_permission_repairs_stale_context_before_forbidding_write() { - // Setup identifiers and stale dashboard context - let community_id = Uuid::new_v4(); - let replacement_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let stale_group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(community_id, replacement_group_id); - - // Setup current and replacement permission expectations + // Setup a stale group selection and a readable replacement without write access + let ids = DashboardIds::new(); + let replacement = DashboardIds { + group_id: Uuid::new_v4(), + ..ids + }; + let groups = sample_user_groups_by_community(ids.community_id, replacement.group_id); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, stale_group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(|_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == stale_group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); + expect_group_session(&mut db, &ids); + expect_group_permission_result(&mut db, &ids, GroupPermission::TeamWrite, false); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, false); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(move |_| Ok(groups.clone())); - expect_group_permission( - &mut db, - community_id, - replacement_group_id, - user_id, - GroupPermission::Read, - ); - db.expect_user_has_group_permission() + expect_group_permission_result(&mut db, &replacement, GroupPermission::Read, true); + expect_group_permission_result(&mut db, &replacement, GroupPermission::TeamWrite, false); + db.expect_update_session() .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == replacement_group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite + .withf(move |record| { + record.id == ids.session_id + && session_selects(record, ids.community_id, Some(replacement.group_id)) }) - .returning(|_, _, _, _| Ok(false)); - db.expect_update_session().times(1).returning(|_| Ok(())); + .returning(|_| Ok(())); db.expect_delete_session().times(0); - // Setup router with a group write requirement - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::TeamWrite), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); + // Setup router with a group write requirement and send request + let router = setup_selected_group_router(db, GroupPermission::TeamWrite).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; - // Check repair occurs before write access is denied - assert_eq!(response.status(), StatusCode::FORBIDDEN); + // Check repair is persisted before write access is denied + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_user_has_selected_group_permission_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and a failing permission check + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); + expect_group_session(&mut db, &ids); db.expect_user_has_group_permission() .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id + .withf(move |cid, gid, uid, permission| { + *cid == ids.community_id + && *gid == ids.group_id + && *uid == ids.user_id + && permission == GroupPermission::Read }) .returning(|_, _, _, _| Err(anyhow!("db error"))); - // Setup router - let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let nm = Arc::new(MockNotificationsManager::new()); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - nm.clone(), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - let router = Router::new() - .route("/protected", get(|| async { StatusCode::OK })) - .layer(middleware::from_fn_with_state( - (db.clone(), GroupPermission::Read), - user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state); - - // Execute request - let request = Request::builder() - .method("GET") - .uri("/protected") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = setup_selected_group_router(db, GroupPermission::Read).await; + let (parts, bytes) = send(router, get_request("/protected", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); +} + +// Redirect builders. + +#[tokio::test] +async fn test_redirect_to_invitations_for_request_matches_request_type() { + // Setup request types and the redirect each one expects + let cases = [ + ("page", None, StatusCode::SEE_OTHER, LOCATION.as_str()), + ("htmx", Some("hx-request"), StatusCode::OK, "HX-Redirect"), + ( + "ocg fetch", + Some("x-ocg-fetch"), + StatusCode::OK, + "X-OCG-Redirect", + ), + ]; + + for (case, request_header, expected_status, redirect_header) in cases { + // Build the redirect for the request headers + let mut headers = HeaderMap::new(); + if let Some(name) = request_header { + headers.insert(name, HeaderValue::from_static("true")); + } + let (parts, body) = redirect_to_invitations_for_request(&headers).into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, expected_status, "{case}"); + assert_eq!( + parts.headers.get(redirect_header), + Some(&HeaderValue::from_static(USER_DASHBOARD_INVITATIONS_URL)), + "{case}", + ); + assert!(bytes.is_empty(), "{case}"); + } +} + +#[tokio::test] +async fn test_redirect_to_log_in_for_request_matches_request_type() { + // Setup request types and the redirect each one expects + let cases = [ + ("page", None, StatusCode::SEE_OTHER, LOCATION.as_str()), + ("htmx", Some("hx-request"), StatusCode::OK, "HX-Redirect"), + ( + "ocg fetch", + Some("x-ocg-fetch"), + StatusCode::UNAUTHORIZED, + "X-OCG-Redirect", + ), + ]; + + for (case, request_header, expected_status, redirect_header) in cases { + // Build the redirect for the request headers + let mut headers = HeaderMap::new(); + if let Some(name) = request_header { + headers.insert(name, HeaderValue::from_static("true")); + } + let (parts, body) = redirect_to_log_in_for_request(&headers).into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, expected_status, "{case}"); + assert_eq!( + parts.headers.get(redirect_header), + Some(&HeaderValue::from_static(LOG_IN_URL)), + "{case}", + ); + assert!(bytes.is_empty(), "{case}"); + } } // Helpers. -/// Asserts the response is the stale dashboard context refresh intercept. -fn assert_stale_dashboard_context_response(status: StatusCode, headers: &HeaderMap) { - assert_eq!(status, StatusCode::NO_CONTENT); +/// Asserts the response is the empty stale dashboard context refresh intercept. +fn assert_stale_dashboard_context_response(parts: &Parts, bytes: &[u8]) { + assert_empty_response(parts, bytes, StatusCode::NO_CONTENT); assert_eq!( - headers.get(CACHE_CONTROL).unwrap(), - &HeaderValue::from_static(CACHE_CONTROL_NO_STORE), + parts.headers.get(CACHE_CONTROL), + Some(&HeaderValue::from_static(CACHE_CONTROL_NO_STORE)), ); assert_eq!( - headers.get(STALE_DASHBOARD_CONTEXT_HEADER).unwrap(), - &HeaderValue::from_static("true"), + parts.headers.get(STALE_DASHBOARD_CONTEXT_HEADER), + Some(&HeaderValue::from_static("true")), ); } -/// Builds a router protecting `/protected` with the community dashboard middleware. -async fn setup_community_dashboard_router(db: MockDB) -> Router { +/// Wraps the routes with the real auth layer and the test state. +async fn protected_router(db: DynDB, routes: Router) -> Router { let server_cfg = HttpServerConfig::default(); - let db: DynDB = Arc::new(db); - let state = test_state_with_server_cfg( + let state = test_state( db.clone(), Arc::new(MockImageStorage::new()), Arc::new(MockNotificationsManager::new()), - &server_cfg, ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) + let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db) .await .unwrap(); - Router::new() + routes.layer(auth_layer).with_state(state) +} + +/// Returns whether the stored session selects exactly the community and group. +fn session_selects(record: &session::Record, community_id: Uuid, group_id: Option) -> bool { + record.data.get(SELECTED_COMMUNITY_ID_KEY) == Some(&json!(community_id)) + && record.data.get(SELECTED_GROUP_ID_KEY) == group_id.map(|id| json!(id)).as_ref() +} + +/// Builds a router protecting `/protected` with the community dashboard middleware. +async fn setup_community_dashboard_router(db: MockDB) -> Router { + let db: DynDB = Arc::new(db); + let routes = Router::new() .route("/protected", get(|| async { StatusCode::OK })) .layer(middleware::from_fn_with_state( db.clone(), user_has_community_dashboard_permission, - )) - .layer(auth_layer) - .with_state(state) + )); + + protected_router(db, routes).await +} + +/// Builds a router protecting `/{community_id}/select` with the path community middleware. +async fn setup_path_community_router(db: MockDB, permission: CommunityPermission) -> Router { + let db: DynDB = Arc::new(db); + let routes = Router::new() + .route("/{community_id}/select", get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), permission), + user_has_path_community_permission, + )); + + protected_router(db, routes).await +} + +/// Builds a router protecting `/{group_id}/select` with the path group middleware. +async fn setup_path_group_router(db: MockDB, permission: GroupPermission) -> Router { + let db: DynDB = Arc::new(db); + let routes = Router::new() + .route("/{group_id}/select", get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), permission), + user_has_path_group_permission, + )); + + protected_router(db, routes).await } /// Builds a router protecting `/protected` with the selected community middleware. async fn setup_selected_community_router(db: MockDB, permission: CommunityPermission) -> Router { - let server_cfg = HttpServerConfig::default(); let db: DynDB = Arc::new(db); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - Arc::new(MockNotificationsManager::new()), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - - Router::new() + let routes = Router::new() .route("/protected", get(|| async { StatusCode::OK })) .layer(middleware::from_fn_with_state( (db.clone(), permission), user_has_selected_community_permission, - )) - .layer(auth_layer) - .with_state(state) + )); + + protected_router(db, routes).await } /// Builds a router protecting `/protected` with the selected group middleware. async fn setup_selected_group_router(db: MockDB, permission: GroupPermission) -> Router { - let server_cfg = HttpServerConfig::default(); let db: DynDB = Arc::new(db); - let state = test_state_with_server_cfg( - db.clone(), - Arc::new(MockImageStorage::new()), - Arc::new(MockNotificationsManager::new()), - &server_cfg, - ); - let auth_layer = crate::auth::setup_layer(&server_cfg, BlockingExecutor::new(1), db.clone()) - .await - .unwrap(); - - Router::new() + let routes = Router::new() .route("/protected", get(|| async { StatusCode::OK })) .layer(middleware::from_fn_with_state( (db.clone(), permission), user_has_selected_group_permission, - )) - .layer(auth_layer) - .with_state(state) + )); + + protected_router(db, routes).await } diff --git a/ocg-server/src/handlers/auth/session_context/tests.rs b/ocg-server/src/handlers/auth/session_context/tests.rs index 33eb43d3a..1cdfa0cdf 100644 --- a/ocg-server/src/handlers/auth/session_context/tests.rs +++ b/ocg-server/src/handlers/auth/session_context/tests.rs @@ -60,13 +60,7 @@ async fn test_resolve_community_dashboard_context_skips_unreadable_candidate() { // Setup stale in-memory dashboard context let db: DynDB = Arc::new(db); - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(SELECTED_COMMUNITY_ID_KEY, selected_community_id) - .await - .unwrap(); - session.insert(SELECTED_GROUP_ID_KEY, stale_group_id).await.unwrap(); + let session = session_with_selection(Some(selected_community_id), Some(stale_group_id)).await; // Resolve the first verified community context let resolved = @@ -75,9 +69,6 @@ async fn test_resolve_community_dashboard_context_skips_unreadable_candidate() { .expect("community context should resolve"); // Check only the readable candidate is persisted - let persisted_community_id: Option = - session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let persisted_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!( resolved, Some(ResolvedCommunityContext { @@ -85,8 +76,10 @@ async fn test_resolve_community_dashboard_context_skips_unreadable_candidate() { has_requested_permission: true, }) ); - assert_eq!(persisted_community_id, Some(accessible_community_id)); - assert_eq!(persisted_group_id, None); + assert_eq!( + selected_ids(&session).await, + (Some(accessible_community_id), None) + ); } #[tokio::test] @@ -119,23 +112,19 @@ async fn test_resolve_group_dashboard_context_prefers_selected_community() { .times(1) .withf(move |uid| *uid == user_id) .returning(move |_| Ok(groups.clone())); - expect_group_permission( - &mut db, - selected_community_id, - selected_group_id, - user_id, - GroupPermission::Read, - ); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == selected_community_id + && *gid == selected_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(true)); // Setup stale in-memory dashboard context let db: DynDB = Arc::new(db); - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(SELECTED_COMMUNITY_ID_KEY, selected_community_id) - .await - .unwrap(); - session.insert(SELECTED_GROUP_ID_KEY, stale_group_id).await.unwrap(); + let session = session_with_selection(Some(selected_community_id), Some(stale_group_id)).await; // Resolve group context from the preferred community let resolved = resolve_group_dashboard_context(&db, &session, &user_id, GroupPermission::Read) @@ -143,9 +132,6 @@ async fn test_resolve_group_dashboard_context_prefers_selected_community() { .expect("group context should resolve"); // Check the selected community wins over listing order - let persisted_community_id: Option = - session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let persisted_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!( resolved, Some(ResolvedGroupContext { @@ -154,8 +140,10 @@ async fn test_resolve_group_dashboard_context_prefers_selected_community() { has_requested_permission: true, }) ); - assert_eq!(persisted_community_id, Some(selected_community_id)); - assert_eq!(persisted_group_id, Some(selected_group_id)); + assert_eq!( + selected_ids(&session).await, + (Some(selected_community_id), Some(selected_group_id)) + ); } #[tokio::test] @@ -197,23 +185,19 @@ async fn test_resolve_group_dashboard_context_repairs_across_communities() { && permission == GroupPermission::Read }) .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - fallback_community_id, - fallback_group_id, - user_id, - GroupPermission::Read, - ); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == fallback_community_id + && *gid == fallback_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(true)); // Setup stale in-memory dashboard context let db: DynDB = Arc::new(db); - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(SELECTED_COMMUNITY_ID_KEY, selected_community_id) - .await - .unwrap(); - session.insert(SELECTED_GROUP_ID_KEY, stale_group_id).await.unwrap(); + let session = session_with_selection(Some(selected_community_id), Some(stale_group_id)).await; // Resolve group context from the fallback community let resolved = resolve_group_dashboard_context(&db, &session, &user_id, GroupPermission::Read) @@ -221,9 +205,6 @@ async fn test_resolve_group_dashboard_context_repairs_across_communities() { .expect("group context should resolve"); // Check both selected identifiers move to the fallback context - let persisted_community_id: Option = - session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let persisted_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!( resolved, Some(ResolvedGroupContext { @@ -232,8 +213,10 @@ async fn test_resolve_group_dashboard_context_repairs_across_communities() { has_requested_permission: true, }) ); - assert_eq!(persisted_community_id, Some(fallback_community_id)); - assert_eq!(persisted_group_id, Some(fallback_group_id)); + assert_eq!( + selected_ids(&session).await, + (Some(fallback_community_id), Some(fallback_group_id)) + ); } #[tokio::test] @@ -271,23 +254,19 @@ async fn test_resolve_group_dashboard_context_skips_unreadable_candidate() { && permission == GroupPermission::Read }) .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - community_id, - accessible_group_id, - user_id, - GroupPermission::Read, - ); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id + && *gid == accessible_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(true)); // Setup stale in-memory dashboard context let db: DynDB = Arc::new(db); - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session.insert(SELECTED_COMMUNITY_ID_KEY, community_id).await.unwrap(); - session - .insert(SELECTED_GROUP_ID_KEY, selected_group_id) - .await - .unwrap(); + let session = session_with_selection(Some(community_id), Some(selected_group_id)).await; // Resolve the first verified group context let resolved = resolve_group_dashboard_context(&db, &session, &user_id, GroupPermission::Read) @@ -295,9 +274,6 @@ async fn test_resolve_group_dashboard_context_skips_unreadable_candidate() { .expect("group context should resolve"); // Check only the readable candidate is persisted - let persisted_community_id: Option = - session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let persisted_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!( resolved, Some(ResolvedGroupContext { @@ -306,8 +282,10 @@ async fn test_resolve_group_dashboard_context_skips_unreadable_candidate() { has_requested_permission: true, }) ); - assert_eq!(persisted_community_id, Some(community_id)); - assert_eq!(persisted_group_id, Some(accessible_group_id)); + assert_eq!( + selected_ids(&session).await, + (Some(community_id), Some(accessible_group_id)) + ); } #[tokio::test] @@ -329,8 +307,7 @@ async fn test_select_first_community_and_group_selects_community_when_user_has_n // Setup in-memory session let db: DynDB = Arc::new(db); - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); + let session = session_with_selection(None, None).await; // Execute helper select_first_community_and_group(&db, &session, &user_id) @@ -338,8 +315,19 @@ async fn test_select_first_community_and_group_selects_community_when_user_has_n .expect("helper should select first available community"); // Check session data matches expectations - let selected_community_id: Option = session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let selected_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); - assert_eq!(selected_community_id, Some(community_id)); - assert_eq!(selected_group_id, None); + assert_eq!(selected_ids(&session).await, (Some(community_id), None)); +} + +// Helpers. + +/// Builds an in-memory session selecting the given community and group. +async fn session_with_selection(community_id: Option, group_id: Option) -> Session { + let session = Session::new(None, Arc::new(MemoryStore::default()), None); + if let Some(community_id) = community_id { + session.insert(SELECTED_COMMUNITY_ID_KEY, community_id).await.unwrap(); + } + if let Some(group_id) = group_id { + session.insert(SELECTED_GROUP_ID_KEY, group_id).await.unwrap(); + } + session } diff --git a/ocg-server/src/handlers/auth/tests.rs b/ocg-server/src/handlers/auth/tests.rs index 8c86c4fd4..bea8df7ba 100644 --- a/ocg-server/src/handlers/auth/tests.rs +++ b/ocg-server/src/handlers/auth/tests.rs @@ -1,29 +1,30 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use anyhow::anyhow; use axum::{ body::{Body, to_bytes}, extract::Query, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST, LOCATION}, - }, + http::{HeaderValue, StatusCode, header::LOCATION}, response::IntoResponse, }; use axum_login::tower_sessions::session; use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, basic::BasicClient}; use openidconnect as oidc; use serde_json::json; -use tower::ServiceExt; use tower_sessions::{MemoryStore, Session}; use uuid::Uuid; use crate::{ - auth::{AUTH_PROVIDER_KEY, OAuth2ProviderDetails, OidcProviderDetails}, - config::{HttpServerConfig, LoginOptions, OAuth2Provider, OAuth2ProviderConfig}, - db::{DynDB, mock::MockDB}, + auth::{ + AUTH_PROVIDER_KEY, EXTERNAL_AUTH_EMAIL_CONFLICT_ERROR, + EXTERNAL_AUTH_IDENTITY_CONFLICT_ERROR, OAuth2ProviderDetails, OidcProviderDetails, + }, + config::{HttpServerConfig, LoginOptions, OAuth2Provider}, + db::{ + DynDB, + mock::{MockDB, expect_site_settings}, + }, handlers::{ - auth::session_context::{SELECTED_COMMUNITY_ID_KEY, SELECTED_GROUP_ID_KEY}, extractors::{OAuth2, Oidc}, tests::*, }, @@ -32,257 +33,158 @@ use crate::{ use super::*; +// Pages and sections. + #[tokio::test] async fn test_log_in_page_success() { // Setup database mock let mut db = MockDB::new(); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_site_settings(&mut db); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(LOG_IN_URL) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = request("GET", LOG_IN_URL).body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_log_in_page_redirects_when_authenticated() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_session(&mut db, &ids); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(LOG_IN_URL) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request(LOG_IN_URL, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/")) ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_sign_up_page_success() { // Setup database mock let mut db = MockDB::new(); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_site_settings(&mut db); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(SIGN_UP_URL) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = request("GET", SIGN_UP_URL).body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_sign_up_page_redirects_when_authenticated() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_session(&mut db, &ids); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(SIGN_UP_URL) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request(SIGN_UP_URL, ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/")) ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_user_menu_section_success() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_session(&mut db, &ids); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/section/user-menu") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request("/section/user-menu", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } +// Log in and log out. + #[tokio::test] async fn test_log_in_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let auth_hash = "hash".to_string(); - let password_hash = password_auth::generate_hash("secret-password"); - let session_record = sample_empty_session_record(session_id); + let ids = DashboardIds::new(); + let user = auth::User { + password: Some(password_auth::generate_hash("secret-password")), + ..sample_auth_user(ids.user_id, "hash") + }; // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(ids.session_id)); db.expect_get_user_by_username() .times(1) .withf(move |username| username == "test-user") - .returning(move |_| { - let mut user = sample_auth_user(user_id, &auth_hash); - user.password = Some(password_hash.clone()); - Ok(Some(user)) - }); + .returning(move |_| Ok(Some(user.clone()))); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(|_| Ok(())); db.expect_create_session() .times(1) .withf(move |record| { session_record_contains_auth_provider(record, AUTH_PROVIDER_EMAIL) - && session_record_contains_selected_group(record, group_id) + && session_record_contains_selected_group(record, ids.group_id) }) .returning(|_| Ok(())); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_groups_by_community(community_id, group_id))); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |uid| uid == &ids.user_id) + .returning(move |_| { + Ok(sample_user_groups_by_community( + ids.community_id, + ids.group_id, + )) + }); - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("POST") - .uri("/log-in?next_url=%2Fdashboard") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("username=test-user&password=secret-password")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "POST", + "/log-in?next_url=%2Fdashboard", + ids.session_id, + "username=test-user&password=secret-password", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert!(parts.headers.contains_key("set-cookie")); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/dashboard"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/dashboard")), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_log_in_invalid_credentials() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_get_user_by_username() .times(1) .withf(move |username| username == "test-user") @@ -297,50 +199,62 @@ async fn test_log_in_invalid_credentials() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) .build() .await; + let request = form_request( + "POST", + "/log-in?next_url=%2Fdashboard", + session_id, + "username=test-user&password=wrong", + ); + let (parts, bytes) = send(router, request).await; - // Setup request - let request = Request::builder() - .method("POST") - .uri("/log-in") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("username=test-user&password=wrong")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + // Check the log in page is shown again keeping the next url + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); - assert!(bytes.is_empty()); } #[tokio::test] -async fn test_log_in_validation_error() { - // Setup identifiers and data structures +async fn test_log_in_returns_unauthorized_when_authentication_fails() { + // Setup database mock with a failing user lookup let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); + let mut db = MockDB::new(); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); + db.expect_get_user_by_username() + .times(1) + .withf(move |username| username == "test-user") + .returning(|_| Err(anyhow!("db error"))); + db.expect_update_session().never(); + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) + .build() + .await; + let request = form_request( + "POST", + "/log-in", + session_id, + "username=test-user&password=secret-password", + ); + let (parts, bytes) = send(router, request).await; + + // Check response matches expectations + assert_empty_response(&parts, &bytes, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_log_in_validation_error() { // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_get_user_by_username().times(0); db.expect_update_session() .times(1) @@ -352,83 +266,54 @@ async fn test_log_in_validation_error() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request with a whitespace-only username + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) .build() .await; + let request = form_request( + "POST", + "/log-in?next_url=%2Fdashboard", + session_id, + "username=+++&password=secret", + ); + let (parts, bytes) = send(router, request).await; - // Setup request (username is whitespace only - validation should fail) - let request = Request::builder() - .method("POST") - .uri("/log-in") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("username=+++&password=secret")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + // Check the log in page is shown again keeping the next url + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_log_out_success() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/log-out") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, empty_request("POST", "/log-out", ids.session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_log_out_success_with_invalid_session() { - // Setup identifiers and data structures + // Setup database mock without a stored session let session_id = session::Id::default(); - - // Setup database mock let mut db = MockDB::new(); db.expect_get_session() .times(1) @@ -437,184 +322,90 @@ async fn test_log_out_success_with_invalid_session() { db.expect_get_user_by_id().times(0); db.expect_delete_session().times(0); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/log-out") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, empty_request("POST", "/log-out", session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } +// OAuth2 callbacks and redirects. + #[tokio::test] async fn test_oauth2_callback_missing_state() { - // Setup identifiers and data structures + // Setup database mock with a session without csrf state let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - - // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_update_session() .times(1) .withf(move |record| message_matches(record, "OAuth2 authorization failed")) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.github = true; - server_cfg.oauth2.insert( - OAuth2Provider::GitHub, - OAuth2ProviderConfig { - auth_url: "https://oauth.example/authorize".to_string(), - client_id: "client-id".to_string(), - client_secret: "client-secret".to_string(), - redirect_uri: "https://app.example/log-in/oauth2/github/callback".to_string(), - scopes: vec!["read:user".to_string()], - token_url: "https://oauth.example/token".to_string(), - }, - ); - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(github_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oauth2/github/callback?code=test-code&state=test-state") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oauth2/github/callback?code=test-code&state=test-state"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oauth2_callback_state_mismatch() { - // Setup identifiers and data structures + // Setup database mock with a session holding another csrf state let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(OAUTH2_CSRF_STATE_KEY.to_string(), json!("state-in-session")); - - // Setup database mock + let session_record = + session_record_with_values(session_id, &[(OAUTH2_CSRF_STATE_KEY, "state-in-session")]); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, session_record); db.expect_update_session() .times(1) .withf(move |record| message_matches(record, "OAuth2 authorization failed")) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.github = true; - server_cfg.oauth2.insert( - OAuth2Provider::GitHub, - OAuth2ProviderConfig { - auth_url: "https://oauth.example/authorize".to_string(), - client_id: "client-id".to_string(), - client_secret: "client-secret".to_string(), - redirect_uri: "https://app.example/log-in/oauth2/github/callback".to_string(), - scopes: vec!["read:user".to_string()], - token_url: "https://oauth.example/token".to_string(), - }, - ); - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(github_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oauth2/github/callback?code=test-code&state=state-in-request") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oauth2/github/callback?code=test-code&state=state-in-request"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oauth2_callback_authorization_error() { - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session - .insert(NEXT_URL_KEY, Some("/dashboard".to_string())) - .await - .unwrap(); - - // Setup database mock + // Setup in-memory session and mocks + let session = callback_session(None, Some("/dashboard")).await; let mut db = MockDB::new(); db.expect_list_user_groups().times(0); - - // Setup callback auth mock + let db: DynDB = Arc::new(db); let mut callback_auth = MockCallbackAuth { - login_called: false, - login_result: Some(Ok(())), - oidc_result: None, oauth2_result: Some(Err("oauth2 auth error".to_string())), + ..Default::default() }; - let db: DynDB = Arc::new(db); // Execute helper - let error_message = std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_error_message = error_message.clone(); + let (error_message, on_error) = capture_error(); let redirect = oauth2_callback_with_auth( &mut callback_auth, session.clone(), @@ -622,68 +413,90 @@ async fn test_oauth2_callback_authorization_error() { OAuth2Provider::GitHub, "test-code".to_string(), oauth2::CsrfToken::new("state-in-session".to_string()), - move |message| { - let mut guard = captured_error_message.lock().unwrap(); - *guard = Some(message); - }, + on_error, ) .await .unwrap(); // Check callback result and side effects let response = redirect.into_response(); - let selected_community_id: Option = session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let selected_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); + let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fdashboard"), + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); assert_eq!( *error_message.lock().unwrap(), Some("OAuth2 authorization failed: oauth2 auth error".to_string()), ); assert!(!callback_auth.login_called); - assert_eq!(selected_community_id, None); - assert_eq!(selected_group_id, None); + assert_eq!(auth_provider, None); + assert_eq!(selected_ids(&session).await, (None, None)); +} + +#[tokio::test] +async fn test_oauth2_callback_rejects_unauthenticated_user() { + // Setup in-memory session and mocks + let session = callback_session(None, Some("/dashboard")).await; + let mut db = MockDB::new(); + db.expect_list_user_groups().times(0); + let db: DynDB = Arc::new(db); + let mut callback_auth = MockCallbackAuth { + oauth2_result: Some(Ok(None)), + ..Default::default() + }; + + // Execute helper + let (error_message, on_error) = capture_error(); + let redirect = oauth2_callback_with_auth( + &mut callback_auth, + session.clone(), + &db, + OAuth2Provider::GitHub, + "test-code".to_string(), + oauth2::CsrfToken::new("state-in-session".to_string()), + on_error, + ) + .await + .unwrap(); + + // Check the user is sent back to log in without being logged in + let response = redirect.into_response(); + let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), + ); + assert_eq!( + *error_message.lock().unwrap(), + Some("OAuth2 authorization failed".to_string()), + ); + assert!(!callback_auth.login_called); + assert_eq!(auth_provider, None); + assert_eq!(selected_ids(&session).await, (None, None)); } #[tokio::test] async fn test_oauth2_callback_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let auth_hash = "hash".to_string(); - let groups = sample_user_groups_by_community(community_id, group_id); - - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session - .insert(NEXT_URL_KEY, Some("/dashboard".to_string())) - .await - .unwrap(); + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); - // Setup database mock + // Setup in-memory session and mocks + let session = callback_session(None, Some("/dashboard")).await; let mut db = MockDB::new(); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |uid| uid == &ids.user_id) .returning(move |_| Ok(groups.clone())); - - // Setup callback auth mock + let db: DynDB = Arc::new(db); let mut callback_auth = MockCallbackAuth { - login_called: false, login_result: Some(Ok(())), - oidc_result: None, - oauth2_result: Some(Ok(Some(sample_auth_user(user_id, &auth_hash)))), + oauth2_result: Some(Ok(Some(sample_auth_user(ids.user_id, "hash")))), + ..Default::default() }; - let db: DynDB = Arc::new(db); // Execute helper let redirect = oauth2_callback_with_auth( @@ -703,40 +516,39 @@ async fn test_oauth2_callback_success() { // Check callback result and side effects let response = redirect.into_response(); let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); - let selected_community_id: Option = session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let selected_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static("/dashboard"), + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/dashboard")), + ); + assert_eq!( + callback_auth.oauth2_call, + Some(("test-code".to_string(), OAuth2Provider::GitHub)), ); assert!(callback_auth.login_called); assert_eq!( auth_provider.as_deref(), Some(OAuth2Provider::GitHub.as_ref()) ); - assert_eq!(selected_community_id, Some(community_id)); - assert_eq!(selected_group_id, Some(group_id)); + assert_eq!( + selected_ids(&session).await, + (Some(ids.community_id), Some(ids.group_id)), + ); } #[tokio::test] async fn test_oauth2_callback_returns_error_when_provider_is_not_configured() { - // Setup identifiers and data structures + // Setup database mock with a valid csrf state and next url let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(OAUTH2_CSRF_STATE_KEY.to_string(), json!("state-in-session")); - session_record - .data - .insert(NEXT_URL_KEY.to_string(), json!("/dashboard")); - - // Setup database mock + let session_record = session_record_with_values( + session_id, + &[ + (OAUTH2_CSRF_STATE_KEY, "state-in-session"), + (NEXT_URL_KEY, "/dashboard"), + ], + ); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, session_record); db.expect_update_session() .times(1) .withf(move |record| { @@ -747,43 +559,26 @@ async fn test_oauth2_callback_returns_error_when_provider_is_not_configured() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.github = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(github_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oauth2/github/callback?code=test-code&state=state-in-session") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oauth2/github/callback?code=test-code&state=state-in-session"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fdashboard"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oauth2_redirect_success() { // Setup session and form input - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); + let session = Session::new(None, Arc::new(MemoryStore::default()), None); let query = Query(NextUrl { next_url: Some("/dashboard".to_string()), }); @@ -817,206 +612,121 @@ async fn test_oauth2_redirect_success() { // Check response matches expectations let csrf_state: Option = session.get(OAUTH2_CSRF_STATE_KEY).await.unwrap(); let next_url: Option> = session.get(NEXT_URL_KEY).await.unwrap(); - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!(base_url, "https://oauth.example/authorize"); assert_eq!( csrf_state.as_deref(), - query - .split('&') - .find_map(|pair| pair.strip_prefix("state=").map(String::from)) - .as_deref(), + query_param(query, "state").as_deref() ); assert_eq!(next_url, Some(Some("/dashboard".to_string()))); - assert!(bytes.is_empty()); } +// OIDC callbacks and redirects. + #[tokio::test] async fn test_oidc_callback_missing_nonce() { - // Setup identifiers and data structures + // Setup database mock with a csrf state but no nonce let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(OAUTH2_CSRF_STATE_KEY.to_string(), json!("state-in-session")); - - // Setup database mock + let session_record = + session_record_with_values(session_id, &[(OAUTH2_CSRF_STATE_KEY, "state-in-session")]); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, session_record); db.expect_update_session() .times(1) .withf(move |record| message_matches(record, "OpenID Connect authorization failed")) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.linuxfoundation = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(linuxfoundation_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-session") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-session"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oidc_callback_missing_state() { - // Setup identifiers and data structures + // Setup database mock with a session without csrf state let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - - // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_update_session() .times(1) .withf(move |record| message_matches(record, "OpenID Connect authorization failed")) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.linuxfoundation = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(linuxfoundation_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oidc/linuxfoundation/callback?code=test-code&state=test-state") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oidc/linuxfoundation/callback?code=test-code&state=test-state"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oidc_callback_state_mismatch() { - // Setup identifiers and data structures + // Setup database mock with a session holding another csrf state let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(OAUTH2_CSRF_STATE_KEY.to_string(), json!("state-in-session")); - session_record - .data - .insert(OIDC_NONCE_KEY.to_string(), json!("nonce-in-session")); - - // Setup database mock + let session_record = session_record_with_values( + session_id, + &[ + (OAUTH2_CSRF_STATE_KEY, "state-in-session"), + (OIDC_NONCE_KEY, "nonce-in-session"), + ], + ); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, session_record); db.expect_update_session() .times(1) .withf(move |record| message_matches(record, "OpenID Connect authorization failed")) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.linuxfoundation = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(linuxfoundation_login_cfg()) .build() .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-request") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-request"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oidc_callback_authorization_error() { - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session.insert(OIDC_NONCE_KEY, "nonce-in-session").await.unwrap(); - session - .insert(NEXT_URL_KEY, Some("/dashboard".to_string())) - .await - .unwrap(); - - // Setup database mock + // Setup in-memory session and mocks + let session = callback_session(Some("nonce-in-session"), Some("/dashboard")).await; let mut db = MockDB::new(); db.expect_list_user_groups().times(0); - - // Setup callback auth mock + let db: DynDB = Arc::new(db); let mut callback_auth = MockCallbackAuth { - login_called: false, - login_result: Some(Ok(())), oidc_result: Some(Err("oidc auth error".to_string())), - oauth2_result: None, + ..Default::default() }; - let db: DynDB = Arc::new(db); // Execute helper - let error_message = std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_error_message = error_message.clone(); + let (error_message, on_error) = capture_error(); let redirect = oidc_callback_with_auth( &mut callback_auth, session.clone(), @@ -1024,10 +734,7 @@ async fn test_oidc_callback_authorization_error() { OidcProvider::LinuxFoundation, "test-code".to_string(), oauth2::CsrfToken::new("state-in-session".to_string()), - move |message| { - let mut guard = captured_error_message.lock().unwrap(); - *guard = Some(message); - }, + on_error, ) .await .unwrap(); @@ -1035,12 +742,10 @@ async fn test_oidc_callback_authorization_error() { // Check callback result and side effects let response = redirect.into_response(); let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); - let selected_community_id: Option = session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let selected_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fdashboard"), + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); assert_eq!( *error_message.lock().unwrap(), @@ -1048,165 +753,71 @@ async fn test_oidc_callback_authorization_error() { ); assert!(!callback_auth.login_called); assert_eq!(auth_provider, None); - assert_eq!(selected_community_id, None); - assert_eq!(selected_group_id, None); + assert_eq!(selected_ids(&session).await, (None, None)); } #[tokio::test] -async fn test_oidc_callback_uses_friendly_external_auth_email_conflict_message() { - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session.insert(OIDC_NONCE_KEY, "nonce-in-session").await.unwrap(); - - // Setup database mock +async fn test_oidc_callback_rejects_unauthenticated_user() { + // Setup in-memory session and mocks + let session = callback_session(Some("nonce-in-session"), Some("/dashboard")).await; let mut db = MockDB::new(); db.expect_list_user_groups().times(0); - - // Setup callback auth mock - let mut callback_auth = MockCallbackAuth { - login_called: false, - login_result: Some(Ok(())), - oidc_result: Some(Err(format!( - "db error: {}", - crate::auth::EXTERNAL_AUTH_EMAIL_CONFLICT_ERROR - ))), - oauth2_result: None, - }; let db: DynDB = Arc::new(db); - - // Execute helper - let error_message = std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_error_message = error_message.clone(); - let redirect = oidc_callback_with_auth( - &mut callback_auth, - session, - &db, - OidcProvider::LinuxFoundation, - "test-code".to_string(), - oauth2::CsrfToken::new("state-in-session".to_string()), - move |message| { - let mut guard = captured_error_message.lock().unwrap(); - *guard = Some(message); - }, - ) - .await - .unwrap(); - - // Check callback result and side effects - let response = redirect.into_response(); - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), - ); - assert_eq!( - *error_message.lock().unwrap(), - Some(LF_SSO_EMAIL_CONFLICT_MESSAGE.to_string()), - ); - assert!(!callback_auth.login_called); -} - -#[tokio::test] -async fn test_oidc_callback_uses_friendly_external_auth_identity_conflict_message() { - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session.insert(OIDC_NONCE_KEY, "nonce-in-session").await.unwrap(); - - // Setup database mock - let mut db = MockDB::new(); - db.expect_list_user_groups().times(0); - - // Setup callback auth mock let mut callback_auth = MockCallbackAuth { - login_called: false, - login_result: Some(Ok(())), - oidc_result: Some(Err(format!( - "db error: {}", - crate::auth::EXTERNAL_AUTH_IDENTITY_CONFLICT_ERROR - ))), - oauth2_result: None, + oidc_result: Some(Ok(None)), + ..Default::default() }; - let db: DynDB = Arc::new(db); // Execute helper - let error_message = std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_error_message = error_message.clone(); + let (error_message, on_error) = capture_error(); let redirect = oidc_callback_with_auth( &mut callback_auth, - session, + session.clone(), &db, OidcProvider::LinuxFoundation, "test-code".to_string(), oauth2::CsrfToken::new("state-in-session".to_string()), - move |message| { - let mut guard = captured_error_message.lock().unwrap(); - *guard = Some(message); - }, + on_error, ) .await .unwrap(); - // Check callback result and side effects + // Check the user is sent back to log in without being logged in let response = redirect.into_response(); + let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); assert_eq!( *error_message.lock().unwrap(), - Some(LF_SSO_IDENTITY_CONFLICT_MESSAGE.to_string()), + Some("OpenID Connect authorization failed".to_string()), ); assert!(!callback_auth.login_called); + assert_eq!(auth_provider, None); + assert_eq!(selected_ids(&session).await, (None, None)); } #[tokio::test] async fn test_oidc_callback_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let auth_hash = "hash".to_string(); - let groups = sample_user_groups_by_community(community_id, group_id); - - // Setup in-memory session - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); - session - .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") - .await - .unwrap(); - session.insert(OIDC_NONCE_KEY, "nonce-in-session").await.unwrap(); - session - .insert(NEXT_URL_KEY, Some("/dashboard".to_string())) - .await - .unwrap(); + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); - // Setup database mock + // Setup in-memory session and mocks + let session = callback_session(Some("nonce-in-session"), Some("/dashboard")).await; let mut db = MockDB::new(); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |uid| uid == &ids.user_id) .returning(move |_| Ok(groups.clone())); - - // Setup callback auth mock + let db: DynDB = Arc::new(db); let mut callback_auth = MockCallbackAuth { - login_called: false, login_result: Some(Ok(())), - oidc_result: Some(Ok(Some(sample_auth_user(user_id, &auth_hash)))), - oauth2_result: None, + oidc_result: Some(Ok(Some(sample_auth_user(ids.user_id, "hash")))), + ..Default::default() }; - let db: DynDB = Arc::new(db); // Execute helper let redirect = oidc_callback_with_auth( @@ -1223,46 +834,49 @@ async fn test_oidc_callback_success() { .await .unwrap(); - // Check callback result and side effects + // Check callback result and side effects, including the session nonce hand-off let response = redirect.into_response(); let auth_provider: Option = session.get(AUTH_PROVIDER_KEY).await.unwrap(); - let selected_community_id: Option = session.get(SELECTED_COMMUNITY_ID_KEY).await.unwrap(); - let selected_group_id: Option = session.get(SELECTED_GROUP_ID_KEY).await.unwrap(); + let nonce: Option = session.get(OIDC_NONCE_KEY).await.unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), - &HeaderValue::from_static("/dashboard"), + response.headers().get(LOCATION), + Some(&HeaderValue::from_static("/dashboard")), ); + assert_eq!( + callback_auth.oidc_call, + Some(( + "test-code".to_string(), + "nonce-in-session".to_string(), + OidcProvider::LinuxFoundation, + )), + ); + assert_eq!(nonce, None); assert!(callback_auth.login_called); assert_eq!( auth_provider.as_deref(), Some(OidcProvider::LinuxFoundation.as_ref()) ); - assert_eq!(selected_community_id, Some(community_id)); - assert_eq!(selected_group_id, Some(group_id)); + assert_eq!( + selected_ids(&session).await, + (Some(ids.community_id), Some(ids.group_id)), + ); } #[tokio::test] async fn test_oidc_callback_returns_error_when_provider_is_not_configured() { - // Setup identifiers and data structures + // Setup database mock with a valid csrf state, nonce and next url let session_id = session::Id::default(); - let mut session_record = sample_empty_session_record(session_id); - session_record - .data - .insert(OAUTH2_CSRF_STATE_KEY.to_string(), json!("state-in-session")); - session_record - .data - .insert(OIDC_NONCE_KEY.to_string(), json!("nonce-in-session")); - session_record - .data - .insert(NEXT_URL_KEY.to_string(), json!("/dashboard")); - - // Setup database mock + let session_record = session_record_with_values( + session_id, + &[ + (OAUTH2_CSRF_STATE_KEY, "state-in-session"), + (OIDC_NONCE_KEY, "nonce-in-session"), + (NEXT_URL_KEY, "/dashboard"), + ], + ); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, session_record); db.expect_update_session() .times(1) .withf(move |record| { @@ -1273,43 +887,26 @@ async fn test_oidc_callback_returns_error_when_provider_is_not_configured() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); - - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.linuxfoundation = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_server_cfg(linuxfoundation_login_cfg()) .build() - .await; - - // Setup request - let request = Request::builder() - .method("GET") - .uri("/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-session") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + .await; + let uri = "/log-in/oidc/linuxfoundation/callback?code=test-code&state=state-in-session"; + let (parts, bytes) = send(router, get_request(uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fdashboard"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fdashboard")), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_oidc_redirect_success() { // Setup session and form input - let store = Arc::new(MemoryStore::default()); - let session = Session::new(None, store, None); + let session = Session::new(None, Arc::new(MemoryStore::default()), None); let query = Query(NextUrl { next_url: Some("/dashboard".to_string()), }); @@ -1357,71 +954,44 @@ async fn test_oidc_redirect_success() { let csrf_state: Option = session.get(OAUTH2_CSRF_STATE_KEY).await.unwrap(); let nonce: Option = session.get(OIDC_NONCE_KEY).await.unwrap(); let next_url: Option> = session.get(NEXT_URL_KEY).await.unwrap(); - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!(base_url, "https://issuer.example/authorize"); assert_eq!( csrf_state.as_deref(), - query - .split('&') - .find_map(|pair| pair.strip_prefix("state=").map(String::from)) - .as_deref(), - ); - assert_eq!( - nonce.as_deref(), - query - .split('&') - .find_map(|pair| pair.strip_prefix("nonce=").map(String::from)) - .as_deref(), + query_param(query, "state").as_deref() ); + assert_eq!(nonce.as_deref(), query_param(query, "nonce").as_deref()); assert_eq!(next_url, Some(Some("/dashboard".to_string()))); - assert!(bytes.is_empty()); } +// Sign up. + #[tokio::test] async fn test_sign_up_success() { // Setup identifiers and data structures let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - let user_for_db = sample_auth_user(Uuid::new_v4(), "hash"); - let site_settings = sample_site_settings(); - let activation_primary_color = site_settings.theme.primary_color.clone(); - let sign_up_primary_color = site_settings.theme.primary_color.clone(); + let user = sample_auth_user(Uuid::new_v4(), "hash"); // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_site_settings() - .times(1) - .returning(move || Ok(site_settings.clone())); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); + expect_site_settings(&mut db); db.expect_activate_pre_registered_user_email_password() .times(1) - .withf(move |summary, verification| { + .withf(move |summary, _| { summary.email == "test@example.test" && !matches!(summary.password.as_deref(), Some("secret-password")) - && verification.template_data["link"] - == format!("https://app.example/verify-email/{}", verification.code) - && verification.template_data["theme"]["primary_color"] == activation_primary_color }) .returning(|_, _| Ok(None)); db.expect_sign_up_user() .times(1) .withf(move |summary, verify, verification| { - !matches!(summary.password.as_deref(), Some("secret-password")) + summary.email == "test@example.test" + && !matches!(summary.password.as_deref(), Some("secret-password")) && !*verify - && verification.as_ref().is_some_and(|verification| { - verification.template_data["link"] - == format!("https://app.example/verify-email/{}", verification.code) - && verification.template_data["theme"]["primary_color"] - == sign_up_primary_color - }) + && verification.is_some() }) - .returning({ - let user = user_for_db; - move |_, _, verification| Ok((user.clone(), verification.map(|value| value.code))) - }); + .returning(move |_, _, verification| Ok((user.clone(), verification.map(|v| v.code)))); db.expect_update_session() .times(1) .withf(move |record| { @@ -1436,76 +1006,44 @@ async fn test_sign_up_success() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router - let server_cfg = HttpServerConfig { - base_url: "https://app.example".to_string(), - login: LoginOptions { - email: true, - ..Default::default() - }, - ..Default::default() - }; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request let form = "email=test%40example.test&name=Test+User&username=test-user&password=secret-password"; - let request = Request::builder() - .method("POST") - .uri("/sign-up?next_url=%2Fwelcome") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("POST", "/sign-up?next_url=%2Fwelcome", session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fwelcome"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/log-in?next_url=%2Fwelcome")), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_sign_up_activates_pre_registered_user() { // Setup identifiers and data structures let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - let user_for_db = sample_auth_user(Uuid::new_v4(), "hash"); - let site_settings = sample_site_settings(); - let activation_primary_color = site_settings.theme.primary_color.clone(); + let user = sample_auth_user(Uuid::new_v4(), "hash"); // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_site_settings() - .times(1) - .returning(move || Ok(site_settings.clone())); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); + expect_site_settings(&mut db); db.expect_activate_pre_registered_user_email_password() .times(1) - .withf(move |summary, verification| { + .withf(move |summary, _| { summary.email == "invited@example.test" && summary.name == "Invited User" && summary.username == "invited-user" && !matches!(summary.password.as_deref(), Some("secret-password")) - && verification.template_data["link"] - == format!("https://app.example/verify-email/{}", verification.code) - && verification.template_data["theme"]["primary_color"] == activation_primary_color }) - .returning({ - let user = user_for_db; - move |_, verification| Ok(Some((user.clone(), verification.code))) - }); + .returning(move |_, verification| Ok(Some((user.clone(), verification.code)))); db.expect_sign_up_user().times(0); db.expect_update_session() .times(1) @@ -1521,54 +1059,32 @@ async fn test_sign_up_activates_pre_registered_user() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router - let server_cfg = HttpServerConfig { - base_url: "https://app.example".to_string(), - login: LoginOptions { - email: true, - ..Default::default() - }, - ..Default::default() - }; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request let form = "email=invited%40example.test&name=Invited+User&username=invited-user&password=secret-password"; - let request = Request::builder() - .method("POST") - .uri("/sign-up?next_url=%2Fdashboard%2Fuser%3Ftab%3Dinvitations") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = "/sign-up?next_url=%2Fdashboard%2Fuser%3Ftab%3Dinvitations"; + let (parts, bytes) = send(router, form_request("POST", uri, session_id, form)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static("/log-in?next_url=%2Fdashboard%2Fuser%3Ftab%3Dinvitations"), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static( + "/log-in?next_url=%2Fdashboard%2Fuser%3Ftab%3Dinvitations" + )), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_sign_up_missing_password() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_activate_pre_registered_user_email_password().times(0); db.expect_sign_up_user().times(0); @@ -1576,26 +1092,14 @@ async fn test_sign_up_missing_password() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request without a password + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request (password not provided) let form = "email=test%40example.test&name=Test+User&username=test-user"; - let request = Request::builder() - .method("POST") - .uri("/sign-up") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, form_request("POST", "/sign-up", session_id, form)).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -1604,16 +1108,10 @@ async fn test_sign_up_missing_password() { #[tokio::test] async fn test_sign_up_rejects_empty_normalized_base_url() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_get_site_settings().times(0); db.expect_activate_pre_registered_user_email_password().times(0); db.expect_sign_up_user().times(0); @@ -1631,57 +1129,34 @@ async fn test_sign_up_rejects_empty_normalized_base_url() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router + // Setup router with a base url that normalizes to empty and send request let server_cfg = HttpServerConfig { base_url: "/".to_string(), - login: LoginOptions { - email: true, - ..Default::default() - }, - ..Default::default() + ..email_login_cfg() }; - let router = TestRouterBuilder::new(db, nm) + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) .with_server_cfg(server_cfg) .build() .await; - - // Setup request let form = "email=test%40example.test&name=Test+User&username=test-user&password=secretpw"; - let request = Request::builder() - .method("POST") - .uri("/sign-up") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, form_request("POST", "/sign-up", session_id, form)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(SIGN_UP_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(SIGN_UP_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_sign_up_db_error() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); + expect_site_settings(&mut db); db.expect_activate_pre_registered_user_email_password() .times(1) .returning(|_, _| Ok(None)); @@ -1702,54 +1177,29 @@ async fn test_sign_up_db_error() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router - let server_cfg = HttpServerConfig { - base_url: "https://app.example".to_string(), - login: LoginOptions { - email: true, - ..Default::default() - }, - ..Default::default() - }; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request let form = "email=test%40example.test&name=Test+User&username=test-user&password=secretpw"; - let request = Request::builder() - .method("POST") - .uri("/sign-up") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, form_request("POST", "/sign-up", session_id, form)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(SIGN_UP_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(SIGN_UP_URL)), ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_sign_up_validation_error() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); - // Setup database mock + let session_id = session::Id::default(); let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_activate_pre_registered_user_email_password().times(0); db.expect_sign_up_user().times(0); db.expect_update_session() @@ -1766,49 +1216,36 @@ async fn test_sign_up_validation_error() { let mut nm = MockNotificationsManager::new(); nm.expect_enqueue().times(0); - // Setup router - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + // Setup router and send request with an invalid email and short password + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .with_server_cfg(email_login_cfg()) .build() .await; - - // Setup request (invalid email - validation should fail) let form = "email=invalid-email&name=Test+User&username=test-user&password=secret"; - let request = Request::builder() - .method("POST") - .uri("/sign-up") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("POST", "/sign-up?next_url=%2Fwelcome", session_id, form); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); + // Check the sign up page is shown again keeping the next url + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(SIGN_UP_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static("/sign-up?next_url=%2Fwelcome")), ); - assert!(bytes.is_empty()); } +// Account maintenance. + #[tokio::test] async fn test_update_user_details_success() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_update_user_details() .times(1) .withf(move |uid, details| { - *uid == user_id + *uid == ids.user_id && details.name == "Updated User" && details.company.as_deref() == Some("Example") && details.github_url.as_deref() == Some("https://github.com/updated-user") @@ -1819,342 +1256,217 @@ async fn test_update_user_details_success() { .withf(move |record| message_matches(record, "User details updated successfully.")) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/details") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from( - "name=Updated+User&company=Example&github_url=https%3A%2F%2Fgithub.com%2Fupdated-user", - )) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let form = + "name=Updated+User&company=Example&github_url=https%3A%2F%2Fgithub.com%2Fupdated-user"; + let request = form_request("PUT", UPDATE_DETAILS_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert_eq!( - parts.headers.get("HX-Trigger").unwrap(), - &HeaderValue::from_static("refresh-body"), - ); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_user_details_returns_error_on_db_failure() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - // Setup database mock with a failing write and no flash message + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_update_user_details() .times(1) - .withf(move |uid, details| *uid == user_id && details.name == "Updated User") + .withf(move |uid, details| *uid == ids.user_id && details.name == "Updated User") .returning(|_, _| Err(anyhow!("db error"))); db.expect_update_session().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/details") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=Updated+User&company=Example")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let form = "name=Updated+User&company=Example"; + let request = form_request("PUT", UPDATE_DETAILS_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] async fn test_update_user_details_invalid_body() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_update_user_details().times(0); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/details") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::from("invalid")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request("PUT", UPDATE_DETAILS_URL, ids.session_id, "invalid"); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] async fn test_update_user_password_accepts_valid_change() { // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let existing_password_hash = password_auth::generate_hash("current-password"); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_password() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(move |_| Ok(Some(existing_password_hash.clone()))); db.expect_update_user_password() .times(1) - .withf(move |uid, new_password| *uid == user_id && new_password != "new-password") + .withf(move |uid, new_password| *uid == ids.user_id && new_password != "new-password") .returning(|_, _| Ok(())); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; + let router = TestRouterBuilder::new(db).build().await; let form = "old_password=current-password&new_password=new-password"; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/password") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("PUT", UPDATE_PASSWORD_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); } #[tokio::test] async fn test_update_user_password_rejects_wrong_old_password() { // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let existing_password_hash = password_auth::generate_hash("current-password"); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_password() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(move |_| Ok(Some(existing_password_hash.clone()))); db.expect_update_user_password().times(0); db.expect_delete_session().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; + let router = TestRouterBuilder::new(db).build().await; let form = "old_password=wrong-password&new_password=new-password"; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/password") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("PUT", UPDATE_PASSWORD_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_update_user_password_returns_bad_request_when_hash_is_missing() { - // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_password() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(|_| Ok(None)); db.expect_update_user_password().times(0); db.expect_delete_session().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; + let router = TestRouterBuilder::new(db).build().await; let form = "old_password=current-password&new_password=new-password"; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/password") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("PUT", UPDATE_PASSWORD_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::BAD_REQUEST); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_update_user_password_returns_error_on_db_failure() { // Setup identifiers and data structures - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let existing_password_hash = password_auth::generate_hash("current-password"); - // Setup database mock + // Setup database mock with a failing write and no session logout let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_password() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(move |_| Ok(Some(existing_password_hash.clone()))); db.expect_update_user_password() .times(1) - .withf(move |uid, new_password| *uid == user_id && new_password != "new-password") + .withf(move |uid, new_password| *uid == ids.user_id && new_password != "new-password") .returning(|_, _| Err(anyhow!("db error"))); db.expect_delete_session().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; + let router = TestRouterBuilder::new(db).build().await; let form = "old_password=current-password&new_password=new-password"; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/password") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request("PUT", UPDATE_PASSWORD_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] async fn test_update_user_password_succeeds_when_session_delete_fails() { // Setup the authenticated user and current password - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let existing_password_hash = password_auth::generate_hash("current-password"); // Setup a successful password update followed by a session deletion failure let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_password() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .returning(move |_| Ok(Some(existing_password_hash.clone()))); db.expect_update_user_password() .times(1) - .withf(move |uid, new_password| *uid == user_id && new_password != "new-password") + .withf(move |uid, new_password| *uid == ids.user_id && new_password != "new-password") .returning(|_, _| Ok(())); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(|_| Err(anyhow!("db error"))); db.expect_update_session() .times(1) - .withf(move |record| record.id == session_id && record.data.is_empty()) + .withf(move |record| record.id == ids.session_id && record.data.is_empty()) .returning(|_| Ok(())); // Send the password update request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/account/update/password") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from( - "old_password=current-password&new_password=new-password", - )) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let form = "old_password=current-password&new_password=new-password"; + let request = form_request("PUT", UPDATE_PASSWORD_URL, ids.session_id, form); + let (parts, bytes) = send(router, request).await; // Check the committed password change still reports success - assert_eq!(parts.status, StatusCode::SEE_OTHER); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); } #[tokio::test] async fn test_verify_email_success() { - // Setup identifiers and data structures + // Setup identifiers and database mock let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); let verification_code = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_verify_email() .times(1) .withf(move |code| *code == verification_code) @@ -2169,49 +1481,29 @@ async fn test_verify_email_success() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) .build() .await; - let request = Request::builder() - .method("GET") - .uri(format!("/verify-email/{verification_code}")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = format!("/verify-email/{verification_code}"); + let (parts, bytes) = send(router, get_request(&uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); } #[tokio::test] async fn test_verify_email_failure() { - // Setup identifiers and data structures + // Setup identifiers and database mock let session_id = session::Id::default(); - let session_record = sample_empty_session_record(session_id); let verification_code = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - db.expect_get_session() - .times(1) - .withf(move |id| *id == session_id) - .returning(move |_| Ok(Some(session_record.clone()))); + expect_stored_session(&mut db, sample_empty_session_record(session_id)); db.expect_verify_email() .times(1) .withf(move |code| *code == verification_code) @@ -2226,89 +1518,128 @@ async fn test_verify_email_failure() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let mut server_cfg = HttpServerConfig::default(); - server_cfg.login.email = true; - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(server_cfg) + let router = TestRouterBuilder::new(db) + .with_server_cfg(email_login_cfg()) .build() .await; - let request = Request::builder() - .method("GET") - .uri(format!("/verify-email/{verification_code}")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let uri = format!("/verify-email/{verification_code}"); + let (parts, bytes) = send(router, get_request(&uri, session_id)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::SEE_OTHER); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::SEE_OTHER); assert_eq!( - parts.headers.get(LOCATION).unwrap(), - &HeaderValue::from_static(LOG_IN_URL), + parts.headers.get(LOCATION), + Some(&HeaderValue::from_static(LOG_IN_URL)), ); } -#[test] -fn test_get_log_in_url_without_next() { - let url = get_log_in_url(None); - assert_eq!(url, LOG_IN_URL); -} +// Pure helpers. #[test] -fn test_get_log_in_url_with_next() { - let url = get_log_in_url(Some("/dashboard")); - assert_eq!(url, "/log-in?next_url=%2Fdashboard"); +fn test_get_log_in_url_appends_sanitized_next_url() { + // Setup next url inputs and the log in url each one expects + let cases = [ + ("no next url", None, LOG_IN_URL), + ( + "internal next url", + Some("/dashboard"), + "/log-in?next_url=%2Fdashboard", + ), + ( + "external next url", + Some("https://evil.example"), + LOG_IN_URL, + ), + ]; + + for (case, next_url, expected) in cases { + assert_eq!(get_log_in_url(next_url), expected, "{case}"); + } } #[test] -fn test_sanitize_next_url_accepts_internal_paths() { - assert_eq!( - sanitize_next_url(Some("/dashboard")), - Some("/dashboard".to_string()) - ); - assert_eq!( - sanitize_next_url(Some("/groups?page=2#section")), - Some("/groups?page=2#section".to_string()) - ); - assert_eq!( - sanitize_next_url(Some(" /profile ")), - Some("/profile".to_string()) - ); +fn test_oidc_authorization_error_message_maps_known_conflicts() { + // Setup authorization errors and the flash message each one expects + let email_conflict = format!("db error: {EXTERNAL_AUTH_EMAIL_CONFLICT_ERROR}"); + let identity_conflict = format!("db error: {EXTERNAL_AUTH_IDENTITY_CONFLICT_ERROR}"); + let cases = [ + ( + "email conflict", + email_conflict.as_str(), + LF_SSO_EMAIL_CONFLICT_MESSAGE, + ), + ( + "identity conflict", + identity_conflict.as_str(), + LF_SSO_IDENTITY_CONFLICT_MESSAGE, + ), + ( + "other error", + "oidc auth error", + "OpenID Connect authorization failed: oidc auth error", + ), + ]; + + for (case, err, expected) in cases { + assert_eq!(oidc_authorization_error_message(err), expected, "{case}"); + } } #[test] -fn test_sanitize_next_url_rejects_external_paths() { - assert_eq!(sanitize_next_url(Some("")), None); - assert_eq!(sanitize_next_url(Some("https://evil.example")), None); - assert_eq!(sanitize_next_url(Some("//evil.example")), None); - assert_eq!(sanitize_next_url(Some("javascript:alert(1)")), None); - assert_eq!(sanitize_next_url(Some("relative/path")), None); +fn test_sanitize_next_url() { + // Setup next url inputs and the sanitized value each one expects + let cases = [ + ("internal path", "/dashboard", Some("/dashboard")), + ( + "internal path with query and fragment", + "/groups?page=2#section", + Some("/groups?page=2#section"), + ), + ("padded internal path", " /profile ", Some("/profile")), + ("empty", "", None), + ("absolute url", "https://evil.example", None), + ("protocol-relative url", "//evil.example", None), + ("javascript url", "javascript:alert(1)", None), + ("relative path", "relative/path", None), + ]; + + for (case, next_url, expected) in cases { + assert_eq!( + sanitize_next_url(Some(next_url)).as_deref(), + expected, + "{case}" + ); + } } // Helpers. +/// Account details update route. +const UPDATE_DETAILS_URL: &str = "/dashboard/account/update/details"; + +/// Account password update route. +const UPDATE_PASSWORD_URL: &str = "/dashboard/account/update/password"; + +/// Callback authentication double that records the arguments it receives. +#[derive(Default)] struct MockCallbackAuth { login_called: bool, login_result: Option>, - oidc_result: Option, String>>, + oauth2_call: Option<(String, OAuth2Provider)>, oauth2_result: Option, String>>, + oidc_call: Option<(String, String, OidcProvider)>, + oidc_result: Option, String>>, } #[async_trait] impl CallbackAuth for MockCallbackAuth { async fn authenticate_oauth2( &mut self, - _code: String, - _provider: OAuth2Provider, + code: String, + provider: OAuth2Provider, ) -> Result, String> { + self.oauth2_call = Some((code, provider)); self.oauth2_result .take() .expect("oauth2 callback auth result should be configured in tests") @@ -2316,10 +1647,11 @@ impl CallbackAuth for MockCallbackAuth { async fn authenticate_oidc( &mut self, - _code: String, - _nonce: oidc::Nonce, - _provider: OidcProvider, + code: String, + nonce: oidc::Nonce, + provider: OidcProvider, ) -> Result, String> { + self.oidc_call = Some((code, nonce.secret().clone(), provider)); self.oidc_result .take() .expect("oidc callback auth result should be configured in tests") @@ -2333,6 +1665,80 @@ impl CallbackAuth for MockCallbackAuth { } } +/// Session record without a logged in user holding the given string values. +fn session_record_with_values(session_id: session::Id, values: &[(&str, &str)]) -> session::Record { + let mut session_record = sample_empty_session_record(session_id); + for (key, value) in values { + session_record.data.insert((*key).to_string(), json!(value)); + } + session_record +} + +/// Builds an in-memory callback session holding the expected csrf state. +async fn callback_session(nonce: Option<&str>, next_url: Option<&str>) -> Session { + let session = Session::new(None, Arc::new(MemoryStore::default()), None); + session + .insert(OAUTH2_CSRF_STATE_KEY, "state-in-session") + .await + .unwrap(); + if let Some(nonce) = nonce { + session.insert(OIDC_NONCE_KEY, nonce).await.unwrap(); + } + if let Some(next_url) = next_url { + session + .insert(NEXT_URL_KEY, Some(next_url.to_string())) + .await + .unwrap(); + } + session +} + +/// Returns a slot and an error callback that stores the reported message in it. +fn capture_error() -> (Arc>>, impl FnOnce(String)) { + let error_message = Arc::new(Mutex::new(None)); + let captured_error_message = error_message.clone(); + let on_error = move |message: String| { + *captured_error_message.lock().unwrap() = Some(message); + }; + (error_message, on_error) +} + +/// Server configuration with email log in enabled. +fn email_login_cfg() -> HttpServerConfig { + HttpServerConfig { + base_url: "https://app.example".to_string(), + login: LoginOptions { + email: true, + ..Default::default() + }, + ..Default::default() + } +} + +/// Server configuration with GitHub log in enabled. +fn github_login_cfg() -> HttpServerConfig { + let mut server_cfg = HttpServerConfig::default(); + server_cfg.login.github = true; + server_cfg +} + +/// Server configuration with Linux Foundation log in enabled. +fn linuxfoundation_login_cfg() -> HttpServerConfig { + let mut server_cfg = HttpServerConfig::default(); + server_cfg.login.linuxfoundation = true; + server_cfg +} + +/// Returns the value of the query string parameter. +fn query_param(query: &str, name: &str) -> Option { + query.split('&').find_map(|pair| { + pair.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('=')) + .map(String::from) + }) +} + +/// Returns whether the stored session tracks the auth provider. fn session_record_contains_auth_provider(record: &session::Record, provider: &str) -> bool { record.data.get(AUTH_PROVIDER_KEY).and_then(|value| value.as_str()) == Some(provider) } diff --git a/ocg-server/src/handlers/badges/tests.rs b/ocg-server/src/handlers/badges/tests.rs index adb6d8185..876d872c6 100644 --- a/ocg-server/src/handlers/badges/tests.rs +++ b/ocg-server/src/handlers/badges/tests.rs @@ -1,28 +1,24 @@ -use std::io::Cursor; - use axum::{ - body::{Body, to_bytes}, + body::Body, http::{ HeaderMap, HeaderValue, Request, StatusCode, header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE}, }, }; use chrono::{TimeZone, Utc}; -use image::{DynamicImage, ImageFormat}; use serde_json::{Value, json}; -use ssi_jwk::JWK; -use tower::ServiceExt; use uuid::Uuid; use crate::{ - config::{BadgeSigningKeyConfig, BadgesConfig, HttpServerConfig}, + config::HttpServerConfig, db::mock::MockDB, - handlers::tests::{TestRouterBuilder, sample_site_settings}, - router::CACHE_CONTROL_PUBLIC_SHARED, - services::{ - badges::{CredentialInput, EmailIdentity, SsiBadgesManager, png}, - notifications::MockNotificationsManager, + handlers::tests::{ + TestRouterBuilder, assert_empty_response, assert_html_response, request, sample_badges_cfg, + sample_server_cfg, sample_site_settings, send, }, + router::{CACHE_CONTROL_PRIVATE_NO_STORE, CACHE_CONTROL_PUBLIC_SHARED}, + services::badges::{CredentialInput, EmailIdentity, SsiBadgesManager, png}, + test_util::sample_png, types::badges::{ BadgeSnapshot, BadgeSnapshotIssuer, BadgeStatusList, PublicBadgeSnapshot, PublicBadgeSnapshotIssuer, PublicUserBadge, UserBadge, @@ -33,30 +29,28 @@ use super::*; #[test] fn test_accepts_credential_requires_the_supported_media_type() { - // Build supported, ordinary, parameterized, and explicitly rejected headers - let mut credential_headers = HeaderMap::new(); - credential_headers.insert( - ACCEPT, - HeaderValue::from_static("text/html, application/vc+ld+json"), - ); - let mut html_headers = HeaderMap::new(); - html_headers.insert(ACCEPT, HeaderValue::from_static("text/html")); - let mut parameterized_headers = HeaderMap::new(); - parameterized_headers.insert( - ACCEPT, - HeaderValue::from_static("application/vc+ld+json; charset=utf-8"), - ); - let mut rejected_headers = HeaderMap::new(); - rejected_headers.insert( - ACCEPT, - HeaderValue::from_static("application/vc+ld+json; q=0, application/vc+ld+json; q=invalid"), - ); + // Setup labeled Accept headers with the expected representation choice + let cases = [ + ("supported", "text/html, application/vc+ld+json", true), + ("ordinary html", "text/html", false), + ( + "parameterized", + "application/vc+ld+json; charset=utf-8", + true, + ), + ( + "explicitly rejected", + "application/vc+ld+json; q=0, application/vc+ld+json; q=invalid", + false, + ), + ]; // Check representation selection remains explicit - assert!(accepts_credential(&credential_headers)); - assert!(!accepts_credential(&html_headers)); - assert!(accepts_credential(¶meterized_headers)); - assert!(!accepts_credential(&rejected_headers)); + for (case, accept, expected) in cases { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static(accept)); + assert_eq!(accepts_credential(&headers), expected, "{case}"); + } } #[tokio::test] @@ -65,36 +59,24 @@ async fn test_credential_html_response_uses_public_snapshot_and_cache_contract() let award = sample_user_badge(); let user_badge_id = award.user_badge_id; let mut db = MockDB::new(); - db.expect_get_public_user_badge() - .times(1) - .withf(move |id| *id == user_badge_id) - .return_once(move |_| Ok(Some(award))); - db.expect_get_site_settings() - .times(1) - .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + expect_award_page_reads(&mut db, award); + let router = TestRouterBuilder::new(db).build().await; // Request the browser representation - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/credentials/{user_badge_id}")) - .header(ACCEPT, "text/html") - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/credentials/{user_badge_id}")) + .header(ACCEPT, "text/html") + .body(Body::empty()) .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check the page and representation-sensitive cache headers - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!(parts.headers.get("vary").unwrap(), CREDENTIAL_CACHE_VARY); - assert!(parts.headers.get("cache-control").is_some()); - assert!(String::from_utf8_lossy(&body).contains("Test Badge")); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + CACHE_CONTROL_PUBLIC_SHARED + ); + assert!(String::from_utf8_lossy(&bytes).contains("Test Badge")); } #[tokio::test] @@ -107,24 +89,18 @@ async fn test_credential_json_ld_response_is_signed_and_shared_cached() { .times(1) .withf(move |id| *id == user_badge_id) .return_once(move |_| Ok(Some(award))); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_server_cfg(badges_server_config()) .build() .await; // Request the signed credential representation explicitly - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/credentials/{user_badge_id}")) - .header(ACCEPT, "application/vc+ld+json") - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/credentials/{user_badge_id}")) + .header(ACCEPT, "application/vc+ld+json") + .body(Body::empty()) .unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Check the signed JSON-LD and bounded shared-cache boundary assert_eq!(parts.status, StatusCode::OK); @@ -157,64 +133,51 @@ async fn test_credential_unknown_award_returns_not_found() { .times(1) .withf(move |id| *id == user_badge_id) .return_once(|_| Ok(None)); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request the missing credential - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/credentials/{user_badge_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/credentials/{user_badge_id}")) + .body(Body::empty()) .unwrap(); + let (parts, bytes) = send(router, request).await; // Check unknown opaque identifiers stay private - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + CACHE_CONTROL_PRIVATE_NO_STORE + ); } #[tokio::test] async fn test_issuer_key_returns_immutable_multikey_document() { // Setup a public router with one active signing method let group_id = Uuid::new_v4(); - let router = TestRouterBuilder::new(MockDB::new(), MockNotificationsManager::new()) + let router = TestRouterBuilder::new(MockDB::new()) .with_server_cfg(badges_server_config()) .build() .await; // Resolve the published key from the issuer profile - let response = router - .clone() - .oneshot( - Request::builder() - .uri(format!("/badges/issuers/{group_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await + let profile_request = request("GET", &format!("/badges/issuers/{group_id}")) + .body(Body::empty()) .unwrap(); - let profile: Value = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); + let (_, profile_bytes) = send(router.clone(), profile_request).await; + let profile: Value = serde_json::from_slice(&profile_bytes).unwrap(); let key_multibase = profile["verificationMethod"][0]["publicKeyMultibase"] .as_str() .unwrap() .to_string(); // Request the dereferenceable standalone key document - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/issuers/{group_id}/keys/{key_multibase}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let key_request = request( + "GET", + &format!("/badges/issuers/{group_id}/keys/{key_multibase}"), + ) + .body(Body::empty()) + .unwrap(); + let (parts, bytes) = send(router, key_request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Check the immutable content-addressed Multikey document assert_eq!(parts.status, StatusCode::OK); @@ -234,47 +197,39 @@ async fn test_issuer_key_returns_immutable_multikey_document() { async fn test_issuer_key_unknown_multibase_returns_not_found() { // Setup a public router with one active signing method let group_id = Uuid::new_v4(); - let router = TestRouterBuilder::new(MockDB::new(), MockNotificationsManager::new()) + let router = TestRouterBuilder::new(MockDB::new()) .with_server_cfg(badges_server_config()) .build() .await; // Request a key document outside the retained allowlist - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/issuers/{group_id}/keys/z6MkUnknown")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let request = request( + "GET", + &format!("/badges/issuers/{group_id}/keys/z6MkUnknown"), + ) + .body(Body::empty()) + .unwrap(); + let (parts, bytes) = send(router, request).await; // Check unknown keys are not published - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_issuer_returns_stable_public_profile() { // Setup a public router with one active signing method let group_id = Uuid::new_v4(); - let router = TestRouterBuilder::new(MockDB::new(), MockNotificationsManager::new()) + let router = TestRouterBuilder::new(MockDB::new()) .with_server_cfg(badges_server_config()) .build() .await; // Request the issuer profile - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/issuers/{group_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/issuers/{group_id}")) + .body(Body::empty()) .unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Check stable issuer identity and public caching assert_eq!(parts.status, StatusCode::OK); @@ -301,7 +256,10 @@ async fn test_issuer_returns_stable_public_profile() { ) ); assert!(method["publicKeyMultibase"].as_str().unwrap().starts_with("z6Mk")); - assert!(parts.headers.get("cache-control").is_some()); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + CACHE_CONTROL_PUBLIC_SHARED + ); } #[tokio::test] @@ -314,23 +272,17 @@ async fn test_status_list_returns_signed_current_state() { .times(1) .withf(move |id| *id == status_list_id) .return_once(move |_| Ok(Some(status))); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_server_cfg(badges_server_config()) .build() .await; // Request the signed status-list credential - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/status-lists/{status_list_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/status-lists/{status_list_id}")) + .body(Body::empty()) .unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Check the signed status response and bounded public cache policy assert_eq!(parts.status, StatusCode::OK); @@ -357,23 +309,16 @@ async fn test_status_list_unknown_id_returns_not_found() { .times(1) .withf(move |id| *id == status_list_id) .return_once(|_| Ok(None)); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request the missing status list - let response = router - .oneshot( - Request::builder() - .uri(format!("/badges/status-lists/{status_list_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", &format!("/badges/status-lists/{status_list_id}")) + .body(Body::empty()) .unwrap(); + let (parts, bytes) = send(router, request).await; // Check missing lists are not synthesized - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] @@ -397,50 +342,39 @@ async fn test_user_profile_badges_returns_public_projection() { .times(1) .withf(move |limit, offset, username| *limit == 50 && *offset == 0 && username == "ada") .return_once(move |_, _, _| Ok(output)); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request the public profile projection - let response = router - .oneshot( - Request::builder() - .uri("/users/ada/badges") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body: Vec = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let request = request("GET", "/users/ada/badges").body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Vec = serde_json::from_slice(&bytes).unwrap(); // Check only the bounded public DTO crosses the shared-cache boundary assert_eq!(parts.status, StatusCode::OK); - assert!(parts.headers.get(CACHE_CONTROL).is_some()); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + CACHE_CONTROL_PUBLIC_SHARED + ); assert_eq!(body, expected); } #[tokio::test] async fn test_user_profile_badges_rejects_page_above_public_cap() { // Build a public router whose database must not be queried for invalid pagination - let router = TestRouterBuilder::new(MockDB::new(), MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(MockDB::new()).build().await; // Request a page larger than the fixed public response cap - let response = router - .oneshot( - Request::builder() - .uri("/users/ada/badges?limit=51") - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("GET", "/users/ada/badges?limit=51") + .body(Body::empty()) .unwrap(); + let (parts, bytes) = send(router, request).await; // Check validation rejects the request before any public-profile reads - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "badge pagination is outside the supported range" + ); } #[tokio::test] @@ -450,19 +384,18 @@ async fn test_verify_page_is_never_cached() { db.expect_get_site_settings() .times(1) .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request the local verification form - let response = router - .oneshot(Request::builder().uri("/badges/verify").body(Body::empty()).unwrap()) - .await - .unwrap(); + let request = request("GET", "/badges/verify").body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; // Check sensitive verification output cannot be cached - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert_eq!( + parts.headers.get(CACHE_CONTROL).unwrap(), + CACHE_CONTROL_NO_STORE + ); } #[tokio::test] @@ -473,61 +406,24 @@ async fn test_verify_post_flags_stale_email_bound_award_as_superseded() { award.identity_hash = Some("1f5c1b4c4459c1197a51122a1e86154bbd1eec1c4bb9e34c7c9e4c4e5f3b2a10".to_string()); award.identity_salt = Some("fedcba9876543210fedcba9876543210".to_string()); - let user_badge_id = award.user_badge_id; let server_cfg = badges_server_config(); - let badges_manager = - SsiBadgesManager::new(&server_cfg.base_url, server_cfg.badges.as_ref().unwrap()); - let credential = badges_manager - .issue_credential(CredentialInput { - award: &award, - created_at: Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(), - email_identity: Some(EmailIdentity { - hash: "fa4e696fed1caae3ce9bda21a14b5d7960f8ef36bf1566164dafb09f4c8ac324" - .to_string(), - salt: "0123456789abcdef0123456789abcdef".to_string(), - }), - }) - .await - .unwrap(); - let credential = serde_json::to_vec(&credential).unwrap(); - let png = png::bake(&sample_png(), &credential).unwrap(); + let email_identity = EmailIdentity { + hash: "fa4e696fed1caae3ce9bda21a14b5d7960f8ef36bf1566164dafb09f4c8ac324".to_string(), + salt: "0123456789abcdef0123456789abcdef".to_string(), + }; + let png = baked_png(&server_cfg, &award, Some(email_identity)).await; // Setup the rebound durable award and verification page settings let mut db = MockDB::new(); - db.expect_get_public_user_badge() - .times(1) - .withf(move |id| *id == user_badge_id) - .returning(move |_| Ok(Some(award.clone()))); - db.expect_get_site_settings() - .times(1) - .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_server_cfg(server_cfg) - .build() - .await; - let boundary = "BADGE-PNG-VERIFY-BOUNDARY"; + expect_award_page_reads(&mut db, award); + let router = TestRouterBuilder::new(db).with_server_cfg(server_cfg).build().await; // Submit the stale exported PNG as multipart form data - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/badges/verify") - .header( - CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(png_multipart(boundary, &png))) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, png_verify_request(&png)).await; // Check the stale export still verifies but is flagged as superseded - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8_lossy(&body); + assert_html_response(&parts, &bytes, StatusCode::OK); + let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Authentic but superseded by a newer export")); assert!(!body.contains("Valid and active")); } @@ -538,44 +434,22 @@ async fn test_verify_post_returns_current_local_award() { let award = sample_user_badge(); let user_badge_id = award.user_badge_id; let mut db = MockDB::new(); - db.expect_get_public_user_badge() - .times(1) - .withf(move |id| *id == user_badge_id) - .returning(move |_| Ok(Some(award.clone()))); - db.expect_get_site_settings() - .times(1) - .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + expect_award_page_reads(&mut db, award); + let router = TestRouterBuilder::new(db) .with_server_cfg(badges_server_config()) .build() .await; - let boundary = "BADGE-VERIFY-BOUNDARY"; // Submit the opaque local award identifier as multipart form data - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/badges/verify") - .header( - CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(credential_multipart(boundary, user_badge_id))) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, credential_verify_request(user_badge_id)).await; // Check verification renders current public identity without caching - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( parts.headers.get(CACHE_CONTROL).unwrap(), CACHE_CONTROL_NO_STORE ); - let body = String::from_utf8_lossy(&body); + let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Valid and active")); assert!(body.contains("Ada")); assert!(body.contains("src=\"/images/badges/test-badge.png\"")); @@ -588,61 +462,24 @@ async fn test_verify_post_returns_current_local_award() { async fn test_verify_post_returns_uploaded_award() { // Issue and bake one portable credential using the router's signing key let award = sample_user_badge(); - let user_badge_id = award.user_badge_id; let server_cfg = badges_server_config(); - let badges_manager = - SsiBadgesManager::new(&server_cfg.base_url, server_cfg.badges.as_ref().unwrap()); - let credential = badges_manager - .issue_credential(CredentialInput { - award: &award, - created_at: Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(), - email_identity: None, - }) - .await - .unwrap(); - let credential = serde_json::to_vec(&credential).unwrap(); - let png = png::bake(&sample_png(), &credential).unwrap(); + let png = baked_png(&server_cfg, &award, None).await; // Setup the matching durable award and verification page settings let mut db = MockDB::new(); - db.expect_get_public_user_badge() - .times(1) - .withf(move |id| *id == user_badge_id) - .returning(move |_| Ok(Some(award.clone()))); - db.expect_get_site_settings() - .times(1) - .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_server_cfg(server_cfg) - .build() - .await; - let boundary = "BADGE-PNG-VERIFY-BOUNDARY"; + expect_award_page_reads(&mut db, award); + let router = TestRouterBuilder::new(db).with_server_cfg(server_cfg).build().await; // Submit the exported PNG as multipart form data - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/badges/verify") - .header( - CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(png_multipart(boundary, &png))) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, png_verify_request(&png)).await; // Check portable verification renders the matched local artwork and award date - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( parts.headers.get(CACHE_CONTROL).unwrap(), CACHE_CONTROL_NO_STORE ); - let body = String::from_utf8_lossy(&body); + let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Valid and active")); assert!(body.contains("src=\"/images/badges/test-badge.png\"")); assert!(body.contains("datetime=\"2024-01-02T03:04:05+00:00\"")); @@ -658,60 +495,24 @@ async fn test_verify_post_returns_uploaded_email_bound_award_without_rendering_i award.identity_bound_at = Some(Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap()); award.identity_hash = Some(identity_hash.to_string()); award.identity_salt = Some(identity_salt.to_string()); - let user_badge_id = award.user_badge_id; let server_cfg = badges_server_config(); - let badges_manager = - SsiBadgesManager::new(&server_cfg.base_url, server_cfg.badges.as_ref().unwrap()); - let credential = badges_manager - .issue_credential(CredentialInput { - award: &award, - created_at: Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(), - email_identity: Some(EmailIdentity { - hash: identity_hash.to_string(), - salt: identity_salt.to_string(), - }), - }) - .await - .unwrap(); - let credential = serde_json::to_vec(&credential).unwrap(); - let png = png::bake(&sample_png(), &credential).unwrap(); + let email_identity = EmailIdentity { + hash: identity_hash.to_string(), + salt: identity_salt.to_string(), + }; + let png = baked_png(&server_cfg, &award, Some(email_identity)).await; // Setup the matching durable award and verification page settings let mut db = MockDB::new(); - db.expect_get_public_user_badge() - .times(1) - .withf(move |id| *id == user_badge_id) - .returning(move |_| Ok(Some(award.clone()))); - db.expect_get_site_settings() - .times(1) - .return_once(|| Ok(sample_site_settings())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_server_cfg(server_cfg) - .build() - .await; - let boundary = "BADGE-PNG-VERIFY-BOUNDARY"; + expect_award_page_reads(&mut db, award); + let router = TestRouterBuilder::new(db).with_server_cfg(server_cfg).build().await; // Submit the exported email-bound PNG as multipart form data - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/badges/verify") - .header( - CONTENT_TYPE, - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(png_multipart(boundary, &png))) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, png_verify_request(&png)).await; // Check the current email-bound export verifies without rendering its identity - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8_lossy(&body); + assert_html_response(&parts, &bytes, StatusCode::OK); + let body = String::from_utf8_lossy(&bytes); assert!(body.contains("Valid and active")); assert!(!body.contains("superseded")); assert!(!body.contains(identity_hash)); @@ -721,37 +522,71 @@ async fn test_verify_post_returns_uploaded_email_bound_award_without_rendering_i // Helpers. +/// Multipart boundary of the verification requests. +const VERIFY_BOUNDARY: &str = "BADGE-VERIFY-BOUNDARY"; + /// Build a server configuration with one active deterministic key identifier. fn badges_server_config() -> HttpServerConfig { HttpServerConfig { - badges: Some(BadgesConfig { - signing_key: BadgeSigningKeyConfig { - key_id: "test-key".to_string(), - private_jwk: JWK::generate_ed25519().unwrap(), - }, - verification_keys: vec![], - }), - base_url: "https://badges.example.test".to_string(), - ..HttpServerConfig::default() + badges: Some(sample_badges_cfg()), + ..sample_server_cfg("https://badges.example.test") } } -/// Encode one credential-reference multipart body. -fn credential_multipart(boundary: &str, user_badge_id: Uuid) -> String { - format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"credential\"\r\n\r\n{user_badge_id}\r\n--{boundary}--\r\n" +/// Issues the award's export credential and bakes it into badge artwork. +async fn baked_png( + server_cfg: &HttpServerConfig, + award: &UserBadge, + email_identity: Option, +) -> Vec { + let badges_manager = + SsiBadgesManager::new(&server_cfg.base_url, server_cfg.badges.as_ref().unwrap()); + let credential = badges_manager + .issue_credential(CredentialInput { + award, + created_at: Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(), + email_identity, + }) + .await + .unwrap(); + let credential = serde_json::to_vec(&credential).unwrap(); + + png::bake(&sample_png(512, 512), &credential).unwrap() +} + +/// Builds a verification request submitting an award identifier. +fn credential_verify_request(user_badge_id: Uuid) -> Request { + verify_request( + format!( + "--{VERIFY_BOUNDARY}\r\nContent-Disposition: form-data; name=\"credential\"\r\n\r\n{user_badge_id}\r\n--{VERIFY_BOUNDARY}--\r\n" + ) + .into_bytes(), ) } -/// Encodes one PNG-upload multipart body. -fn png_multipart(boundary: &str, png: &[u8]) -> Vec { +/// Expects the award read and site settings read of the public award pages, +/// with the award lookup returning `award`. +fn expect_award_page_reads(db: &mut MockDB, award: UserBadge) { + let user_badge_id = award.user_badge_id; + db.expect_get_public_user_badge() + .times(1) + .withf(move |id| *id == user_badge_id) + .return_once(move |_| Ok(Some(award))); + db.expect_get_site_settings() + .times(1) + .return_once(|| Ok(sample_site_settings())); +} + +/// Builds a verification request uploading baked badge artwork. +fn png_verify_request(png: &[u8]) -> Request { let mut body = format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"png\"; filename=\"badge.png\"\r\nContent-Type: image/png\r\n\r\n" + "--{VERIFY_BOUNDARY}\r\nContent-Disposition: form-data; name=\"png\"; filename=\"badge.png\"\r\nContent-Type: image/png\r\n\r\n" ) .into_bytes(); body.extend_from_slice(png); - body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); - body + body.extend_from_slice(format!("\r\n--{VERIFY_BOUNDARY}--\r\n").as_bytes()); + + verify_request(body) } /// Build one deterministic current status-list row. @@ -763,15 +598,6 @@ fn sample_badge_status_list() -> BadgeStatusList { } } -/// Encodes valid 512×512 PNG artwork. -fn sample_png() -> Vec { - let mut output = Cursor::new(Vec::new()); - DynamicImage::new_rgba8(512, 512) - .write_to(&mut output, ImageFormat::Png) - .unwrap(); - output.into_inner() -} - /// Build one active public badge fixture. fn sample_user_badge() -> UserBadge { let group_id = Uuid::new_v4(); @@ -810,3 +636,14 @@ fn sample_user_badge() -> UserBadge { user_id: Some(Uuid::new_v4()), } } + +/// Builds a multipart verification request with the encoded body. +fn verify_request(body: Vec) -> Request { + request("POST", "/badges/verify") + .header( + CONTENT_TYPE, + format!("multipart/form-data; boundary={VERIFY_BOUNDARY}"), + ) + .body(Body::from(body)) + .unwrap() +} diff --git a/ocg-server/src/handlers/community/tests.rs b/ocg-server/src/handlers/community/tests.rs index daf68ef40..d4769b29e 100644 --- a/ocg-server/src/handlers/community/tests.rs +++ b/ocg-server/src/handlers/community/tests.rs @@ -1,17 +1,14 @@ -use axum::body::{Body, to_bytes}; -use axum::http::{ - HeaderValue, Request, StatusCode, - header::{CACHE_CONTROL, CONTENT_TYPE}, +use axum::{ + body::Body, + http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, }; -use tower::ServiceExt; use uuid::Uuid; use crate::{ activity_tracker::{Activity, MockActivityTracker}, - db::mock::MockDB, + db::mock::{MockDB, expect_site_settings}, handlers::tests::*, router::CACHE_CONTROL_PUBLIC_SHARED, - services::notifications::MockNotificationsManager, types::{community::CommunityStats, event::EventKind}, }; @@ -27,10 +24,7 @@ async fn test_page_success() { // Setup database mock let mut db = MockDB::new(); - db.expect_get_community_id_by_name() - .times(1) - .withf(|name| name == "test-community") - .returning(move |_| Ok(Some(community_id))); + expect_test_community(&mut db, community_id); db.expect_get_community_full() .times(1) .withf(move |id| *id == community_id) @@ -55,61 +49,41 @@ async fn test_page_success() { .times(1) .withf(move |id| *id == community_id) .returning(move |_| Ok(CommunityStats::default())); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_site_settings(&mut db); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) - .with_server_cfg(sample_tracking_server_cfg()) + let router = TestRouterBuilder::new(db) + .with_server_cfg(sample_server_cfg("https://example.test")) .build() .await; - let request = Request::builder() - .method("GET") - .uri("/test-community") - .body(axum::body::Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = request("GET", "/test-community").body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8") - ); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( parts.headers.get(CACHE_CONTROL).unwrap(), &HeaderValue::from_static(CACHE_CONTROL_PUBLIC_SHARED) ); let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(body.contains("Test Community community")); - assert!(body.contains(r#""#) - ); - assert!(body.contains(r#""#)); - assert!(body.contains(r#""#)); - assert!( - body.contains(r#""#) - ); - assert!(body.contains( - r#""# - )); - assert!(body.contains( - r#""# - )); - assert!(body.contains(r#""#)); - assert!(body.contains( - r#""# - )); - assert!(body.contains( - r#""# - )); + let description = "Open Community Groups, where Open Source communities thrive."; + let url = "https://example.test/test-community"; + let image = "https://example.test/images/og/community-og.png"; + for tag in [ + "Test Community community".to_string(), + r#""#), + format!(r#""#), + r#""#.to_string(), + format!(r#""#), + format!(r#""#), + format!(r#""#), + r#""#.to_string(), + format!(r#""#), + format!(r#""#), + ] { + assert!(body.contains(&tag), "missing {tag}"); + } } #[tokio::test] @@ -120,30 +94,15 @@ async fn test_page_community_not_found() { .times(1) .withf(|name| name == "missing-community") .returning(|_| Ok(None)); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_site_settings(&mut db); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/missing-community") - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = request("GET", "/missing-community").body(Body::empty()).unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8") - ); + assert_html_response(&parts, &bytes, StatusCode::NOT_FOUND); assert_eq!( parts.headers.get(CACHE_CONTROL).unwrap(), &HeaderValue::from_static(CACHE_CONTROL_PUBLIC_SHARED) @@ -155,16 +114,8 @@ async fn test_page_community_not_found() { #[tokio::test] async fn test_track_view_accepts_same_origin_request() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - - // Setup database mock - let db = MockDB::new(); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup activity tracker mock + let community_id = Uuid::new_v4(); let mut activity_tracker = MockActivityTracker::new(); activity_tracker .expect_track() @@ -173,87 +124,60 @@ async fn test_track_view_accepts_same_origin_request() { .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) + let router = TestRouterBuilder::new(MockDB::new()) .with_activity_tracker(activity_tracker) - .with_server_cfg(sample_tracking_server_cfg()) + .with_server_cfg(sample_server_cfg("https://example.test")) .build() .await; - let request = Request::builder() - .method("POST") - .uri(format!("/communities/{community_id}/views")) + let request = request("POST", &format!("/communities/{community_id}/views")) .header("origin", "https://example.test") .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); } #[tokio::test] async fn test_track_view_rejects_cross_origin_request() { - // Setup database mock - let db = MockDB::new(); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup activity tracker mock + // Setup an activity tracker that must not record the request let mut activity_tracker = MockActivityTracker::new(); activity_tracker.expect_track().times(0); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) + let router = TestRouterBuilder::new(MockDB::new()) .with_activity_tracker(activity_tracker) - .with_server_cfg(sample_tracking_server_cfg()) + .with_server_cfg(sample_server_cfg("https://example.test")) .build() .await; - let request = Request::builder() - .method("POST") - .uri(format!("/communities/{}/views", Uuid::new_v4())) + let request = request("POST", &format!("/communities/{}/views", Uuid::new_v4())) .header("origin", "https://evil.test") .body(Body::empty()) .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_track_view_rejects_missing_origin_request() { - // Setup dependencies that must not record the unverified request - let db = MockDB::new(); - let nm = MockNotificationsManager::new(); + // Setup an activity tracker that must not record the request let mut activity_tracker = MockActivityTracker::new(); - activity_tracker.expect_track().never(); + activity_tracker.expect_track().times(0); - // Send a headerless tracking request - let router = TestRouterBuilder::new(db, nm) + // Send a request without origin headers, which the middleware lets through + let router = TestRouterBuilder::new(MockDB::new()) .with_activity_tracker(activity_tracker) - .with_server_cfg(sample_tracking_server_cfg()) + .with_server_cfg(sample_server_cfg("https://example.test")) .build() .await; - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/communities/{}/views", Uuid::new_v4())) - .body(Body::empty()) - .unwrap(), - ) - .await + let request = request("POST", &format!("/communities/{}/views", Uuid::new_v4())) + .body(Body::empty()) .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; - // Check missing same-origin evidence is forbidden - assert_eq!(parts.status, StatusCode::FORBIDDEN); - assert!(bytes.is_empty()); + // Check the handler rejects the request without same-origin evidence + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } diff --git a/ocg-server/src/handlers/csv/tests.rs b/ocg-server/src/handlers/csv/tests.rs index 4ae0d705c..400d2f53d 100644 --- a/ocg-server/src/handlers/csv/tests.rs +++ b/ocg-server/src/handlers/csv/tests.rs @@ -33,15 +33,21 @@ async fn test_attachment_response_sets_csv_download_headers() { } #[test] -fn test_csv_optional_timestamp_formats_missing_value_as_empty_cell() { - assert_eq!(csv_optional_timestamp(None), ""); -} - -#[test] -fn test_csv_optional_timestamp_formats_present_value() { - let value = Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(); - - assert_eq!(csv_optional_timestamp(Some(value)), "2024-02-03 04:05 UTC"); +fn test_csv_optional_timestamp() { + // Setup labeled optional timestamps with the expected cell + let cases = [ + ("missing value", None, ""), + ( + "present value", + Some(Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap()), + "2024-02-03 04:05 UTC", + ), + ]; + + // Check each timestamp is formatted as expected + for (case, value, expected) in cases { + assert_eq!(csv_optional_timestamp(value), expected, "{case}"); + } } #[test] diff --git a/ocg-server/src/handlers/dashboard/common/tests.rs b/ocg-server/src/handlers/dashboard/common/tests.rs index 2ef02a2f3..421cb8004 100644 --- a/ocg-server/src/handlers/dashboard/common/tests.rs +++ b/ocg-server/src/handlers/dashboard/common/tests.rs @@ -1,56 +1,27 @@ use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, + body::Body, + http::{HeaderValue, Request, StatusCode, header::CONTENT_TYPE}, }; -use axum_login::tower_sessions::session; -use serde_json::{from_slice, to_value}; -use tower::ServiceExt; +use serde_json::{from_slice, json, to_value}; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, - services::notifications::MockNotificationsManager, types::permissions::CommunityPermission, }; #[tokio::test] async fn test_search_user_missing_query() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let session_user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, session_user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == session_user_id - && permission == CommunityPermission::TeamWrite - }) - .returning(|_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/users/search") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/users/search", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -59,52 +30,45 @@ async fn test_search_user_missing_query() { #[tokio::test] async fn test_search_user_short_query_returns_empty_list() { - assert_short_search_query_returns_empty_list("a").await; + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); + db.expect_search_user().never(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, search_request(&ids, "a")).await; + + // Check an empty JSON list is returned without searching + assert_eq!(parts.status, StatusCode::OK); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("application/json") + ); + assert_eq!(from_slice::(&bytes).unwrap(), json!([])); } #[tokio::test] async fn test_search_user_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let session_user_id = Uuid::new_v4(); - let search_user_id = Uuid::new_v4(); - let expected_users = vec![sample_user_search_result(search_user_id)]; + let ids = DashboardIds::new(); + let expected_users = vec![sample_user_search_result(Uuid::new_v4())]; let expected_body = to_value(&expected_users).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, session_user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == session_user_id - && permission == CommunityPermission::TeamWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_search_user() .times(1) - .withf(move |query| query == "john") + .withf(|query| query == "ab") .returning(move |_| Ok(expected_users.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + // Setup router and send a query of exactly the minimum length + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, search_request(&ids, "ab")).await; - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/users/search?q=john") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + // Check the users found are returned assert_eq!(parts.status, StatusCode::OK); assert_eq!( parts.headers.get(CONTENT_TYPE).unwrap(), @@ -116,61 +80,52 @@ async fn test_search_user_success() { #[tokio::test] async fn test_search_user_supplementary_character_query_returns_empty_list() { - assert_short_search_query_returns_empty_list("%F0%9F%98%80").await; + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); + db.expect_search_user().never(); + + // Setup router and send a single code point encoded as four bytes + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, search_request(&ids, "%F0%9F%98%80")).await; + + // Check an empty JSON list is returned without searching + assert_eq!(parts.status, StatusCode::OK); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + &HeaderValue::from_static("application/json") + ); + assert_eq!(from_slice::(&bytes).unwrap(), json!([])); } #[tokio::test] async fn test_search_user_whitespace_padded_short_query_returns_empty_list() { - assert_short_search_query_returns_empty_list("%20a%20").await; -} - -// Helpers. - -/// Checks a search query shorter than the minimum returns an empty list -/// without searching the database. -async fn assert_short_search_query_returns_empty_list(encoded_query: &str) { - // Setup identifiers - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let session_user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, session_user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == session_user_id - && permission == CommunityPermission::TeamWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_search_user().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + // Setup router and send a one character query padded with spaces + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, search_request(&ids, "%20a%20")).await; - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/community/users/search?q={encoded_query}" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check an empty JSON list is returned + // Check an empty JSON list is returned without searching assert_eq!(parts.status, StatusCode::OK); assert_eq!( parts.headers.get(CONTENT_TYPE).unwrap(), &HeaderValue::from_static("application/json") ); - let body: serde_json::Value = from_slice(&bytes).unwrap(); - assert_eq!(body, serde_json::json!([])); + assert_eq!(from_slice::(&bytes).unwrap(), json!([])); +} + +// Helpers. + +/// Builds a community dashboard user search request for the encoded query. +fn search_request(ids: &DashboardIds, encoded_query: &str) -> Request { + get_request( + &format!("/dashboard/community/users/search?q={encoded_query}"), + ids.session_id, + ) } diff --git a/ocg-server/src/handlers/dashboard/community/analytics/tests.rs b/ocg-server/src/handlers/dashboard/community/analytics/tests.rs index 05834e101..1eea3289d 100644 --- a/ocg-server/src/handlers/dashboard/community/analytics/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/analytics/tests.rs @@ -1,57 +1,28 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; -use uuid::Uuid; +use axum::http::StatusCode; -use crate::{ - db::mock::MockDB, handlers::tests::*, services::notifications::MockNotificationsManager, - types::permissions::CommunityPermission, -}; +use crate::{db::mock::MockDB, handlers::tests::*, types::permissions::CommunityPermission}; #[tokio::test] async fn test_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let stats = sample_community_stats(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_get_community_stats() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(stats.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/analytics") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/analytics", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + // Check the analytics page is rendered + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#"data-analytics-tab="groups""#)); } diff --git a/ocg-server/src/handlers/dashboard/community/contact/tests.rs b/ocg-server/src/handlers/dashboard/community/contact/tests.rs index c2b8e415a..295ebf54c 100644 --- a/ocg-server/src/handlers/dashboard/community/contact/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/contact/tests.rs @@ -1,18 +1,12 @@ use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, + body::Body, + http::{HeaderValue, Request, StatusCode, header::CONTENT_TYPE}, }; -use axum_login::tower_sessions::session; -use tower::ServiceExt; use uuid::Uuid; use crate::{ - db::mock::MockDB, + db::mock::{MockDB, expect_site_settings}, handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, - services::notifications::MockNotificationsManager, types::{ dashboard::community::contact::{ CommunityContactFilterOptions, CommunityContactGroupCategoryOption, @@ -27,48 +21,26 @@ use crate::{ #[tokio::test] async fn test_page_success_can_send() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_contact_page_reads(&mut db, community_id, user_id, true); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); + expect_contact_page_reads(&mut db, &ids); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/contact") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/contact", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Check the page renders the filters, subgroups hint, summary and enabled send button - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + // Check the page renders the filter fields, options, summary and enabled send button + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(html.contains(r#"name="filters[group_category_ids]""#)); assert!(html.contains(r#"name="filters[regions]""#)); assert!(html.contains(r#"name="filters[roles]""#)); assert!(html.contains(r#"name="filters[group_ids]""#)); - assert!(html.contains("Platform (2)")); - assert!(html.contains("No region (1)")); assert!(html.contains("Group One · Platform · Europe")); - assert!(html.contains("Selecting a group doesn't include its subgroups; select them too.")); - assert!(html.contains(r#"hx-post="/dashboard/community/contact/recipients""#)); assert!(html.contains(r#"data-people-count="3""#)); assert!(html.contains(r#"data-can-send="true""#)); assert!(html.contains(r#"data-skip-validation="true""#)); @@ -77,35 +49,21 @@ async fn test_page_success_can_send() { #[tokio::test] async fn test_page_success_cannot_send() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_contact_page_reads(&mut db, community_id, user_id, false); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, false); + expect_contact_page_reads(&mut db, &ids); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/contact") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/contact", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check sending is disabled for the read-only user - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(html.contains(r#"data-can-send="false""#)); assert!(html.contains("Your role cannot send emails to group teams.")); } @@ -113,20 +71,17 @@ async fn test_page_success_cannot_send() { #[tokio::test] async fn test_recipients_defaults_missing_filters_to_all() { // Setup identifiers and forms without filters, as posted once every filter is cleared - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let forms_data = ["", "subject=Team+update&body=Hello+teams"]; for form_data in forms_data { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_get_community_contact_recipients_summary() .times(1) - .withf(move |cid, filters| { - *cid == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.group_category_ids.is_empty() && filters.group_ids.is_empty() && filters.regions.is_empty() @@ -135,15 +90,8 @@ async fn test_recipients_defaults_missing_filters_to_all() { .returning(|_, _| Ok(sample_summary())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router - .oneshot(recipients_request(session_id, form_data)) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, recipients_request(&ids, form_data)).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check the summary covers every group team with an empty filters key @@ -167,33 +115,30 @@ async fn test_recipients_defaults_missing_filters_to_all() { #[tokio::test] async fn test_recipients_parses_filters() { // Setup identifiers and a form carrying the message of the contact form - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let region_id = Uuid::new_v4(); - let session_id = session::Id::default(); let unmatched_group_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); let form_data = format!( "subject=Team+update&body=Hello+teams\ &filters%5Bgroup_category_ids%5D%5B%5D={group_category_id}\ &filters%5Bregions%5D%5B%5D=none\ &filters%5Bregions%5D%5B%5D={region_id}\ &filters%5Broles%5D%5B%5D=events-manager\ - &filters%5Bgroup_ids%5D%5B%5D={group_id}\ - &filters%5Bgroup_ids%5D%5B%5D={unmatched_group_id}" + &filters%5Bgroup_ids%5D%5B%5D={}\ + &filters%5Bgroup_ids%5D%5B%5D={unmatched_group_id}", + ids.group_id ); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_get_community_contact_recipients_summary() .times(1) - .withf(move |cid, filters| { - *cid == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.group_category_ids == vec![group_category_id] - && filters.group_ids == vec![group_id, unmatched_group_id] + && filters.group_ids == vec![ids.group_id, unmatched_group_id] && filters.regions == vec![ RegionFilterValue::NoRegion, @@ -212,31 +157,21 @@ async fn test_recipients_parses_filters() { }); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router - .oneshot(recipients_request(session_id, &form_data)) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, recipients_request(&ids, &form_data)).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check the summary renders with the canonical filters key, sorted by filter // name with values in their submitted order, and the unmatched groups - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); let filters_key = format!( "filters%5Bgroup_category_ids%5D%5B%5D={group_category_id}\ - &filters%5Bgroup_ids%5D%5B%5D={group_id}\ + &filters%5Bgroup_ids%5D%5B%5D={}\ &filters%5Bgroup_ids%5D%5B%5D={unmatched_group_id}\ &filters%5Bregions%5D%5B%5D=none\ &filters%5Bregions%5D%5B%5D={region_id}\ - &filters%5Broles%5D%5B%5D=events-manager" + &filters%5Broles%5D%5B%5D=events-manager", + ids.group_id ); assert!(html.contains(r#"data-people-count="3""#)); assert!(html.contains(&format!(r#"data-filters-key="{filters_key}""#))); @@ -247,41 +182,40 @@ async fn test_recipients_parses_filters() { #[tokio::test] async fn test_recipients_rejects_get_requests() { - // Setup identifiers - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_get_community_contact_recipients_summary().never(); // Setup router and send a query string request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/contact/recipients?filters%5Broles%5D%5B%5D=admin") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/community/contact/recipients?filters%5Broles%5D%5B%5D=admin", + ids.session_id, + ); + let (parts, _) = send(router, request).await; // Check the preview only accepts posted filters - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(parts.status, StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn test_recipients_rejects_malformed_group_ids() { - // Send a group filter that is not a UUID - let body = assert_recipients_rejected("filters%5Bgroup_ids%5D%5B%5D=not-a-uuid").await; + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + db.expect_get_community_contact_recipients_summary().never(); + + // Setup router and send a group filter that is not a UUID + let router = TestRouterBuilder::new(db).build().await; + let request = recipients_request(&ids, "filters%5Bgroup_ids%5D%5B%5D=not-a-uuid"); + let (parts, bytes) = send(router, request).await; // Check the parser output stays out of the response - assert_eq!(body, INVALID_REQUEST_PAYLOAD); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] @@ -295,13 +229,26 @@ async fn test_recipients_rejects_oversized_filters() { ]; for (field, value) in filters { - // Send one value more than allowed for the filter + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + db.expect_get_community_contact_recipients_summary().never(); + + // Setup router and send one value more than allowed for the filter let form_data = vec![format!("filters%5B{field}%5D%5B%5D={value}"); MAX_CONTACT_FILTER_VALUES + 1] .join("&"); - let body = assert_recipients_rejected(&form_data).await; + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, recipients_request(&ids, &form_data)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the report names the oversized filter + assert_eq!( + parts.status, + StatusCode::UNPROCESSABLE_ENTITY, + "field {field}" + ); assert!( body.contains(&format!("filters.{field}")), "field {field}: {body}" @@ -311,93 +258,60 @@ async fn test_recipients_rejects_oversized_filters() { #[tokio::test] async fn test_send_community_custom_notification_blank_body() { - assert_send_rejected("subject=Hello&body=+++").await; -} - -#[tokio::test] -async fn test_send_community_custom_notification_db_error() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::GroupsWrite, - ); - expect_send_context(&mut db, community_id); - db.expect_enqueue_tracked_community_custom_notification() - .times(1) - .returning(|_| Err(anyhow::anyhow!("db error"))); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); + db.expect_enqueue_tracked_community_custom_notification().never(); - // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router - .oneshot(send_request(session_id, "subject=Hello&body=World")) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send a contact form whose body is only whitespace + let router = TestRouterBuilder::new(db).build().await; + let request = notification_request(&ids, "subject=Hello&body=+++"); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); + // Check the form is rejected + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + bytes.as_ref(), + b"content.body: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_send_community_custom_notification_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let form_data = format!( "subject=Team+update&body=Hello+teams\ &filters%5Bgroup_category_ids%5D%5B%5D={group_category_id}\ &filters%5Bregions%5D%5B%5D=none\ &filters%5Broles%5D%5B%5D=admin\ - &filters%5Bgroup_ids%5D%5B%5D={group_id}" + &filters%5Bgroup_ids%5D%5B%5D={}", + ids.group_id ); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::GroupsWrite, - ); - expect_send_context(&mut db, community_id); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); + expect_send_context(&mut db, &ids); db.expect_enqueue_tracked_community_custom_notification() .times(1) .withf(move |input| { input.body == "Hello teams" - && input.community_id == community_id - && input.created_by == user_id + && input.community_id == ids.community_id + && input.created_by == ids.user_id && input.filters.group_category_ids == vec![group_category_id] - && input.filters.group_ids == vec![group_id] + && input.filters.group_ids == vec![ids.group_id] && input.filters.regions == vec![RegionFilterValue::NoRegion] && input.filters.roles == vec![GroupRole::Admin] && input.subject == "Team update" - && input.template_data["community_display_name"] == "Test" }) .returning(|_| Ok(2)); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router.oneshot(send_request(session_id, &form_data)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, notification_request(&ids, &form_data)).await; // Check response matches expectations assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); @@ -405,95 +319,47 @@ async fn test_send_community_custom_notification_success() { #[tokio::test] async fn test_send_community_custom_notification_too_long_body() { - let body = "a".repeat(MAX_LEN_NOTIFICATION_BODY + 1); - assert_send_rejected(&format!("subject=Hello&body={body}")).await; -} - -// Helpers. - -/// Posts invalid recipients filters, checks they are rejected before reading -/// recipients and returns the response body. -async fn assert_recipients_rejected(form_data: &str) -> String { - // Setup identifiers - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_get_community_contact_recipients_summary().never(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router - .oneshot(recipients_request(session_id, form_data)) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the filters are rejected - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - String::from_utf8(bytes.to_vec()).unwrap() -} - -/// Sends an invalid contact form and checks it is rejected before enqueueing. -async fn assert_send_rejected(form_data: &str) { - // Setup identifiers - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::GroupsWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_enqueue_tracked_community_custom_notification().never(); - // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router.oneshot(send_request(session_id, form_data)).await.unwrap(); + // Setup router and send a contact form whose body exceeds the maximum length + let body = "a".repeat(MAX_LEN_NOTIFICATION_BODY + 1); + let router = TestRouterBuilder::new(db).build().await; + let request = notification_request(&ids, &format!("subject=Hello&body={body}")); + let (parts, bytes) = send(router, request).await; // Check the form is rejected - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + format!("content.body: length is greater than {MAX_LEN_NOTIFICATION_BODY}\n") + ); } -/// Configures the reads used to prepare the contact page. -fn expect_contact_page_reads(db: &mut MockDB, community_id: Uuid, user_id: Uuid, can_send: bool) { +// Helpers. + +/// Configures the reads used to prepare the contact page, other than the +/// send permission. +fn expect_contact_page_reads(db: &mut MockDB, ids: &DashboardIds) { + let ids = *ids; db.expect_get_community_summary() .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(sample_community_summary(community_id))); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && *permission == CommunityPermission::GroupsWrite - }) - .returning(move |_, _, _| Ok(can_send)); + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(sample_community_summary(ids.community_id))); db.expect_list_community_contact_filter_options() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(|_| Ok(sample_filter_options())); db.expect_list_group_roles() .times(1) .returning(|| Ok(vec![sample_group_role_summary()])); db.expect_get_community_contact_recipients_summary() .times(1) - .withf(move |cid, filters| { - *cid == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.group_category_ids.is_empty() && filters.group_ids.is_empty() && filters.regions.is_empty() @@ -503,26 +369,33 @@ fn expect_contact_page_reads(db: &mut MockDB, community_id: Uuid, user_id: Uuid, } /// Configures the context reads used to build the community email. -fn expect_send_context(db: &mut MockDB, community_id: Uuid) { +fn expect_send_context(db: &mut MockDB, ids: &DashboardIds) { + let ids = *ids; db.expect_get_community_summary() .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(sample_community_summary(community_id))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(sample_community_summary(ids.community_id))); + expect_site_settings(db); +} + +/// Builds a send request for the community contact form. +fn notification_request(ids: &DashboardIds, form_data: &str) -> Request { + form_request( + "POST", + "/dashboard/community/notifications", + ids.session_id, + form_data.to_string(), + ) } /// Builds a recipients summary request for the contact filters. -fn recipients_request(session_id: session::Id, form_data: &str) -> Request { - Request::builder() - .method("POST") - .uri("/dashboard/community/contact/recipients") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data.to_string())) - .unwrap() +fn recipients_request(ids: &DashboardIds, form_data: &str) -> Request { + form_request( + "POST", + "/dashboard/community/contact/recipients", + ids.session_id, + form_data.to_string(), + ) } /// Returns contact filter options with one category, group and region, in a @@ -564,15 +437,3 @@ fn sample_summary() -> CommunityContactRecipientsSummary { unmatched_groups: vec![], } } - -/// Builds a send request for the community contact form. -fn send_request(session_id: session::Id, form_data: &str) -> Request { - Request::builder() - .method("POST") - .uri("/dashboard/community/notifications") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data.to_string())) - .unwrap() -} diff --git a/ocg-server/src/handlers/dashboard/community/event_categories/tests.rs b/ocg-server/src/handlers/dashboard/community/event_categories/tests.rs index a327f35cb..a4c6280ea 100644 --- a/ocg-server/src/handlers/dashboard/community/event_categories/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/event_categories/tests.rs @@ -1,140 +1,85 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; +use axum::http::StatusCode; use uuid::Uuid; use crate::{ - db::mock::MockDB, handlers::tests::*, services::notifications::MockNotificationsManager, - types::permissions::CommunityPermission, + db::mock::MockDB, + handlers::tests::*, + types::{event::EventCategory, permissions::CommunityPermission}, }; use super::EventCategoryInput; #[tokio::test] async fn test_add_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/event-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/event-categories/add", + ids.session_id, + "name=", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + bytes.as_ref(), + b"name: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_add_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/event-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/event-categories/add", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_add_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let form = EventCategoryInput { name: "Conference".to_string(), }; - let expected_name = form.name.clone(); let body = serde_qs::to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_add_event_category() .times(1) - .withf(move |uid, cid, category| { - *uid == user_id && *cid == community_id && category.name == expected_name + .withf(move |user_id, community_id, category| { + *user_id == ids.user_id + && *community_id == ids.community_id + && category.name == form.name }) .returning(|_, _, _| Ok(Uuid::new_v4())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/event-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/event-categories/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -147,45 +92,26 @@ async fn test_add_success() { #[tokio::test] async fn test_delete_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_delete_event_category() .times(1) - .withf(move |uid, cid, ecid| { - *uid == user_id && *cid == community_id && *ecid == event_category_id + .withf(move |user_id, community_id, id| { + *user_id == ids.user_id && *community_id == ids.community_id && *id == event_category_id }) .returning(|_, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/community/event-categories/{event_category_id}/delete" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/event-categories/{event_category_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -198,134 +124,63 @@ async fn test_delete_success() { #[tokio::test] async fn test_list_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_event_category()]; - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_event_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_event_category()]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/event-categories") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/event-categories", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the listed category is rendered + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(r#"aria-label="Edit event category: Meetup""#)); } #[tokio::test] async fn test_update_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/community/event-categories/{event_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/event-categories/{event_category_id}/update"), + ids.session_id, + "name=", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + bytes.as_ref(), + b"name: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_update_page_not_found() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_event_category()]; - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_event_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_event_category()]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/community/event-categories/{event_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/event-categories/{event_category_id}/update"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -335,106 +190,67 @@ async fn test_update_page_not_found() { #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut category = sample_event_category(); - category.event_category_id = event_category_id; - let categories = vec![category]; + let category = EventCategory { + event_category_id, + name: "Conference".to_string(), + ..sample_event_category() + }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_event_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_event_category(), category]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/community/event-categories/{event_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/event-categories/{event_category_id}/update"), + ids.session_id, ); - assert!(!bytes.is_empty()); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the requested category is rendered in the form + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(&format!( + r#"hx-put="/dashboard/community/event-categories/{event_category_id}/update""# + ))); + assert!(body.contains(r#"value="Conference""#)); } #[tokio::test] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let form = EventCategoryInput { name: "Meetup".to_string(), }; - let expected_name = form.name.clone(); let body = serde_qs::to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_update_event_category() .times(1) - .withf(move |uid, cid, ecid, category| { - *uid == user_id - && *cid == community_id - && *ecid == event_category_id - && category.name == expected_name + .withf(move |user_id, community_id, id, category| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *id == event_category_id + && category.name == form.name }) .returning(|_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/community/event-categories/{event_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/event-categories/{event_category_id}/update"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -444,3 +260,16 @@ async fn test_update_success() { "refresh-community-dashboard-table", ); } + +// Helpers. + +/// Expects the reads the list and update pages perform. +fn expect_page_reads(db: &mut MockDB, ids: &DashboardIds, categories: Vec) { + let ids = *ids; + expect_community_access(db, &ids, CommunityPermission::Read); + expect_community_permission_result(db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_event_categories() + .times(1) + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(categories.clone())); +} diff --git a/ocg-server/src/handlers/dashboard/community/group_categories/tests.rs b/ocg-server/src/handlers/dashboard/community/group_categories/tests.rs index e492fe56c..483182952 100644 --- a/ocg-server/src/handlers/dashboard/community/group_categories/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/group_categories/tests.rs @@ -1,140 +1,85 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; +use axum::http::StatusCode; use uuid::Uuid; use crate::{ - db::mock::MockDB, handlers::tests::*, services::notifications::MockNotificationsManager, - types::permissions::CommunityPermission, + db::mock::MockDB, + handlers::tests::*, + types::{group::GroupCategory, permissions::CommunityPermission}, }; use super::GroupCategoryInput; #[tokio::test] async fn test_add_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/group-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/group-categories/add", + ids.session_id, + "name=", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + bytes.as_ref(), + b"name: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_add_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/group-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/group-categories/add", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_add_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let form = GroupCategoryInput { name: "Platform Engineering".to_string(), }; - let expected_name = form.name.clone(); let body = serde_qs::to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_add_group_category() .times(1) - .withf(move |uid, cid, category| { - *uid == user_id && *cid == community_id && category.name == expected_name + .withf(move |user_id, community_id, category| { + *user_id == ids.user_id + && *community_id == ids.community_id + && category.name == form.name }) .returning(|_, _, _| Ok(Uuid::new_v4())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/group-categories/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/group-categories/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -147,45 +92,26 @@ async fn test_add_success() { #[tokio::test] async fn test_delete_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_delete_group_category() .times(1) - .withf(move |uid, cid, gcid| { - *uid == user_id && *cid == community_id && *gcid == group_category_id + .withf(move |user_id, community_id, id| { + *user_id == ids.user_id && *community_id == ids.community_id && *id == group_category_id }) .returning(|_, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/community/group-categories/{group_category_id}/delete" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/group-categories/{group_category_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -198,134 +124,63 @@ async fn test_delete_success() { #[tokio::test] async fn test_list_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_group_category()]; - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_group_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_group_category()]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/group-categories") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/group-categories", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the listed category is rendered + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(r#"aria-label="Edit group category: Meetup""#)); } #[tokio::test] async fn test_update_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/community/group-categories/{group_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/group-categories/{group_category_id}/update"), + ids.session_id, + "name=", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + bytes.as_ref(), + b"name: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_update_page_not_found() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_group_category()]; - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_group_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_group_category()]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/community/group-categories/{group_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/group-categories/{group_category_id}/update"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -335,106 +190,67 @@ async fn test_update_page_not_found() { #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut category = sample_group_category(); - category.group_category_id = group_category_id; - let categories = vec![category]; + let category = GroupCategory { + group_category_id, + name: "Platform Engineering".to_string(), + ..sample_group_category() + }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); - db.expect_list_group_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_page_reads(&mut db, &ids, vec![sample_group_category(), category]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/community/group-categories/{group_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/group-categories/{group_category_id}/update"), + ids.session_id, ); - assert!(!bytes.is_empty()); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the requested category is rendered in the form + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(&format!( + r#"hx-put="/dashboard/community/group-categories/{group_category_id}/update""# + ))); + assert!(body.contains(r#"value="Platform Engineering""#)); } #[tokio::test] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let form = GroupCategoryInput { name: "Cloud Native".to_string(), }; - let expected_name = form.name.clone(); let body = serde_qs::to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TaxonomyWrite, - ); + expect_community_access(&mut db, &ids, CommunityPermission::TaxonomyWrite); db.expect_update_group_category() .times(1) - .withf(move |uid, cid, gcid, category| { - *uid == user_id - && *cid == community_id - && *gcid == group_category_id - && category.name == expected_name + .withf(move |user_id, community_id, id, category| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *id == group_category_id + && category.name == form.name }) .returning(|_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/community/group-categories/{group_category_id}/update" - )) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/group-categories/{group_category_id}/update"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -444,3 +260,16 @@ async fn test_update_success() { "refresh-community-dashboard-table", ); } + +// Helpers. + +/// Expects the reads the list and update pages perform. +fn expect_page_reads(db: &mut MockDB, ids: &DashboardIds, categories: Vec) { + let ids = *ids; + expect_community_access(db, &ids, CommunityPermission::Read); + expect_community_permission_result(db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_group_categories() + .times(1) + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(categories.clone())); +} diff --git a/ocg-server/src/handlers/dashboard/community/groups/tests.rs b/ocg-server/src/handlers/dashboard/community/groups/tests.rs index 952ea658b..5e03b6d4d 100644 --- a/ocg-server/src/handlers/dashboard/community/groups/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/groups/tests.rs @@ -1,20 +1,13 @@ use anyhow::anyhow; -use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use serde_json::json; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::{auth::session_context::SELECTED_GROUP_ID_KEY, tests::*}, - services::notifications::MockNotificationsManager, + handlers::{ + auth::session_context::SELECTED_GROUP_ID_KEY, error::INVALID_REQUEST_PAYLOAD, tests::*, + }, types::{ dashboard::{DASHBOARD_PAGINATION_LIMIT, community::groups::CommunityGroupsOutput}, group::{GroupFull, GroupParentOption}, @@ -25,56 +18,30 @@ use crate::{ #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let ts_query = "rust".to_string(); + let ids = DashboardIds::new(); let groups_output = CommunityGroupsOutput { - groups: vec![sample_group_summary(group_id)], + groups: vec![sample_group_summary(ids.group_id)], total: 1, }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_list_community_groups() .times(1) - .withf({ - let ts_query = ts_query.clone(); - move |cid, filters| { - *cid == community_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && filters.ts_query.as_deref() == Some(ts_query.as_str()) - } + .withf(move |community_id, filters| { + *community_id == ids.community_id + && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) + && filters.offset == Some(0) + && filters.ts_query.as_deref() == Some("rust") }) .returning(move |_, _| Ok(groups_output.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/community/groups?ts_query={ts_query}")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/groups?ts_query=rust", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check the listed groups are rendered @@ -85,52 +52,30 @@ async fn test_list_page_success() { #[tokio::test] async fn test_list_page_allows_empty_search_query() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let groups_output = CommunityGroupsOutput { - groups: vec![sample_group_summary(group_id)], + groups: vec![sample_group_summary(ids.group_id)], total: 1, }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_list_community_groups() .times(1) - .withf(move |cid, filters| { - *cid == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) && filters.ts_query.is_none() }) .returning(move |_, _| Ok(groups_output.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/groups?ts_query=") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/groups?ts_query=", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); @@ -139,44 +84,32 @@ async fn test_list_page_allows_empty_search_query() { #[tokio::test] async fn test_add_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let categories = vec![sample_group_category()]; let regions = vec![sample_group_region()]; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); db.expect_list_group_categories() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(categories.clone())); db.expect_list_group_parent_options() .times(1) - .withf(move |cid, uid, gid| *cid == community_id && *uid == user_id && gid.is_none()) + .withf(move |community_id, user_id, group_id| { + *community_id == ids.community_id && *user_id == ids.user_id && group_id.is_none() + }) .returning(|_, _, _| Ok(vec![])); db.expect_list_regions() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(regions.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/groups/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/groups/add", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); @@ -185,84 +118,35 @@ async fn test_add_page_success() { #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_group_category()]; - let regions = vec![sample_group_region()]; + let ids = DashboardIds::new(); let group_full = GroupFull { description_short: Some("Short group description".to_string()), - ..sample_group_full(community_id, group_id) + ..sample_group_full(ids.community_id, ids.group_id) }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); - db.expect_get_group_full() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(group_full.clone())); - db.expect_group_has_child_links() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(false)); - db.expect_list_group_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - db.expect_list_group_parent_options() - .times(1) - .withf(move |cid, uid, gid| { - *cid == community_id && *uid == user_id && *gid == Some(group_id) - }) - .returning(|_, _, _| Ok(vec![])); - db.expect_list_regions() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(regions.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_update_page_reads(&mut db, &ids, group_full, vec![]); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/community/groups/{group_id}/update")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/groups/{}/update", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Check response matches expectations + // Check the group details are rendered in the form assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); assert!(html.contains(r#"value="Short group description""#)); } #[tokio::test] async fn test_update_page_selects_current_inactive_parent_option() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let parent_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let categories = vec![sample_group_category()]; - let group_full = sample_group_full(community_id, group_id); let parent_option = GroupParentOption { active: false, group_id: parent_group_id, @@ -270,60 +154,25 @@ async fn test_update_page_selects_current_inactive_parent_option() { is_selectable: false, name: "Inactive Parent".to_string(), }; - let regions = vec![sample_group_region()]; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); - db.expect_get_group_full() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(group_full.clone())); - db.expect_group_has_child_links() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(false)); - db.expect_list_group_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(categories.clone())); - db.expect_list_group_parent_options() - .times(1) - .withf(move |cid, uid, gid| { - *cid == community_id && *uid == user_id && *gid == Some(group_id) - }) - .returning(move |_, _, _| Ok(vec![parent_option.clone()])); - db.expect_list_regions() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(regions.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_update_page_reads( + &mut db, + &ids, + sample_group_full(ids.community_id, ids.group_id), + vec![parent_option], + ); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/community/groups/{group_id}/update")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/community/groups/{}/update", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations + // Check the current parent is selected and flagged as no longer selectable assert_html_response(&parts, &bytes, StatusCode::OK); let body = String::from_utf8(bytes.to_vec()).unwrap(); let option_start = body @@ -340,29 +189,19 @@ async fn test_update_page_selects_current_inactive_parent_option() { #[tokio::test] async fn test_add_success_auto_selects_group() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let category_id = Uuid::new_v4(); let new_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let body = serde_qs::to_string(&sample_group_form(category_id)).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_add_group() .times(1) - .withf(move |uid, cid, group| { - *uid == user_id - && *cid == community_id + .withf(move |user_id, community_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id && group.name == "Test Group" && group.category_id == category_id && group.description == "Group description" @@ -372,7 +211,7 @@ async fn test_add_success_auto_selects_group() { db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_GROUP_ID_KEY) @@ -380,22 +219,15 @@ async fn test_add_success_auto_selects_group() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/groups/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/groups/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -408,54 +240,34 @@ async fn test_add_success_auto_selects_group() { #[tokio::test] async fn test_add_success_keeps_existing_group_selection() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and data structures, with a group already selected + let ids = DashboardIds::new(); let category_id = Uuid::new_v4(); - let existing_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let body = serde_qs::to_string(&sample_group_form(category_id)).unwrap(); - // Setup database mock + // Setup database mock without a group selection write let mut db = MockDB::new(); - expect_authenticated_group_session( - &mut db, - session_id, - user_id, - community_id, - existing_group_id, - ); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_add_group() .times(1) - .withf(move |uid, cid, group| { - *uid == user_id && *cid == community_id && group.category_id == category_id + .withf(move |user_id, community_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && group.category_id == category_id }) .returning(|_, _, _| Ok(Uuid::new_v4())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + db.expect_update_session().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/groups/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/groups/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -469,47 +281,32 @@ async fn test_add_success_keeps_existing_group_selection() { #[tokio::test] async fn test_add_db_error() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let body = serde_qs::to_string(&sample_group_form(category_id)).unwrap(); // Setup database mock with a failing write and no group selection write let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_add_group() .times(1) - .withf(move |uid, cid, group| { - *uid == user_id && *cid == community_id && group.category_id == category_id + .withf(move |user_id, community_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && group.category_id == category_id }) - .returning(move |_, _, _| Err(anyhow!("db error"))); + .returning(|_, _, _| Err(anyhow!("db error"))); db.expect_update_session().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/groups/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/groups/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); @@ -517,92 +314,56 @@ async fn test_add_db_error() { #[tokio::test] async fn test_add_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/groups/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("invalid-body")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/community/groups/add", + ids.session_id, + "invalid-body", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let category_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let body = serde_qs::to_string(&sample_group_form(category_id)).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.category_id == category_id && group.description_short.as_deref() == Some("Short group description") }) .returning(|_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/groups/{group_id}/update")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/groups/{}/update", ids.group_id), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -615,84 +376,49 @@ async fn test_update_success() { #[tokio::test] async fn test_update_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/groups/{group_id}/update")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("invalid-body")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/groups/{}/update", ids.group_id), + ids.session_id, + "invalid-body", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] async fn test_activate_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_activate_group() .times(1) - .withf(move |uid, cid, gid| *uid == user_id && *cid == community_id && *gid == group_id) - .returning(move |_, _, _| Ok(())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |user_id, community_id, group_id| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + }) + .returning(|_, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/groups/{group_id}/activate")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/community/groups/{}/activate", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -705,43 +431,27 @@ async fn test_activate_success() { #[tokio::test] async fn test_deactivate_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::GroupsWrite); db.expect_deactivate_group() .times(1) - .withf(move |uid, cid, gid| *uid == user_id && *cid == community_id && *gid == group_id) - .returning(move |_, _, _| Ok(())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |user_id, community_id, group_id| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + }) + .returning(|_, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/groups/{group_id}/deactivate")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/community/groups/{}/deactivate", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -754,44 +464,33 @@ async fn test_deactivate_success() { #[tokio::test] async fn test_delete_non_selected_group() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let other_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers, deleting a group other than the selected one + let ids = DashboardIds::new(); + let deleted_group_id = Uuid::new_v4(); - // Setup database mock + // Setup database mock without a group selection change let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, other_group_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_delete_group() .times(1) - .withf(move |uid, cid, gid| *uid == user_id && *cid == community_id && *gid == group_id) - .returning(move |_, _, _| Ok(())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |user_id, community_id, group_id| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == deleted_group_id + }) + .returning(|_, _, _| Ok(())); + db.expect_list_user_groups().never(); + db.expect_update_session().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/community/groups/{group_id}/delete")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/groups/{deleted_group_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -804,35 +503,35 @@ async fn test_delete_non_selected_group() { #[tokio::test] async fn test_delete_selected_group_updates_selection() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers, deleting the selected group + let ids = DashboardIds::new(); let next_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_delete_group() .times(1) - .withf(move |uid, cid, gid| *uid == user_id && *cid == community_id && *gid == group_id) - .returning(move |_, _, _| Ok(())); + .withf(move |user_id, community_id, group_id| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + }) + .returning(|_, _, _| Ok(())); db.expect_list_user_groups() .times(1) - .withf(move |id| *id == user_id) - .returning(move |_| Ok(sample_user_groups_by_community(community_id, next_group_id))); + .withf(move |user_id| *user_id == ids.user_id) + .returning(move |_| { + Ok(sample_user_groups_by_community( + ids.community_id, + next_group_id, + )) + }); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_GROUP_ID_KEY) @@ -840,21 +539,14 @@ async fn test_delete_selected_group_updates_selection() { }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/community/groups/{group_id}/delete")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/groups/{}/delete", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -867,53 +559,40 @@ async fn test_delete_selected_group_updates_selection() { #[tokio::test] async fn test_delete_selected_group_without_fallback_clears_selection() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers, deleting the selected group + let ids = DashboardIds::new(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_group_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_delete_group() .times(1) - .withf(move |uid, cid, gid| *uid == user_id && *cid == community_id && *gid == group_id) - .returning(move |_, _, _| Ok(())); + .withf(move |user_id, community_id, group_id| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + }) + .returning(|_, _, _| Ok(())); db.expect_list_user_groups() .times(1) - .withf(move |id| *id == user_id) + .withf(move |user_id| *user_id == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id && !record.data.contains_key(SELECTED_GROUP_ID_KEY) + record.id == ids.session_id && !record.data.contains_key(SELECTED_GROUP_ID_KEY) }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/community/groups/{group_id}/delete")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/groups/{}/delete", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -923,3 +602,46 @@ async fn test_delete_selected_group_without_fallback_clears_selection() { "refresh-community-dashboard-table", ); } + +// Helpers. + +/// Expects the reads the update page performs for the group and its parent +/// options. +fn expect_update_page_reads( + db: &mut MockDB, + ids: &DashboardIds, + group_full: GroupFull, + parent_options: Vec, +) { + let ids = *ids; + expect_community_access(db, &ids, CommunityPermission::Read); + expect_community_permission_result(db, &ids, CommunityPermission::GroupsWrite, true); + db.expect_get_group_full() + .times(1) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) + .returning(move |_, _| Ok(group_full.clone())); + db.expect_group_has_child_links() + .times(1) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) + .returning(|_, _| Ok(false)); + db.expect_list_group_categories() + .times(1) + .withf(move |community_id| *community_id == ids.community_id) + .returning(|_| Ok(vec![sample_group_category()])); + db.expect_list_group_parent_options() + .times(1) + .withf(move |community_id, user_id, group_id| { + *community_id == ids.community_id + && *user_id == ids.user_id + && *group_id == Some(ids.group_id) + }) + .returning(move |_, _, _| Ok(parent_options.clone())); + db.expect_list_regions() + .times(1) + .withf(move |community_id| *community_id == ids.community_id) + .returning(|_| Ok(vec![sample_group_region()])); +} diff --git a/ocg-server/src/handlers/dashboard/community/home/tests.rs b/ocg-server/src/handlers/dashboard/community/home/tests.rs index 58571af63..8910f5493 100644 --- a/ocg-server/src/handlers/dashboard/community/home/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/home/tests.rs @@ -1,18 +1,8 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; -use uuid::Uuid; +use axum::http::StatusCode; use crate::{ - db::mock::MockDB, + db::mock::{MockDB, expect_site_settings}, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{ community::CommunityFull, dashboard::{ @@ -21,6 +11,7 @@ use crate::{ community::{ contact::{CommunityContactFilterOptions, CommunityContactRecipientsSummary}, groups::CommunityGroupsOutput, + team::CommunityTeamOutput, }, }, permissions::CommunityPermission, @@ -30,108 +21,51 @@ use crate::{ #[tokio::test] async fn test_page_analytics_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let stats = sample_community_stats(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); db.expect_get_community_stats() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(stats.clone())); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=analytics") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=analytics", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the analytics page is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#"data-analytics-tab="groups""#)); } #[tokio::test] async fn test_page_contact_tab_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && *permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); db.expect_list_community_contact_filter_options() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(|_| Ok(CommunityContactFilterOptions::default())); db.expect_list_group_roles() .times(1) .returning(|| Ok(vec![sample_group_role_summary()])); db.expect_get_community_contact_recipients_summary() .times(1) - .withf(move |cid, _| *cid == community_id) + .withf(move |community_id, _| *community_id == ids.community_id) .returning(|_, _| Ok(CommunityContactRecipientsSummary::default())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=contact") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=contact", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check the contact page is rendered in the dashboard @@ -141,306 +75,179 @@ async fn test_page_contact_tab_success() { } #[tokio::test] -async fn test_page_groups_tab_success() { +async fn test_page_event_categories_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let ts_query = "rust".to_string(); - let groups_output = CommunityGroupsOutput { - groups: vec![sample_group_summary(group_id)], - total: 1, - }; + let ids = DashboardIds::new(); + let categories = vec![sample_event_category()]; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::GroupsWrite - }) - .returning(|_, _, _| Ok(true)); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - db.expect_list_community_groups() + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_event_categories() .times(1) - .withf({ - let ts_query = ts_query.clone(); - move |cid, filters| { - *cid == community_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && filters.ts_query.as_deref() == Some(ts_query.as_str()) - } - }) - .returning(move |_, _| Ok(groups_output.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(categories.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=groups&ts_query=rust") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=event-categories", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the event categories list is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#"aria-label="Edit event category: Meetup""#)); } #[tokio::test] -async fn test_page_logs_tab_success() { +async fn test_page_group_categories_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let output = sample_audit_logs_output(); + let ids = DashboardIds::new(); + let categories = vec![sample_group_category()]; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_list_community_audit_logs() - .times(1) - .withf(move |cid, filters| { - *cid == community_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && filters.sort == Some(AuditLogSort::CreatedDesc) - }) - .returning(move |_, _| Ok(output.clone())); - db.expect_get_site_settings() + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_group_categories() .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(categories.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=logs") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=group-categories", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the group categories list is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#"aria-label="Edit group category: Meetup""#)); } #[tokio::test] -async fn test_page_settings_tab_success() { +async fn test_page_groups_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); + let groups_output = CommunityGroupsOutput { + groups: vec![sample_group_summary(ids.group_id)], + total: 1, + }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::GroupsWrite, true); + db.expect_list_community_groups() .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite + .withf(move |community_id, filters| { + *community_id == ids.community_id + && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) + && filters.offset == Some(0) + && filters.ts_query.as_deref() == Some("rust") }) - .returning(|_, _, _| Ok(true)); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(move |_, _| Ok(groups_output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=settings") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/community?tab=groups&ts_query=rust", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the groups list is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains("Open group dashboard: Test Group")); } #[tokio::test] -async fn test_page_warns_when_community_is_inactive() { - // Setup identifiers and an inactive community - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community = CommunityFull { - active: false, - ..sample_community_full(community_id) - }; +async fn test_page_logs_tab_success() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); + let output = sample_audit_logs_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + db.expect_list_community_audit_logs() .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite + .withf(move |community_id, filters| { + *community_id == ids.community_id + && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) + && filters.offset == Some(0) + && filters.sort == Some(AuditLogSort::CreatedDesc) }) - .returning(|_, _, _| Ok(false)); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(community.clone())); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(move |_, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=logs", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Check the inactive community warning is rendered + // Check the audit logs list is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); - let body = std::str::from_utf8(&bytes).unwrap(); - assert!( - body.contains( - "This community has been deactivated. Please contact the site administrators." - ) - ); + assert!(html.contains("Schedule updated")); } #[tokio::test] -async fn test_page_without_tab_defaults_to_settings() { +async fn test_page_regions_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); + let regions = vec![sample_group_region()]; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite - }) - .returning(|_, _, _| Ok(false)); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); - db.expect_get_community_stats().never(); - db.expect_get_site_settings() + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_regions() .times(1) - .returning(|| Ok(sample_site_settings())); + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(regions.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=regions", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Setup router and send request without selecting a tab - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the settings tab is rendered instead of analytics + // Check the regions list is rendered in the dashboard + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#"aria-label="Edit region: North America""#)); +} + +#[tokio::test] +async fn test_page_settings_tab_success() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::SettingsWrite, true); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=settings", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the editable settings form is rendered assert_html_response(&parts, &bytes, StatusCode::OK); - let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains("hx-get=\"/dashboard/community/settings/update\"")); - assert!(!body.contains("hx-get=\"/dashboard/community/analytics\"")); - assert!(!body.contains("This community has been deactivated.")); + assert!(!body.contains("Your role cannot update community settings.")); } #[tokio::test] async fn test_page_team_tab_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let members = vec![ sample_community_team_member(true), sample_community_team_member(false), ]; - let output = crate::types::dashboard::community::team::CommunityTeamOutput { + let output = CommunityTeamOutput { members: members.clone(), total: members.len(), total_accepted: 1, @@ -449,221 +256,97 @@ async fn test_page_team_tab_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - expect_community_permission( - &mut db, - community_id, - user_id, - CommunityPermission::TeamWrite, - ); - db.expect_get_community_full() - .times(1) - .withf(move |id| *id == community_id) - .returning(move |_| Ok(sample_community_full(community_id))); - db.expect_list_user_communities() - .times(1) - .withf(move |uid| uid == &user_id) - .returning(move |_| Ok(sample_user_communities(community_id))); + expect_page_reads(&mut db, &ids, sample_community_full(ids.community_id)); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, true); db.expect_list_community_team_members() .times(1) - .withf(move |id, filters| { - *id == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); - db.expect_get_site_settings() - .times(1) - .returning(|| Ok(sample_site_settings())); db.expect_list_community_roles() .times(1) .returning(|| Ok(vec![sample_community_role_summary()])); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community?tab=team") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community?tab=team", ids.session_id); + let (parts, bytes) = send(router, request).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the team list is rendered in the dashboard assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(html.contains(r#") { + let ids = *ids; + expect_community_access(db, &ids, CommunityPermission::Read); + expect_community_permission_result(db, &ids, CommunityPermission::TaxonomyWrite, true); + db.expect_list_regions() + .times(1) + .withf(move |community_id| *community_id == ids.community_id) + .returning(move |_| Ok(regions.clone())); +} diff --git a/ocg-server/src/handlers/dashboard/community/settings/tests.rs b/ocg-server/src/handlers/dashboard/community/settings/tests.rs index f8f4f8c39..3d0d2659d 100644 --- a/ocg-server/src/handlers/dashboard/community/settings/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/settings/tests.rs @@ -1,70 +1,37 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; -use uuid::Uuid; +use axum::http::StatusCode; use crate::{ - db::mock::MockDB, handlers::tests::*, services::notifications::MockNotificationsManager, + db::mock::MockDB, + handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, types::permissions::CommunityPermission, }; #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community = sample_community_full(community_id); + let ids = DashboardIds::new(); + let community = sample_community_full(ids.community_id); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::SettingsWrite, true); db.expect_get_community_full() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(community.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/settings/update") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/settings/update", ids.session_id); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + // Check the editable form is rendered + assert_html_response(&parts, &bytes, StatusCode::OK); + let html = std::str::from_utf8(&bytes).unwrap(); + assert!(!html.contains("Your role cannot update community settings.")); // Check image fields render target sizes and validated upload targets - let html = std::str::from_utf8(&bytes).unwrap(); let logo_field = html .split(r#"name="logo_url""#) .nth(1) @@ -81,94 +48,55 @@ async fn test_update_page_success() { #[tokio::test] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let update = sample_community_update(); let expected_display_name = update.display_name.clone(); let body = serde_qs::to_string(&update).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite - }) - .returning(|_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::SettingsWrite); db.expect_update_community() .times(1) - .withf(move |uid, cid, update| { - *uid == user_id && *cid == community_id && update.display_name == expected_display_name + .withf(move |user_id, community_id, update| { + *user_id == ids.user_id + && *community_id == ids.community_id + && update.display_name == expected_display_name }) .returning(|_, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/community/settings/update") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + "/dashboard/community/settings/update", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert_eq!( - parts.headers.get("HX-Trigger").unwrap(), - &HeaderValue::from_static("refresh-body"), - ); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_invalid_payload() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id - && *uid == user_id - && permission == CommunityPermission::SettingsWrite - }) - .returning(|_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_community_access(&mut db, &ids, CommunityPermission::SettingsWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/community/settings/update") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("invalid-body")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + "/dashboard/community/settings/update", + ids.session_id, + "invalid-body", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } diff --git a/ocg-server/src/handlers/dashboard/community/team/tests.rs b/ocg-server/src/handlers/dashboard/community/team/tests.rs index fed3f0027..b5c6c5dd3 100644 --- a/ocg-server/src/handlers/dashboard/community/team/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/team/tests.rs @@ -1,22 +1,16 @@ use anyhow::anyhow; -use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{CONTENT_TYPE, COOKIE, HOST}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; +use axum::{body::Body, http::StatusCode}; use uuid::Uuid; use crate::{ - db::mock::MockDB, - handlers::{auth::LOG_IN_URL, tests::*}, + db::mock::{MockDB, expect_site_settings}, + handlers::{auth::LOG_IN_URL, error::INVALID_REQUEST_PAYLOAD, tests::*}, services::notifications::MockNotificationsManager, types::{ - community::CommunityRole, dashboard::DASHBOARD_PAGINATION_LIMIT, - notifications::NotificationKind, permissions::CommunityPermission, + community::CommunityRole, + dashboard::{DASHBOARD_PAGINATION_LIMIT, community::team::CommunityTeamOutput}, + notifications::NotificationKind, + permissions::CommunityPermission, }, }; @@ -25,18 +19,12 @@ use super::NewTeamMember; #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let members = vec![ sample_community_team_member(true), sample_community_team_member(false), ]; - let role = crate::types::community::CommunityRoleSummary { - community_role_id: "admin".to_string(), - display_name: "Admin".to_string(), - }; - let output = crate::types::dashboard::community::team::CommunityTeamOutput { + let output = CommunityTeamOutput { members: members.clone(), total: members.len(), total_accepted: 1, @@ -45,63 +33,80 @@ async fn test_list_page_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, true); db.expect_list_community_team_members() .times(1) - .withf(move |cid, filters| { - *cid == community_id + .withf(move |community_id, filters| { + *community_id == ids.community_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); db.expect_list_community_roles() .times(1) - .returning(move || Ok(vec![role.clone()])); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(|| Ok(vec![sample_community_role_summary()])); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/team") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/team", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); - // Check response matches expectations + // Check the admin warning is rendered assert_html_response(&parts, &bytes, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); assert!(body.contains("At least one accepted admin is required.")); } #[tokio::test] -async fn test_list_page_with_pagination_params() { +async fn test_list_page_read_only_hides_team_management() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); + let member = sample_community_team_member(false); + let member_id = member.user_id; + let output = CommunityTeamOutput { + members: vec![member], + total: 1, + total_accepted: 0, + total_admins_accepted: 1, + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, false); + db.expect_list_community_team_members() + .times(1) + .withf(move |community_id, _| *community_id == ids.community_id) + .returning(move |_, _| Ok(output.clone())); + db.expect_list_community_roles() + .times(1) + .returning(|| Ok(vec![sample_community_role_summary()])); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/community/team", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the role form is hidden and the delete action disabled + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(r#"can-manage-team="false""#)); + assert!(!body.contains(&format!( + r#"hx-put="/dashboard/community/team/{member_id}/role""# + ))); + assert!(body.contains(r#"disabled title="Your role cannot delete team members.""#)); +} + +#[tokio::test] +async fn test_list_page_with_pagination_params_and_enough_admins() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let members = vec![ sample_community_team_member(true), sample_community_team_member(true), ]; - let role = crate::types::community::CommunityRoleSummary { - community_role_id: "admin".to_string(), - display_name: "Admin".to_string(), - }; - let output = crate::types::dashboard::community::team::CommunityTeamOutput { + let output = CommunityTeamOutput { members: members.clone(), total: members.len(), total_accepted: 2, @@ -110,55 +115,40 @@ async fn test_list_page_with_pagination_params() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_community_access(&mut db, &ids, CommunityPermission::Read); + expect_community_permission_result(&mut db, &ids, CommunityPermission::TeamWrite, true); db.expect_list_community_team_members() .times(1) - .withf(move |cid, filters| { - *cid == community_id && filters.limit == Some(5) && filters.offset == Some(10) + .withf(move |community_id, filters| { + *community_id == ids.community_id + && filters.limit == Some(5) + && filters.offset == Some(10) }) .returning(move |_, _| Ok(output.clone())); db.expect_list_community_roles() .times(1) - .returning(move || Ok(vec![role.clone()])); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(|| Ok(vec![sample_community_role_summary()])); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/community/team?limit=5&offset=10") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/community/team?limit=5&offset=10", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); - // Check response matches expectations + // Check the admin warning is not rendered assert_html_response(&parts, &bytes, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); assert!(!body.contains("At least one accepted admin is required.")); } #[tokio::test] async fn test_add_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let new_member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let community = sample_community_summary(community_id); - let site_settings = sample_site_settings(); + let community = sample_community_summary(ids.community_id); let new_member_form = NewTeamMember { role: CommunityRole::Admin, user_id: new_member_id, @@ -167,29 +157,21 @@ async fn test_add_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_add_community_team_member() .times(1) - .withf(move |actor_user_id, cid, uid, role| { - *actor_user_id == user_id - && *cid == community_id - && *uid == new_member_id + .withf(move |actor_user_id, community_id, user_id, role| { + *actor_user_id == ids.user_id + && *community_id == ids.community_id + && *user_id == new_member_id && *role == CommunityRole::Admin }) - .returning(move |_, _, _, _| Ok(())); + .returning(|_, _, _, _| Ok(())); db.expect_get_community_summary() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(community.clone())); - db.expect_get_site_settings() - .times(1) - .returning(move || Ok(site_settings.clone())); + expect_site_settings(&mut db); // Setup notifications manager mock let mut nm = MockNotificationsManager::new(); @@ -202,18 +184,17 @@ async fn test_add_success() { .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/team/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let request = form_request( + "POST", + "/dashboard/community/team/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -227,10 +208,8 @@ async fn test_add_success() { #[tokio::test] async fn test_add_db_error() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let new_member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let new_member_form = NewTeamMember { role: CommunityRole::Admin, user_id: new_member_id, @@ -239,22 +218,16 @@ async fn test_add_db_error() { // Setup database mock with a failing write and no notification context reads let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_add_community_team_member() .times(1) - .withf(move |actor_user_id, cid, uid, role| { - *actor_user_id == user_id - && *cid == community_id - && *uid == new_member_id + .withf(move |actor_user_id, community_id, user_id, role| { + *actor_user_id == ids.user_id + && *community_id == ids.community_id + && *user_id == new_member_id && *role == CommunityRole::Admin }) - .returning(move |_, _, _, _| Err(anyhow!("db error"))); + .returning(|_, _, _, _| Err(anyhow!("db error"))); db.expect_get_community_summary().never(); db.expect_get_site_settings().never(); @@ -263,63 +236,46 @@ async fn test_add_db_error() { nm.expect_enqueue().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/community/team/add") - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let request = form_request( + "POST", + "/dashboard/community/team/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] async fn test_delete_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_delete_community_team_member() .times(1) - .withf(move |actor_user_id, cid, uid| { - *actor_user_id == user_id && *cid == community_id && *uid == member_id + .withf(move |actor_user_id, community_id, user_id| { + *actor_user_id == ids.user_id + && *community_id == ids.community_id + && *user_id == member_id }) - .returning(move |_, _, _| Ok(())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(|_, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/community/team/{member_id}/delete")) - .header(HOST, "example.test") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/community/team/{member_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -332,48 +288,95 @@ async fn test_delete_success() { #[tokio::test] async fn test_delete_current_user_logs_out() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); - db.expect_user_has_community_permission() - .times(1) - .withf(move |cid, uid, permission| { - *cid == community_id && *uid == user_id && permission == CommunityPermission::TeamWrite - }) - .returning(move |_, _, _| Ok(true)); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); db.expect_delete_community_team_member() .times(1) - .withf(move |actor_user_id, cid, uid| { - *actor_user_id == user_id && *cid == community_id && *uid == user_id + .withf(move |actor_user_id, community_id, user_id| { + *actor_user_id == ids.user_id + && *community_id == ids.community_id + && *user_id == ids.user_id }) - .returning(move |_, _, _| Ok(())); + .returning(|_, _, _| Ok(())); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + // Setup router and send an HTMX request removing the current user + let router = TestRouterBuilder::new(db).build().await; + let request = authenticated_request( + "DELETE", + &format!("/dashboard/community/team/{}/delete", ids.user_id), + ids.session_id, + ) + .header("HX-Request", "true") + .body(Body::empty()) + .unwrap(); + let (parts, bytes) = send(router, request).await; + + // Check response matches expectations + assert_empty_hx_redirect_response(&parts, &bytes, StatusCode::OK, LOG_IN_URL); +} + +#[tokio::test] +async fn test_update_role_invalid_payload() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let member_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); + db.expect_update_community_team_member_role().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/community/team/{user_id}/delete")) - .header(HOST, "example.test") - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/team/{member_id}/role"), + ids.session_id, + "role=unknown", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_empty_hx_redirect_response(&parts, &bytes, StatusCode::OK, LOG_IN_URL); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); +} + +#[tokio::test] +async fn test_update_role_success() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let member_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_community_access(&mut db, &ids, CommunityPermission::TeamWrite); + db.expect_update_community_team_member_role() + .times(1) + .withf(move |actor_user_id, community_id, user_id, role| { + *actor_user_id == ids.user_id + && *community_id == ids.community_id + && *user_id == member_id + && *role == CommunityRole::GroupsManager + }) + .returning(|_, _, _, _| Ok(())); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/community/team/{member_id}/role"), + ids.session_id, + "role=groups-manager", + ); + let (parts, bytes) = send(router, request).await; + + // Check response matches expectations + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::NO_CONTENT, + "refresh-community-dashboard-table", + ); } diff --git a/ocg-server/src/handlers/dashboard/community/tests.rs b/ocg-server/src/handlers/dashboard/community/tests.rs index 885d45eed..c1f660d27 100644 --- a/ocg-server/src/handlers/dashboard/community/tests.rs +++ b/ocg-server/src/handlers/dashboard/community/tests.rs @@ -1,10 +1,5 @@ -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::COOKIE}, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use serde_json::json; -use tower::ServiceExt; use uuid::Uuid; use crate::{ @@ -13,56 +8,46 @@ use crate::{ auth::session_context::{SELECTED_COMMUNITY_ID_KEY, SELECTED_GROUP_ID_KEY}, tests::*, }, - services::notifications::MockNotificationsManager, types::permissions::CommunityPermission, }; #[tokio::test] async fn test_select_community_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(community_id, group_id); + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + expect_session(&mut db, &ids); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, true); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |user_id| *user_id == ids.user_id) .returning(move |_| Ok(groups.clone())); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) + .is_some_and(|value| value == &json!(ids.community_id)) && record .data .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(group_id)) + .is_some_and(|value| value == &json!(ids.group_id)) }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/community/{}/select", ids.community_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); @@ -70,56 +55,53 @@ async fn test_select_community_success() { #[tokio::test] async fn test_select_community_without_groups() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let stale_group_id = Uuid::new_v4(); // Stale group from a different community - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and a session holding a stale group from another community + let ids = DashboardIds::new(); + let stale_group_id = Uuid::new_v4(); let auth_hash = "hash".to_string(); - let session_record = - sample_session_record(session_id, user_id, &auth_hash, None, Some(stale_group_id)); + let session_record = sample_session_record( + ids.session_id, + ids.user_id, + &auth_hash, + None, + Some(stale_group_id), + ); // Setup database mock let mut db = MockDB::new(); db.expect_get_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |id| *id == ids.session_id) .returning(move |_| Ok(Some(session_record.clone()))); db.expect_get_user_by_id() .times(1) - .withf(move |id| *id == user_id) - .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); - expect_community_permission(&mut db, community_id, user_id, CommunityPermission::Read); + .withf(move |id| *id == ids.user_id) + .returning(move |_| Ok(Some(sample_auth_user(ids.user_id, &auth_hash)))); + expect_community_permission_result(&mut db, &ids, CommunityPermission::Read, true); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |user_id| *user_id == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) + .is_some_and(|value| value == &json!(ids.community_id)) && !record.data.contains_key(SELECTED_GROUP_ID_KEY) }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/community/{}/select", ids.community_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); diff --git a/ocg-server/src/handlers/dashboard/group/analytics/tests.rs b/ocg-server/src/handlers/dashboard/group/analytics/tests.rs index f294596ce..346fc3dd2 100644 --- a/ocg-server/src/handlers/dashboard/group/analytics/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/analytics/tests.rs @@ -1,25 +1,15 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; -use uuid::Uuid; +use axum::http::StatusCode; use crate::{ - db::mock::MockDB, handlers::tests::*, services::notifications::MockNotificationsManager, + db::mock::MockDB, + handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, + types::permissions::GroupPermission, }; #[tokio::test] async fn test_page_hides_cohosted_events_without_cohosted_events() { // Setup identifiers and stats without co-hosted events - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut stats = sample_group_stats(); stats.cohosted_events.per_month.clear(); stats.cohosted_events.running_total.clear(); @@ -27,35 +17,25 @@ async fn test_page_hides_cohosted_events_without_cohosted_events() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .returning(|_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_get_group_stats() .times(1) + .withf(move |cid, gid, include_subgroups| { + *cid == ids.community_id && *gid == ids.group_id && !*include_subgroups + }) .returning(move |_, _, _| Ok(stats.clone())); db.expect_group_has_active_subgroups() .times(1) .returning(|_, _| Ok(false)); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/analytics") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/analytics", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the co-hosted events section is omitted - assert_eq!(parts.status, StatusCode::OK); - let body = std::str::from_utf8(&bytes).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("id=\"events-monthly-chart\"")); assert!(!body.contains("Co-hosted events")); assert!(!body.contains("cohosted-events-monthly-chart")); @@ -63,94 +43,67 @@ async fn test_page_hides_cohosted_events_without_cohosted_events() { #[tokio::test] async fn test_page_rejects_invalid_query() { - // Setup identifiers - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock without statistics reads + // Setup identifiers and database mock without statistics reads + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .returning(|_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_get_group_stats().never(); db.expect_group_has_active_subgroups().never(); // Setup router and send request with an invalid flag - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/analytics?include_subgroups=maybe") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/analytics?include_subgroups=maybe", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the query is rejected before loading statistics assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(bytes.as_ref(), b"invalid request payload"); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] async fn test_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let stats = sample_group_stats(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id - }) - .returning(|_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_get_group_stats() .times(1) .withf(move |cid, gid, include_subgroups| { - *cid == community_id && *gid == group_id && *include_subgroups + *cid == ids.community_id && *gid == ids.group_id && *include_subgroups }) .returning(move |_, _, _| Ok(stats.clone())); db.expect_group_has_active_subgroups() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(|_, _| Ok(true)); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/analytics?include_subgroups=true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/analytics?include_subgroups=true", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); - let body = std::str::from_utf8(&bytes).unwrap(); - assert!(body.contains("Include subgroups")); - assert!(body.contains("checked")); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(include_subgroups_input(&body).contains("checked")); assert!(body.contains("Co-hosted events")); assert!(body.contains("id=\"cohosted-events-monthly-chart\"")); } + +// Helpers. + +/// Returns the include-subgroups checkbox markup from the rendered page. +fn include_subgroups_input(body: &str) -> &str { + let start = body.find("').unwrap(); + &body[start..end] +} diff --git a/ocg-server/src/handlers/dashboard/group/attendees/tests.rs b/ocg-server/src/handlers/dashboard/group/attendees/tests.rs index e3dd4588d..4e70bb6c3 100644 --- a/ocg-server/src/handlers/dashboard/group/attendees/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/attendees/tests.rs @@ -1,17 +1,14 @@ use std::fmt::Write as _; -use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, + body::Body, http::{ HeaderValue, Request, StatusCode, - header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }, }; -use axum_login::tower_sessions::session; use chrono::{TimeZone, Utc}; use mockall::Sequence; -use tower::ServiceExt; use uuid::Uuid; use crate::{ @@ -20,12 +17,11 @@ use crate::{ dashboard::group::attendees::{ EventCustomNotification, EventCustomNotificationRecipientScope, }, - error::HandlerError, + error::{HandlerError, INVALID_REQUEST_PAYLOAD}, tests::*, }, services::{ enrollment::{AdmissionAllocationOutcome, EnrollmentError, MockEnrollmentManager}, - notifications::MockNotificationsManager, payments::{MockPaymentsManager, PaymentsError}, }, types::{ @@ -34,7 +30,8 @@ use crate::{ group::{ PresenceFilter, attendees::{ - AttendeeEnrollmentStatus, AttendeeEnrollmentStatusFilter, AttendeesSort, + AttendeeEnrollmentStatus, AttendeeEnrollmentStatusFilter, AttendeesOutput, + AttendeesSort, }, }, }, @@ -53,24 +50,12 @@ use super::MAX_ATTENDEES_EXPORT_ROWS; #[tokio::test] async fn test_accept_invitation_request_returns_conflict_when_queue_has_priority() { - // Setup identifiers and the conflict outcome - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let target_user_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -86,22 +71,19 @@ async fn test_accept_invitation_request_returns_conflict_when_queue_has_priority }); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri(format!( + let request = form_request( + "PUT", + &format!( "/dashboard/group/events/{event_id}/attendees/{target_user_id}/invitation-request/accept" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ids.session_id, + "", + ); + let (parts, bytes) = send(router, request).await; // Check the conflict payload and refresh triggers assert_eq!(parts.status, StatusCode::CONFLICT); @@ -121,25 +103,13 @@ async fn test_accept_invitation_request_returns_conflict_when_queue_has_priority #[tokio::test] async fn test_accept_invitation_request_returns_no_content() { - // Setup identifiers and the allocation outcome - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let event_ticket_type_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let target_user_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -147,33 +117,28 @@ async fn test_accept_invitation_request_returns_no_content() { .expect_accept_invitation_request() .times(1) .withf(move |input| { - input.actor_user_id == user_id + input.actor_user_id == ids.user_id && input.event_id == event_id - && input.group_id == group_id + && input.group_id == ids.group_id && input.user_id == target_user_id && input.event_ticket_type_id == Some(event_ticket_type_id) }) .returning(|_| Box::pin(async { Ok(AdmissionAllocationOutcome::Allocated) })); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri(format!( + let request = form_request( + "PUT", + &format!( "/dashboard/group/events/{event_id}/attendees/{target_user_id}/invitation-request/accept" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( - "event_ticket_type_id={event_ticket_type_id}" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ids.session_id, + format!("event_ticket_type_id={event_ticket_type_id}"), + ); + let (parts, bytes) = send(router, request).await; // Check the refresh triggers assert_empty_hx_trigger_response( @@ -185,87 +150,58 @@ async fn test_accept_invitation_request_returns_no_content() { } #[tokio::test] -async fn test_approve_refund_request_returns_no_content_when_payments_manager_succeeds() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let event_purchase_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock +async fn test_accept_invitation_request_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let target_user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup payments manager mock - let mut payments_manager = MockPaymentsManager::new(); - payments_manager - .expect_approve_refund_request() + // Setup enrollment manager mock rejecting the acceptance + let mut enrollment_manager = MockEnrollmentManager::new(); + enrollment_manager + .expect_accept_invitation_request() .times(1) - .withf(move |input| { - input.actor_user_id == user_id - && input.event_purchase_id == event_purchase_id - && input.group_id == group_id - && input.review_note.is_none() - }) - .returning(|_| Box::pin(async { Ok(()) })); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .withf(move |input| input.event_id == event_id && input.user_id == target_user_id) + .returning(|_| { + Box::pin(async { + Err(EnrollmentError::Rejected( + "pending invitation request not found".to_string(), + )) + }) + }); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) - .with_payments_manager(payments_manager) + let router = TestRouterBuilder::new(db) + .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/refunds/{event_purchase_id}/approve" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "PUT", + &format!( + "/dashboard/group/events/{event_id}/attendees/{target_user_id}/invitation-request/accept" + ), + ids.session_id, + "", + ); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations - assert_empty_hx_trigger_response( - &parts, - &bytes, - StatusCode::NO_CONTENT, - "refresh-event-attendees, refresh-event-invitation-requests, refresh-event-waitlist, refresh-group-refunds", + // Check the rejection message is returned + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "pending invitation request not found" ); } #[tokio::test] -async fn test_approve_refund_request_returns_internal_server_error_when_payments_manager_fails() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); +async fn test_approve_refund_request_returns_no_content_when_payments_manager_succeeds() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_purchase_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup payments manager mock let mut payments_manager = MockPaymentsManager::new(); @@ -273,110 +209,86 @@ async fn test_approve_refund_request_returns_internal_server_error_when_payments .expect_approve_refund_request() .times(1) .withf(move |input| { - input.actor_user_id == user_id + input.actor_user_id == ids.user_id && input.event_purchase_id == event_purchase_id - && input.group_id == group_id + && input.group_id == ids.group_id && input.review_note.is_none() }) - .returning(|_| Box::pin(async { Err(anyhow!("payments error").into()) })); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/refunds/{event_purchase_id}/approve" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "PUT", + &format!("/dashboard/group/refunds/{event_purchase_id}/approve"), + ids.session_id, + "", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::NO_CONTENT, + "refresh-event-attendees, refresh-event-invitation-requests, refresh-event-waitlist, refresh-group-refunds", + ); } #[tokio::test] -async fn test_cancel_event_attendee_attendance_returns_internal_server_error_when_manager_fails() { - // Setup identifiers and an internal manager failure - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let target_user_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock +async fn test_approve_refund_request_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let event_purchase_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup enrollment manager mock - let mut enrollment_manager = MockEnrollmentManager::new(); - enrollment_manager - .expect_cancel_attendance_as_organizer() + // Setup payments manager mock rejecting the approval + let mut payments_manager = MockPaymentsManager::new(); + payments_manager + .expect_approve_refund_request() .times(1) + .withf(move |input| input.event_purchase_id == event_purchase_id) .returning(|_| { - Box::pin(async { Err(EnrollmentError::Other(anyhow!("queue unavailable"))) }) + Box::pin(async { + Err(PaymentsError::Rejected( + "refund request not found".to_string(), + )) + }) }); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_enrollment_manager(enrollment_manager) + let router = TestRouterBuilder::new(db) + .with_payments_manager(payments_manager) .build() .await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/{target_user_id}/attendance" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "PUT", + &format!("/dashboard/group/refunds/{event_purchase_id}/approve"), + ids.session_id, + "", + ); + let (parts, bytes) = send(router, request).await; - // Check the internal failure is hidden - assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR); - assert!(bytes.is_empty()); + // Check the rejection message is returned + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "refund request not found" + ); } #[tokio::test] async fn test_cancel_event_attendee_attendance_returns_no_content() { - // Setup identifiers and the organizer cancellation - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let target_user_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -384,30 +296,25 @@ async fn test_cancel_event_attendee_attendance_returns_no_content() { .expect_cancel_attendance_as_organizer() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.community_id == community_id + input.actor_user_id == ids.user_id + && input.community_id == ids.community_id && input.event_id == event_id - && input.group_id == group_id + && input.group_id == ids.group_id && input.user_id == target_user_id }) .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/{target_user_id}/attendance" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = empty_request( + "DELETE", + &format!("/dashboard/group/events/{event_id}/attendees/{target_user_id}/attendance"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the refresh triggers assert_empty_hx_trigger_response( @@ -419,116 +326,79 @@ async fn test_cancel_event_attendee_attendance_returns_no_content() { } #[tokio::test] -async fn test_cancel_event_attendee_attendance_returns_unprocessable_entity_for_database_rejection() -{ - // Setup identifiers and a user-facing database rejection - let community_id = Uuid::new_v4(); +async fn test_cancel_event_attendee_attendance_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let target_user_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup enrollment manager mock + // Setup enrollment manager mock rejecting the cancellation let mut enrollment_manager = MockEnrollmentManager::new(); enrollment_manager .expect_cancel_attendance_as_organizer() .times(1) + .withf(move |input| input.event_id == event_id && input.user_id == target_user_id) .returning(|_| { Box::pin(async { - Err(EnrollmentError::Other( - HandlerError::Database("attendance is not confirmed".to_string()).into(), + Err(EnrollmentError::Rejected( + "confirmed event attendee not found".to_string(), )) }) }); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/{target_user_id}/attendance" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = empty_request( + "DELETE", + &format!("/dashboard/group/events/{event_id}/attendees/{target_user_id}/attendance"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( String::from_utf8(bytes.to_vec()).unwrap(), - "attendance is not confirmed" + "confirmed event attendee not found" ); } #[tokio::test] async fn test_cancel_event_offer_returns_no_content() { - // Setup identifiers and data structures + // Setup identifiers and database mock + let ids = DashboardIds::new(); let admission_offer_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_cancel_event_admission_offer() .times(1) .withf(move |actor_id, gid, oid, payment_provider| { - *actor_id == user_id - && *gid == group_id + *actor_id == ids.user_id + && *gid == ids.group_id && *oid == admission_offer_id && payment_provider.is_none() }) .returning(move |_, _, _, _| { Ok(EventEnrollmentReconciliationOutcome { - community_id, + community_id: ids.community_id, event_id: Uuid::from_u128(1), - group_id, + group_id: ids.group_id, }) }); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/admission-offers/{admission_offer_id}/cancel" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/group/admission-offers/{admission_offer_id}/cancel"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -539,14 +409,42 @@ async fn test_cancel_event_offer_returns_no_content() { ); } +#[tokio::test] +async fn test_download_csv_rejects_event_above_supported_export_size() { + // Setup identifiers and an attendee total above the export bound + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let output = AttendeesOutput { + all_attendees_email_recipient_total: MAX_ATTENDEES_EXPORT_ROWS + 1, + attendees: vec![sample_attendee()], + total: MAX_ATTENDEES_EXPORT_ROWS + 1, + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_export_reads(&mut db, &ids, event_id, output); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/attendees.csv"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + + // Check the export is rejected with the supported size + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "attendee export supports up to 10000 confirmed attendees; this event has 10001", + ); +} + #[tokio::test] async fn test_download_csv_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut attendee = sample_attendee(); attendee.created_at = Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 0).unwrap(); attendee.user.name = Some("Doe, Jane".to_string()); @@ -558,8 +456,7 @@ async fn test_download_csv_success() { attendee_without_name.user.username = "anonymous-attendee".to_string(); attendee_without_name.user.company = None; attendee_without_name.user.title = None; - let event = sample_event_summary(event_id, group_id); - let output = crate::types::dashboard::group::attendees::AttendeesOutput { + let output = AttendeesOutput { all_attendees_email_recipient_total: 2, attendees: vec![attendee, attendee_without_name], total: 2, @@ -567,43 +464,15 @@ async fn test_download_csv_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_search_event_attendees() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(MAX_ATTENDEES_EXPORT_ROWS) - && filters.offset.is_none() - && filters.status == Some(AttendeeEnrollmentStatusFilter::Confirmed) - }) - .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_export_reads(&mut db, &ids, event_id, output); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/attendees.csv")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/attendees.csv"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::OK); @@ -621,173 +490,29 @@ async fn test_download_csv_success() { ); } -#[tokio::test] -async fn test_download_csv_rejects_event_above_supported_export_size() { - // Setup identifiers and an attendee total above the export bound - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let output = crate::types::dashboard::group::attendees::AttendeesOutput { - all_attendees_email_recipient_total: MAX_ATTENDEES_EXPORT_ROWS + 1, - attendees: vec![sample_attendee()], - total: MAX_ATTENDEES_EXPORT_ROWS + 1, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_search_event_attendees() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id && *eid == event_id && filters.limit == Some(MAX_ATTENDEES_EXPORT_ROWS) - }) - .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/attendees.csv")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the export is rejected with the supported size - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "attendee export supports up to 10000 confirmed attendees; this event has 10001", - ); -} - -#[tokio::test] -async fn test_download_csv_with_answers_neutralizes_formula_cells() { - // Setup identifiers and attendee data starting with formula characters - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let question_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut attendee = sample_attendee(); - attendee.user.name = Some("=HYPERLINK(\"https://example.com\")".to_string()); - attendee.user.company = Some("+Corp".to_string()); - attendee.user.title = Some("@Lead".to_string()); - attendee.registration_answers = Some(QuestionnaireAnswers { - answers: vec![QuestionnaireAnswer { - question_id, - value: QuestionnaireAnswerValue::One("\tcmd".to_string()), - }], - }); - let event = sample_event_summary(event_id, group_id); - let registration_questions = vec![QuestionnaireQuestion { - id: question_id, - kind: QuestionnaireQuestionKind::FreeText, - prompt: "-Notes".to_string(), - required: false, - - options: vec![], - }]; - let output = crate::types::dashboard::group::attendees::AttendeesOutput { - all_attendees_email_recipient_total: 1, - attendees: vec![attendee], - total: 1, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_search_event_attendees() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(MAX_ATTENDEES_EXPORT_ROWS) - && filters.status == Some(AttendeeEnrollmentStatusFilter::Confirmed) - }) - .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(move |_, _| Ok(registration_questions.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees-with-answers.csv" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the question header and every formula cell are neutralized - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "Name,Company,Title,Invited,Enrollment date,Payment method,Amount,Payment deadline,Paid at,Marked by,Payment details,'-Notes\n\"'=HYPERLINK(\"\"https://example.com\"\")\",'+Corp,'@Lead,No,2024-01-01 12:00 UTC,,,,,,,'\tcmd\n", - ); -} - #[tokio::test] #[allow(clippy::too_many_lines)] async fn test_download_csv_with_answers_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let multi_option_id_1 = Uuid::new_v4(); let multi_option_id_2 = Uuid::new_v4(); let question_id_1 = Uuid::new_v4(); let question_id_2 = Uuid::new_v4(); let question_id_3 = Uuid::new_v4(); let single_option_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + + // Prefix a header, user fields and a free-text answer with formula characters let mut attendee = sample_attendee(); + attendee.user.name = Some("=HYPERLINK(\"https://example.com\")".to_string()); + attendee.user.company = Some("+Corp".to_string()); + attendee.user.title = Some("@Lead".to_string()); attendee.registration_answers = Some(QuestionnaireAnswers { answers: vec![ QuestionnaireAnswer { question_id: question_id_1, - value: QuestionnaireAnswerValue::One("No peanuts".to_string()), + value: QuestionnaireAnswerValue::One("\tNo peanuts".to_string()), }, QuestionnaireAnswer { question_id: question_id_2, @@ -802,12 +527,11 @@ async fn test_download_csv_with_answers_success() { let mut attendee_without_answers = sample_attendee(); attendee_without_answers.user.name = Some("No Answers".to_string()); attendee_without_answers.registration_answers = None; - let event = sample_event_summary(event_id, group_id); let registration_questions = vec![ QuestionnaireQuestion { id: question_id_1, kind: QuestionnaireQuestionKind::FreeText, - prompt: "Dietary restrictions?".to_string(), + prompt: "-Dietary restrictions?".to_string(), required: false, options: vec![], @@ -841,7 +565,7 @@ async fn test_download_csv_with_answers_success() { ], }, ]; - let output = crate::types::dashboard::group::attendees::AttendeesOutput { + let output = AttendeesOutput { all_attendees_email_recipient_total: 2, attendees: vec![attendee, attendee_without_answers], total: 2, @@ -849,51 +573,21 @@ async fn test_download_csv_with_answers_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_search_event_attendees() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(MAX_ATTENDEES_EXPORT_ROWS) - && filters.offset.is_none() - && filters.status == Some(AttendeeEnrollmentStatusFilter::Confirmed) - }) - .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); + expect_export_reads(&mut db, &ids, event_id, output); db.expect_get_event_registration_questions() .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) + .withf(move |cid, eid| *cid == ids.community_id && *eid == event_id) .returning(move |_, _| Ok(registration_questions.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees-with-answers.csv" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/attendees-with-answers.csv"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations + // Check response matches expectations, with every formula cell neutralized assert_eq!(parts.status, StatusCode::OK); assert_eq!( parts.headers.get(CONTENT_TYPE).unwrap(), @@ -907,54 +601,43 @@ async fn test_download_csv_with_answers_success() { ); assert_eq!( String::from_utf8(bytes.to_vec()).unwrap(), - "Name,Company,Title,Invited,Enrollment date,Payment method,Amount,Payment deadline,Paid at,Marked by,Payment details,Dietary restrictions?,Meal preference,Topics\nEvent Attendee,Example,Engineer,No,2024-01-01 12:00 UTC,,,,,,,No peanuts,Vegetarian,\"Rust, Databases\"\nNo Answers,Example,Engineer,No,2024-01-01 12:00 UTC,,,,,,,,,\n", + concat!( + "Name,Company,Title,Invited,Enrollment date,Payment method,Amount,Payment deadline,", + "Paid at,Marked by,Payment details,'-Dietary restrictions?,Meal preference,Topics\n", + "\"'=HYPERLINK(\"\"https://example.com\"\")\",'+Corp,'@Lead,No,2024-01-01 12:00 UTC,", + ",,,,,,'\tNo peanuts,Vegetarian,\"Rust, Databases\"\n", + "No Answers,Example,Engineer,No,2024-01-01 12:00 UTC,,,,,,,,,\n", + ), ); } #[tokio::test] async fn test_invite_event_attendee_returns_bad_request_when_target_conflicts() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let invited_user_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_invite_event_attendee().times(0); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); + // Setup enrollment manager mock + let mut enrollment_manager = MockEnrollmentManager::new(); + enrollment_manager.expect_invite_event_attendee().times(0); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( - "user_id={invited_user_id}&email=invitee%40example.com" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations + let router = TestRouterBuilder::new(db) + .with_enrollment_manager(enrollment_manager) + .build() + .await; + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + format!("user_id={invited_user_id}&email=invitee%40example.com"), + ); + let (parts, bytes) = send(router, request).await; + + // Check response matches expectations assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!( String::from_utf8(bytes.to_vec()).unwrap(), @@ -964,43 +647,28 @@ async fn test_invite_event_attendee_returns_bad_request_when_target_conflicts() #[tokio::test] async fn test_invite_event_attendee_returns_bad_request_when_target_missing() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_invite_event_attendee().times(0); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); + // Setup enrollment manager mock + let mut enrollment_manager = MockEnrollmentManager::new(); + enrollment_manager.expect_invite_event_attendee().times(0); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_enrollment_manager(enrollment_manager) + .build() + .await; + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + "", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -1012,24 +680,12 @@ async fn test_invite_event_attendee_returns_bad_request_when_target_missing() { #[tokio::test] async fn test_invite_event_attendee_returns_created_for_email_target() { - // Setup identifiers and the allocation outcome - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let ticket_type_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -1037,9 +693,9 @@ async fn test_invite_event_attendee_returns_created_for_email_target() { .expect_invite_event_attendee() .times(1) .withf(move |input| { - input.actor_user_id == user_id + input.actor_user_id == ids.user_id && input.event_id == event_id - && input.group_id == group_id + && input.group_id == ids.group_id && input.email.as_deref() == Some("Invitee@Example.com") && input.event_ticket_type_id == Some(ticket_type_id) && input.user_id.is_none() @@ -1047,24 +703,17 @@ async fn test_invite_event_attendee_returns_created_for_email_target() { .returning(|_| Box::pin(async { Ok(AdmissionAllocationOutcome::Allocated) })); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( - "email=Invitee%40Example.com&event_ticket_type_id={ticket_type_id}" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + format!("email=Invitee%40Example.com&event_ticket_type_id={ticket_type_id}"), + ); + let (parts, bytes) = send(router, request).await; // Check the refresh triggers assert_empty_hx_trigger_response( @@ -1077,24 +726,12 @@ async fn test_invite_event_attendee_returns_created_for_email_target() { #[tokio::test] async fn test_invite_event_attendee_returns_created_for_registered_user() { - // Setup identifiers and the allocation outcome - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let invitee_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -1109,22 +746,17 @@ async fn test_invite_event_attendee_returns_created_for_registered_user() { .returning(|_| Box::pin(async { Ok(AdmissionAllocationOutcome::Allocated) })); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!("user_id={invitee_id}"))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + format!("user_id={invitee_id}"), + ); + let (parts, bytes) = send(router, request).await; // Check the refresh triggers assert_empty_hx_trigger_response( @@ -1135,26 +767,58 @@ async fn test_invite_event_attendee_returns_created_for_registered_user() { ); } +#[tokio::test] +async fn test_invite_event_attendee_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let invitee_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); + + // Setup enrollment manager mock rejecting the invitation + let mut enrollment_manager = MockEnrollmentManager::new(); + enrollment_manager + .expect_invite_event_attendee() + .times(1) + .withf(move |input| input.event_id == event_id && input.user_id == Some(invitee_id)) + .returning(|_| { + Box::pin(async { + Err(EnrollmentError::Rejected( + "user is already attending this event".to_string(), + )) + }) + }); + + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_enrollment_manager(enrollment_manager) + .build() + .await; + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + format!("user_id={invitee_id}"), + ); + let (parts, bytes) = send(router, request).await; + + // Check the rejection message is returned + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "user is already attending this event" + ); +} + #[tokio::test] async fn test_invite_event_attendee_returns_ticket_type_sold_out_conflict() { - // Setup identifiers and the conflict outcome - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let ticket_type_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); // Setup enrollment manager mock let mut enrollment_manager = MockEnrollmentManager::new(); @@ -1170,24 +834,17 @@ async fn test_invite_event_attendee_returns_ticket_type_sold_out_conflict() { }); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_enrollment_manager(enrollment_manager) .build() .await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( - "email=invitee%40example.com&event_ticket_type_id={ticket_type_id}" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + format!("email=invitee%40example.com&event_ticket_type_id={ticket_type_id}"), + ); + let (parts, bytes) = send(router, request).await; // Check the conflict payload and refresh triggers assert_eq!(parts.status, StatusCode::CONFLICT); @@ -1207,64 +864,44 @@ async fn test_invite_event_attendee_returns_ticket_type_sold_out_conflict() { #[tokio::test] async fn test_invite_event_attendee_returns_unprocessable_entity_when_email_is_invalid() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_invite_event_attendee().times(0); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup notifications manager mock - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().times(0); + // Setup enrollment manager mock + let mut enrollment_manager = MockEnrollmentManager::new(); + enrollment_manager.expect_invite_event_attendee().times(0); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/events/{event_id}/attendees/invite" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("email=not-an-email")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_enrollment_manager(enrollment_manager) + .build() + .await; + let request = form_request( + "POST", + &format!("/dashboard/group/events/{event_id}/attendees/invite"), + ids.session_id, + "email=not-an-email", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!( - String::from_utf8(bytes.to_vec()) - .unwrap() - .contains("email: not a valid email") + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "email: not a valid email: value is missing `@`\n" ); } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_list_page_disables_reissue_for_sold_out_ticket_type() { // Setup identifiers and data structures + let ids = DashboardIds::new(); let available_ticket_type_id = Uuid::from_u128(1); - let community_id = Uuid::new_v4(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let sold_out_ticket_type_id = Uuid::from_u128(2); - let user_id = Uuid::new_v4(); let event = EventSummary { ticket_types: Some(vec![ EventTicketType { @@ -1282,7 +919,7 @@ async fn test_list_page_disables_reissue_for_sold_out_ticket_type() { ..Default::default() }, ]), - ..sample_event_summary(event_id, group_id) + ..sample_event_summary(event_id) }; let mut available_attendee = sample_attendee(); available_attendee.checked_in = false; @@ -1295,7 +932,7 @@ async fn test_list_page_disables_reissue_for_sold_out_ticket_type() { sold_out_attendee.user.user_id = Uuid::new_v4(); sold_out_attendee.admission_offer_status = Some(EventAdmissionOfferStatus::Expired); sold_out_attendee.event_ticket_type_id = Some(sold_out_ticket_type_id); - let output = crate::types::dashboard::group::attendees::AttendeesOutput { + let output = AttendeesOutput { all_attendees_email_recipient_total: 2, attendees: vec![available_attendee, sold_out_attendee], total: 2, @@ -1303,34 +940,19 @@ async fn test_list_page_disables_reissue_for_sold_out_ticket_type() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_attendee_list_permissions(&mut db, community_id, group_id, user_id, true); + expect_list_page_context(&mut db, &ids, event, true); db.expect_search_event_attendees() .times(1) - .withf(move |gid, eid, _| *gid == group_id && *eid == event_id) + .withf(move |gid, eid, _| *gid == ids.group_id && *eid == event_id) .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/attendees")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/attendees"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check both reissue actions render and only the sold-out one is disabled assert_html_response(&parts, &bytes, StatusCode::OK); @@ -1345,150 +967,16 @@ async fn test_list_page_disables_reissue_for_sold_out_ticket_type() { ); } -#[tokio::test] -async fn test_list_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut attendee = sample_attendee(); - attendee.checked_in = false; - attendee.manually_invited = true; - let pending_questions_attendee_id = Uuid::new_v4(); - let mut pending_questions_attendee = sample_attendee(); - pending_questions_attendee.checked_in = false; - pending_questions_attendee.enrollment_status = AttendeeEnrollmentStatus::RegistrationPending; - pending_questions_attendee.manually_invited = false; - pending_questions_attendee.user.user_id = pending_questions_attendee_id; - let event = sample_event_summary(event_id, group_id); - let output = crate::types::dashboard::group::attendees::AttendeesOutput { - all_attendees_email_recipient_total: 2, - attendees: vec![attendee.clone(), pending_questions_attendee], - total: 2, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_attendee_list_permissions(&mut db, community_id, group_id, user_id, false); - db.expect_search_event_attendees() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && filters.status.is_none() - }) - .returning(move |_, _, _| Ok(output.clone())); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/attendees")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_html_response(&parts, &bytes, StatusCode::OK); - let body = std::str::from_utf8(&bytes).unwrap(); - assert!(body.contains("id=\"attendees-enrollment-status\"")); - assert!(body.contains("name=\"status\"") && !body.contains("name=\"attendance\"")); - assert!(body.contains(&pending_questions_attendee_id.to_string())); - assert!(body.contains("Invited")); - assert!(body.contains("Check in attendee")); - assert!(!body.contains("Your role cannot manage check-in")); - let email_actions = body.find("id=\"attendee-email-actions-button\"").unwrap(); - let management_container = body[..email_actions].rfind("
, +) -> Request { + let form = serde_qs::to_string(&EventCustomNotification { + content: CustomNotificationContent { + body: "Body".to_string(), + subject: "Subject".to_string(), + }, + recipient_scope, + recipient_user_ids, + }) + .unwrap(); + + form_request( + "POST", + &format!("/dashboard/group/notifications/{event_id}"), + ids.session_id, + form, + ) +} + +/// Expects the event and site settings reads that compose a custom notification. +fn expect_custom_notification_context(db: &mut MockDB, ids: &DashboardIds, event_id: Uuid) { + let ids = *ids; + let event = sample_event_summary(event_id); + let site_settings = sample_site_settings(); + + db.expect_get_event_summary_by_id() + .times(1) + .withf(move |cid, eid| *cid == ids.community_id && *eid == event_id) + .returning(move |_, _| Ok(event.clone())); + db.expect_get_site_settings() + .times(1) + .returning(move || Ok(site_settings.clone())); +} + +/// Expects the session, permission, event summary and confirmed attendee rows +/// read by the CSV exports. +fn expect_export_reads( db: &mut MockDB, - community_id: Uuid, - group_id: Uuid, - user_id: Uuid, + ids: &DashboardIds, + event_id: Uuid, + output: AttendeesOutput, +) { + let ids = *ids; + let event = sample_event_summary(event_id); + + expect_group_access(db, &ids, GroupPermission::Read); + db.expect_get_event_summary() + .times(1) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) + .returning(move |_, _, _| Ok(event.clone())); + db.expect_search_event_attendees() + .times(1) + .withf(move |gid, eid, filters| { + *gid == ids.group_id + && *eid == event_id + && filters.limit == Some(MAX_ATTENDEES_EXPORT_ROWS) + && filters.offset.is_none() + && filters.status == Some(AttendeeEnrollmentStatusFilter::Confirmed) + }) + .returning(move |_, _, _| Ok(output.clone())); +} + +/// Expects the session, permissions, event summary and registration questions +/// read by the list page; `can_manage_events` is the events-write read result. +fn expect_list_page_context( + db: &mut MockDB, + ids: &DashboardIds, + event: EventSummary, can_manage_events: bool, ) { - for expected_permission in [ - GroupPermission::CheckInsWrite, - GroupPermission::EventsWrite, - GroupPermission::Read, - ] { - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == expected_permission - }) - .returning(move |_, _, _, _| { - Ok(expected_permission != GroupPermission::EventsWrite || can_manage_events) - }); - } + let ids = *ids; + let event_id = event.event_id; + + expect_group_access(db, &ids, GroupPermission::Read); + expect_group_permission_result(db, &ids, GroupPermission::CheckInsWrite, true); + expect_group_permission_result(db, &ids, GroupPermission::EventsWrite, can_manage_events); + db.expect_get_event_summary_dashboard() + .times(1) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) + .returning(move |_, _, _| Ok(event.clone())); + db.expect_get_event_registration_questions() + .times(1) + .withf(move |cid, eid| *cid == ids.community_id && *eid == event_id) + .returning(|_, _| Ok(vec![])); +} + +/// Builds a mark-paid request for the event purchase with the form body. +fn external_payment_request( + ids: &DashboardIds, + event_id: Uuid, + event_purchase_id: Uuid, + body: &'static str, +) -> Request { + form_request( + "POST", + &format!( + "/dashboard/group/events/{event_id}/purchases/{event_purchase_id}/external-payment" + ), + ids.session_id, + body, + ) } diff --git a/ocg-server/src/handlers/dashboard/group/badges/tests.rs b/ocg-server/src/handlers/dashboard/group/badges/tests.rs index 3caa83851..a5ba4a3e5 100644 --- a/ocg-server/src/handlers/dashboard/group/badges/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/badges/tests.rs @@ -1,21 +1,17 @@ use std::io::Cursor; use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::CONTENT_TYPE, header::COOKIE}, + body::Body, + http::{Request, StatusCode}, }; -use axum_login::tower_sessions::session; -use image::{DynamicImage, ImageFormat}; +use image::DynamicImage; use serde_json::json; -use tower::ServiceExt; use crate::{ db::mock::MockDB, - handlers::tests::{ - TestRouterBuilder, assert_empty_hx_trigger_response, assert_empty_response, - expect_authenticated_group_session, expect_group_permission, - }, - services::{images::MockImageStorage, notifications::MockNotificationsManager}, + handlers::tests::*, + services::images::MockImageStorage, + test_util::sample_png, types::{ badges::{ AwardBadgeOutcome, BadgeAwardInput, BadgeAwardSourceFilter, BadgeInput, @@ -28,258 +24,536 @@ use crate::{ use super::*; +/// Message returned for any badge artwork that fails validation. +const INVALID_ARTWORK_MESSAGE: &str = "badge artwork must be a 512x512 PNG, JPEG, or WebP image"; + #[tokio::test] -async fn test_add_artwork_rejects_missing_image() { - // Setup an authorized group session and a missing stored image - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +async fn test_artwork_page_renders_independent_tab() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_badge_artwork() + .times(1) + .withf(move |group_id| *group_id == ids.group_id) + .return_once(|_| Ok(Vec::new())); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/artwork", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check artwork renders alone and pushes its full dashboard URL + assert_html_response(&parts, &bytes, StatusCode::OK); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=artwork" + ); + assert!(body.contains(">Badges Artwork")); + assert!(!body.contains("data-badge-pane")); +} + +#[tokio::test] +async fn test_awards_page_builds_navigation() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); + let badge_id = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let expected_from = NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + let expected_to = NaiveDate::from_ymd_opt(2026, 2, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_awarded_badges() + .times(1) + .withf(move |group_id, filters| { + *group_id == ids.group_id + && filters.limit == 25 + && filters.offset == 50 + && filters.badge_id == Some(badge_id) + && filters.from.as_ref() == Some(&expected_from) + && filters.query.as_deref() == Some("alice") + && filters.source == Some(BadgeAwardSourceFilter::Event(event_id)) + && filters.status.as_deref() == Some("active") + && filters.to.as_ref() == Some(&expected_to) + }) + .return_once(|_, _| { + Ok(GroupAwardedBadges { + total: 120, + ..Default::default() + }) + }); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( + "/dashboard/group/awards?awards_offset=50&awards_query=alice&badge_id={badge_id}\ + &from=2026-01-01&limit=25&source={event_id}&status=active&to=2026-01-31" + ), + ids.session_id, ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check full and partial navigation stay within the awards tab + assert_html_response(&parts, &bytes, StatusCode::OK); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + &format!( + "/dashboard/group?tab=awards&awards_offset=50&awards_query=alice&badge_id={badge_id}\ + &from=2026-01-01&limit=25&source={event_id}&status=active&to=2026-01-31" + ), + ); + assert!(body.contains("badge-awards-pagination-next-spinner")); + assert!(body.contains("awards_offset=75")); + assert!(body.contains("awards_query=alice")); + assert!(body.contains("href=\"/dashboard/group?tab=awards")); + assert!(body.contains("hx-get=\"/dashboard/group/awards?")); + assert!(body.contains(">Badges Awards")); +} + +#[tokio::test] +async fn test_awards_page_filters_direct_group_awards() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_awarded_badges() + .times(1) + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.source == Some(BadgeAwardSourceFilter::Group) + }) + .return_once(|_, _| Ok(GroupAwardedBadges::default())); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/awards?source=group", ids.session_id); + let (parts, bytes) = send(router, request).await; + + // Check the group sentinel reaches the award-history query + assert_html_response(&parts, &bytes, StatusCode::OK); +} + +#[tokio::test] +async fn test_awards_page_rejects_invalid_from_date() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_awarded_badges().never(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/awards?from=not-a-date", ids.session_id); + let (parts, bytes) = send(router, request).await; + + // Check date validation fails before querying award history + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "from date is invalid"); +} + +#[tokio::test] +async fn test_awards_page_rejects_invalid_to_date() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_awarded_badges().never(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/awards?to=2026-02-31", ids.session_id); + let (parts, bytes) = send(router, request).await; + + // Check date validation fails before querying award history + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "to date is invalid"); +} + +#[tokio::test] +async fn test_awards_page_rejects_oversized_offset() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_awarded_badges().never(); + + // Setup router and send request with an offset PostgreSQL cannot bind + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/awards?awards_offset=2147483648", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + + // Check invalid pagination is rejected before any page query + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "award history offset is too large"); +} + +#[tokio::test] +async fn test_badges_page_builds_navigation() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_badge_artwork() + .times(1) + .withf(move |group_id| *group_id == ids.group_id) + .return_once(|_| Ok(Vec::new())); + db.expect_list_badges() + .times(1) + .withf(move |group_id, filters| { + *group_id == ids.group_id + && filters.limit == 25 + && filters.offset == 75 + && filters.query.as_deref() == Some("helper") + }) + .return_once(|_, _| { + Ok(GroupBadges { + total: 125, + ..Default::default() + }) + }); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/badges?badges_offset=75&badges_query=helper&limit=25", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check full and partial navigation stay within the badges tab + assert_html_response(&parts, &bytes, StatusCode::OK); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=badges&badges_offset=75&badges_query=helper&limit=25", + ); + assert!(body.contains("badge-definitions-pagination-next-spinner")); + assert!(body.contains("badges_offset=100")); + assert!(body.contains("badges_query=helper")); + assert!(body.contains("href=\"/dashboard/group?tab=badges")); + assert!(body.contains("hx-get=\"/dashboard/group/badges?")); + assert!(body.contains(">Badges")); +} + +#[tokio::test] +async fn test_badges_page_rejects_oversized_offset() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_badge_artwork().never(); + db.expect_list_badges().never(); + + // Setup router and send request with an offset PostgreSQL cannot bind + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/badges?badges_offset=2147483648", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + + // Check invalid pagination is rejected before any page query + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "badge definition offset is too large"); +} + +#[tokio::test] +async fn test_options_trims_search_and_returns_json() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_badges() + .times(1) + .withf(move |group_id, filters| { + *group_id == ids.group_id + && filters.limit == 50 + && filters.offset == 0 + && filters.query.as_deref() == Some("term") + }) + .return_once(|_, _| Ok(GroupBadges::default())); + + // Setup router and send request with surrounding whitespace + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/badges/options?query=%20term%20", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body: GroupBadges = serde_json::from_slice(&bytes).unwrap(); + + // Check the JSON page contract + assert_eq!(parts.status, StatusCode::OK); + assert_eq!(body.total, 0); + assert!( + body.badges.is_empty(), + "badges page should be empty, got {:?}", + body.badges + ); +} + +#[tokio::test] +async fn test_recipients_resolves_all_attendees() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let recipient_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_event_attendees_ids() + .times(1) + .withf(move |group_id, event, checked_in_only| { + *group_id == ids.group_id && *event == event_id && !*checked_in_only + }) + .return_once(move |_, _, _| Ok(vec![recipient_id])); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/badges/recipients?scope=all-attendees"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + // Check the explicit recipient list crosses the HTTP boundary + assert_eq!(parts.status, StatusCode::OK); + assert_eq!(body, json!({ "user_ids": [recipient_id] })); +} + +#[tokio::test] +async fn test_recipients_resolves_checked_in_attendees() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let recipient_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_list_event_attendees_ids() + .times(1) + .withf(move |group_id, event, checked_in_only| { + *group_id == ids.group_id && *event == event_id && *checked_in_only + }) + .return_once(move |_, _, _| Ok(vec![recipient_id])); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/badges/recipients?scope=checked-in-attendees"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + // Check the explicit recipient list crosses the HTTP boundary + assert_eq!(parts.status, StatusCode::OK); + assert_eq!(body, json!({ "user_ids": [recipient_id] })); +} + +#[tokio::test] +async fn test_add_rejects_invalid_input() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); + let mut input = sample_badge_input(); + input.description = " ".to_string(); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_add_badge().never(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/badges", + ids.session_id, + serde_qs::to_string(&input).unwrap(), + ); + let (parts, bytes) = send(router, request).await; + + // Check input validation fails before the badge mutation + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "all badge fields are required"); +} + +#[tokio::test] +async fn test_add_success() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); + let input = sample_badge_input(); + let body = serde_qs::to_string(&input).unwrap(); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_add_badge() + .times(1) + .withf(move |actor_id, community_id, group_id, badge| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + && badge.criteria == input.criteria + && badge.description == input.description + && badge.image_file_name == input.image_file_name + && badge.name == input.name + }) + .return_once(|_, _, _, _| Ok(())); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request("POST", "/dashboard/group/badges", ids.session_id, body); + let (parts, bytes) = send(router, request).await; + + // Check the dashboard refresh contract + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::CREATED, + "refresh-group-dashboard-table", + ); +} + +#[tokio::test] +async fn test_add_artwork_rejects_invalid_file_name() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_add_badge_artwork().never(); + + // Setup image storage mock, which must not be read + let mut storage = MockImageStorage::new(); + storage.expect_get().never(); + + // Setup router and send request with a path instead of a basename + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; + let (parts, bytes) = send(router, add_artwork_request(&ids, "../badge.png")).await; + + // Check the filename is rejected before storage or database access + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "artwork filename is invalid"); +} + +#[tokio::test] +async fn test_add_artwork_rejects_missing_image() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_add_badge_artwork().never(); + + // Setup image storage mock without the stored image let mut storage = MockImageStorage::new(); storage .expect_get() .times(1) .withf(|file_name| file_name == "badge.png") .returning(|_| Box::pin(async { Ok(None) })); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_image_storage(storage) - .build() - .await; - - // Register the missing basename through the protected gallery route - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/artwork") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("file_name=badge.png")) - .unwrap(), - ) - .await - .unwrap(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; + let (parts, bytes) = send(router, add_artwork_request(&ids, "badge.png")).await; // Check a nonexistent object cannot enter the reusable gallery - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "badge artwork was not found"); } #[tokio::test] async fn test_add_artwork_rejects_wrong_dimensions() { - // Setup an authorized group session and undersized stored PNG - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_add_badge_artwork().never(); + + // Setup image storage mock with an undersized PNG let mut storage = MockImageStorage::new(); storage .expect_get() .times(1) .withf(|file_name| file_name == "badge.png") - .return_once(|_| { - Box::pin(async { - Ok(Some(Image { - bytes: png_bytes(1, 1), - content_type: "image/png".to_string(), - })) - }) - }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_image_storage(storage) - .build() - .await; - - // Register the undersized image through the protected gallery route - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/artwork") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("file_name=badge.png")) - .unwrap(), - ) - .await - .unwrap(); + .return_once(|_| Box::pin(async { Ok(Some(png_image(1, 1))) })); + + // Setup router and send request + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; + let (parts, bytes) = send(router, add_artwork_request(&ids, "badge.png")).await; // Check invalid dimensions are rejected before the database mutation - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, INVALID_ARTWORK_MESSAGE); } #[tokio::test] async fn test_add_artwork_success() { - // Setup an authorized group session and valid stored badge artwork - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_add_badge_artwork() .times(1) - .withf(move |actor_id, community, group, file_name| { - *actor_id == user_id - && *community == community_id - && *group == group_id + .withf(move |actor_id, community_id, group_id, file_name| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && file_name == "badge.png" }) .return_once(|_, _, _, _| Ok(())); + + // Setup image storage mock with valid badge artwork let mut storage = MockImageStorage::new(); storage .expect_get() .times(1) .withf(|file_name| file_name == "badge.png") - .return_once(|_| { - Box::pin(async { - Ok(Some(Image { - bytes: png_bytes(512, 512), - content_type: "image/png".to_string(), - })) - }) - }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_image_storage(storage) - .build() - .await; - - // Register the validated image through the protected gallery route - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/artwork") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("file_name=badge.png")) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the dashboard refresh contract - assert_empty_hx_trigger_response( - &parts, - &body, - StatusCode::CREATED, - "refresh-group-dashboard-table", - ); -} + .return_once(|_| Box::pin(async { Ok(Some(png_image(512, 512))) })); -#[tokio::test] -async fn test_add_success() { - // Setup an authorized group session and valid definition - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let input = sample_badge_input(); - let body = serde_qs::to_string(&input).unwrap(); - let expected = input.clone(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_add_badge() - .times(1) - .withf(move |actor_id, community, group, badge| { - *actor_id == user_id - && *community == community_id - && *group == group_id - && badge.criteria == expected.criteria - && badge.description == expected.description - && badge.image_file_name == expected.image_file_name - && badge.name == expected.name - }) - .return_once(|_, _, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit the definition through its protected route - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; + let (parts, bytes) = send(router, add_artwork_request(&ids, "badge.png")).await; // Check the dashboard refresh contract assert_empty_hx_trigger_response( &parts, - &body, + &bytes, StatusCode::CREATED, "refresh-group-dashboard-table", ); } #[tokio::test] -async fn test_award_success() { - // Setup an authorized group session and one explicit recipient +async fn test_award_event_scoped_success() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let recipient_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_award_badge() .times(1) - .withf(move |actor, community, group, input| { - *actor == user_id - && *community == community_id - && *group == group_id + .withf(move |actor_id, community_id, group_id, input| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && *input == BadgeAwardInput { badge_id, @@ -293,103 +567,44 @@ async fn test_award_success() { skipped_count: 0, }) }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit the explicit recipient award - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/award") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - json!({ - "badge_id": badge_id, - "event_id": event_id, - "user_ids": [recipient_id], - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - - // Check the complete explicit set reaches the award mutation - assert_eq!(response.status(), StatusCode::CREATED); -} -#[tokio::test] -async fn test_award_requires_recipient() { - // Setup an authorized group session - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let body = json!({ + "badge_id": badge_id, + "event_id": event_id, + "user_ids": [recipient_id], + }); + let request = json_request( + "POST", + "/dashboard/group/badges/award", + ids.session_id, + &body, ); - db.expect_award_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit an award without recipient identifiers - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/award") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - json!({"badge_id": Uuid::new_v4(), "user_ids": []}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - // Check the handler rejects an empty set before mutation - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body, "badge recipients cannot be empty"); + // Check the complete explicit set reaches the award mutation + assert_eq!(parts.status, StatusCode::CREATED); + assert_eq!(body, json!({ "queued_count": 1, "skipped_count": 0 })); } #[tokio::test] -async fn test_group_scoped_award_success() { - // Setup an authorized group session and one recipient +async fn test_award_group_scoped_success() { + // Setup identifiers and data structures + let ids = DashboardIds::new(); let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let recipient_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_award_badge() .times(1) - .withf(move |actor, community, group, input| { - *actor == user_id - && *community == community_id - && *group == group_id + .withf(move |actor_id, community_id, group_id, input| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && *input == BadgeAwardInput { badge_id, @@ -400,112 +615,71 @@ async fn test_group_scoped_award_success() { .return_once(|_, _, _, _| { Ok(AwardBadgeOutcome { queued_count: 1, - skipped_count: 0, + skipped_count: 2, }) }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit the group-scoped recipient award - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/award") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - json!({ - "badge_id": badge_id, - "user_ids": [recipient_id], - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body: AwardBadgeOutcome = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); - // Check the inserted and skipped counts cross the HTTP boundary + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let body = json!({ + "badge_id": badge_id, + "user_ids": [recipient_id], + }); + let request = json_request( + "POST", + "/dashboard/group/badges/award", + ids.session_id, + &body, + ); + let (parts, bytes) = send(router, request).await; + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + // Check the queued and skipped counts cross the HTTP boundary assert_eq!(parts.status, StatusCode::CREATED); - assert_eq!(body.queued_count, 1); - assert_eq!(body.skipped_count, 0); + assert_eq!(body, json!({ "queued_count": 1, "skipped_count": 2 })); } #[tokio::test] -async fn test_delete_forbidden() { - // Setup an authenticated group session without badge write access - let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +async fn test_award_requires_recipient() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_badges_write_forbidden(&mut db, community_id, group_id, user_id); - db.expect_delete_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to delete a badge definition without write permission - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/badges/{badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_award_badge().never(); + + // Setup router and send request without recipient identifiers + let router = TestRouterBuilder::new(db).build().await; + let body = json!({ "badge_id": Uuid::new_v4(), "user_ids": [] }); + let request = json_request( + "POST", + "/dashboard/group/badges/award", + ids.session_id, + &body, + ); + let (parts, bytes) = send(router, request).await; - // Check permission fails before the badge mutation - assert_empty_response(&parts, &body, StatusCode::FORBIDDEN); + // Check the handler rejects an empty set before mutation + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "badge recipients cannot be empty"); } #[tokio::test] async fn test_delete_rejects_invalid_badge_id() { - // Setup an authorized group session without mutation expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_delete_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to delete a badge definition with a malformed path identifier - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri("/dashboard/group/badges/not-a-uuid") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + + // Setup router and send request with a malformed path identifier + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + "/dashboard/group/badges/not-a-uuid", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check path validation fails before the badge mutation assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -515,318 +689,116 @@ async fn test_delete_rejects_invalid_badge_id() { #[tokio::test] async fn test_delete_success() { - // Setup an authorized group session and existing badge definition + // Setup identifiers and database mock + let ids = DashboardIds::new(); let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_delete_badge() .times(1) - .withf(move |actor, community, group, badge| { - *actor == user_id - && *community == community_id - && *group == group_id + .withf(move |actor_id, community_id, group_id, badge| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && *badge == badge_id }) .return_once(|_, _, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Delete the badge definition through the protected route - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/badges/{badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/group/badges/{badge_id}"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the dashboard refresh contract assert_empty_hx_trigger_response( &parts, - &body, + &bytes, StatusCode::NO_CONTENT, "refresh-group-dashboard-table", ); } #[tokio::test] -async fn test_delete_artwork_forbidden() { - // Setup an authenticated group session without badge write access - let badge_artwork_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +async fn test_delete_artwork_rejects_invalid_artwork_id() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_badges_write_forbidden(&mut db, community_id, group_id, user_id); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_delete_badge_artwork().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to delete gallery artwork without write permission - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/badges/artwork/{badge_artwork_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); - // Check permission fails before the artwork mutation - assert_empty_response(&parts, &body, StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn test_delete_artwork_rejects_invalid_artwork_id() { - // Setup an authorized group session without mutation expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, + // Setup router and send request with a malformed path identifier + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + "/dashboard/group/badges/artwork/not-a-uuid", + ids.session_id, ); - db.expect_delete_badge_artwork().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to delete gallery artwork with a malformed path identifier - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri("/dashboard/group/badges/artwork/not-a-uuid") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check path validation fails before the artwork mutation assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert!(body.contains("Cannot parse")); - assert!(body.contains("badge_artwork_id")); -} - -#[tokio::test] -async fn test_delete_artwork_success() { - // Setup an authorized group session and unreferenced artwork - let badge_artwork_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_delete_badge_artwork() - .times(1) - .withf(move |actor, community, group, artwork| { - *actor == user_id - && *community == community_id - && *group == group_id - && *artwork == badge_artwork_id - }) - .return_once(|_, _, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Delete the gallery artwork through the protected route - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/badges/artwork/{badge_artwork_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the dashboard refresh contract - assert_empty_hx_trigger_response( - &parts, - &body, - StatusCode::NO_CONTENT, - "refresh-group-dashboard-table", - ); -} - -#[tokio::test] -async fn test_resolve_checked_in_attendee_recipients() { - // Setup an authorized event session and its current checked-in recipients - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let recipient_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_event_attendees_ids() - .times(1) - .withf(move |group, event, checked_in_only| { - *group == group_id && *event == event_id && *checked_in_only - }) - .return_once(move |_, _, _| Ok(vec![recipient_id])); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Resolve the checked-in bypass option - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/badges/recipients?scope=checked-in-attendees" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body: serde_json::Value = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); - - // Check the explicit recipient list crosses the HTTP boundary - assert_eq!(parts.status, StatusCode::OK); - assert_eq!(body, json!({ "user_ids": [recipient_id] })); + assert!(body.contains("badge_artwork_id")); } #[tokio::test] -async fn test_revoke_forbidden() { - // Setup an authenticated group session without badge write access - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); +async fn test_delete_artwork_success() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let badge_artwork_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_badges_write_forbidden(&mut db, community_id, group_id, user_id); - db.expect_revoke_group_user_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to revoke a credential without write permission - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/badges/awards/{user_badge_id}/revoke" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("reason=duplicate")) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); + db.expect_delete_badge_artwork() + .times(1) + .withf(move |actor_id, community_id, group_id, artwork| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + && *artwork == badge_artwork_id + }) + .return_once(|_, _, _, _| Ok(())); - // Check permission fails before the revocation mutation - assert_empty_response(&parts, &body, StatusCode::FORBIDDEN); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/group/badges/artwork/{badge_artwork_id}"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + + // Check the dashboard refresh contract + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::NO_CONTENT, + "refresh-group-dashboard-table", + ); } #[tokio::test] async fn test_revoke_rejects_invalid_user_badge_id() { - // Setup an authorized group session without mutation expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_revoke_group_user_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to revoke a credential with a malformed path identifier - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/dashboard/group/badges/awards/not-a-uuid/revoke") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("reason=duplicate")) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + + // Setup router and send request with a malformed path identifier + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/badges/awards/not-a-uuid/revoke", + ids.session_id, + "reason=duplicate", + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check path validation fails before the revocation mutation assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -836,729 +808,376 @@ async fn test_revoke_rejects_invalid_user_badge_id() { #[tokio::test] async fn test_revoke_success() { - // Setup an authorized group session and active issued credential - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_revoke_group_user_badge() .times(1) - .withf(move |actor, community, group, badge, reason| { - *actor == user_id - && *community == community_id - && *group == group_id + .withf(move |actor_id, community_id, group_id, badge, reason| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && *badge == user_badge_id && reason == "duplicate" }) .return_once(|_, _, _, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Revoke the issued credential with its private reason - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri(format!( - "/dashboard/group/badges/awards/{user_badge_id}/revoke" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("reason=duplicate")) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + + // Setup router and send request with the private reason + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + &format!("/dashboard/group/badges/awards/{user_badge_id}/revoke"), + ids.session_id, + "reason=duplicate", + ); + let (parts, bytes) = send(router, request).await; // Check the dashboard refresh contract assert_empty_hx_trigger_response( &parts, - &body, + &bytes, StatusCode::NO_CONTENT, "refresh-group-dashboard-table", ); } -#[tokio::test] -async fn test_update_forbidden() { - // Setup an authenticated group session without badge write access - let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let input = sample_badge_input(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_badges_write_forbidden(&mut db, community_id, group_id, user_id); - db.expect_update_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Attempt to update a badge definition without write permission - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/badges/{badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(serde_qs::to_string(&input).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); - - // Check permission fails before the badge mutation - assert_empty_response(&parts, &body, StatusCode::FORBIDDEN); -} - #[tokio::test] async fn test_update_rejects_invalid_input() { - // Setup an authorized group session and invalid badge definition + // Setup identifiers and data structures + let ids = DashboardIds::new(); let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let mut input = sample_badge_input(); input.name.clear(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_update_badge().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit a badge definition missing a required field - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/badges/{badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(serde_qs::to_string(&input).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + + // Setup router and send request with a missing required field + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/badges/{badge_id}"), + ids.session_id, + serde_qs::to_string(&input).unwrap(), + ); + let (parts, bytes) = send(router, request).await; // Check input validation fails before the badge mutation assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body, "all badge fields are required"); + assert_eq!(bytes, "all badge fields are required"); } #[tokio::test] async fn test_update_success() { - // Setup an authorized group session and valid badge definition + // Setup identifiers and data structures + let ids = DashboardIds::new(); let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let input = sample_badge_input(); let body = serde_qs::to_string(&input).unwrap(); - let expected = input.clone(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::BadgesWrite); db.expect_update_badge() .times(1) - .withf(move |actor, community, group, badge_id_arg, badge| { - *actor == user_id - && *community == community_id - && *group == group_id - && *badge_id_arg == badge_id - && badge.criteria == expected.criteria - && badge.description == expected.description - && badge.image_file_name == expected.image_file_name - && badge.name == expected.name - }) - .return_once(|_, _, _, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Submit the updated definition through the protected route - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/badges/{badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), + .withf( + move |actor_id, community_id, group_id, badge_id_arg, badge| { + *actor_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + && *badge_id_arg == badge_id + && badge.criteria == input.criteria + && badge.description == input.description + && badge.image_file_name == input.image_file_name + && badge.name == input.name + }, ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + .return_once(|_, _, _, _, _| Ok(())); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/badges/{badge_id}"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the dashboard refresh contract assert_empty_hx_trigger_response( &parts, - &body, + &bytes, StatusCode::NO_CONTENT, "refresh-group-dashboard-table", ); } #[test] -fn test_is_safe_artwork_file_name_rejects_paths() { - // Check the image-service basename shape and unsafe path forms - assert!(is_safe_artwork_file_name("0123456789abcdef.png")); - assert!(!is_safe_artwork_file_name("../../log-out")); - assert!(!is_safe_artwork_file_name("badge/image.png")); -} - -#[test] -fn test_parse_to_date_filter_rejects_out_of_range_exclusive_bound() { - // Check an unrepresentable exclusive upper bound remains an error - assert!(matches!( - parse_to_date_filter("+262142-12-31"), - Err(HandlerError::Rejected(message)) if message == "to date is invalid" - )); +fn test_is_safe_artwork_file_name() { + let cases = [ + ( + "content-addressed basename", + "0123456789abcdef.png".to_string(), + true, + ), + ( + "separators after the first byte", + "a1_b-c.d.webp".to_string(), + true, + ), + ("80 characters", format!("{}.png", "a".repeat(76)), true), + ("81 characters", format!("{}.png", "a".repeat(77)), false), + ("empty", String::new(), false), + ("leading dot", ".badge.png".to_string(), false), + ("leading hyphen", "-badge.png".to_string(), false), + ("parent traversal", "../../log-out".to_string(), false), + ("nested path", "badge/image.png".to_string(), false), + ("inner space", "badge image.png".to_string(), false), + ("non-ascii", "bädge.png".to_string(), false), + ]; + + for (label, file_name, expected) in cases { + assert_eq!(is_safe_artwork_file_name(&file_name), expected, "{label}"); + } } #[test] -fn test_validate_badge_input_rejects_oversized_text() { - // Build otherwise-valid definitions exceeding each credential text bound - let mut criteria = sample_badge_input(); - criteria.criteria = "a".repeat(BADGE_CRITERIA_MAX_CHARS + 1); - let mut description = sample_badge_input(); - description.description = "a".repeat(BADGE_DESCRIPTION_MAX_CHARS + 1); - let mut name = sample_badge_input(); - name.name = "a".repeat(BADGE_NAME_MAX_CHARS + 1); - - // Check every bounded field is rejected before persistence - assert!(validate_badge_input(&criteria).is_err()); - assert!(validate_badge_input(&description).is_err()); - assert!(validate_badge_input(&name).is_err()); -} - -#[tokio::test] -async fn test_options_trims_search_and_returns_json() { - // Setup an authorized group session and empty matching page - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_badges() - .times(1) - .withf(move |id, filters| { - *id == group_id - && filters.limit == 50 - && filters.offset == 0 - && filters.query.as_deref() == Some("term") - }) - .return_once(|_, _| Ok(GroupBadges::default())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request modal options with surrounding whitespace - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/badges/options?query=%20term%20") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body: GroupBadges = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); - - // Check the JSON page contract - assert_eq!(parts.status, StatusCode::OK); - assert_eq!(body.total, 0); - assert!( - body.badges.is_empty(), - "badges page should be empty, got {:?}", - body.badges - ); -} - -#[tokio::test] -async fn test_artwork_page_renders_independent_tab() { - // Setup an authorized group session and empty artwork library - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_badge_artwork() - .times(1) - .withf(move |id| *id == group_id) - .return_once(|_| Ok(Vec::new())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request the artwork partial through its top-level dashboard route - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/artwork") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); - - // Check artwork renders alone and pushes its full dashboard URL - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get("hx-push-url").unwrap(), - "/dashboard/group?tab=artwork" - ); - assert!(body.contains(">Badges Artwork")); - assert!(!body.contains("data-badge-pane")); -} - -#[tokio::test] -async fn test_awards_page_builds_navigation() { - // Setup an authorized group session and paginated award history - let badge_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let expected_from = NaiveDate::from_ymd_opt(2026, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc(); - let expected_to = NaiveDate::from_ymd_opt(2026, 2, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_awarded_badges() - .times(1) - .withf(move |id, filters| { - *id == group_id - && filters.limit == 25 - && filters.offset == 50 - && filters.badge_id == Some(badge_id) - && filters.from.as_ref() == Some(&expected_from) - && filters.query.as_deref() == Some("alice") - && filters.source == Some(BadgeAwardSourceFilter::Event(event_id)) - && filters.status.as_deref() == Some("active") - && filters.to.as_ref() == Some(&expected_to) - }) - .return_once(|_, _| { - Ok(GroupAwardedBadges { - total: 120, - ..Default::default() - }) - }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request filtered award navigation through its top-level dashboard route - let response = router - .oneshot( - Request::builder() - .uri(format!( - "/dashboard/group/awards?awards_offset=50&awards_query=alice&badge_id={badge_id}\ - &from=2026-01-01&limit=25&source={event_id}&status=active&to=2026-01-31" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); - - // Check full and partial navigation stay within the awards tab - assert_eq!(parts.status, StatusCode::OK); - assert!( - parts - .headers - .get("hx-push-url") - .unwrap() - .to_str() - .unwrap() - .starts_with("/dashboard/group?tab=awards&") - ); - assert!(body.contains("badge-awards-pagination-next-spinner")); - assert!(body.contains("awards_offset=75")); - assert!(body.contains("awards_query=alice")); - assert!(body.contains("href=\"/dashboard/group?tab=awards")); - assert!(body.contains("hx-get=\"/dashboard/group/awards?")); - assert!(body.contains(">Badges Awards")); -} - -#[tokio::test] -async fn test_awards_page_filters_direct_group_awards() { - // Setup an authorized group session expecting the group source filter - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_awarded_badges() - .times(1) - .withf(move |id, filters| { - *id == group_id && filters.source == Some(BadgeAwardSourceFilter::Group) - }) - .return_once(|_, _| Ok(GroupAwardedBadges::default())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request award history filtered to direct group awards - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/awards?source=group") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Check the group sentinel reaches the award-history query - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn test_awards_page_rejects_invalid_from_date() { - // Setup an authorized group session without database page expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_awarded_badges().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request award history with an invalid lower date bound - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/awards?from=not-a-date") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); - - // Check date validation fails before querying award history - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body, "from date is invalid"); -} - -#[tokio::test] -async fn test_awards_page_rejects_invalid_to_date() { - // Setup an authorized group session without database page expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_awarded_badges().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request award history with an invalid upper date bound - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/awards?to=2026-02-31") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); - - // Check date validation fails before querying award history - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body, "to date is invalid"); -} - -#[tokio::test] -async fn test_badges_page_builds_navigation() { - // Setup an authorized group session and paginated badge definitions - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - db.expect_list_badge_artwork() - .times(1) - .withf(move |id| *id == group_id) - .return_once(|_| Ok(Vec::new())); - db.expect_list_badges() - .times(1) - .withf(move |id, filters| { - *id == group_id - && filters.limit == 25 - && filters.offset == 75 - && filters.query.as_deref() == Some("helper") - }) - .return_once(|_, _| { - Ok(GroupBadges { - total: 125, - ..Default::default() - }) - }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request filtered definition navigation through its top-level dashboard route - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/badges?badges_offset=75&badges_query=helper&limit=25") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); - - // Check full and partial navigation stay within the badges tab - assert_eq!(parts.status, StatusCode::OK); - assert!( - parts - .headers - .get("hx-push-url") +fn test_parse_date_filters() { + let midnight = |year, month, day| { + NaiveDate::from_ymd_opt(year, month, day) .unwrap() - .to_str() + .and_hms_opt(0, 0, 0) .unwrap() - .starts_with("/dashboard/group?tab=badges&") - ); - assert!(body.contains("badge-definitions-pagination-next-spinner")); - assert!(body.contains("badges_offset=100")); - assert!(body.contains("badges_query=helper")); - assert!(body.contains("href=\"/dashboard/group?tab=badges")); - assert!(body.contains("hx-get=\"/dashboard/group/badges?")); - assert!(body.contains(">Badges")); + .and_utc() + }; + let cases = [ + ("empty from", parse_date_filter("from", ""), Ok(None)), + ( + "valid from", + parse_date_filter("from", "2026-01-31"), + Ok(Some(midnight(2026, 1, 31))), + ), + ( + "malformed from", + parse_date_filter("from", "not-a-date"), + Err("from date is invalid"), + ), + ("empty to", parse_to_date_filter(""), Ok(None)), + ( + "valid to is exclusive next day", + parse_to_date_filter("2026-01-31"), + Ok(Some(midnight(2026, 2, 1))), + ), + ( + "impossible to date", + parse_to_date_filter("2026-02-31"), + Err("to date is invalid"), + ), + ( + "unrepresentable exclusive bound", + parse_to_date_filter("+262142-12-31"), + Err("to date is invalid"), + ), + ]; + + for (label, result, expected) in cases { + match (result, expected) { + (Ok(date), Ok(expected_date)) => assert_eq!(date, expected_date, "{label}"), + (Err(HandlerError::Rejected(message)), Err(expected_message)) => { + assert_eq!(message, expected_message, "{label}"); + } + (result, expected) => panic!("{label}: got {result:?}, expected {expected:?}"), + } + } } -#[tokio::test] -async fn test_awards_page_rejects_oversized_offset() { - // Setup an authorized group session without database page expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request an offset that cannot bind to the PostgreSQL integer parameter - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/awards?awards_offset=2147483648") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Check invalid pagination is rejected before any page query - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +#[test] +fn test_validate_badge_artwork() { + let mut oversized_png = sample_png(512, 512); + oversized_png.resize(MAX_BADGE_ARTWORK_SIZE_BYTES + 1, 0); + let cases = [ + ("512x512 png", "badge.png", png_image(512, 512), true), + ( + "uppercase extension", + "badge.PNG", + png_image(512, 512), + true, + ), + ( + "512x512 jpeg with jpg extension", + "badge.jpg", + encoded_image(ImageFormat::Jpeg, "image/jpeg"), + true, + ), + ( + "512x512 jpeg with jpeg extension", + "badge.jpeg", + encoded_image(ImageFormat::Jpeg, "image/jpeg"), + true, + ), + ( + "512x512 webp", + "badge.webp", + encoded_image(ImageFormat::WebP, "image/webp"), + true, + ), + ("undersized png", "badge.png", png_image(1, 1), false), + ( + "non-square png", + "badge.png", + stored_image(sample_png(512, 256), "image/png"), + false, + ), + ( + "oversized png", + "badge.png", + stored_image(oversized_png, "image/png"), + false, + ), + ( + "png bytes with jpg extension", + "badge.jpg", + png_image(512, 512), + false, + ), + ( + "png bytes without extension", + "badge", + png_image(512, 512), + false, + ), + ( + "png bytes with jpeg content type", + "badge.png", + stored_image(sample_png(512, 512), "image/jpeg"), + false, + ), + ( + "unsupported gif format", + "badge.gif", + encoded_image(ImageFormat::Gif, "image/gif"), + false, + ), + ( + "non-image bytes", + "badge.png", + stored_image(b"not an image".to_vec(), "image/png"), + false, + ), + ]; + + for (label, file_name, image, accepted) in cases { + match validate_badge_artwork(file_name, &image) { + Ok(()) => assert!(accepted, "{label}: should be rejected"), + Err(HandlerError::Rejected(message)) => { + assert!(!accepted, "{label}: should be accepted, got {message:?}"); + assert_eq!(message, INVALID_ARTWORK_MESSAGE, "{label}"); + } + Err(err) => panic!("{label}: unexpected error {err:?}"), + } + } } -#[tokio::test] -async fn test_badges_page_rejects_oversized_offset() { - // Setup an authorized group session without database page expectations - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::BadgesWrite, - ); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - - // Request an offset that cannot bind to the PostgreSQL integer parameter - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/group/badges?badges_offset=2147483648") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - // Check invalid pagination is rejected before any page query - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +#[test] +fn test_validate_badge_input() { + let with = |update: fn(&mut BadgeInput)| { + let mut input = sample_badge_input(); + update(&mut input); + input + }; + let cases = [ + ("valid definition", sample_badge_input(), None), + ( + "text at every bound", + with(|input| { + input.criteria = "a".repeat(BADGE_CRITERIA_MAX_CHARS); + input.description = "a".repeat(BADGE_DESCRIPTION_MAX_CHARS); + input.name = "é".repeat(BADGE_NAME_MAX_CHARS); + }), + None, + ), + ( + "blank criteria", + with(|input| input.criteria = " ".to_string()), + Some("all badge fields are required"), + ), + ( + "empty description", + with(|input| input.description.clear()), + Some("all badge fields are required"), + ), + ( + "empty image file name", + with(|input| input.image_file_name.clear()), + Some("all badge fields are required"), + ), + ( + "blank name", + with(|input| input.name = "\t".to_string()), + Some("all badge fields are required"), + ), + ( + "unsafe image file name", + with(|input| input.image_file_name = "../badge.png".to_string()), + Some("all badge fields are required"), + ), + ( + "oversized criteria", + with(|input| input.criteria = "a".repeat(BADGE_CRITERIA_MAX_CHARS + 1)), + Some("badge text exceeds the allowed length"), + ), + ( + "oversized description", + with(|input| input.description = "a".repeat(BADGE_DESCRIPTION_MAX_CHARS + 1)), + Some("badge text exceeds the allowed length"), + ), + ( + "oversized name", + with(|input| input.name = "a".repeat(BADGE_NAME_MAX_CHARS + 1)), + Some("badge text exceeds the allowed length"), + ), + ]; + + for (label, input, expected_error) in cases { + match (validate_badge_input(&input), expected_error) { + (Ok(()), None) => {} + (Err(HandlerError::Rejected(message)), Some(expected_message)) => { + assert_eq!(message, expected_message, "{label}"); + } + (result, expected) => panic!("{label}: got {result:?}, expected {expected:?}"), + } + } } // Helpers. -/// Expect badge write permission to be denied while read access remains. -fn expect_badges_write_forbidden( - db: &mut MockDB, - community_id: Uuid, - group_id: Uuid, - user_id: Uuid, -) { - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::BadgesWrite - }) - .returning(|_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(true)); +/// Build the request that adds a stored image to the artwork gallery. +fn add_artwork_request(ids: &DashboardIds, file_name: &str) -> Request { + form_request( + "POST", + "/dashboard/group/badges/artwork", + ids.session_id, + format!("file_name={file_name}"), + ) } -/// Encode a PNG fixture with the requested dimensions. -fn png_bytes(width: u32, height: u32) -> Vec { +/// Encode a 512x512 image in the format with the content type. +fn encoded_image(format: ImageFormat, content_type: &str) -> Image { let mut output = Cursor::new(Vec::new()); - DynamicImage::new_rgba8(width, height) - .write_to(&mut output, ImageFormat::Png) + DynamicImage::new_rgb8(BADGE_ARTWORK_SIZE, BADGE_ARTWORK_SIZE) + .write_to(&mut output, format) .unwrap(); - output.into_inner() + stored_image(output.into_inner(), content_type) +} + +/// Build a stored PNG image with the dimensions. +fn png_image(width: u32, height: u32) -> Image { + stored_image(sample_png(width, height), "image/png") } /// Build one valid badge definition form fixture. @@ -1570,3 +1189,11 @@ fn sample_badge_input() -> BadgeInput { name: "Participant".to_string(), } } + +/// Build a stored image from its bytes and content type. +fn stored_image(bytes: Vec, content_type: &str) -> Image { + Image { + bytes, + content_type: content_type.to_string(), + } +} diff --git a/ocg-server/src/handlers/dashboard/group/check_in/tests.rs b/ocg-server/src/handlers/dashboard/group/check_in/tests.rs index f5acf94c2..7d73e2941 100644 --- a/ocg-server/src/handlers/dashboard/group/check_in/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/check_in/tests.rs @@ -1,20 +1,17 @@ use axum::{ + Router, body::{Body, to_bytes}, - http::{Request, StatusCode, header::COOKIE}, + http::{Request, StatusCode, header::CONTENT_TYPE}, }; use axum_login::tower_sessions::session; use chrono::Utc; use serde_json::{Value, json}; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::tests::{ - TestRouterBuilder, assert_html_response, expect_authenticated_group_session, - expect_group_permission, - }, - services::{check_in::CheckInScanRejection, notifications::MockNotificationsManager}, + handlers::tests::*, + services::check_in::CheckInScanRejection, types::{ dashboard::group::check_in::{CheckInAttendee, CheckInOutcome, CheckInScanResult}, permissions::GroupPermission, @@ -26,73 +23,105 @@ use super::scan_rejection_response; #[tokio::test] async fn test_list_page_returns_group_check_in_fragment() { // Setup an authorized group scanner list request - let community_id = Uuid::from_u128(1); - let group_id = Uuid::from_u128(2); - let session_id = session::Id::default(); - let user_id = Uuid::from_u128(3); + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::CheckInsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::CheckInsWrite); db.expect_list_group_check_in_events() .times(1) - .withf(move |gid| *gid == group_id) + .withf(move |gid| *gid == ids.group_id) .returning(|_| Ok(vec![])); // Request the group check-in fragment - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/check-in") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/check-in", ids.session_id); + let (parts, bytes) = send(router, request).await; // Verify the fragment renders its empty scanner state - assert_html_response(&parts, &body, StatusCode::OK); - assert!(String::from_utf8_lossy(&body).contains("No events available for check-in")); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(String::from_utf8_lossy(&bytes).contains("No events available for check-in")); } #[tokio::test] async fn test_scan_returns_already_checked_in_result() { - // Exercise and verify the neutral duplicate-scan result - assert_scan_result(CheckInOutcome::AlreadyCheckedIn, "already-checked-in").await; + // Setup an authorized scanner whose attendee was already checked in + let ids = DashboardIds::new(); + let check_in_code = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::CheckInsWrite); + expect_check_in_attendee_by_code( + &mut db, + &ids, + event_id, + check_in_code, + CheckInOutcome::AlreadyCheckedIn, + ); + + // Submit a valid credential + let router = TestRouterBuilder::new(db).build().await; + let request = scan_request( + ids.session_id, + event_id, + &credential(event_id, check_in_code), + ); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); + + // Verify the neutral duplicate-scan result + assert_eq!(parts.status, StatusCode::OK); + assert_eq!(body["outcome"], "already-checked-in"); + assert_eq!(body["attendee"]["name"], "Test Attendee"); + assert_eq!(body["attendee"]["photo_url"], Value::Null); + assert!(body["checked_in_at"].is_number()); + assert_eq!(body["ticket_title"], "General admission"); } #[tokio::test] async fn test_scan_returns_checked_in_result() { - // Exercise and verify the first-scan result - assert_scan_result(CheckInOutcome::CheckedIn, "checked-in").await; + // Setup an authorized scanner whose attendee is checked in now + let ids = DashboardIds::new(); + let check_in_code = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::CheckInsWrite); + expect_check_in_attendee_by_code( + &mut db, + &ids, + event_id, + check_in_code, + CheckInOutcome::CheckedIn, + ); + + // Submit a valid credential + let router = TestRouterBuilder::new(db).build().await; + let request = scan_request( + ids.session_id, + event_id, + &credential(event_id, check_in_code), + ); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); + + // Verify the complete scanner response + assert_eq!(parts.status, StatusCode::OK); + assert_eq!(body["outcome"], "checked-in"); + assert_eq!(body["attendee"]["name"], "Test Attendee"); + assert_eq!(body["attendee"]["photo_url"], Value::Null); + assert!(body["checked_in_at"].is_number()); + assert_eq!(body["ticket_title"], "General admission"); } #[tokio::test] async fn test_scan_returns_malformed_credential_error() { - // Setup an authorized scanner request with an invalid credential envelope - let (router, session_id) = scan_test_router(true).await; + // Setup an authorized scanner request with an unparseable credential + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let request = Request::builder() - .method("POST") - .uri(format!("/dashboard/group/events/{event_id}/check-ins/scan")) - .header(COOKIE, format!("id={session_id}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ "credential": "not-a-code" }).to_string(), - )) - .unwrap(); + let router = scan_router_without_check_in(&ids).await; + // Submit the invalid credential - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let request = scan_request(ids.session_id, event_id, "not-a-code"); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Verify the typed failure response assert_eq!(parts.status, StatusCode::BAD_REQUEST); @@ -100,151 +129,171 @@ async fn test_scan_returns_malformed_credential_error() { } #[tokio::test] -async fn test_scan_returns_malformed_credential_for_invalid_json_shapes() { - // Define extractor failures that must share the scanner error contract - let cases = [ - ("{", true), - ("{}", true), - (r#"{"credential": 1}"#, true), - (r#"{"credential": "unused"}"#, false), - ]; +async fn test_scan_returns_malformed_credential_for_missing_credential() { + // Setup an authorized scanner request without a credential field + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let router = scan_router_without_check_in(&ids).await; - for (body, include_content_type) in cases { - // Setup an independently authorized scanner request - let (router, session_id) = scan_test_router(true).await; - let event_id = Uuid::from_u128(10); - let mut request = Request::builder() - .method("POST") - .uri(format!("/dashboard/group/events/{event_id}/check-ins/scan")) - .header(COOKIE, format!("id={session_id}")); - if include_content_type { - request = request.header("content-type", "application/json"); - } - - // Submit the malformed JSON body - let response = router.oneshot(request.body(Body::from(body)).unwrap()).await.unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + // Submit the JSON body without a credential + let request = raw_scan_request(ids.session_id, event_id) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); - // Verify every extractor failure is normalized - assert_eq!(parts.status, StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "malformed-credential"); - } + // Verify the extractor failure is normalized + assert_eq!(parts.status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "malformed-credential"); +} + +#[tokio::test] +async fn test_scan_returns_malformed_credential_for_non_string_credential() { + // Setup an authorized scanner request with a numeric credential + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let router = scan_router_without_check_in(&ids).await; + + // Submit the non-string credential + let request = raw_scan_request(ids.session_id, event_id) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"credential": 1}"#)) + .unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); + + // Verify the extractor failure is normalized + assert_eq!(parts.status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "malformed-credential"); +} + +#[tokio::test] +async fn test_scan_returns_malformed_credential_for_unterminated_json() { + // Setup an authorized scanner request with an unterminated JSON body + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let router = scan_router_without_check_in(&ids).await; + + // Submit the unterminated JSON body + let request = raw_scan_request(ids.session_id, event_id) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); + + // Verify the extractor failure is normalized + assert_eq!(parts.status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "malformed-credential"); +} + +#[tokio::test] +async fn test_scan_returns_malformed_credential_without_json_content_type() { + // Setup an authorized scanner request without a JSON content type + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let router = scan_router_without_check_in(&ids).await; + + // Submit the body without a content type + let request = raw_scan_request(ids.session_id, event_id) + .body(Body::from(r#"{"credential": "unused"}"#)) + .unwrap(); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); + + // Verify the extractor failure is normalized + assert_eq!(parts.status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "malformed-credential"); } #[tokio::test] -async fn test_scan_returns_mapped_database_domain_errors() { - // Define every stable database error mapping +async fn test_scan_rejection_response_maps_every_rejection() { + // Define every stable scanner rejection response let cases = [ ( - "attendance is not confirmed", + CheckInScanRejection::NonConfirmedAttendance, StatusCode::CONFLICT, "non-confirmed-attendance", + "This attendee no longer has confirmed attendance.", ), ( - "check-in credential not found", - StatusCode::UNPROCESSABLE_ENTITY, - "unknown-code", - ), - ( - "event unavailable for check-in", + CheckInScanRejection::UnavailableEvent, StatusCode::CONFLICT, "unavailable-event", + "This event is not available for check-in.", + ), + ( + CheckInScanRejection::UnknownCode, + StatusCode::UNPROCESSABLE_ENTITY, + "unknown-code", + "This check-in credential is not recognized.", ), ]; - for (message, expected_status, expected_code) in cases { + for (rejection, expected_status, expected_code, expected_message) in cases { // Map and decode the typed response - let rejection = CheckInScanRejection::from_db_message(message).unwrap(); let response = scan_rejection_response(rejection); let (parts, body) = response.into_parts(); - let body: Value = - serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); - // Verify the stable status and error code - assert_eq!(parts.status, expected_status); - assert_eq!(body["error"]["code"], expected_code); + // Verify the stable status, error code and message + assert_eq!(parts.status, expected_status, "case: {rejection:?}"); + assert_eq!(body["error"]["code"], expected_code, "case: {rejection:?}"); + assert_eq!( + body["error"]["message"], expected_message, + "case: {rejection:?}" + ); } } #[tokio::test] async fn test_scan_returns_wrong_event_error() { // Setup a credential for a different event - let (router, session_id) = scan_test_router(true).await; + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let other_event_id = Uuid::new_v4(); - let check_in_code = Uuid::new_v4(); - let request = Request::builder() - .method("POST") - .uri(format!("/dashboard/group/events/{event_id}/check-ins/scan")) - .header(COOKIE, format!("id={session_id}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "credential": format!("ocg-check-in:v1:{other_event_id}:{check_in_code}"), - }) - .to_string(), - )) - .unwrap(); + let router = scan_router_without_check_in(&ids).await; + // Submit the credential to the selected event - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + let request = scan_request( + ids.session_id, + event_id, + &credential(other_event_id, Uuid::new_v4()), + ); + let (parts, bytes) = send(router, request).await; + let body: Value = serde_json::from_slice(&bytes).unwrap(); // Verify the typed event mismatch response assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(body["error"]["code"], "wrong-event"); } -#[tokio::test] -async fn test_scan_validates_check_in_permission() { - // Setup a scanner request without check-in permission - let (router, session_id) = scan_test_router(false).await; - let event_id = Uuid::new_v4(); - let request = Request::builder() - .method("POST") - .uri(format!("/dashboard/group/events/{event_id}/check-ins/scan")) - .header(COOKIE, format!("id={session_id}")) - .header("content-type", "application/json") - .body(Body::from(json!({ "credential": "unused" }).to_string())) - .unwrap(); - // Submit the scanner request - let response = router.oneshot(request).await.unwrap(); - - // Verify the route permission guard - assert_eq!(response.status(), StatusCode::FORBIDDEN); -} - // Helpers. -/// Exercises the successful scanner response for one database outcome. -async fn assert_scan_result(outcome: CheckInOutcome, expected_outcome: &str) { - // Setup identities and database expectations - let check_in_code = Uuid::from_u128(1); - let community_id = Uuid::from_u128(2); - let event_id = Uuid::from_u128(3); - let group_id = Uuid::from_u128(4); - let session_id = session::Id::default(); - let user_id = Uuid::from_u128(5); +/// Returns the versioned credential for the event and check-in code. +fn credential(event_id: Uuid, check_in_code: Uuid) -> String { + format!("ocg-check-in:v1:{event_id}:{check_in_code}") +} - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::CheckInsWrite, - ); +/// Expects one check-in by code that returns the outcome for a sample attendee. +fn expect_check_in_attendee_by_code( + db: &mut MockDB, + ids: &DashboardIds, + event_id: Uuid, + check_in_code: Uuid, + outcome: CheckInOutcome, +) { + let ids = *ids; db.expect_check_in_attendee_by_code() .times(1) .withf(move |actor, code, community, event, group| { - *actor == user_id + *actor == ids.user_id && *code == check_in_code - && *community == community_id + && *community == ids.community_id && *event == event_id - && *group == group_id + && *group == ids.group_id }) .returning(move |_, _, _, _, _| { Ok(CheckInScanResult { @@ -258,67 +307,32 @@ async fn assert_scan_result(outcome: CheckInOutcome, expected_outcome: &str) { ticket_title: Some("General admission".to_string()), }) }); +} - // Submit a valid credential - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("POST") - .uri(format!("/dashboard/group/events/{event_id}/check-ins/scan")) - .header(COOKIE, format!("id={session_id}")) - .header("content-type", "application/json") - .body(Body::from( - json!({ - "credential": format!("ocg-check-in:v1:{event_id}:{check_in_code}"), - }) - .to_string(), - )) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body: Value = serde_json::from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); - - // Verify the complete scanner response - assert_eq!(parts.status, StatusCode::OK); - assert_eq!(body["outcome"], expected_outcome); - assert_eq!(body["attendee"]["name"], "Test Attendee"); - assert_eq!(body["attendee"]["photo_url"], Value::Null); - assert!(body["checked_in_at"].is_number()); - assert_eq!(body["ticket_title"], "General admission"); +/// Builds a scan request carrying the credential. +fn scan_request(session_id: session::Id, event_id: Uuid, credential: &str) -> Request { + json_request( + "POST", + &format!("/dashboard/group/events/{event_id}/check-ins/scan"), + session_id, + &json!({ "credential": credential }), + ) } -/// Builds an authenticated scanner router with the selected permission result. -async fn scan_test_router(has_permission: bool) -> (axum::Router, session::Id) { - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +/// Builds an authenticated scan request without content type or body. +fn raw_scan_request(session_id: session::Id, event_id: Uuid) -> axum::http::request::Builder { + authenticated_request( + "POST", + &format!("/dashboard/group/events/{event_id}/check-ins/scan"), + session_id, + ) +} +/// Builds an authorized scanner router that must reject before checking in. +async fn scan_router_without_check_in(ids: &DashboardIds) -> Router { let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::CheckInsWrite - }) - .returning(move |_, _, _, _| Ok(has_permission)); - if !has_permission { - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - } + expect_group_access(&mut db, ids, GroupPermission::CheckInsWrite); db.expect_check_in_attendee_by_code().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - (router, session_id) + TestRouterBuilder::new(db).build().await } diff --git a/ocg-server/src/handlers/dashboard/group/cohosts/tests.rs b/ocg-server/src/handlers/dashboard/group/cohosts/tests.rs index 57355c129..873cf7704 100644 --- a/ocg-server/src/handlers/dashboard/group/cohosts/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/cohosts/tests.rs @@ -1,21 +1,15 @@ -use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::COOKIE}, + body::Body, + http::{Request, StatusCode}, }; -use axum_login::tower_sessions::session; use chrono::Utc; use serde_json::{from_slice, json}; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::{error::HandlerError, tests::*}, - services::{ - events::{EventsError, MockEventsManager}, - notifications::MockNotificationsManager, - }, + handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, + services::events::{EventsError, MockEventsManager}, types::{ dashboard::{ DASHBOARD_PAGINATION_LIMIT, @@ -28,13 +22,11 @@ use crate::{ #[tokio::test] async fn test_approve_passes_invitation_and_selected_group_to_manager() { - // Setup identifiers and the approval - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let invitation_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); @@ -42,20 +34,19 @@ async fn test_approve_passes_invitation_and_selected_group_to_manager() { .expect_approve_cohosting() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.cohost_group_id == group_id + input.actor_user_id == ids.user_id + && input.cohost_group_id == ids.group_id && input.invitation_id == invitation_id }) .returning(|_| Box::pin(async { Ok(()) })); - // Send the approval request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{invitation_id}/approve"), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, invitation_id, "approve"); + let (parts, bytes) = send(router, request).await; // Check the list refresh response assert_empty_hx_trigger_response( @@ -66,80 +57,12 @@ async fn test_approve_passes_invitation_and_selected_group_to_manager() { ); } -#[tokio::test] -async fn test_approve_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager.expect_approve_cohosting().times(1).returning(|_| { - Box::pin(async { - Err(EventsError::Other( - HandlerError::Database("co-hosting invitation is no longer pending".to_string()) - .into(), - )) - }) - }); - - // Send the approval request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/approve", Uuid::new_v4()), - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "co-hosting invitation is no longer pending" - ); -} - -#[tokio::test] -async fn test_approve_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager - .expect_approve_cohosting() - .times(1) - .returning(|_| Box::pin(async { Err(EventsError::Other(anyhow!("db error"))) })); - - // Send the approval request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/approve", Uuid::new_v4()), - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - #[tokio::test] async fn test_approve_returns_rejected_message() { - // Setup identifiers and a manager rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); @@ -147,14 +70,13 @@ async fn test_approve_returns_rejected_message() { Box::pin(async { Err(EventsError::Rejected("co-hosting unavailable".to_string())) }) }); - // Send the approval request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/approve", Uuid::new_v4()), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, Uuid::new_v4(), "approve"); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -166,13 +88,11 @@ async fn test_approve_returns_rejected_message() { #[tokio::test] async fn test_cancel_passes_invitation_and_selected_group_to_manager() { - // Setup identifiers and the withdrawal - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let invitation_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); @@ -180,20 +100,19 @@ async fn test_cancel_passes_invitation_and_selected_group_to_manager() { .expect_cancel_cohosting() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.cohost_group_id == group_id + input.actor_user_id == ids.user_id + && input.cohost_group_id == ids.group_id && input.invitation_id == invitation_id }) .returning(|_| Box::pin(async { Ok(()) })); - // Send the withdrawal request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{invitation_id}/cancel"), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, invitation_id, "cancel"); + let (parts, bytes) = send(router, request).await; // Check the list refresh response assert_empty_hx_trigger_response( @@ -205,32 +124,29 @@ async fn test_cancel_passes_invitation_and_selected_group_to_manager() { } #[tokio::test] -async fn test_cancel_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); +async fn test_cancel_returns_rejected_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); events_manager.expect_cancel_cohosting().times(1).returning(|_| { Box::pin(async { - Err(EventsError::Other( - HandlerError::Database("co-hosting is not approved".to_string()).into(), + Err(EventsError::Rejected( + "co-hosting is not approved".to_string(), )) }) }); - // Send the withdrawal request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/cancel", Uuid::new_v4()), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, Uuid::new_v4(), "cancel"); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -240,110 +156,33 @@ async fn test_cancel_returns_database_rejection_message() { ); } -#[tokio::test] -async fn test_cancel_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager - .expect_cancel_cohosting() - .times(1) - .returning(|_| Box::pin(async { Err(EventsError::Other(anyhow!("db error"))) })); - - // Send the withdrawal request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/cancel", Uuid::new_v4()), - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - -#[tokio::test] -async fn test_cancel_returns_rejected_message() { - // Setup identifiers and a manager rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager.expect_cancel_cohosting().times(1).returning(|_| { - Box::pin(async { Err(EventsError::Rejected("co-hosting unavailable".to_string())) }) - }); - - // Send the withdrawal request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/cancel", Uuid::new_v4()), - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "co-hosting unavailable" - ); -} - #[tokio::test] async fn test_group_options_rejects_invalid_community_id() { - // Setup an events manager session - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_list_cohost_group_options().never(); - // Request the options with a malformed community identifier - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/events/cohosts/groups?community_id=invalid") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); + // Setup router and send request with a malformed community identifier + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/events/cohosts/groups?community_id=invalid", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the malformed query is rejected - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] async fn test_group_options_returns_community_groups_excluding_selected_group() { - // Setup identifiers and the lookup result - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and data structures + let ids = DashboardIds::new(); let other_community_id = Uuid::new_v4(); let other_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let option = EventCohostGroup { community_display_name: "Other Community".to_string(), community_name: "other".to_string(), @@ -357,37 +196,22 @@ async fn test_group_options_returns_community_groups_excluding_selected_group() // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_list_cohost_group_options() .times(1) - .withf(move |cid, exclude_group_id| { - *cid == other_community_id && *exclude_group_id == group_id + .withf(move |community_id, exclude_group_id| { + *community_id == other_community_id && *exclude_group_id == ids.group_id }) .returning(move |_, _| Ok(vec![option.clone()])); - // Request the options for another community - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/cohosts/groups?community_id={other_community_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let payload: serde_json::Value = - from_slice(&to_bytes(body, usize::MAX).await.unwrap()).unwrap(); + // Setup router and send request for another community + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/cohosts/groups?community_id={other_community_id}"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let payload: serde_json::Value = from_slice(&bytes).unwrap(); // Check the JSON options assert_eq!(parts.status, StatusCode::OK); @@ -406,22 +230,17 @@ async fn test_group_options_returns_community_groups_excluding_selected_group() #[tokio::test] async fn test_list_page_hides_actions_without_settings_write() { - // Setup identifiers and a pending invitation - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = list_page_db( - session_id, - user_id, - community_id, - group_id, - false, - sample_cohosted_events_output(), - ); - - // Request the co-hosts partial - let (parts, bytes) = send_list_page(db, session_id).await; + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::SettingsWrite, false); + expect_list_cohosted_events(&mut db, &ids, sample_cohosted_events_output()); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/cohosts", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check the invitation renders without response actions @@ -431,39 +250,49 @@ async fn test_list_page_hides_actions_without_settings_write() { "/dashboard/group?tab=cohosts&limit=50&offset=0" ); assert!(html.contains("Cloud Native Meetup")); - assert!(!html.contains("/approve")); + assert!(!html.contains("hx-put=\"/dashboard/group/cohosts/")); } #[tokio::test] async fn test_list_page_renders_actions_with_settings_write() { - // Setup identifiers and a pending invitation - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and data structures + let ids = DashboardIds::new(); let output = sample_cohosted_events_output(); let invitation_id = output.events[0].invitation_id; - let db = list_page_db(session_id, user_id, community_id, group_id, true, output); - // Request the co-hosts partial - let (parts, bytes) = send_list_page(db, session_id).await; + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::SettingsWrite, true); + expect_list_cohosted_events(&mut db, &ids, output); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/cohosts", ids.session_id); + let (parts, bytes) = send(router, request).await; let html = String::from_utf8(bytes.to_vec()).unwrap(); - // Check the invitation renders with its response actions + // Check the pending invitation renders its approve and reject actions assert_html_response(&parts, &bytes, StatusCode::OK); assert!(html.contains("Cloud Native Meetup")); - assert!(html.contains(&format!("{invitation_id}"))); + assert!(html.contains(&format!( + "hx-put=\"/dashboard/group/cohosts/{invitation_id}/approve\"" + ))); + assert!(html.contains(&format!( + "hx-put=\"/dashboard/group/cohosts/{invitation_id}/reject\"" + ))); + assert!(!html.contains(&format!( + "hx-put=\"/dashboard/group/cohosts/{invitation_id}/cancel\"" + ))); } #[tokio::test] async fn test_reject_passes_invitation_and_selected_group_to_manager() { - // Setup identifiers and the rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let invitation_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); @@ -471,20 +300,19 @@ async fn test_reject_passes_invitation_and_selected_group_to_manager() { .expect_reject_cohosting() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.cohost_group_id == group_id + input.actor_user_id == ids.user_id + && input.cohost_group_id == ids.group_id && input.invitation_id == invitation_id }) .returning(|_| Box::pin(async { Ok(()) })); - // Send the rejection request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{invitation_id}/reject"), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, invitation_id, "reject"); + let (parts, bytes) = send(router, request).await; // Check the list refresh response assert_empty_hx_trigger_response( @@ -496,32 +324,29 @@ async fn test_reject_passes_invitation_and_selected_group_to_manager() { } #[tokio::test] -async fn test_reject_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); +async fn test_reject_returns_rejected_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); // Setup events manager mock let mut events_manager = MockEventsManager::new(); events_manager.expect_reject_cohosting().times(1).returning(|_| { Box::pin(async { - Err(EventsError::Other( - HandlerError::Database("co-hosting invitation not found".to_string()).into(), + Err(EventsError::Rejected( + "co-hosting invitation not found".to_string(), )) }) }); - // Send the rejection request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/reject", Uuid::new_v4()), - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_events_manager(events_manager) + .build() + .await; + let request = action_request(&ids, Uuid::new_v4(), "reject"); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -531,96 +356,20 @@ async fn test_reject_returns_database_rejection_message() { ); } -#[tokio::test] -async fn test_reject_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager - .expect_reject_cohosting() - .times(1) - .returning(|_| Box::pin(async { Err(EventsError::Other(anyhow!("db error"))) })); - - // Send the rejection request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/reject", Uuid::new_v4()), - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - -#[tokio::test] -async fn test_reject_returns_rejected_message() { - // Setup identifiers and a manager rejection - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let db = settings_write_db(session_id, user_id, community_id, group_id); - - // Setup events manager mock - let mut events_manager = MockEventsManager::new(); - events_manager.expect_reject_cohosting().times(1).returning(|_| { - Box::pin(async { Err(EventsError::Rejected("co-hosting unavailable".to_string())) }) - }); +// Helpers. - // Send the rejection request - let (parts, bytes) = send_action( - db, - events_manager, - session_id, - &format!("/dashboard/group/cohosts/{}/reject", Uuid::new_v4()), +/// Builds the request for a co-hosting action on an invitation. +fn action_request(ids: &DashboardIds, invitation_id: Uuid, action: &str) -> Request { + empty_request( + "PUT", + &format!("/dashboard/group/cohosts/{invitation_id}/{action}"), + ids.session_id, ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "co-hosting unavailable" - ); } -// Helpers. - -/// Builds a database mock for the co-hosts list page reads. -fn list_page_db( - session_id: session::Id, - user_id: Uuid, - community_id: Uuid, - group_id: Uuid, - can_manage_cohosts: bool, - output: CohostedEventsOutput, -) -> MockDB { - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::SettingsWrite - }) - .returning(move |_, _, _, _| Ok(can_manage_cohosts)); +/// Registers the co-hosted events read for the first list page. +fn expect_list_cohosted_events(db: &mut MockDB, ids: &DashboardIds, output: CohostedEventsOutput) { + let group_id = ids.group_id; db.expect_list_group_cohosted_events() .times(1) .withf(move |gid, filters| { @@ -629,8 +378,6 @@ fn list_page_db( && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); - - db } /// Returns one pending invitation to co-host an unpublished event. @@ -662,68 +409,3 @@ fn sample_cohosted_events_output() -> CohostedEventsOutput { total: 1, } } - -/// Sends a co-hosting action request through the router. -async fn send_action( - db: MockDB, - events_manager: MockEventsManager, - session_id: session::Id, - uri: &str, -) -> (axum::http::response::Parts, axum::body::Bytes) { - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_events_manager(events_manager) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(uri) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - (parts, bytes) -} - -/// Sends the co-hosts list page request through the router. -async fn send_list_page( - db: MockDB, - session_id: session::Id, -) -> (axum::http::response::Parts, axum::body::Bytes) { - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/cohosts") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - (parts, bytes) -} - -/// Builds a database mock for a user allowed to respond to co-hosting invitations. -fn settings_write_db( - session_id: session::Id, - user_id: Uuid, - community_id: Uuid, - group_id: Uuid, -) -> MockDB { - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); - - db -} diff --git a/ocg-server/src/handlers/dashboard/group/event_analytics/tests.rs b/ocg-server/src/handlers/dashboard/group/event_analytics/tests.rs index 339e632b5..edc6a19d2 100644 --- a/ocg-server/src/handlers/dashboard/group/event_analytics/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/event_analytics/tests.rs @@ -1,19 +1,13 @@ use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{COOKIE, LOCATION}, - }, + body::Body, + http::{Request, StatusCode, header::LOCATION}, }; -use axum_login::tower_sessions::session; use chrono::{Duration, Utc}; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{dashboard::group::event_analytics::EventAnalytics, permissions::GroupPermission}, }; @@ -25,11 +19,19 @@ async fn test_page_explains_missing_daily_charts_for_unpublished_events() { stats.event.published_at = None; stats.range = None; - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the daily charts are replaced by an explanation - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("data-event-analytics-range-unavailable")); assert!(body.contains("this event is not published")); assert!(!body.contains("id=\"event-views-daily-chart\"")); @@ -44,11 +46,19 @@ async fn test_page_explains_missing_daily_charts_without_publication_date() { stats.event.published_at = None; stats.range = None; - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the explanation names the missing publication date - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("which is not recorded for this event")); assert!(!body.contains("this event is not published")); assert!(!body.contains("id=\"event-views-daily-chart\"")); @@ -61,11 +71,19 @@ async fn test_page_hides_check_ins_before_the_event_ends() { stats.event.ends_at = Some(Utc::now() + Duration::days(3)); stats.event.starts_at = Some(Utc::now() + Duration::days(2)); - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the check-in card and section are omitted - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(!body.contains("Check-in rate")); assert!(!body.contains("data-event-analytics-check-ins")); assert!(!body.contains("id=\"event-check-ins-chart\"")); @@ -79,11 +97,19 @@ async fn test_page_hides_surveys_without_ready_surveys() { stats.surveys.total_eligible_count = 0; stats.surveys.total_eligible_respondents_count = 0; - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the surveys section is omitted - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(!body.contains("data-event-analytics-surveys")); assert!(!body.contains("Overall response rate")); } @@ -95,107 +121,72 @@ async fn test_page_hides_tickets_for_free_events() { stats.event.is_paid = false; stats.tickets = None; - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the tickets section is omitted - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(!body.contains("data-event-analytics-tickets")); assert!(!body.contains("role=\"progressbar\"")); } #[tokio::test] async fn test_page_redirects_users_without_group_access() { - // Setup identifiers - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - // Setup a session whose user lost access to every group + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(|_, _, _, _| Ok(false)); + expect_group_session(&mut db, &ids); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, false); db.expect_list_user_groups() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id && !record.data.contains_key("selected_group_id") + record.id == ids.session_id && !record.data.contains_key("selected_group_id") }) .returning(|_| Ok(())); db.expect_group_has_event().never(); db.expect_get_event_analytics().never(); - // Run the request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/analytics")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, _) = send(router, page_request(&ids, event_id)).await; // Check the user is sent away before any analytics read - assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(parts.status, StatusCode::SEE_OTHER); assert_eq!( - response.headers().get(LOCATION).unwrap(), + parts.headers.get(LOCATION).unwrap(), "/dashboard/user?tab=invitations" ); } #[tokio::test] async fn test_page_returns_not_found_for_unavailable_events() { - // Setup identifiers - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - // Setup a foreign, co-hosted or deleted event + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_group_has_event() .times(1) - .withf(move |gid, eid| *gid == group_id && *eid == event_id) + .withf(move |gid, eid| *gid == ids.group_id && *eid == event_id) .returning(|_, _| Ok(false)); db.expect_get_event_analytics().never(); - // Run the request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/analytics")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; // Check the cached statistics are never served assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -203,45 +194,23 @@ async fn test_page_returns_not_found_for_unavailable_events() { #[tokio::test] async fn test_page_returns_not_found_without_stats() { - // Setup identifiers - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - // Setup an available event whose statistics are missing + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_group_has_event() .times(1) - .withf(move |gid, eid| *gid == group_id && *eid == event_id) + .withf(move |gid, eid| *gid == ids.group_id && *eid == event_id) .returning(|_, _| Ok(true)); db.expect_get_event_analytics() .times(1) - .withf(move |gid, eid| *gid == group_id && *eid == event_id) + .withf(move |gid, eid| *gid == ids.group_id && *eid == event_id) .returning(|_, _| Ok(None)); - // Run the request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/analytics")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; // Check the missing statistics are reported as not found assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -249,11 +218,19 @@ async fn test_page_returns_not_found_without_stats() { #[tokio::test] async fn test_page_success() { - // Run the request for a past paid event - let (response_status, body) = request_page(sample_event_analytics()).await; + // Setup database mock for a past paid event + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, sample_event_analytics()); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the summary cards - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("Page views")); assert!(body.contains("Fill rate")); assert!(body.contains("40%")); @@ -334,11 +311,19 @@ async fn test_page_warns_when_attribution_is_inconsistent() { let mut stats = sample_event_analytics(); stats.traffic.attribution_consistent = false; - // Run the request - let (response_status, body) = request_page(stats).await; + // Setup database mock + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_page_reads(&mut db, &ids, event_id, stats); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, page_request(&ids, event_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check shares are replaced by warnings and raw totals remain - assert_eq!(response_status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("Traffic source attribution is unavailable")); assert!(body.contains("Shares of page views are unavailable")); assert!(!body.contains("id=\"event-utm-share-chart\"")); @@ -347,48 +332,26 @@ async fn test_page_warns_when_attribution_is_inconsistent() { // Helpers. -/// Requests the analytics tab as a read-only group user and returns the -/// response status and body. -async fn request_page(stats: EventAnalytics) -> (StatusCode, String) { - // Setup identifiers - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +/// Expects the read-only group access and analytics reads of an available +/// event with the given statistics. +fn expect_page_reads(db: &mut MockDB, ids: &DashboardIds, event_id: Uuid, stats: EventAnalytics) { + let ids = *ids; - // Setup a read-only group session and the analytics reads - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(db, &ids, GroupPermission::Read); db.expect_group_has_event() .times(1) - .withf(move |gid, eid| *gid == group_id && *eid == event_id) + .withf(move |gid, eid| *gid == ids.group_id && *eid == event_id) .returning(|_, _| Ok(true)); db.expect_get_event_analytics() .times(1) - .withf(move |gid, eid| *gid == group_id && *eid == event_id) + .withf(move |gid, eid| *gid == ids.group_id && *eid == event_id) .returning(move |_, _| Ok(Some(stats.clone()))); +} - // Run the request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/analytics")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - (parts.status, String::from_utf8(bytes.to_vec()).unwrap()) +/// Builds the analytics tab request for the event. +fn page_request(ids: &DashboardIds, event_id: Uuid) -> Request { + get_request( + &format!("/dashboard/group/events/{event_id}/analytics"), + ids.session_id, + ) } diff --git a/ocg-server/src/handlers/dashboard/group/events/tests.rs b/ocg-server/src/handlers/dashboard/group/events/tests.rs index 152b00703..cd1c618dd 100644 --- a/ocg-server/src/handlers/dashboard/group/events/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/events/tests.rs @@ -1,26 +1,16 @@ use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, + body::Body, + http::{HeaderValue, Request, StatusCode, header::CONTENT_TYPE, response::Parts}, }; -use axum_login::tower_sessions::session; use serde_json::{Value, from_slice, json, to_value}; -use tower::ServiceExt; use uuid::Uuid; use crate::{ - config::{HttpClientConfig, PaymentsConfig, PaymentsStripeConfig}, db::mock::MockDB, - handlers::{ - error::{HandlerError, INVALID_REQUEST_PAYLOAD}, - tests::*, - }, + handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, services::{ events::{AutomaticTaxCheckError, EventsError, MockEventsManager}, - notifications::MockNotificationsManager, payments::{AutomaticTaxReadiness, AutomaticTaxReadinessError}, }, types::{ @@ -35,8 +25,8 @@ use crate::{ }, event::{EventCohostStatus, EventFull}, payments::{ - EventTicketPriceWindow, EventTicketType, GroupExternalPaymentsContext, PaymentMode, - TicketTaxBehavior, TicketTaxRate, + EventTicketPriceWindow, EventTicketType, GroupExternalPaymentsContext, + GroupPaymentRecipient, TicketTaxBehavior, TicketTaxRate, }, permissions::GroupPermission, user::User, @@ -44,121 +34,32 @@ use crate::{ }; #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_add_page_renders_external_ticketing_without_payment_recipient() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let category = sample_event_category(); - let kind = sample_event_kind_summary(); - let payment_currency_codes = vec!["EUR".to_string(), "USD".to_string()]; - let session_kind = sample_session_kind_summary(); - let sponsor = sample_group_sponsor(); - let timezones = vec!["UTC".to_string()]; + let ids = DashboardIds::new(); + let external_payments = GroupExternalPaymentsContext { + configured: true, + eligible: true, + enabled: true, + country_code: Some("KR".to_string()), + default_payment_window_hours: Some(72), + max_payment_window_hours: Some(336), + seller_display_name: Some("External Payee Co".to_string()), + }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_list_event_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(vec![category.clone()])); - db.expect_list_communities() - .times(1) - .returning(move || Ok(sample_user_communities(community_id))); - db.expect_list_event_kinds() - .times(1) - .returning(move || Ok(vec![kind.clone()])); - db.expect_list_payment_currency_codes() - .times(1) - .returning(move || Ok(payment_currency_codes.clone())); - db.expect_list_session_kinds() - .times(1) - .returning(move || Ok(vec![session_kind.clone()])); - db.expect_list_group_sponsors() - .times(1) - .withf(move |id, filters, full_list| { - *id == group_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && *full_list - }) - .returning(move |_, _, _| { - Ok( - crate::types::dashboard::group::sponsors::GroupSponsorsOutput { - sponsors: vec![sponsor.clone()], - total: 1, - }, - ) - }); - db.expect_list_timezones() - .times(1) - .returning(move || Ok(timezones.clone())); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(None)); - db.expect_get_group_external_payments_context() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| { - Ok(GroupExternalPaymentsContext { - configured: true, - eligible: true, - enabled: true, - country_code: Some("KR".to_string()), - default_payment_window_hours: Some(72), - max_payment_window_hours: Some(336), - seller_display_name: Some("External Payee Co".to_string()), - }) - }); - db.expect_list_group_organizers() - .times(1) - .withf(move |gid| *gid == group_id) - .returning(|_| Ok(vec![])); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_editor_option_reads(&mut db, &ids, external_payments, vec![], None); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) - .with_payments_cfg(PaymentsConfig::Stripe(PaymentsStripeConfig { - connected_webhook_secret: "whsec_connect_test".to_string(), - mode: PaymentMode::Test, - secret_key: "sk_test_123".to_string(), - ticket_tax_api_version: "2026-07-29.preview".to_string(), - webhook_secret: "whsec_test_123".to_string(), - - http_client: HttpClientConfig::default(), - platform_fee_bps: 0, - })) + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) .build() .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/events/add") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = get_request("/dashboard/group/events/add", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check external ticketing is available without a Stripe recipient assert_html_response(&parts, &bytes, StatusCode::OK); @@ -172,122 +73,27 @@ async fn test_add_page_renders_external_ticketing_without_payment_recipient() { } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_add_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let category = sample_event_category(); - let kind = sample_event_kind_summary(); - let payment_currency_codes = vec!["EUR".to_string(), "USD".to_string()]; - let session_kind = sample_session_kind_summary(); - let sponsor = sample_group_sponsor(); - let timezones = vec!["UTC".to_string()]; - let team_members = sample_team_members(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_editor_option_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, + &ids, + GroupExternalPaymentsContext::default(), + sample_team_members(), + None, ); - db.expect_list_event_categories() - .times(1) - .withf(move |cid| *cid == community_id) - .returning(move |_| Ok(vec![category.clone()])); - db.expect_list_communities() - .times(1) - .returning(move || Ok(sample_user_communities(community_id))); - db.expect_list_event_kinds() - .times(1) - .returning(move || Ok(vec![kind.clone()])); - db.expect_list_payment_currency_codes() - .times(1) - .returning(move || Ok(payment_currency_codes.clone())); - db.expect_list_session_kinds() - .times(1) - .returning(move || Ok(vec![session_kind.clone()])); - db.expect_list_group_sponsors() - .times(1) - .withf(move |id, filters, full_list| { - *id == group_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - && *full_list - }) - .returning(move |_, _, _| { - Ok( - crate::types::dashboard::group::sponsors::GroupSponsorsOutput { - sponsors: vec![sponsor.clone()], - total: 1, - }, - ) - }); - db.expect_list_timezones() - .times(1) - .returning(move || Ok(timezones.clone())); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(None)); - db.expect_get_group_external_payments_context() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| { - Ok(GroupExternalPaymentsContext { - configured: false, - eligible: false, - enabled: false, - country_code: None, - default_payment_window_hours: None, - max_payment_window_hours: None, - seller_display_name: None, - }) - }); - db.expect_list_group_organizers() - .times(1) - .withf(move |gid| *gid == group_id) - .returning(move |_| Ok(team_members.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) - .with_payments_cfg(PaymentsConfig::Stripe(PaymentsStripeConfig { - connected_webhook_secret: "whsec_connect_test".to_string(), - mode: PaymentMode::Test, - secret_key: "sk_test_123".to_string(), - ticket_tax_api_version: "2026-07-29.preview".to_string(), - webhook_secret: "whsec_test_123".to_string(), - - http_client: HttpClientConfig::default(), - platform_fee_bps: 0, - })) + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) .build() .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/events/add") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let request = get_request("/dashboard/group/events/add", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); @@ -336,73 +142,36 @@ async fn test_add_page_success() { #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group_events = sample_group_events(Uuid::new_v4(), group_id); + let ids = DashboardIds::new(); + let mut group_events = sample_group_events(Uuid::new_v4()); group_events.upcoming.events[0].canceled = true; group_events.upcoming.events[0].test_event = true; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); db.expect_list_group_events() .times(1) - .withf(move |id, filters| { - *id == group_id + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.past_offset == Some(0) && filters.upcoming_offset == Some(0) }) - .returning({ - let group_events = group_events.clone(); - move |_, _| Ok(group_events.clone()) - }); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(move |_, _| Ok(group_events.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, nm) - .with_payments_cfg(PaymentsConfig::Stripe(PaymentsStripeConfig { - connected_webhook_secret: "whsec_connect_test".to_string(), - mode: PaymentMode::Test, - secret_key: "sk_test_123".to_string(), - ticket_tax_api_version: "2026-07-29.preview".to_string(), - webhook_secret: "whsec_test_123".to_string(), - - http_client: HttpClientConfig::default(), - platform_fee_bps: 0, - })) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/events") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/events", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=events&limit=50&past_offset=0&upcoming_offset=0" + ); let body = String::from_utf8(bytes.to_vec()).unwrap(); assert!(body.contains("aria-label=\"Open event details: Sample Event\"")); assert!(body.contains(">Test")); @@ -413,12 +182,9 @@ async fn test_list_page_success() { #[tokio::test] async fn test_list_page_waits_for_pending_cohosts_before_publishing() { // Setup identifiers and an unpublished event with pending co-hosts - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group_events = sample_group_events(event_id, group_id); + let mut group_events = sample_group_events(event_id); group_events.past.events.clear(); group_events.past.total = 0; group_events.upcoming.events[0].pending_cohosts_count = Some(2); @@ -426,39 +192,16 @@ async fn test_list_page_waits_for_pending_cohosts_before_publishing() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); db.expect_list_group_events() .times(1) .returning(move |_, _| Ok(group_events.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/events") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/events", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check the publish action is disabled with the pending co-hosts reason assert_html_response(&parts, &bytes, StatusCode::OK); @@ -473,36 +216,36 @@ async fn test_list_page_waits_for_pending_cohosts_before_publishing() { } #[tokio::test] -async fn test_update_page_hides_contributor_awards_for_canceled_events() { +async fn test_update_page_hides_awards_and_surveys_for_canceled_events() { // Setup identifiers and a canceled event - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let event_full = EventFull { canceled: true, - ..sample_event_full(community_id, event_id, group_id) + ..sample_event_full(ids.community_id, event_id, ids.group_id) }; - // Setup database expectations for an events manager + // Setup database mock without enrollment rows let mut db = MockDB::new(); - expect_update_page_context( + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_update_page_reads( &mut db, - session_id, - community_id, - group_id, - user_id, + &ids, event_full, EventCohostsEditor::default(), + None, ); - expect_invitation_requests_tab_search(&mut db, group_id, event_id, 0); - expect_waitlist_tab_search(&mut db, group_id, event_id, 0); + expect_invitation_requests_tab_search(&mut db, &ids, event_id, 0); + expect_waitlist_tab_search(&mut db, &ids, event_id, 0); - // Run the request - let response = send_update_page_request(db, session_id, event_id).await; - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let request = update_page_request(&ids, event_id); + let (parts, bytes) = send(router, request).await; // Check organizers render without award wiring assert_html_response(&parts, &bytes, StatusCode::OK); @@ -513,51 +256,42 @@ async fn test_update_page_hides_contributor_awards_for_canceled_events() { assert!(!body.contains("event-organizers-award-button")); assert!(!body.contains("event-hosts-award-button")); assert!(!body.contains("can-award-badges")); + + // Check the surveys tab and its content are not rendered + assert!(!body.contains("
")); @@ -69,9 +65,13 @@ async fn test_close_passes_identifiers_and_renders_thread() { #[tokio::test] async fn test_close_renders_notice_when_thread_cannot_be_reloaded() { - // Setup identifiers and a failing thread reload - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); + + // Setup database mock with a failing thread reload + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) .returning(|_, _| Err(anyhow!("db error"))); @@ -83,20 +83,21 @@ async fn test_close_renders_notice_when_thread_cannot_be_reloaded() { .times(1) .returning(|_| Box::pin(async { Ok(()) })); - // Send the close request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", &format!("/dashboard/group/inbox/{inbox_conversation_id}/close"), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the committed close is reported as done assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("data-inbox-action-done")); assert!(html.contains("Conversation closed.")); assert!(html.contains(&format!( @@ -104,114 +105,59 @@ async fn test_close_renders_notice_when_thread_cannot_be_reloaded() { ))); } -#[tokio::test] -async fn test_close_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager.expect_close_conversation().times(1).returning(|_| { - Box::pin(async { - Err(InboxError::Other( - HandlerError::Database("conversation not found".to_string()).into(), - )) - }) - }); - - // Send the close request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/close"), - None, - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "conversation not found" - ); -} - -#[tokio::test] -async fn test_close_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_close_conversation() - .times(1) - .returning(|_| Box::pin(async { Err(InboxError::Other(anyhow!("db error"))) })); - - // Send the close request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/close"), - None, - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - #[tokio::test] async fn test_close_returns_rejected_message() { - // Setup identifiers and a manager rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); + + // Setup inbox manager mock with a rejection let mut inbox_manager = MockInboxManager::new(); inbox_manager.expect_close_conversation().times(1).returning(|_| { Box::pin(async { Err(InboxError::Rejected("inbox unavailable".to_string())) }) }); - // Send the close request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/close"), - None, - ) - .await; + &format!("/dashboard/group/inbox/{}/close", Uuid::new_v4()), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "inbox unavailable" - ); + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "inbox unavailable"); } #[tokio::test] async fn test_conversation_page_rejects_conversations_of_other_groups() { - // Setup identifiers and a conversation outside the group - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); + + // Setup database mock with a conversation outside the group + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) - .withf(move |gid, cid| *gid == group_id && *cid == inbox_conversation_id) + .withf(move |group_id, conversation_id| { + *group_id == ids.group_id && *conversation_id == inbox_conversation_id + }) .returning(|_, _| Ok(None)); - // Request the thread - let (parts, bytes) = send_request( - db, - MockInboxManager::new(), - session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!("/dashboard/group/inbox/{inbox_conversation_id}"), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the conversation is not found assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -220,24 +166,27 @@ async fn test_conversation_page_rejects_conversations_of_other_groups() { #[tokio::test] async fn test_conversation_page_renders_thread_with_push_url() { // Setup identifiers and the thread - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); let conversation = sample_inbox_conversation(inbox_conversation_id); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) - .withf(move |gid, cid| *gid == group_id && *cid == inbox_conversation_id) + .withf(move |group_id, conversation_id| { + *group_id == ids.group_id && *conversation_id == inbox_conversation_id + }) .returning(move |_, _| Ok(Some(conversation.clone()))); - // Request the thread - let (parts, bytes) = send_request( - db, - MockInboxManager::new(), - session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!("/dashboard/group/inbox/{inbox_conversation_id}"), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the thread and the full dashboard URL assert_html_response(&parts, &bytes, StatusCode::OK); @@ -245,7 +194,7 @@ async fn test_conversation_page_renders_thread_with_push_url() { parts.headers.get("hx-push-url").unwrap(), &format!("/dashboard/group?tab=inbox&conversation_id={inbox_conversation_id}") ); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("Inbox User")); assert!(html.contains("id=\"inbox-message-form\"")); assert!(!html.contains("data-inbox-action-notice")); @@ -254,13 +203,16 @@ async fn test_conversation_page_renders_thread_with_push_url() { #[tokio::test] async fn test_list_page_filters_by_status_and_pushes_url() { // Setup identifiers and the filtered list - let (community_id, group_id, _, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + let ids = DashboardIds::new(); let summary = sample_inbox_conversation_summary(Uuid::new_v4()); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_list_group_inbox_conversations() .times(1) - .withf(move |gid, filters| { - *gid == group_id + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) && filters.status == Some(InboxConversationStatus::Open) @@ -272,16 +224,10 @@ async fn test_list_page_filters_by_status_and_pushes_url() { }) }); - // Request the list - let (parts, bytes) = send_request( - db, - MockInboxManager::new(), - session_id, - "GET", - "/dashboard/group/inbox?status=open", - None, - ) - .await; + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/inbox?status=open", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check the list and the full dashboard URL assert_html_response(&parts, &bytes, StatusCode::OK); @@ -289,7 +235,7 @@ async fn test_list_page_filters_by_status_and_pushes_url() { parts.headers.get("hx-push-url").unwrap(), "/dashboard/group?tab=inbox&limit=50&offset=0&status=open" ); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("Inbox User")); assert!(html.contains("When do doors open?")); } @@ -297,13 +243,19 @@ async fn test_list_page_filters_by_status_and_pushes_url() { #[tokio::test] async fn test_mark_spam_passes_identifiers_and_renders_thread() { // Setup identifiers and the reloaded thread - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); let mut conversation = sample_inbox_conversation(inbox_conversation_id); conversation.status = InboxConversationStatus::Spam; + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) - .withf(move |gid, cid| *gid == group_id && *cid == inbox_conversation_id) + .withf(move |group_id, conversation_id| { + *group_id == ids.group_id && *conversation_id == inbox_conversation_id + }) .returning(move |_, _| Ok(Some(conversation.clone()))); // Setup inbox manager mock @@ -312,99 +264,41 @@ async fn test_mark_spam_passes_identifiers_and_renders_thread() { .expect_mark_conversation_as_spam() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.group_id == group_id + input.actor_user_id == ids.user_id + && input.group_id == ids.group_id && input.inbox_conversation_id == inbox_conversation_id }) .returning(|_| Box::pin(async { Ok(()) })); - // Send the mark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", &format!("/dashboard/group/inbox/{inbox_conversation_id}/mark-spam"), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the refreshed thread reports the spam mark in a focused notice assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("id=\"inbox-conversation\"")); assert!(html.contains("data-inbox-spam-notice")); assert!(html.contains("data-inbox-action-notice>Conversation marked as spam.")); assert!(html.contains("autofocus")); } -#[tokio::test] -async fn test_mark_spam_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_mark_conversation_as_spam() - .times(1) - .returning(|_| { - Box::pin(async { - Err(InboxError::Other( - HandlerError::Database("conversation not found".to_string()).into(), - )) - }) - }); - - // Send the mark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/mark-spam"), - None, - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "conversation not found" - ); -} - -#[tokio::test] -async fn test_mark_spam_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_mark_conversation_as_spam() - .times(1) - .returning(|_| Box::pin(async { Err(InboxError::Other(anyhow!("db error"))) })); - - // Send the mark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/mark-spam"), - None, - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - #[tokio::test] async fn test_mark_spam_returns_rejected_message() { - // Setup identifiers and a manager rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); + + // Setup inbox manager mock with a rejection let mut inbox_manager = MockInboxManager::new(); inbox_manager .expect_mark_conversation_as_spam() @@ -413,34 +307,38 @@ async fn test_mark_spam_returns_rejected_message() { Box::pin(async { Err(InboxError::Rejected("inbox unavailable".to_string())) }) }); - // Send the mark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/mark-spam"), - None, - ) - .await; + &format!("/dashboard/group/inbox/{}/mark-spam", Uuid::new_v4()), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "inbox unavailable" - ); + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "inbox unavailable"); } #[tokio::test] async fn test_reply_passes_identifiers_and_renders_focused_thread() { // Setup identifiers and the reloaded thread - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); let conversation = sample_inbox_conversation(inbox_conversation_id); + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) - .withf(move |gid, cid| *gid == group_id && *cid == inbox_conversation_id) + .withf(move |group_id, conversation_id| { + *group_id == ids.group_id && *conversation_id == inbox_conversation_id + }) .returning(move |_, _| Ok(Some(conversation.clone()))); // Setup inbox manager mock @@ -449,27 +347,29 @@ async fn test_reply_passes_identifiers_and_renders_focused_thread() { .expect_add_group_reply() .times(1) .withf(move |input| { - input.actor_user_id == user_id + input.actor_user_id == ids.user_id && input.body == "Doors open at 6pm." - && input.group_id == group_id + && input.group_id == ids.group_id && input.inbox_conversation_id == inbox_conversation_id }) .returning(move |_| Box::pin(async move { Ok(inbox_conversation_id) })); - // Send the reply - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = form_request( "POST", &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=Doors+open+at+6pm."), - ) - .await; + ids.session_id, + "body=Doors+open+at+6pm.", + ); + let (parts, bytes) = send(router, request).await; // Check the refreshed thread focuses the reply field without a notice assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("id=\"inbox-conversation\"")); assert!(html.contains("autofocus")); assert!(!html.contains("data-inbox-action-notice")); @@ -477,32 +377,45 @@ async fn test_reply_passes_identifiers_and_renders_focused_thread() { #[tokio::test] async fn test_reply_rejects_whitespace_only_body() { - // Setup identifiers without manager calls - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); + + // Setup inbox manager mock without manager calls let mut inbox_manager = MockInboxManager::new(); inbox_manager.expect_add_group_reply().never(); - // Send an empty reply - let (parts, _) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request with a whitespace-only reply + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = form_request( "POST", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=+%0A+"), - ) - .await; + &format!("/dashboard/group/inbox/{}/replies", Uuid::new_v4()), + ids.session_id, + "body=+%0A+", + ); + let (parts, bytes) = send(router, request).await; // Check the validation failure assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + "body: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_reply_renders_notice_when_thread_cannot_be_reloaded() { - // Setup identifiers and a failing thread reload - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); + + // Setup database mock with a failing thread reload + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) .returning(|_, _| Err(anyhow!("db error"))); @@ -514,122 +427,73 @@ async fn test_reply_renders_notice_when_thread_cannot_be_reloaded() { .times(1) .returning(move |_| Box::pin(async move { Ok(inbox_conversation_id) })); - // Send the reply - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = form_request( "POST", &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=Hi"), - ) - .await; + ids.session_id, + "body=Hi", + ); + let (parts, bytes) = send(router, request).await; // Check the committed reply is reported as sent assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("data-inbox-action-done")); assert!(html.contains("Message sent.")); } -#[tokio::test] -async fn test_reply_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager.expect_add_group_reply().times(1).returning(|_| { - Box::pin(async { - Err(InboxError::Other( - HandlerError::Database("conversation is read-only".to_string()).into(), - )) - }) - }); - - // Send the reply - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "POST", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=Hi"), - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "conversation is read-only" - ); -} - -#[tokio::test] -async fn test_reply_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_add_group_reply() - .times(1) - .returning(|_| Box::pin(async { Err(InboxError::Other(anyhow!("db error"))) })); - - // Send the reply - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "POST", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=Hi"), - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - #[tokio::test] async fn test_reply_returns_rejected_message() { - // Setup identifiers and a manager rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); + + // Setup inbox manager mock with a rejection let mut inbox_manager = MockInboxManager::new(); inbox_manager.expect_add_group_reply().times(1).returning(|_| { Box::pin(async { Err(InboxError::Rejected("inbox unavailable".to_string())) }) }); - // Send the reply - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = form_request( "POST", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/replies"), - Some("body=Hi"), - ) - .await; + &format!("/dashboard/group/inbox/{}/replies", Uuid::new_v4()), + ids.session_id, + "body=Hi", + ); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "inbox unavailable" - ); + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "inbox unavailable"); } #[tokio::test] async fn test_unmark_spam_passes_identifiers_and_renders_thread() { // Setup identifiers and the reloaded thread - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let mut db = inbox_db(session_id, user_id, community_id, group_id); + let ids = DashboardIds::new(); + let inbox_conversation_id = Uuid::new_v4(); let mut conversation = sample_inbox_conversation(inbox_conversation_id); conversation.status = InboxConversationStatus::Open; + + // Setup database mock + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); db.expect_get_group_inbox_conversation() .times(1) - .withf(move |gid, cid| *gid == group_id && *cid == inbox_conversation_id) + .withf(move |group_id, conversation_id| { + *group_id == ids.group_id && *conversation_id == inbox_conversation_id + }) .returning(move |_, _| Ok(Some(conversation.clone()))); // Setup inbox manager mock @@ -638,99 +502,41 @@ async fn test_unmark_spam_passes_identifiers_and_renders_thread() { .expect_unmark_conversation_as_spam() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.group_id == group_id + input.actor_user_id == ids.user_id + && input.group_id == ids.group_id && input.inbox_conversation_id == inbox_conversation_id }) .returning(|_| Box::pin(async { Ok(()) })); - // Send the unmark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", &format!("/dashboard/group/inbox/{inbox_conversation_id}/unmark-spam"), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the refreshed thread reports the spam unmark in a focused notice assert_html_response(&parts, &bytes, StatusCode::OK); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("id=\"inbox-conversation\"")); assert!(html.contains("id=\"inbox-message-form\"")); assert!(html.contains("data-inbox-action-notice>Conversation unmarked as spam.")); assert!(html.contains("autofocus")); } -#[tokio::test] -async fn test_unmark_spam_returns_database_rejection_message() { - // Setup identifiers and a database rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_unmark_conversation_as_spam() - .times(1) - .returning(|_| { - Box::pin(async { - Err(InboxError::Other( - HandlerError::Database("conversation not found".to_string()).into(), - )) - }) - }); - - // Send the unmark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/unmark-spam"), - None, - ) - .await; - - // Check the rejection message is returned - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "conversation not found" - ); -} - -#[tokio::test] -async fn test_unmark_spam_returns_internal_error_without_body() { - // Setup identifiers and an internal failure - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); - let mut inbox_manager = MockInboxManager::new(); - inbox_manager - .expect_unmark_conversation_as_spam() - .times(1) - .returning(|_| Box::pin(async { Err(InboxError::Other(anyhow!("db error"))) })); - - // Send the unmark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, - "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/unmark-spam"), - None, - ) - .await; - - // Check the internal error hides the detail - assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); -} - #[tokio::test] async fn test_unmark_spam_returns_rejected_message() { - // Setup identifiers and a manager rejection - let (community_id, group_id, inbox_conversation_id, session_id, user_id) = sample_ids(); - let db = inbox_db(session_id, user_id, community_id, group_id); + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::InboxWrite); + + // Setup inbox manager mock with a rejection let mut inbox_manager = MockInboxManager::new(); inbox_manager .expect_unmark_conversation_as_spam() @@ -739,79 +545,19 @@ async fn test_unmark_spam_returns_rejected_message() { Box::pin(async { Err(InboxError::Rejected("inbox unavailable".to_string())) }) }); - // Send the unmark spam request - let (parts, bytes) = send_request( - db, - inbox_manager, - session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db) + .with_inbox_manager(inbox_manager) + .build() + .await; + let request = empty_request( "PUT", - &format!("/dashboard/group/inbox/{inbox_conversation_id}/unmark-spam"), - None, - ) - .await; + &format!("/dashboard/group/inbox/{}/unmark-spam", Uuid::new_v4()), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rejection message is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "inbox unavailable" - ); -} - -// Helpers. - -/// Builds a database mock for a session allowed to use the group inbox. -fn inbox_db(session_id: session::Id, user_id: Uuid, community_id: Uuid, group_id: Uuid) -> MockDB { - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::InboxWrite, - ); - - db -} - -/// Returns community, group, conversation, session and user identifiers. -fn sample_ids() -> (Uuid, Uuid, Uuid, session::Id, Uuid) { - ( - Uuid::new_v4(), - Uuid::new_v4(), - Uuid::new_v4(), - session::Id::default(), - Uuid::new_v4(), - ) -} - -/// Sends a group inbox request through the router. -async fn send_request( - db: MockDB, - inbox_manager: MockInboxManager, - session_id: session::Id, - method: &str, - uri: &str, - form: Option<&str>, -) -> (axum::http::response::Parts, axum::body::Bytes) { - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_inbox_manager(inbox_manager) - .build() - .await; - let mut request = Request::builder() - .method(method) - .uri(uri) - .header(COOKIE, format!("id={session_id}")); - if form.is_some() { - request = request.header(CONTENT_TYPE, "application/x-www-form-urlencoded"); - } - let request = request - .body(Body::from(form.unwrap_or_default().to_string())) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - (parts, bytes) + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "inbox unavailable"); } diff --git a/ocg-server/src/handlers/dashboard/group/invitation_requests/tests.rs b/ocg-server/src/handlers/dashboard/group/invitation_requests/tests.rs index 3842da63d..dfa20aeda 100644 --- a/ocg-server/src/handlers/dashboard/group/invitation_requests/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/invitation_requests/tests.rs @@ -1,19 +1,10 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use mockall::Sequence; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{ dashboard::{ DASHBOARD_PAGINATION_LIMIT, @@ -36,16 +27,12 @@ use crate::{ }; #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); let question_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); + let event = sample_event_summary(event_id); let mut invitation_request = sample_invitation_request(); invitation_request.registration_answers = Some(QuestionnaireAnswers { answers: vec![QuestionnaireAnswer { @@ -68,33 +55,11 @@ async fn test_list_page_success() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(move |_, _| Ok(registration_questions.clone())); + expect_list_context(&mut db, &ids, event, registration_questions); db.expect_search_event_invitation_requests() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) @@ -103,27 +68,15 @@ async fn test_list_page_success() { .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains("status=pending")); @@ -136,16 +89,12 @@ async fn test_list_page_success() { } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_list_page_disables_accept_for_sold_out_requested_ticket_type() { // Setup identifiers and data structures + let ids = DashboardIds::new(); let available_ticket_type_id = Uuid::from_u128(1); - let community_id = Uuid::new_v4(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); let sold_out_ticket_type_id = Uuid::from_u128(2); - let user_id = Uuid::new_v4(); let event = EventSummary { attendee_approval_required: true, ticket_types: Some(vec![ @@ -164,7 +113,7 @@ async fn test_list_page_disables_accept_for_sold_out_requested_ticket_type() { ..Default::default() }, ]), - ..sample_event_summary(event_id, group_id) + ..sample_event_summary(event_id) }; let available_request = InvitationRequest { requested_event_ticket_type_id: Some(available_ticket_type_id), @@ -183,52 +132,22 @@ async fn test_list_page_disables_accept_for_sold_out_requested_ticket_type() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); + expect_list_context(&mut db, &ids, event, vec![]); db.expect_search_event_invitation_requests() .times(1) - .withf(move |gid, eid, _| *gid == group_id && *eid == event_id) + .withf(move |gid, eid, _| *gid == ids.group_id && *eid == event_id) .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); let body = std::str::from_utf8(&bytes).unwrap(); // Check only the sold-out request disables acceptance with guidance @@ -251,94 +170,63 @@ async fn test_list_page_disables_accept_for_sold_out_requested_ticket_type() { #[tokio::test] async fn test_list_page_rejects_zero_pagination_limit() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + db.expect_search_event_invitation_requests().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests?limit=0" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests?limit=0"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "limit: lower than 1\n" + ); } #[tokio::test] async fn test_list_page_rejects_pagination_limit_above_maximum() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + db.expect_search_event_invitation_requests().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests?limit=101" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests?limit=101"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "limit: greater than 100\n" + ); } #[tokio::test] async fn test_list_page_shows_status_reset_when_status_filtered_list_is_empty() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); + let event = sample_event_summary(event_id); let output = InvitationRequestsOutput { invitation_requests: vec![], total: 0, @@ -346,33 +234,11 @@ async fn test_list_page_shows_status_reset_when_status_filtered_list_is_empty() // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); + expect_list_context(&mut db, &ids, event, vec![]); db.expect_search_event_invitation_requests() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) @@ -382,27 +248,15 @@ async fn test_list_page_shows_status_reset_when_status_filtered_list_is_empty() .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); // Check the empty state names the status filter and offers a reset let body = std::str::from_utf8(&bytes).unwrap(); @@ -414,12 +268,9 @@ async fn test_list_page_shows_status_reset_when_status_filtered_list_is_empty() #[tokio::test] async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); + let event = sample_event_summary(event_id); let empty_page = InvitationRequestsOutput { invitation_requests: vec![], total: 6, @@ -431,29 +282,7 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { // Setup session, permission, and event context expectations let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); + expect_list_context(&mut db, &ids, event, vec![]); // Setup the empty requested page followed by the last page with rows let mut sequence = Sequence::new(); @@ -461,7 +290,7 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { .times(1) .in_sequence(&mut sequence) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(5) && filters.offset == Some(10) @@ -472,7 +301,7 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { .times(1) .in_sequence(&mut sequence) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(5) && filters.offset == Some(5) @@ -481,27 +310,15 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { .return_once(move |_, _, _| Ok(last_page)); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests?limit=5&offset=10" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests?limit=5&offset=10"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the last page with rows is rendered and refreshed in place - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains("Requesting User")); assert!(body.contains("offset=5")); @@ -511,12 +328,9 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { #[tokio::test] async fn test_list_page_with_all_status_filter() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); + let event = sample_event_summary(event_id); let output = InvitationRequestsOutput { invitation_requests: vec![], total: 0, @@ -524,33 +338,11 @@ async fn test_list_page_with_all_status_filter() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); + expect_list_context(&mut db, &ids, event, vec![]); db.expect_search_event_invitation_requests() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) @@ -559,27 +351,15 @@ async fn test_list_page_with_all_status_filter() { .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests?status=all" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/invitation-requests?status=all"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains("status=all")); @@ -588,90 +368,12 @@ async fn test_list_page_with_all_status_filter() { assert!(!body.contains("Show all statuses")); } -#[tokio::test] -async fn test_list_page_with_pagination_params() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let output = InvitationRequestsOutput { - invitation_requests: vec![], - total: 0, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); - db.expect_search_event_invitation_requests() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(5) - && filters.offset == Some(10) - && filters.status == InvitationRequestsStatusFilter::Pending - }) - .returning(move |_, _, _| Ok(output.clone())); - - // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/invitation-requests?limit=5&offset=10" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); -} - #[tokio::test] async fn test_list_page_with_search_query() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); + let event = sample_event_summary(event_id); let output = InvitationRequestsOutput { invitation_requests: vec![sample_invitation_request()], total: 2, @@ -679,33 +381,11 @@ async fn test_list_page_with_search_query() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_get_event_registration_questions() - .times(1) - .withf(move |cid, eid| *cid == community_id && *eid == event_id) - .returning(|_, _| Ok(vec![])); + expect_list_context(&mut db, &ids, event, vec![]); db.expect_search_event_invitation_requests() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(1) && filters.offset == Some(0) @@ -717,32 +397,22 @@ async fn test_list_page_with_search_query() { .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( concat!( "/dashboard/group/events/{event_id}/invitation-requests?", "limit=1&sort=name-desc&status=accepted&", "title=missing&ts_query=req" ), event_id = event_id, - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains("name=\"ts_query\"")); assert!(body.contains("value=\"req\"")); @@ -752,3 +422,30 @@ async fn test_list_page_with_search_query() { assert!(body.contains("ts_query=req")); assert!(body.contains("refresh-event-invitation-requests")); } + +// Helpers. + +/// Expects the session, permissions, event summary and registration questions +/// read by the list page. +fn expect_list_context( + db: &mut MockDB, + ids: &DashboardIds, + event: EventSummary, + registration_questions: Vec, +) { + let ids = *ids; + let event_id = event.event_id; + + expect_group_access(db, &ids, GroupPermission::Read); + expect_group_permission_result(db, &ids, GroupPermission::EventsWrite, true); + db.expect_get_event_summary_dashboard() + .times(1) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) + .returning(move |_, _, _| Ok(event.clone())); + db.expect_get_event_registration_questions() + .times(1) + .withf(move |cid, eid| *cid == ids.community_id && *eid == event_id) + .returning(move |_, _| Ok(registration_questions.clone())); +} diff --git a/ocg-server/src/handlers/dashboard/group/logs/tests.rs b/ocg-server/src/handlers/dashboard/group/logs/tests.rs index 156a8dd09..e04c05254 100644 --- a/ocg-server/src/handlers/dashboard/group/logs/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/logs/tests.rs @@ -1,45 +1,25 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::{HeaderValue, StatusCode}; use chrono::NaiveDate; -use tower::ServiceExt; -use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{dashboard::common::AuditLogSort, permissions::GroupPermission}, }; #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let output = sample_audit_logs_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_list_group_audit_logs() .times(1) .withf(move |id, filters| { - *id == group_id + *id == ids.group_id && filters.action.as_deref() == Some("group_updated") && filters.actor.as_deref() == Some("test-user") && filters.date_from == Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()) @@ -50,31 +30,18 @@ async fn test_list_page_success() { }) .returning(move |_, _| Ok(output.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri( - "/dashboard/group/logs?action=group_updated&actor=test-user\ - &date_from=2024-01-01&date_to=2024-01-31&limit=5&offset=10&sort=created-asc", - ) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/logs?action=group_updated&actor=test-user\ + &date_from=2024-01-01&date_to=2024-01-31&limit=5&offset=10&sort=created-asc", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( parts.headers.get("hx-push-url").unwrap(), &HeaderValue::from_static(concat!( diff --git a/ocg-server/src/handlers/dashboard/group/members/tests.rs b/ocg-server/src/handlers/dashboard/group/members/tests.rs index 0bf9ade02..a159bc434 100644 --- a/ocg-server/src/handlers/dashboard/group/members/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/members/tests.rs @@ -1,20 +1,12 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; +use axum::http::StatusCode; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{ - dashboard::DASHBOARD_PAGINATION_LIMIT, notifications::NotificationKind, + dashboard::{DASHBOARD_PAGINATION_LIMIT, group::members::GroupMembersOutput}, + notifications::NotificationKind, permissions::GroupPermission, }, }; @@ -22,69 +14,36 @@ use crate::{ #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let member = sample_group_member(); - let group = sample_group_summary(group_id); - let output = crate::types::dashboard::group::members::GroupMembersOutput { - members: vec![member.clone()], - total: 1, - }; + let ids = DashboardIds::new(); + let group = sample_group_summary(ids.group_id); + let output = sample_members_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::MembersWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::MembersWrite, true); db.expect_list_group_members() .times(1) .withf(move |id, filters| { - *id == group_id + *id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); db.expect_get_group_summary() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(move |_, _| Ok(group.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/members") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/members", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - let body = std::str::from_utf8(&bytes).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("Group Member")); assert!(body.contains("name=\"subject\"")); assert!(body.contains("value=\"Test Group\"")); } @@ -92,111 +51,59 @@ async fn test_list_page_success() { #[tokio::test] async fn test_list_page_with_pagination_params() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let member = sample_group_member(); - let group = sample_group_summary(group_id); - let output = crate::types::dashboard::group::members::GroupMembersOutput { - members: vec![member.clone()], - total: 1, - }; + let ids = DashboardIds::new(); + let group = sample_group_summary(ids.group_id); + let output = sample_members_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::MembersWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::MembersWrite, true); db.expect_list_group_members() .times(1) .withf(move |id, filters| { - *id == group_id && filters.limit == Some(5) && filters.offset == Some(10) + *id == ids.group_id && filters.limit == Some(5) && filters.offset == Some(10) }) .returning(move |_, _| Ok(output.clone())); db.expect_get_group_summary() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(move |_, _| Ok(group.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/members?limit=5&offset=10") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/members?limit=5&offset=10", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("Group Member")); } #[tokio::test] async fn test_send_group_custom_notification_no_members() { - // Setup identifiers and data structures - let group_id = Uuid::new_v4(); - let community_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let form_data = "body=Body&subject=Subject"; - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::MembersWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::MembersWrite); db.expect_get_group_summary().never(); db.expect_list_group_audience_ids() .times(1) - .withf(move |gid| *gid == group_id) + .withf(move |gid| *gid == ids.group_id) .returning(move |_| Ok(vec![])); db.expect_get_site_settings().never(); db.expect_enqueue_tracked_custom_notification().never(); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/notifications") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/notifications", + ids.session_id, + "body=Body&subject=Subject", + ); + let (parts, bytes) = send(router, request).await; // Check the empty audience is rejected assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -209,63 +116,57 @@ async fn test_send_group_custom_notification_no_members() { #[tokio::test] async fn test_send_group_custom_notification_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let site_settings = sample_site_settings(); - let group_summary = sample_group_summary(group_id); - let form_data = "body=Hello%2C+group+members%21&title=Important+Update"; + let group_summary = sample_group_summary(ids.group_id); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::MembersWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::MembersWrite); db.expect_list_group_audience_ids() .times(1) - .withf(move |gid| *gid == group_id) + .withf(move |gid| *gid == ids.group_id) .returning(move |_| Ok(vec![member_id])); db.expect_get_site_settings() .times(1) .returning(move || Ok(site_settings.clone())); db.expect_get_group_summary() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |cid, gid| *cid == ids.community_id && *gid == ids.group_id) .returning(move |_, _| Ok(group_summary.clone())); db.expect_enqueue_tracked_custom_notification() .times(1) .withf(move |notification, tracking| { + // The legacy `title` field still maps to the subject matches!(notification.kind, NotificationKind::GroupCustom) + && notification.recipients == vec![member_id] && tracking.body == "Hello, group members!" - && tracking.created_by == user_id + && tracking.created_by == ids.user_id && tracking.subject == "Important Update" }) .returning(|_, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/notifications") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/notifications", + ids.session_id, + "body=Hello%2C+group+members%21&title=Important+Update", + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert!(bytes.is_empty()); + assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); +} + +// Helpers. + +/// Returns a members list output with the sample member. +fn sample_members_output() -> GroupMembersOutput { + GroupMembersOutput { + members: vec![sample_group_member()], + total: 1, + } } diff --git a/ocg-server/src/handlers/dashboard/group/refunds/tests.rs b/ocg-server/src/handlers/dashboard/group/refunds/tests.rs index 98a5fcbd0..eb4c6380b 100644 --- a/ocg-server/src/handlers/dashboard/group/refunds/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/refunds/tests.rs @@ -1,23 +1,18 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use chrono::Utc; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::tests::*, - services::{notifications::MockNotificationsManager, payments::MockPaymentsManager}, + handlers::{error::HandlerError, tests::*}, + services::payments::MockPaymentsManager, types::{ - dashboard::group::refunds::{ - FinancialRecoveryKind, GroupFinancialRecovery, GroupRefund, GroupRefundStatus, - RefundEvent, RefundsFilters, RefundsOutput, RefundsView, + dashboard::{ + DASHBOARD_PAGINATION_LIMIT, + group::refunds::{ + FinancialRecoveryKind, GroupFinancialRecovery, GroupRefund, GroupRefundStatus, + RefundEvent, RefundsFilters, RefundsOutput, RefundsView, + }, }, permissions::GroupPermission, }, @@ -26,11 +21,7 @@ use crate::{ #[tokio::test] async fn test_list_page_renders_history_without_payments_setup() { // Setup a readable group after its current payment setup was removed - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - + let ids = DashboardIds::new(); let output = RefundsOutput { events: vec![], financial_recoveries: vec![], @@ -40,67 +31,37 @@ async fn test_list_page_renders_history_without_payments_setup() { // Setup authentication and historical refund-list expectations let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, false); db.expect_list_group_refunds() .times(1) .withf(move |gid, filters| { - *gid == group_id - && filters.limit == Some(crate::types::dashboard::DASHBOARD_PAGINATION_LIMIT) + *gid == ids.group_id + && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) && filters.view == RefundsView::Active }) .returning(move |_, _| Ok(output.clone())); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - // Request the refunds partial without a configured payments provider - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/refunds") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + // Request the refunds partial without a configured payments provider; this + // guards against gating the route on payments setup like the webhooks + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/refunds", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check historical refund operations remain accessible assert_html_response(&parts, &bytes, StatusCode::OK); - assert!( - std::str::from_utf8(&bytes) - .expect("refunds response to be UTF-8") - .contains("Review attendee requests") - ); + assert!(body.contains("Review attendee requests")); } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_list_page_renders_filtered_refund_workflows() { // Setup identifiers and filtered refund output - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let event_purchase_id = Uuid::new_v4(); let recovery_purchase_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut output = RefundsOutput { events: vec![RefundEvent { event_id, @@ -152,25 +113,12 @@ async fn test_list_page_renders_filtered_refund_workflows() { // Setup authentication, authorization, and filtered database expectations let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); db.expect_list_group_refunds() .times(1) .withf(move |gid, filters| { - *gid == group_id + *gid == ids.group_id && filters.event_id == Some(event_id) && filters.limit == Some(10) && filters.offset == Some(10) @@ -180,28 +128,17 @@ async fn test_list_page_renders_filtered_refund_workflows() { .returning(move |_, _| Ok(output.clone())); // Request the filtered refunds partial - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( "/dashboard/group/refunds?event_id={event_id}&limit=10&offset=10&ts_query=alice&view=attention" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rendered operational state, filtered refresh, and stable action address - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - "text/html; charset=utf-8" - ); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( parts.headers.get("hx-push-url").unwrap().to_str().unwrap(), format!( @@ -226,121 +163,40 @@ async fn test_list_page_renders_filtered_refund_workflows() { #[tokio::test] async fn test_list_page_rejects_invalid_pagination_limit() { - // Setup authenticated group dashboard context - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - // Expect invalid filters to fail before refund list work starts + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Request a page size outside the validated dashboard range - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/refunds?limit=0") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/refunds?limit=0", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check the invalid page size is rejected - assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn test_complete_payment_job_recovery_forbids_user_without_event_write_access() { - // Setup an authenticated group member without event-management access - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - - // Reject recovery before the payment job mutation can run - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "limit: lower than 1\n" ); - db.expect_complete_payment_job_recovery().never(); - - // Attempt recovery with otherwise valid evidence - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/group/payment-jobs/recovery") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( - "payment_job_id={payment_job_id}&provider_object_id=fr_test_123&recovery_note=Verified+Stripe+activity&recovery_reference=ticket-456" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the route rejects recovery before calling the database function - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_complete_payment_job_recovery_records_provider_evidence() { - // Setup an authenticated event manager and payment job - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let payment_job_id = Uuid::new_v4(); - - // Expect provider evidence to reach the payments manager let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup payments manager mock + // Expect provider evidence to reach the payments manager let mut payments_manager = MockPaymentsManager::new(); payments_manager .expect_complete_payment_job_recovery() .times(1) .withf(move |input| { - input.actor_user_id == user_id - && input.group_id == group_id + input.actor_user_id == ids.user_id + && input.group_id == ids.group_id && input.payment_job_id == payment_job_id && input.provider_object_id == "fr_test_123" && input.recovery_note == "Verified Stripe activity" @@ -349,22 +205,19 @@ async fn test_complete_payment_job_recovery_records_provider_evidence() { .returning(|_| Box::pin(async { Ok(()) })); // Submit the payment job recovery evidence - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/group/payment-jobs/recovery") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( + let request = form_request( + "PUT", + "/dashboard/group/payment-jobs/recovery", + ids.session_id, + format!( "payment_job_id={payment_job_id}&provider_object_id=fr_test_123&recovery_note=Verified+Stripe+activity&recovery_reference=ticket-456" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ); + let (parts, bytes) = send(router, request).await; // Check the current financial-work view is refreshed assert_empty_hx_trigger_response( @@ -375,57 +228,85 @@ async fn test_complete_payment_job_recovery_records_provider_evidence() { ); } +#[tokio::test] +async fn test_complete_payment_job_recovery_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let payment_job_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); + + // Setup payments manager mock rejecting the recovery + let mut payments_manager = MockPaymentsManager::new(); + payments_manager + .expect_complete_payment_job_recovery() + .times(1) + .withf(move |input| input.payment_job_id == payment_job_id) + .returning(|_| { + Box::pin(async { + Err(HandlerError::Database("recoverable payment job not found".to_string()).into()) + }) + }); + + // Submit the payment job recovery evidence + let router = TestRouterBuilder::new(db) + .with_payments_manager(payments_manager) + .build() + .await; + let request = form_request( + "PUT", + "/dashboard/group/payment-jobs/recovery", + ids.session_id, + format!( + "payment_job_id={payment_job_id}&provider_object_id=fr_test_123&recovery_note=Verified+Stripe+activity&recovery_reference=ticket-456" + ), + ); + let (parts, bytes) = send(router, request).await; + + // Check the rejection message is returned + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "recoverable payment job not found" + ); +} + #[tokio::test] async fn test_complete_refund_recovery_allows_event_manager() { - // Setup an authenticated event manager - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_purchase_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Expect the validated recovery evidence to reach the payments service let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup payments manager mock + // Expect the validated recovery evidence to reach the payments service let mut payments_manager = MockPaymentsManager::new(); payments_manager .expect_complete_refund_recovery() .times(1) .withf(move |input| { - input.actor_user_id == user_id + input.actor_user_id == ids.user_id && input.event_purchase_id == event_purchase_id - && input.group_id == group_id + && input.group_id == ids.group_id && input.recovery_note == "Verified bank receipt" && input.recovery_reference == "bank-transfer-123" }) .returning(|_| Box::pin(async { Ok(()) })); // Submit recovery from the refunds page - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/group/refunds/recovery") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( + let request = form_request( + "PUT", + "/dashboard/group/refunds/recovery", + ids.session_id, + format!( "event_purchase_id={event_purchase_id}&recovery_note=Verified+bank+receipt&recovery_reference=bank-transfer-123" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ); + let (parts, bytes) = send(router, request).await; // Check both operational views are refreshed after completion assert_empty_hx_trigger_response( @@ -437,149 +318,71 @@ async fn test_complete_refund_recovery_allows_event_manager() { } #[tokio::test] -async fn test_complete_refund_recovery_forbids_user_without_event_write_access() { - // Setup an authenticated group member without event-management access - let community_id = Uuid::new_v4(); +async fn test_complete_refund_recovery_returns_rejection_message() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_purchase_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Reject recovery before the payments service can run let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); - // Setup payments manager mock + // Setup payments manager mock rejecting the recovery let mut payments_manager = MockPaymentsManager::new(); - payments_manager.expect_complete_refund_recovery().never(); + payments_manager + .expect_complete_refund_recovery() + .times(1) + .withf(move |input| input.event_purchase_id == event_purchase_id) + .returning(|_| { + Box::pin(async { + Err(HandlerError::Database( + "recoverable event purchase refund not found".to_string(), + ) + .into()) + }) + }); - // Attempt recovery with otherwise valid evidence - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Submit recovery from the refunds page + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let request = Request::builder() - .method("PUT") - .uri("/dashboard/group/refunds/recovery") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(format!( + let request = form_request( + "PUT", + "/dashboard/group/refunds/recovery", + ids.session_id, + format!( "event_purchase_id={event_purchase_id}&recovery_note=Verified+bank+receipt&recovery_reference=bank-transfer-123" - ))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the route rejects recovery before calling the payments service - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn test_retry_payment_job_forbids_user_without_event_write_access() { - // Setup an authenticated group member without event-management access - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - - // Reject retry before the payment job mutation can run - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::EventsWrite - }) - .returning(|_, _, _, _| Ok(false)); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, + ), ); - db.expect_requeue_payment_job().never(); - - // Attempt a retry with otherwise valid input - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/payment-jobs/{payment_job_id}/retry" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, request).await; - // Check the route rejects retry before calling the database function - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); + // Check the rejection message is returned + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "recoverable event purchase refund not found" + ); } #[tokio::test] async fn test_retry_payment_job_requeues_selected_job() { - // Setup an authenticated event manager and payment job - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let payment_job_id = Uuid::new_v4(); - // Expect the selected payment job to be requeued + let ids = DashboardIds::new(); + let payment_job_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_requeue_payment_job() .times(1) - .withf(move |gid, id| *gid == group_id && *id == payment_job_id) + .withf(move |gid, id| *gid == ids.group_id && *id == payment_job_id) .returning(|_, _| Ok(())); // Submit a payment job retry request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/payment-jobs/{payment_job_id}/retry" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/group/payment-jobs/{payment_job_id}/retry"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the current payment job view is refreshed assert_empty_hx_trigger_response( diff --git a/ocg-server/src/handlers/dashboard/group/settings/tests.rs b/ocg-server/src/handlers/dashboard/group/settings/tests.rs index 65192110c..8738960dc 100644 --- a/ocg-server/src/handlers/dashboard/group/settings/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/settings/tests.rs @@ -1,25 +1,22 @@ +use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, + body::Body, + http::{Request, StatusCode}, }; -use axum_login::tower_sessions::session; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::{error::HandlerError, tests::*}, - services::{ - notifications::MockNotificationsManager, - payments::{ - AutomaticTaxReadiness, AutomaticTaxReadinessError, FiscalSponsorReadinessError, - MockPaymentsManager, - }, + handlers::{ + error::{HandlerError, INVALID_REQUEST_PAYLOAD}, + tests::*, + }, + services::payments::{ + AutomaticTaxReadiness, AutomaticTaxReadinessError, FiscalSponsorReadinessError, + MockPaymentsManager, }, types::{ + dashboard::community::groups::GroupInput, group::{GroupFull, GroupParentOption}, payments::{GroupExternalPaymentsContext, GroupPaymentRecipient, PaymentProvider}, permissions::GroupPermission, @@ -27,55 +24,37 @@ use crate::{ }, }; +use super::upcoming_event_automatic_tax_error; + +/// Route serving the group settings page and update action. +const SETTINGS_URL: &str = "/dashboard/group/settings/update"; + #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group = GroupFull { description_short: Some("Short group description".to_string()), - ..sample_group_full(community_id, group_id) + ..sample_group_full(ids.community_id, ids.group_id) }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_update_page_context( &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, + &ids, unconfigured_external_payments(), group, vec![], ); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let html = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); - let html = String::from_utf8(bytes.to_vec()).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(html.contains(r#"value="Short group description""#)); assert!(html.contains(r#"name="utm_present" value="true""#)); assert!(html.contains(r#"items="["newsletter"]""#)); @@ -89,12 +68,9 @@ async fn test_update_page_success() { #[tokio::test] async fn test_update_page_selects_current_inactive_parent_option() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let parent_group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let group = sample_group_full(community_id, group_id); + let group = sample_group_full(ids.community_id, ids.group_id); let parent_option = GroupParentOption { active: false, group_id: parent_group_id, @@ -105,36 +81,21 @@ async fn test_update_page_selects_current_inactive_parent_option() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( + expect_update_page_context( &mut db, - community_id, - group_id, - user_id, + &ids, unconfigured_external_payments(), group, vec![parent_option], ); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); let option_start = body .find(&format!("value=\"{parent_group_id}\"")) .expect("parent option should render"); @@ -146,250 +107,391 @@ async fn test_update_page_selects_current_inactive_parent_option() { assert!(option_html.contains("(current, no longer selectable)")); } +#[tokio::test] +async fn test_update_page_hides_fiscal_sponsor_controls_when_onboarding_is_blocked() { + // Setup an allowlisted group without a stored recipient + let ids = DashboardIds::new(); + let group = sample_group_full(ids.community_id, ids.group_id); + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context( + &mut db, + &ids, + allowlisted_external_payments(), + group, + vec![], + ); + + // Render the page with Stripe payments enabled + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the notice replaces every payment recipient control + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("A Stripe connected account cannot be added or changed here")); + assert!(body.contains("United States")); + assert!(!body.contains("name=\"payment_recipient[provider]\"")); + assert!(!body.contains("name=\"payment_recipient[recipient_id]\"")); + assert!(!body.contains("name=\"payment_recipient[seller_display_name]\"")); + assert!(!body.contains("id=\"payment_recipient_recipient_id\"")); + assert!(body.contains( + "data-hx-keep-empty=\"external_payments_seller_display_name payment_recipient[recipient_id] payment_recipient[seller_display_name]\"" + )); +} + +#[tokio::test] +async fn test_update_page_keeps_fiscal_sponsor_controls_for_stored_recipient_when_blocked() { + // Setup an allowlisted group with a stored recipient + let ids = DashboardIds::new(); + let mut group = sample_group_full(ids.community_id, ids.group_id); + group.payment_recipient = Some(sample_group_payment_recipient()); + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context( + &mut db, + &ids, + allowlisted_external_payments(), + group, + vec![], + ); + + // Render the page with Stripe payments enabled + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the notice and the prefilled controls render together + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("A Stripe connected account cannot be added or changed here")); + assert!(body.contains("The stored fiscal sponsor can still be used for paid events on Stripe")); + assert!(body.contains("name=\"payment_recipient[provider]\"")); + assert!(body.contains("name=\"payment_recipient[recipient_id]\"")); + assert!(body.contains("name=\"payment_recipient[seller_display_name]\"")); + assert!(body.contains("value=\"acct_test\"")); + assert!(body.contains("value=\"Test Fiscal Sponsor\"")); +} + +#[tokio::test] +async fn test_update_page_shows_fiscal_sponsor_controls_when_onboarding_is_open() { + // Setup a group whose country is not allowlisted + let ids = DashboardIds::new(); + let group = sample_group_full(ids.community_id, ids.group_id); + let external_payments = GroupExternalPaymentsContext { + eligible: false, + ..allowlisted_external_payments() + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context(&mut db, &ids, external_payments, group, vec![]); + + // Render the page with Stripe payments enabled + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the regular Fiscal Sponsor section renders + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(!body.contains("A Stripe connected account cannot be added or changed here")); + assert!(body.contains("The fiscal sponsor owns Tax Rate definitions in Stripe.")); + assert!(body.contains("name=\"payment_recipient[provider]\"")); + assert!(body.contains("name=\"payment_recipient[recipient_id]\"")); + assert!(body.contains("name=\"payment_recipient[seller_display_name]\"")); +} + +#[tokio::test] +async fn test_update_page_renders_external_payee_legal_name_for_enabled_group() { + // Setup an allowlisted group that opted into external payments + let ids = DashboardIds::new(); + let mut group = sample_group_full(ids.community_id, ids.group_id); + group.external_payments_enabled = true; + group.external_payments_seller_display_name = Some("External Payee Co".to_string()); + let external_payments = GroupExternalPaymentsContext { + enabled: true, + seller_display_name: Some("External Payee Co".to_string()), + ..allowlisted_external_payments() + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context(&mut db, &ids, external_payments, group, vec![]); + + // Render the page + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the editable legal name input renders with the stored value + assert_html_response(&parts, &bytes, StatusCode::OK); + let input = external_payee_input(&body); + assert!(input.contains("value=\"External Payee Co\"")); + assert!(!input.contains("disabled")); +} + +#[tokio::test] +async fn test_update_page_keeps_external_payee_input_editable_for_delisted_enabled_group() { + // Setup an enabled legacy group whose country left the allowlist without a payee name + let ids = DashboardIds::new(); + let mut group = sample_group_full(ids.community_id, ids.group_id); + group.external_payments_enabled = true; + let external_payments = GroupExternalPaymentsContext { + eligible: false, + enabled: true, + ..allowlisted_external_payments() + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context(&mut db, &ids, external_payments, group, vec![]); + + // Render the page + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the legal name stays editable while the toggle is locked on + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("name=\"external_payments_enabled\"")); + let input = external_payee_input(&body); + assert!(!input.contains("value=")); + assert!(!input.contains("disabled")); +} + +#[tokio::test] +async fn test_update_page_disables_external_payee_input_for_ineligible_opted_out_group() { + // Setup a group that cannot opt into external payments + let ids = DashboardIds::new(); + let group = sample_group_full(ids.community_id, ids.group_id); + let external_payments = GroupExternalPaymentsContext { + eligible: false, + ..allowlisted_external_payments() + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context(&mut db, &ids, external_payments, group, vec![]); + + // Render the page + let router = TestRouterBuilder::new(db) + .with_payments_cfg(sample_payments_cfg()) + .build() + .await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the legal name input is disabled alongside the toggle + assert_html_response(&parts, &bytes, StatusCode::OK); + let input = external_payee_input(&body); + assert!(input.contains("disabled")); +} + +#[tokio::test] +async fn test_update_page_renders_external_payments_section_for_enabled_group_without_config() { + // Setup an enabled legacy group after the operator removed the external payments config + let ids = DashboardIds::new(); + let mut group = sample_group_full(ids.community_id, ids.group_id); + group.external_payments_enabled = true; + let external_payments = GroupExternalPaymentsContext { + enabled: true, + ..unconfigured_external_payments() + }; + + // Setup database mock + let mut db = MockDB::new(); + expect_update_page_context(&mut db, &ids, external_payments, group, vec![]); + + // Render the page without Stripe payments enabled + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, get_request(SETTINGS_URL, ids.session_id)).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the section renders so the missing legal name can be saved + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("name=\"external_payments_enabled\"")); + let input = external_payee_input(&body); + assert!(!input.contains("disabled")); +} + #[tokio::test] async fn test_update_trims_external_payments_seller_display_name() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.external_payments_enabled = Some(true); update.external_payments_seller_display_name = Some(" External Payee Co ".to_string()); - let body = serde_qs::to_string(&update).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.external_payments_enabled == Some(true) && group.external_payments_seller_display_name.as_deref() == Some("External Payee Co") }) - .returning(move |_, _, _, _| Ok(())); + .returning(|_, _, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_manager(MockPaymentsManager::new()) - .build() - .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_keeps_blank_external_payments_seller_display_name_for_clearing() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.external_payments_enabled = Some(false); update.external_payments_seller_display_name = Some(" ".to_string()); - let body = serde_qs::to_string(&update).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.external_payments_seller_display_name.as_deref() == Some("") }) - .returning(move |_, _, _, _| Ok(())); + .returning(|_, _, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_manager(MockPaymentsManager::new()) - .build() - .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_normalizes_unchanged_payment_recipient() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut update = sample_group_update(); + let ids = DashboardIds::new(); let payment_recipient = sample_group_payment_recipient(); + let mut update = sample_group_update(); update.payment_recipient = Some(GroupPaymentRecipient { recipient_id: format!(" {} ", payment_recipient.recipient_id), seller_display_name: format!(" {} ", payment_recipient.seller_display_name), ..payment_recipient.clone() }); - let body = serde_qs::to_string(&update).unwrap(); + let name = update.name.clone(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + db.expect_get_group_payment_recipient() + .times(1) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) + .returning(move |_, _| Ok(Some(payment_recipient.clone()))); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id - && group.name == update.name + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id + && group.name == name && group.payment_recipient.as_ref().is_some_and(|recipient| { recipient.recipient_id == "acct_test" && recipient.seller_display_name == "Test Fiscal Sponsor" }) }) - .returning(move |_, _, _, _| Ok(())); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(Some(payment_recipient.clone()))); + .returning(|_, _, _, _| Ok(())); // Keep unchanged provider accounts independent of Stripe availability let mut payments_manager = MockPaymentsManager::new(); payments_manager.expect_validate_fiscal_sponsor().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert_eq!( - parts.headers.get("HX-Trigger").unwrap(), - &HeaderValue::from_static("refresh-body"), - ); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_passes_utm_values() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.utm_campaigns = Some(vec!["spring-launch".to_string()]); update.utm_mediums = Some(vec!["podcast".to_string()]); update.utm_present = Some(true); update.utm_sources = Some(vec!["newsletter".to_string(), "partner.site".to_string()]); - let body = serde_qs::to_string(&update).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.utm_campaigns == Some(vec!["spring-launch".to_string()]) && group.utm_mediums == Some(vec!["podcast".to_string()]) && group.utm_present == Some(true) && group.utm_sources == Some(vec!["newsletter".to_string(), "partner.site".to_string()]) }) - .returning(move |_, _, _, _| Ok(())); + .returning(|_, _, _, _| Ok(())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_manager(MockPaymentsManager::new()) - .build() - .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert!(bytes.is_empty()); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_rejects_invalid_utm_values() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.utm_present = Some(true); update.utm_sources = Some(vec!["News Letter".to_string()]); - let body = serde_qs::to_string(&update).unwrap(); // Setup database mock that must not persist the invalid values let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_update_group().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_manager(MockPaymentsManager::new()) - .build() - .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the validation error is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -398,95 +500,48 @@ async fn test_update_rejects_invalid_utm_values() { #[tokio::test] async fn test_update_invalid_body() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + db.expect_update_group().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let response = router - .oneshot(update_request(session_id, "invalid".to_string())) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request("PUT", SETTINGS_URL, ids.session_id, "invalid"); + let (parts, bytes) = send(router, request).await; - // Check response matches expectations + // Check the fixed deserialization body is returned assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!(bytes, INVALID_REQUEST_PAYLOAD); } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_update_binds_changed_sponsor_validation_to_locked_state() { - let community_id = Uuid::new_v4(); + // Setup a settings update replacing the fiscal sponsor + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_new".to_string(), - seller_display_name: "New Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_new", "New Sponsor")); + // Setup database mock loading one upcoming automatic-tax event let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, + &ids, + Some(stripe_recipient("acct_current", "Current Sponsor")), + "US", + false, ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| { - Ok(Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_current".to_string(), - seller_display_name: "Current Sponsor".to_string(), - })) - }); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("US") - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_group_automatic_tax_readiness_event_ids() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(vec![event_id])); - db.expect_get_event_full() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(sample_event_full(community_id, event_id, group_id))); + expect_automatic_tax_event_ids(&mut db, &ids, vec![event_id]); + expect_event_full(&mut db, &ids, event_id); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.payment_validation.as_ref().is_some_and(|validation| { validation.require_automatic_tax && validation @@ -501,18 +556,13 @@ async fn test_update_binds_changed_sponsor_validation_to_locked_state() { }) .returning(|_, _, _, _| Ok(())); + // Setup payments manager mock accepting the sponsor and the event venue let mut payments_manager = MockPaymentsManager::new(); - payments_manager - .expect_validate_fiscal_sponsor() - .withf(|recipient, automatic_tax_jurisdiction| { - recipient.recipient_id == "acct_new" && automatic_tax_jurisdiction.is_none() - }) - .times(1) - .returning(|_, _| Box::pin(async { Ok(()) })); + expect_validate_fiscal_sponsor(&mut payments_manager, "acct_new", Ok(())); payments_manager .expect_ensure_automatic_tax_readiness() - .withf(|recipient, _| recipient.recipient_id == "acct_new") .times(1) + .withf(|recipient, _| recipient.recipient_id == "acct_new") .returning(|_, _| { Box::pin(async { Ok(AutomaticTaxReadiness { @@ -524,212 +574,119 @@ async fn test_update_binds_changed_sponsor_validation_to_locked_state() { }) }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; - assert_eq!(response.status(), StatusCode::NO_CONTENT); + // Check the validated update is persisted + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_update_rejects_changed_sponsor_when_upcoming_event_is_not_ready() { - // Setup an authenticated settings update selecting a new fiscal sponsor - let community_id = Uuid::new_v4(); + // Setup a settings update replacing the fiscal sponsor + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_new".to_string(), - seller_display_name: "New Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_new", "New Sponsor")); - // Authorize the request and load the upcoming automatic-tax event + // Setup database mock loading one upcoming automatic-tax event let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, + &ids, + Some(stripe_recipient("acct_current", "Current Sponsor")), + "US", + false, ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| { - Ok(Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_current".to_string(), - seller_display_name: "Current Sponsor".to_string(), - })) - }); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("US") - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_group_automatic_tax_readiness_event_ids() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(vec![event_id])); - db.expect_get_event_full() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(sample_event_full(community_id, event_id, group_id))); + expect_automatic_tax_event_ids(&mut db, &ids, vec![event_id]); + expect_event_full(&mut db, &ids, event_id); db.expect_update_group().never(); - // Accept the new sponsor, then reject the upcoming event venue + // Setup payments manager mock accepting the sponsor and rejecting the event venue let mut payments_manager = MockPaymentsManager::new(); - payments_manager - .expect_validate_fiscal_sponsor() - .withf(|recipient, automatic_tax_jurisdiction| { - recipient.recipient_id == "acct_new" && automatic_tax_jurisdiction.is_none() - }) - .times(1) - .returning(|_, _| Box::pin(async { Ok(()) })); + expect_validate_fiscal_sponsor(&mut payments_manager, "acct_new", Ok(())); payments_manager .expect_ensure_automatic_tax_readiness() - .withf(|recipient, _| recipient.recipient_id == "acct_new") .times(1) + .withf(|recipient, _| recipient.recipient_id == "acct_new") .returning(|_, _| Box::pin(async { Err(AutomaticTaxReadinessError::InvalidAddress) })); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the event-specific failure is reported without persisting assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), + bytes, "cannot update fiscal sponsor: upcoming event \"Test Event\" is not ready for payments: the venue address is invalid" ); } #[tokio::test] -async fn test_update_rejects_changed_sponsor_without_required_automatic_tax() { - // Setup an authenticated settings update selecting a fiscal sponsor - let community_id = Uuid::new_v4(); +async fn test_update_rejects_changed_sponsor_when_provider_reports_not_ready() { + // Setup a settings update replacing the fiscal sponsor + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_unready".to_string(), - seller_display_name: "Unready Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_unready", "Unready Sponsor")); - // Authorize the request while forbidding any database write + // Setup database mock that must not load events or persist let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, + &ids, + Some(stripe_recipient("acct_current", "Current Sponsor")), + "US", + false, ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| { - Ok(Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_current".to_string(), - seller_display_name: "Current Sponsor".to_string(), - })) - }); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("US") - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_group_automatic_tax_readiness_event_ids() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(move |_, _| Ok(vec![event_id])); + expect_automatic_tax_event_ids(&mut db, &ids, vec![event_id]); + db.expect_get_event_full().never(); db.expect_update_group().never(); - // Reject the sponsor at the provider boundary before persistence + // Setup payments manager mock rejecting the sponsor at the provider boundary let mut payments_manager = MockPaymentsManager::new(); - payments_manager - .expect_validate_fiscal_sponsor() - .withf(|recipient, automatic_tax_jurisdiction| { - recipient.provider == PaymentProvider::Stripe - && recipient.recipient_id == "acct_unready" - && automatic_tax_jurisdiction.is_none() - }) - .times(1) - .returning(|_, _| { - Box::pin(async { - Err(FiscalSponsorReadinessError::NotReady( - "Stripe account is not ready".to_string(), - )) - }) - }); + expect_validate_fiscal_sponsor( + &mut payments_manager, + "acct_unready", + Err(FiscalSponsorReadinessError::NotReady( + "Stripe account is not ready".to_string(), + )), + ); + payments_manager.expect_ensure_automatic_tax_readiness().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; - // Check the provider failure prevents persistence - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + // Check the provider rejection is reported without persisting + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(bytes, "Stripe account is not ready"); } #[tokio::test] async fn test_update_rejects_new_sponsor_when_resulting_country_is_allowlisted() { // Setup a settings update adding a first fiscal sponsor - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_new".to_string(), - seller_display_name: "New Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_new", "New Sponsor")); - // Authorize the request and report the resulting country as allowlisted + // Setup database mock reporting the resulting country as allowlisted let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(None)); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("US") - }) - .returning(|_, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads(&mut db, &ids, None, "US", true); db.expect_list_group_automatic_tax_readiness_event_ids().never(); db.expect_update_group().never(); @@ -738,19 +695,17 @@ async fn test_update_rejects_new_sponsor_when_resulting_country_is_allowlisted() payments_manager.expect_validate_fiscal_sponsor().never(); payments_manager.expect_ensure_automatic_tax_readiness().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the policy rejection is reported before any provider call assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), + bytes, "stripe connected account cannot be added or changed for this group country" ); } @@ -758,40 +713,22 @@ async fn test_update_rejects_new_sponsor_when_resulting_country_is_allowlisted() #[tokio::test] async fn test_update_rejects_replacement_sponsor_when_moving_between_allowlisted_countries() { // Setup a settings update replacing the sponsor while moving to another allowlisted country - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.country_code = Some("JP".to_string()); update.country_name = Some("Japan".to_string()); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_replacement".to_string(), - seller_display_name: "Replacement Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_replacement", "Replacement Sponsor")); - // Authorize the request and evaluate the submitted country + // Setup database mock evaluating the submitted country let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, + &ids, + Some(sample_group_payment_recipient()), + "JP", + true, ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(Some(sample_group_payment_recipient()))); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("JP") - }) - .returning(|_, _, _| Ok(true)); db.expect_list_group_automatic_tax_readiness_event_ids().never(); db.expect_update_group().never(); @@ -800,19 +737,17 @@ async fn test_update_rejects_replacement_sponsor_when_moving_between_allowlisted payments_manager.expect_validate_fiscal_sponsor().never(); payments_manager.expect_ensure_automatic_tax_readiness().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the policy rejection is reported before any provider call assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), + bytes, "stripe connected account cannot be added or changed for this group country" ); } @@ -820,49 +755,22 @@ async fn test_update_rejects_replacement_sponsor_when_moving_between_allowlisted #[tokio::test] async fn test_update_validates_sponsor_when_same_save_leaves_allowlist() { // Setup a settings update adding a sponsor while moving off the allowlist - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); update.country_code = Some("US".to_string()); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: "acct_new".to_string(), - seller_display_name: "New Sponsor".to_string(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("acct_new", "New Sponsor")); - // Authorize the request and report the submitted country as not allowlisted + // Setup database mock reporting the submitted country as not allowlisted let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); - db.expect_get_group_payment_recipient() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(None)); - db.expect_get_group_external_payments_eligibility() - .times(1) - .withf(move |cid, gid, country_code| { - *cid == community_id && *gid == group_id && country_code.as_deref() == Some("US") - }) - .returning(|_, _, _| Ok(false)); - db.expect_list_group_automatic_tax_readiness_event_ids() - .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(vec![])); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); + expect_sponsor_change_reads(&mut db, &ids, None, "US", false); + expect_automatic_tax_event_ids(&mut db, &ids, vec![]); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.country_code.as_deref() == Some("US") && group.payment_validation.as_ref().is_some_and(|validation| { !validation.require_automatic_tax @@ -877,54 +785,37 @@ async fn test_update_validates_sponsor_when_same_save_leaves_allowlist() { // Validate the new sponsor once at the provider boundary let mut payments_manager = MockPaymentsManager::new(); - payments_manager - .expect_validate_fiscal_sponsor() - .withf(|recipient, automatic_tax_jurisdiction| { - recipient.recipient_id == "acct_new" && automatic_tax_jurisdiction.is_none() - }) - .times(1) - .returning(|_, _| Box::pin(async { Ok(()) })); + expect_validate_fiscal_sponsor(&mut payments_manager, "acct_new", Ok(())); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the update is persisted - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_saves_without_recipient_controls() { // Setup a settings update that omits every payment recipient field - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let update = sample_group_update(); - let body = serde_qs::to_string(&update).unwrap(); - assert!(!body.contains("payment_recipient")); + assert!(!serde_qs::to_string(&update).unwrap().contains("payment_recipient")); - // Authorize the request and persist without any recipient reads + // Setup database mock persisting without any recipient reads let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_get_group_payment_recipient().never(); db.expect_get_group_external_payments_eligibility().never(); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.description_short.as_deref() == Some("Updated short description") && group.payment_recipient.is_none() && group.payment_validation.is_none() @@ -935,53 +826,44 @@ async fn test_update_saves_without_recipient_controls() { let mut payments_manager = MockPaymentsManager::new(); payments_manager.expect_validate_fiscal_sponsor().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the update is persisted with the recipient left untouched - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_renames_stored_sponsor_without_provider_calls() { // Setup a settings update changing only the stored sponsor's legal name - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let stored_recipient = sample_group_payment_recipient(); let mut update = sample_group_update(); update.payment_recipient = Some(GroupPaymentRecipient { seller_display_name: "Renamed Fiscal Sponsor".to_string(), ..stored_recipient.clone() }); - let body = serde_qs::to_string(&update).unwrap(); - // Authorize the request and persist the rename without policy or provider checks + // Setup database mock persisting the rename without policy checks let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_get_group_payment_recipient() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(move |_, _| Ok(Some(stored_recipient.clone()))); db.expect_get_group_external_payments_eligibility().never(); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.payment_validation.is_none() && group.payment_recipient.as_ref().is_some_and(|recipient| { recipient.recipient_id == "acct_test" @@ -994,118 +876,35 @@ async fn test_update_renames_stored_sponsor_without_provider_calls() { let mut payments_manager = MockPaymentsManager::new(); payments_manager.expect_validate_fiscal_sponsor().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the rename is persisted - assert_eq!(response.status(), StatusCode::NO_CONTENT); -} - -#[tokio::test] -async fn test_update_clear_propagates_recipient_safeguard() { - // Setup a settings update submitting both sponsor fields blank - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: String::new(), - seller_display_name: String::new(), - }); - let body = serde_qs::to_string(&update).unwrap(); - - // Authorize the request and reject the clear with the database safeguard - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); - db.expect_get_group_payment_recipient().never(); - db.expect_get_group_external_payments_eligibility().never(); - db.expect_update_group() - .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id - && group.payment_validation.is_none() - && group.payment_recipient.as_ref().is_some_and(|recipient| { - recipient.provider == PaymentProvider::Stripe - && recipient.recipient_id.is_empty() - && recipient.seller_display_name.is_empty() - }) - }) - .returning(|_, _, _, _| { - Err(HandlerError::Database( - "paid-capable events require a payment recipient".to_string(), - ) - .into()) - }); - - // Forbid any provider call - let mut payments_manager = MockPaymentsManager::new(); - payments_manager.expect_validate_fiscal_sponsor().never(); - - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_manager(payments_manager) - .build() - .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the safeguard message reaches the organizer - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "paid-capable events require a payment recipient" - ); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } #[tokio::test] async fn test_update_clears_stored_sponsor_without_provider_calls() { // Setup a settings update submitting both sponsor fields blank - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut update = sample_group_update(); - update.payment_recipient = Some(GroupPaymentRecipient { - provider: PaymentProvider::Stripe, - recipient_id: String::new(), - seller_display_name: String::new(), - }); - let body = serde_qs::to_string(&update).unwrap(); + update.payment_recipient = Some(stripe_recipient("", "")); - // Authorize the request and hand the clearing recipient to the database + // Setup database mock handing the clearing recipient to the database let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SettingsWrite); db.expect_get_group_payment_recipient().never(); db.expect_get_group_external_payments_eligibility().never(); db.expect_update_group() .times(1) - .withf(move |uid, cid, gid, group| { - *uid == user_id - && *cid == community_id - && *gid == group_id + .withf(move |user_id, community_id, group_id, group| { + *user_id == ids.user_id + && *community_id == ids.community_id + && *group_id == ids.group_id && group.payment_validation.is_none() && group.payment_recipient.as_ref().is_some_and(|recipient| { recipient.provider == PaymentProvider::Stripe @@ -1119,402 +918,59 @@ async fn test_update_clears_stored_sponsor_without_provider_calls() { let mut payments_manager = MockPaymentsManager::new(); payments_manager.expect_validate_fiscal_sponsor().never(); - // Submit the update through the routed handler - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + // Setup router and send request + let router = TestRouterBuilder::new(db) .with_payments_manager(payments_manager) .build() .await; - let response = router.oneshot(update_request(session_id, body)).await.unwrap(); + let (parts, bytes) = send(router, update_request(&ids, &update)).await; // Check the clear is handed to the database - assert_eq!(response.status(), StatusCode::NO_CONTENT); -} - -#[tokio::test] -async fn test_update_page_hides_fiscal_sponsor_controls_when_onboarding_is_blocked() { - // Setup an allowlisted group without a stored recipient - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let group = sample_group_full(community_id, group_id); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - allowlisted_external_payments(), - group, - vec![], - ); - - // Render the page with Stripe payments enabled - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the notice replaces every payment recipient control - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(body.contains("A Stripe connected account cannot be added or changed here")); - assert!(body.contains("United States")); - assert!(!body.contains("name=\"payment_recipient[provider]\"")); - assert!(!body.contains("name=\"payment_recipient[recipient_id]\"")); - assert!(!body.contains("name=\"payment_recipient[seller_display_name]\"")); - assert!(!body.contains("id=\"payment_recipient_recipient_id\"")); - assert!(body.contains( - "data-hx-keep-empty=\"external_payments_seller_display_name payment_recipient[recipient_id] payment_recipient[seller_display_name]\"" - )); -} - -#[tokio::test] -async fn test_update_page_keeps_fiscal_sponsor_controls_for_stored_recipient_when_blocked() { - // Setup an allowlisted group with a stored recipient - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group = sample_group_full(community_id, group_id); - group.payment_recipient = Some(sample_group_payment_recipient()); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - allowlisted_external_payments(), - group, - vec![], - ); - - // Render the page with Stripe payments enabled - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the notice and the prefilled controls render together - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(body.contains("A Stripe connected account cannot be added or changed here")); - assert!(body.contains("The stored fiscal sponsor can still be used for paid events on Stripe")); - assert!(body.contains("name=\"payment_recipient[provider]\"")); - assert!(body.contains("name=\"payment_recipient[recipient_id]\"")); - assert!(body.contains("name=\"payment_recipient[seller_display_name]\"")); - assert!(body.contains("value=\"acct_test\"")); - assert!(body.contains("value=\"Test Fiscal Sponsor\"")); -} - -#[tokio::test] -async fn test_update_page_shows_fiscal_sponsor_controls_when_onboarding_is_open() { - // Setup a group whose country is not allowlisted - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let group = sample_group_full(community_id, group_id); - let external_payments = GroupExternalPaymentsContext { - configured: true, - eligible: false, - enabled: false, - country_code: Some("US".to_string()), - default_payment_window_hours: Some(72), - max_payment_window_hours: Some(336), - seller_display_name: None, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - external_payments, - group, - vec![], - ); - - // Render the page with Stripe payments enabled - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the regular Fiscal Sponsor section renders - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(!body.contains("A Stripe connected account cannot be added or changed here")); - assert!(body.contains("The fiscal sponsor owns Tax Rate definitions in Stripe.")); - assert!(body.contains("name=\"payment_recipient[provider]\"")); - assert!(body.contains("name=\"payment_recipient[recipient_id]\"")); - assert!(body.contains("name=\"payment_recipient[seller_display_name]\"")); -} - -#[tokio::test] -async fn test_update_page_renders_external_payee_legal_name_for_enabled_group() { - // Setup an allowlisted group that opted into external payments - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group = sample_group_full(community_id, group_id); - group.external_payments_enabled = true; - group.external_payments_seller_display_name = Some("External Payee Co".to_string()); - let external_payments = GroupExternalPaymentsContext { - enabled: true, - seller_display_name: Some("External Payee Co".to_string()), - ..allowlisted_external_payments() - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - external_payments, - group, - vec![], - ); - - // Render the page - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the editable legal name input renders with the stored value - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - let input = external_payee_input(&body); - assert!(input.contains("value=\"External Payee Co\"")); - assert!(!input.contains("disabled")); -} - -#[tokio::test] -async fn test_update_page_keeps_external_payee_input_editable_for_delisted_enabled_group() { - // Setup an enabled legacy group whose country left the allowlist without a payee name - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group = sample_group_full(community_id, group_id); - group.external_payments_enabled = true; - let external_payments = GroupExternalPaymentsContext { - configured: true, - eligible: false, - enabled: true, - country_code: Some("US".to_string()), - default_payment_window_hours: Some(72), - max_payment_window_hours: Some(336), - seller_display_name: None, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - external_payments, - group, - vec![], - ); - - // Render the page - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the legal name stays editable while the toggle is locked on - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(body.contains("name=\"external_payments_enabled\"")); - let input = external_payee_input(&body); - assert!(!input.contains("value=")); - assert!(!input.contains("disabled")); -} - -#[tokio::test] -async fn test_update_page_disables_external_payee_input_for_ineligible_opted_out_group() { - // Setup a group that cannot opt into external payments - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let group = sample_group_full(community_id, group_id); - let external_payments = GroupExternalPaymentsContext { - configured: true, - eligible: false, - enabled: false, - country_code: Some("US".to_string()), - default_payment_window_hours: Some(72), - max_payment_window_hours: Some(336), - seller_display_name: None, - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - external_payments, - group, - vec![], - ); - - // Render the page - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_payments_cfg(sample_payments_cfg()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the legal name input is disabled alongside the toggle - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - let input = external_payee_input(&body); - assert!(input.contains("disabled")); + assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); } -#[tokio::test] -async fn test_update_page_renders_external_payments_section_for_enabled_group_without_config() { - // Setup an enabled legacy group after the operator removed the external payments config - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let mut group = sample_group_full(community_id, group_id); - group.external_payments_enabled = true; - let external_payments = GroupExternalPaymentsContext { - enabled: true, - ..unconfigured_external_payments() - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_update_page_reads( - &mut db, - community_id, - group_id, - user_id, - external_payments, - group, - vec![], - ); - - // Render the page without Stripe payments enabled - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let response = router.oneshot(update_page_request(session_id)).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check the section renders so the missing legal name can be saved - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); - assert!(body.contains("name=\"external_payments_enabled\"")); - let input = external_payee_input(&body); - assert!(!input.contains("disabled")); +#[test] +fn test_upcoming_event_automatic_tax_error() { + let prefix = + "cannot update fiscal sponsor: upcoming event \"Meetup\" is not ready for payments"; + let cases = [ + ( + "invalid address is prefixed", + AutomaticTaxReadinessError::InvalidAddress, + Some(format!("{prefix}: the venue address is invalid")), + ), + ( + "fiscal sponsor not ready is prefixed", + AutomaticTaxReadinessError::FiscalSponsorNotReady( + "sponsor tax settings are incomplete".to_string(), + ), + Some(format!("{prefix}: sponsor tax settings are incomplete")), + ), + ( + "unexpected failure passes through", + AutomaticTaxReadinessError::Unexpected(anyhow!("provider unavailable")), + None, + ), + ]; + + for (label, error, expected_message) in cases { + match ( + upcoming_event_automatic_tax_error("Meetup", error), + expected_message, + ) { + (HandlerError::Rejected(message), Some(expected_message)) => { + assert_eq!(message, expected_message, "{label}"); + } + (HandlerError::Other(err), None) => { + assert_eq!(err.to_string(), "provider unavailable", "{label}"); + } + (error, expected) => panic!("{label}: got {error:?}, expected {expected:?}"), + } + } } // Helpers. -/// Returns the rendered external payee legal name input tag. -fn external_payee_input(body: &str) -> &str { - let start = body - .find("id=\"external_payments_seller_display_name\"") - .expect("external payee input should render"); - let end = body[start..].find('>').expect("external payee input should close"); - &body[start..start + end] -} - /// External-payments context for a group whose country is allowlisted. fn allowlisted_external_payments() -> GroupExternalPaymentsContext { GroupExternalPaymentsContext { @@ -1528,52 +984,105 @@ fn allowlisted_external_payments() -> GroupExternalPaymentsContext { } } -/// Registers the database reads performed while preparing the settings page. -fn expect_update_page_reads( +/// Expect the list of upcoming events that require automatic tax readiness. +fn expect_automatic_tax_event_ids(db: &mut MockDB, ids: &DashboardIds, event_ids: Vec) { + let ids = *ids; + db.expect_list_group_automatic_tax_readiness_event_ids() + .times(1) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) + .returning(move |_, _| Ok(event_ids.clone())); +} + +/// Expect the upcoming event rechecked against the new fiscal sponsor. +fn expect_event_full(db: &mut MockDB, ids: &DashboardIds, event_id: Uuid) { + let ids = *ids; + db.expect_get_event_full() + .times(1) + .withf(move |community_id, group_id, id| { + *community_id == ids.community_id && *group_id == ids.group_id && *id == event_id + }) + .returning(move |_, _, _| Ok(sample_event_full(ids.community_id, event_id, ids.group_id))); +} + +/// Expect the stored recipient and allowlist reads made for a changed sponsor. +/// +/// The current recipient and the allowlist result decide the scenario. +fn expect_sponsor_change_reads( db: &mut MockDB, - community_id: Uuid, - group_id: Uuid, - user_id: Uuid, + ids: &DashboardIds, + current_recipient: Option, + country_code: &'static str, + allowlisted: bool, +) { + let ids = *ids; + db.expect_get_group_payment_recipient() + .times(1) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) + .returning(move |_, _| Ok(current_recipient.clone())); + db.expect_get_group_external_payments_eligibility() + .times(1) + .withf(move |community_id, group_id, submitted_country_code| { + *community_id == ids.community_id + && *group_id == ids.group_id + && submitted_country_code.as_deref() == Some(country_code) + }) + .returning(move |_, _, _| Ok(allowlisted)); +} + +/// Expect the session, permissions and reads the settings page always performs. +fn expect_update_page_context( + db: &mut MockDB, + ids: &DashboardIds, external_payments: GroupExternalPaymentsContext, group: GroupFull, parent_options: Vec, ) { - expect_group_permission( - db, - community_id, - group_id, - user_id, - GroupPermission::SettingsWrite, - ); + let ids = *ids; + expect_group_access(db, &ids, GroupPermission::Read); + expect_group_permission_result(db, &ids, GroupPermission::SettingsWrite, true); db.expect_get_group_full() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(move |_, _| Ok(group.clone())); db.expect_group_has_child_links() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(|_, _| Ok(false)); db.expect_list_group_categories() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(|_| Ok(vec![sample_group_category()])); db.expect_list_group_parent_options() .times(1) - .withf(move |cid, uid, gid| { - *cid == community_id && *uid == user_id && *gid == Some(group_id) + .withf(move |community_id, user_id, group_id| { + *community_id == ids.community_id + && *user_id == ids.user_id + && *group_id == Some(ids.group_id) }) .returning(move |_, _, _| Ok(parent_options.clone())); db.expect_list_regions() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(|_| Ok(vec![sample_group_region()])); db.expect_get_group_external_payments_context() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(move |_, _| Ok(external_payments.clone())); db.expect_get_group_utm_settings() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(|_, _| { Ok(GroupUtmSettings { campaigns: vec!["spring-launch".to_string()], @@ -1586,6 +1095,41 @@ fn expect_update_page_reads( .returning(|| Ok(vec!["email".to_string(), "social".to_string()])); } +/// Expect one fiscal sponsor validation for the recipient without a jurisdiction. +fn expect_validate_fiscal_sponsor( + payments_manager: &mut MockPaymentsManager, + recipient_id: &'static str, + result: Result<(), FiscalSponsorReadinessError>, +) { + payments_manager + .expect_validate_fiscal_sponsor() + .times(1) + .withf(move |recipient, automatic_tax_jurisdiction| { + recipient.provider == PaymentProvider::Stripe + && recipient.recipient_id == recipient_id + && automatic_tax_jurisdiction.is_none() + }) + .return_once(move |_, _| Box::pin(async move { result })); +} + +/// Returns the rendered external payee legal name input tag. +fn external_payee_input(body: &str) -> &str { + let start = body + .find("id=\"external_payments_seller_display_name\"") + .expect("external payee input should render"); + let end = body[start..].find('>').expect("external payee input should close"); + &body[start..start + end] +} + +/// Build a Stripe fiscal sponsor recipient. +fn stripe_recipient(recipient_id: &str, seller_display_name: &str) -> GroupPaymentRecipient { + GroupPaymentRecipient { + provider: PaymentProvider::Stripe, + recipient_id: recipient_id.to_string(), + seller_display_name: seller_display_name.to_string(), + } +} + /// External-payments context for a community without payments configured. fn unconfigured_external_payments() -> GroupExternalPaymentsContext { GroupExternalPaymentsContext { @@ -1599,23 +1143,12 @@ fn unconfigured_external_payments() -> GroupExternalPaymentsContext { } } -/// Builds an authenticated GET request for the settings update page. -fn update_page_request(session_id: session::Id) -> Request { - Request::builder() - .method("GET") - .uri("/dashboard/group/settings/update") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap() -} - -/// Builds an authenticated form-encoded PUT request for the settings update. -fn update_request(session_id: session::Id, body: String) -> Request { - Request::builder() - .method("PUT") - .uri("/dashboard/group/settings/update") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap() +/// Build the form request that submits the settings update. +fn update_request(ids: &DashboardIds, update: &GroupInput) -> Request { + form_request( + "PUT", + SETTINGS_URL, + ids.session_id, + serde_qs::to_string(update).unwrap(), + ) } diff --git a/ocg-server/src/handlers/dashboard/group/sponsors/tests.rs b/ocg-server/src/handlers/dashboard/group/sponsors/tests.rs index 66778c473..c3d9c53df 100644 --- a/ocg-server/src/handlers/dashboard/group/sponsors/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/sponsors/tests.rs @@ -1,152 +1,88 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use serde_qs::to_string; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::{dashboard::group::sponsors::SponsorFeatured, tests::*}, - services::notifications::MockNotificationsManager, - types::{dashboard::DASHBOARD_PAGINATION_LIMIT, permissions::GroupPermission}, + types::{ + dashboard::{DASHBOARD_PAGINATION_LIMIT, group::sponsors::GroupSponsorsOutput}, + permissions::GroupPermission, + }, }; #[tokio::test] async fn test_add_invalid_body() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/sponsors/add") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=&logo_url=not-a-url")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/sponsors/add", + ids.session_id, + INVALID_SPONSOR_FORM, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert!(body.contains("name: value cannot be empty or whitespace-only")); + assert!(body.contains("logo_url: invalid image URL: not-a-url")); } #[tokio::test] async fn test_add_page_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/sponsors/add") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/sponsors/add", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); - assert!(String::from_utf8_lossy(&bytes).contains("name=\"featured\"")); - assert!(String::from_utf8_lossy(&bytes).contains("checked")); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(featured_input(&body).contains("checked")); } #[tokio::test] async fn test_add_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let form = sample_sponsor_form(); let body = to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); db.expect_add_group_sponsor() .times(1) - .withf(move |actor_user_id, id, sponsor| { - *actor_user_id == user_id - && *id == group_id + .withf(move |actor_user_id, group_id, sponsor| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id && sponsor.featured == form.featured && sponsor.name == form.name && sponsor.logo_url == form.logo_url }) .returning(move |_, _, _| Ok(Uuid::new_v4())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/sponsors/add") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "POST", + "/dashboard/group/sponsors/add", + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -159,46 +95,28 @@ async fn test_add_success() { #[tokio::test] async fn test_delete_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let group_sponsor_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); db.expect_delete_group_sponsor() .times(1) - .withf(move |actor_user_id, id, sponsor_id| { - *actor_user_id == user_id && *id == group_id && *sponsor_id == group_sponsor_id + .withf(move |actor_user_id, group_id, sponsor_id| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id + && *sponsor_id == group_sponsor_id }) .returning(move |_, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!( - "/dashboard/group/sponsors/{group_sponsor_id}/delete" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/group/sponsors/{group_sponsor_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -212,176 +130,102 @@ async fn test_delete_success() { #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let sponsor = sample_group_sponsor(); - let output = crate::types::dashboard::group::sponsors::GroupSponsorsOutput { - sponsors: vec![sponsor.clone()], - total: 1, - }; + let ids = DashboardIds::new(); + let output = sample_sponsors_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::SponsorsWrite, true); db.expect_list_group_sponsors() .times(1) - .withf(move |id, filters, full_list| { - *id == group_id + .withf(move |group_id, filters, full_list| { + *group_id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) && !*full_list }) .returning(move |_, _, _| Ok(output.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/sponsors") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/sponsors", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("aria-label=\"Edit sponsor: Sponsor\"")); + assert!(body.contains("src=\"https://example.test/logo.png\"")); } #[tokio::test] async fn test_list_page_with_pagination_params() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let sponsor = sample_group_sponsor(); - let output = crate::types::dashboard::group::sponsors::GroupSponsorsOutput { - sponsors: vec![sponsor.clone()], - total: 1, - }; + let ids = DashboardIds::new(); + let output = sample_sponsors_output(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::SponsorsWrite, true); db.expect_list_group_sponsors() .times(1) - .withf(move |id, filters, full_list| { - *id == group_id && filters.limit == Some(5) && filters.offset == Some(10) && !*full_list + .withf(move |group_id, filters, full_list| { + *group_id == ids.group_id + && filters.limit == Some(5) + && filters.offset == Some(10) + && !*full_list }) .returning(move |_, _, _| Ok(output.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/sponsors?limit=5&offset=10") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + "/dashboard/group/sponsors?limit=5&offset=10", + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=sponsors&limit=5&offset=10", ); - assert!(!bytes.is_empty()); + assert!(body.contains("aria-label=\"Edit sponsor: Sponsor\"")); } #[tokio::test] async fn test_update_featured_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_sponsor_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let form = SponsorFeatured { featured: false }; - let body = to_string(&form).unwrap(); + let body = to_string(&SponsorFeatured { featured: false }).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); db.expect_update_group_sponsor_featured() .times(1) - .withf(move |actor_user_id, id, sponsor_id, featured| { - *actor_user_id == user_id - && *id == group_id + .withf(move |actor_user_id, group_id, sponsor_id, featured| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id && *sponsor_id == group_sponsor_id && !*featured }) .returning(move |_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/sponsors/{group_sponsor_id}/featured" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/sponsors/{group_sponsor_id}/featured"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -394,154 +238,100 @@ async fn test_update_featured_success() { #[tokio::test] async fn test_update_invalid_body() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let group_sponsor_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/sponsors/{group_sponsor_id}/update" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("name=&logo_url=not-a-url")) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/sponsors/{group_sponsor_id}/update"), + ids.session_id, + INVALID_SPONSOR_FORM, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert!(body.contains("name: value cannot be empty or whitespace-only")); + assert!(body.contains("logo_url: invalid image URL: not-a-url")); } #[tokio::test] async fn test_update_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_sponsor_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let mut sponsor = sample_group_sponsor(); + sponsor.featured = false; sponsor.group_sponsor_id = group_sponsor_id; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::SponsorsWrite, true); db.expect_get_group_sponsor() .times(1) - .withf(move |id, sponsor_id| *id == group_id && *sponsor_id == group_sponsor_id) + .withf(move |group_id, sponsor_id| { + *group_id == ids.group_id && *sponsor_id == group_sponsor_id + }) .returning(move |_, _| Ok(sponsor.clone())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/sponsors/{group_sponsor_id}/update" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/sponsors/{group_sponsor_id}/update"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(&format!( + "hx-put=\"/dashboard/group/sponsors/{group_sponsor_id}/update\"" + ))); + assert!(body.contains("value=\"Sponsor\"")); + assert!(body.contains("value=\"https://example.test/logo.png\"")); + assert!(!featured_input(&body).contains("checked")); } #[tokio::test] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let group_sponsor_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); let form = sample_sponsor_form(); let body = to_string(&form).unwrap(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::SponsorsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::SponsorsWrite); db.expect_update_group_sponsor() .times(1) - .withf(move |actor_user_id, id, sponsor_id, sponsor| { - *actor_user_id == user_id - && *id == group_id + .withf(move |actor_user_id, group_id, sponsor_id, sponsor| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id && *sponsor_id == group_sponsor_id && sponsor.featured == form.featured && sponsor.name == form.name + && sponsor.logo_url == form.logo_url }) .returning(move |_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/sponsors/{group_sponsor_id}/update" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/sponsors/{group_sponsor_id}/update"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -551,3 +341,23 @@ async fn test_update_success() { "refresh-group-dashboard-table", ); } + +// Helpers. + +/// Sponsor form with an empty name and an invalid logo URL. +const INVALID_SPONSOR_FORM: &str = "name=&logo_url=not-a-url"; + +/// Returns the featured checkbox tag of a rendered sponsor form. +fn featured_input(body: &str) -> &str { + let start = body.find("').unwrap(); + &body[start..end] +} + +/// Returns a sponsors list output with the sample sponsor. +fn sample_sponsors_output() -> GroupSponsorsOutput { + GroupSponsorsOutput { + sponsors: vec![sample_group_sponsor()], + total: 1, + } +} diff --git a/ocg-server/src/handlers/dashboard/group/submissions/tests.rs b/ocg-server/src/handlers/dashboard/group/submissions/tests.rs index 90dc9434e..ab17c5f68 100644 --- a/ocg-server/src/handlers/dashboard/group/submissions/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/submissions/tests.rs @@ -1,21 +1,22 @@ use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, + body::Body, + http::{Request, StatusCode}, }; -use axum_login::tower_sessions::session; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, - handlers::{error::HandlerError, tests::*}, + handlers::{error::INVALID_REQUEST_PAYLOAD, tests::*}, services::notifications::MockNotificationsManager, types::{ - dashboard::DASHBOARD_PAGINATION_LIMIT, notifications::NotificationKind, + dashboard::{ + DASHBOARD_PAGINATION_LIMIT, + group::submissions::{ + CfsSubmissionNotificationData, CfsSubmissionUpdate, CfsSubmissionsOutput, + }, + }, + notifications::NotificationKind, permissions::GroupPermission, }, }; @@ -23,272 +24,138 @@ use crate::{ #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let session_proposal_id = Uuid::new_v4(); - let cfs_submission_id = Uuid::new_v4(); - let speaker_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let submissions_output = crate::types::dashboard::group::submissions::CfsSubmissionsOutput { + let output = CfsSubmissionsOutput { submissions: vec![sample_group_cfs_submission( - cfs_submission_id, - session_proposal_id, - speaker_id, + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), )], total: 1, }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_list_page_reads( &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, + &ids, + event_id, + (DASHBOARD_PAGINATION_LIMIT, 0), + output, ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_list_event_labels() - .times(1) - .withf(move |eid| *eid == event_id) - .returning(|_| Ok(vec![])); - db.expect_list_event_cfs_submissions() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) - && filters.offset == Some(0) - }) - .returning(move |_, _, _| Ok(submissions_output.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/submissions")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/submissions"), + ids.session_id, ); - assert!(!bytes.is_empty()); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + + // Check the submission row renders the proposal title + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains(">Proposal title")); } #[tokio::test] async fn test_list_page_with_pagination_params() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let submissions_output = crate::types::dashboard::group::submissions::CfsSubmissionsOutput { + let output = CfsSubmissionsOutput { submissions: vec![], total: 0, }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_list_event_labels() - .times(1) - .withf(move |eid| *eid == event_id) - .returning(|_| Ok(vec![])); - db.expect_list_event_cfs_submissions() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(5) - && filters.offset == Some(10) - }) - .returning(move |_, _, _| Ok(submissions_output.clone())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_list_page_reads(&mut db, &ids, event_id, (5, 10), output); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions?limit=5&offset=10" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/submissions?limit=5&offset=10"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); } #[tokio::test] async fn test_list_page_rejects_limit_zero() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions?limit=0" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( + "/dashboard/group/events/{}/submissions?limit=0", + Uuid::new_v4() + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "limit: lower than 1\n" + ); } #[tokio::test] async fn test_list_page_rejects_unknown_sort() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions?sort=updated-desc" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( + "/dashboard/group/events/{}/submissions?sort=updated-desc", + Uuid::new_v4() + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!(bytes.as_ref(), INVALID_REQUEST_PAYLOAD.as_bytes()); } #[tokio::test] async fn test_update_db_error() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let cfs_submission_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let update = crate::types::dashboard::group::submissions::CfsSubmissionUpdate { - label_ids: vec![], - status_id: "approved".to_string(), - action_required_message: None, - rating_comment: None, - rating_stars: None, - }; - let form_data = serde_qs::to_string(&update).unwrap(); + let form_data = serde_qs::to_string(&sample_submission_update()).unwrap(); // Setup database mock with a failing write and no notification context reads let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_cfs_submission() .times(1) .withf(move |uid, gid, eid, sid, _| { - *uid == user_id && *gid == group_id && *eid == event_id && *sid == cfs_submission_id + *uid == ids.user_id + && *gid == ids.group_id + && *eid == event_id + && *sid == cfs_submission_id }) .returning(|_, _, _, _, _| Err(anyhow!("db error"))); db.expect_get_cfs_submission_notification_data().never(); @@ -300,136 +167,47 @@ async fn test_update_db_error() { nm.expect_enqueue().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions/{cfs_submission_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let request = update_request(&ids, event_id, cfs_submission_id, form_data); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] -async fn test_update_rejects_event_outside_group() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let cfs_submission_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let update = crate::types::dashboard::group::submissions::CfsSubmissionUpdate { - label_ids: vec![], - status_id: "approved".to_string(), - action_required_message: None, - rating_comment: None, - rating_stars: None, - }; - let form_data = serde_qs::to_string(&update).unwrap(); - - // Setup database mock with a scope rejection and no notification context reads - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_update_cfs_submission() - .times(1) - .withf(move |uid, gid, eid, sid, _| { - *uid == user_id && *gid == group_id && *eid == event_id && *sid == cfs_submission_id - }) - .returning(|_, _, _, _, _| { - Err(HandlerError::Database("event not found or inactive".to_string()).into()) - }); - db.expect_get_cfs_submission_notification_data().never(); - db.expect_get_event_summary().never(); - db.expect_get_site_settings().never(); - - // Setup notifications manager mock, which must not be called - let mut nm = MockNotificationsManager::new(); - nm.expect_enqueue().never(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions/{cfs_submission_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - String::from_utf8(bytes.to_vec()).unwrap(), - "event not found or inactive" - ); -} - -#[tokio::test] -#[allow(clippy::too_many_lines)] async fn test_update_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let cfs_submission_id = Uuid::new_v4(); let notification_user_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let update = crate::types::dashboard::group::submissions::CfsSubmissionUpdate { - label_ids: vec![], - status_id: "approved".to_string(), + let event = sample_event_summary(event_id); + let update = CfsSubmissionUpdate { action_required_message: Some("Please update your slides.".to_string()), - rating_comment: None, - rating_stars: None, + ..sample_submission_update() }; let form_data = serde_qs::to_string(&update).unwrap(); - let notification_data = - crate::types::dashboard::group::submissions::CfsSubmissionNotificationData { - status_id: update.status_id.clone(), - status_name: "Approved".to_string(), - user_id: notification_user_id, - - action_required_message: update.action_required_message.clone(), - }; + let notification_data = CfsSubmissionNotificationData { + status_id: update.status_id.clone(), + status_name: "Approved".to_string(), + user_id: notification_user_id, + + action_required_message: update.action_required_message.clone(), + }; let site_settings = sample_site_settings(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_cfs_submission() .times(1) .withf(move |uid, gid, eid, sid, submission| { - *uid == user_id - && *gid == group_id + *uid == ids.user_id + && *gid == ids.group_id && *eid == event_id && *sid == cfs_submission_id && submission.status_id == "approved" @@ -442,12 +220,14 @@ async fn test_update_success() { // Setup the notification context reads made by the best-effort helper db.expect_get_event_summary() .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) .returning(move |_, _, _| Ok(event.clone())); db.expect_get_cfs_submission_notification_data() .times(1) .withf(move |gid, eid, sid| { - *gid == group_id && *eid == event_id && *sid == cfs_submission_id + *gid == ids.group_id && *eid == event_id && *sid == cfs_submission_id }) .returning(move |_, _, _| Ok(notification_data.clone())); db.expect_get_site_settings() @@ -465,62 +245,42 @@ async fn test_update_success() { .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions/{cfs_submission_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let request = update_request(&ids, event_id, cfs_submission_id, form_data); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert_eq!( - parts.headers.get("HX-Trigger").unwrap(), - &HeaderValue::from_static("refresh-event-submissions"), + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::NO_CONTENT, + "refresh-event-submissions", ); - assert!(bytes.is_empty()); } #[tokio::test] async fn test_update_without_notification_skips_context_reads() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); let cfs_submission_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let update = crate::types::dashboard::group::submissions::CfsSubmissionUpdate { - label_ids: vec![], - status_id: "approved".to_string(), - action_required_message: None, - rating_comment: None, + let update = CfsSubmissionUpdate { rating_stars: Some(4), + ..sample_submission_update() }; let form_data = serde_qs::to_string(&update).unwrap(); // Setup database mock with a write that needs no notification let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_cfs_submission() .times(1) .withf(move |uid, gid, eid, sid, submission| { - *uid == user_id - && *gid == group_id + *uid == ids.user_id + && *gid == ids.group_id && *eid == event_id && *sid == cfs_submission_id && submission.rating_stars == Some(4) @@ -535,25 +295,78 @@ async fn test_update_without_notification_skips_context_reads() { nm.expect_enqueue().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!( - "/dashboard/group/events/{event_id}/submissions/{cfs_submission_id}" - )) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(form_data)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let request = update_request(&ids, event_id, cfs_submission_id, form_data); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::NO_CONTENT); - assert_eq!( - parts.headers.get("HX-Trigger").unwrap(), - &HeaderValue::from_static("refresh-event-submissions"), + assert_empty_hx_trigger_response( + &parts, + &bytes, + StatusCode::NO_CONTENT, + "refresh-event-submissions", ); - assert!(bytes.is_empty()); +} + +// Helpers. + +/// Registers the event, labels and submissions reads the list page performs. +fn expect_list_page_reads( + db: &mut MockDB, + ids: &DashboardIds, + event_id: Uuid, + (expected_limit, expected_offset): (usize, usize), + output: CfsSubmissionsOutput, +) { + let ids = *ids; + let event = sample_event_summary(event_id); + db.expect_get_event_summary() + .times(1) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) + .returning(move |_, _, _| Ok(event.clone())); + db.expect_list_event_labels() + .times(1) + .withf(move |eid| *eid == event_id) + .returning(|_| Ok(vec![])); + db.expect_list_event_cfs_submissions() + .times(1) + .withf(move |gid, eid, filters| { + *gid == ids.group_id + && *eid == event_id + && filters.limit == Some(expected_limit) + && filters.offset == Some(expected_offset) + }) + .returning(move |_, _, _| Ok(output.clone())); +} + +/// Returns a submission update that approves without extra review details. +fn sample_submission_update() -> CfsSubmissionUpdate { + CfsSubmissionUpdate { + label_ids: vec![], + status_id: "approved".to_string(), + + action_required_message: None, + rating_comment: None, + rating_stars: None, + } +} + +/// Builds the submission update request. +fn update_request( + ids: &DashboardIds, + event_id: Uuid, + cfs_submission_id: Uuid, + form_data: String, +) -> Request { + form_request( + "PUT", + &format!("/dashboard/group/events/{event_id}/submissions/{cfs_submission_id}"), + ids.session_id, + form_data, + ) } diff --git a/ocg-server/src/handlers/dashboard/group/surveys/tests.rs b/ocg-server/src/handlers/dashboard/group/surveys/tests.rs index bb4a2ab4d..76b7fac78 100644 --- a/ocg-server/src/handlers/dashboard/group/surveys/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/surveys/tests.rs @@ -1,22 +1,15 @@ -use axum::{ - Router, - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}, - response::Parts, - }, +use axum::http::{ + HeaderValue, StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }; -use axum_login::tower_sessions::session; use chrono::{NaiveDate, TimeZone, Utc}; use serde_json::json; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, + test_util::normalize_whitespace, types::{ dashboard::{ EVENT_SURVEY_RESPONSES_PAGINATION_LIMIT, EVENT_SURVEY_TEXT_ANSWERS_PAGINATION_LIMIT, @@ -43,31 +36,33 @@ use super::{ #[tokio::test] async fn test_add_accepts_draft_without_questions() { // Setup identifiers and a draft submitted without questions - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let body = "anonymous=true&audience=confirmed&title=Feedback&questions_present=true"; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_add_event_survey() .times(1) - .withf(move |actor_user_id, group_id, event_id, survey| { + .withf(move |actor_user_id, group_id, survey_event_id, survey| { *actor_user_id == ids.user_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && survey.questions.is_empty() && survey.revision.is_none() }) .returning(|_, _, _, _| Ok(Uuid::new_v4())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "POST", - &format!("/dashboard/group/events/{}/surveys/add", ids.event_id), - Some(body), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/add"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -82,7 +77,8 @@ async fn test_add_accepts_draft_without_questions() { async fn test_add_accepts_normalized_browser_dates() { // Setup identifiers and a survey with dates in the normalized browser format // and a percent-encoded single-select question - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let body = "anonymous=false&audience=checked-in&title=Feedback&description=Tell%20us&\ opens_at=2099-05-10T09%3A00%3A00&closes_at=2099-05-20T18%3A30%3A00&questions_present=true&\ questions%5B0%5D%5Bid%5D=00000000-0000-0000-0000-000000000001&\ @@ -95,13 +91,14 @@ questions%5B1%5D%5Boptions%5D%5B0%5D%5Bid%5D=00000000-0000-0000-0000-00000000000 questions%5B1%5D%5Boptions%5D%5B0%5D%5Blabel%5D=Good"; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_add_event_survey() .times(1) - .withf(move |actor_user_id, group_id, event_id, survey| { + .withf(move |actor_user_id, group_id, survey_event_id, survey| { *actor_user_id == ids.user_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && !survey.anonymous && survey.audience == EventSurveyAudience::CheckedIn && survey.title == "Feedback" @@ -119,15 +116,15 @@ questions%5B1%5D%5Boptions%5D%5B0%5D%5Blabel%5D=Good"; }) .returning(|_, _, _, _| Ok(Uuid::new_v4())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "POST", - &format!("/dashboard/group/events/{}/surveys/add", ids.event_id), - Some(body), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/add"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -141,27 +138,26 @@ questions%5B1%5D%5Boptions%5D%5B0%5D%5Blabel%5D=Good"; #[tokio::test] async fn test_add_page_success() { // Setup identifiers and database expectations - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::Read); - expect_events_write_permission(&mut db, &ids, true); - expect_event_summary(&mut db, &ids); - - // Run the request - let (parts, bytes) = send( - db, + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_summary(&mut db, &ids, event_id); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/add"), ids.session_id, - "GET", - &format!("/dashboard/group/events/{}/surveys/add", ids.event_id), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the response renders an empty form with its own submit target assert_html_response(&parts, &bytes, StatusCode::OK); let html = String::from_utf8_lossy(&bytes); assert!(html.contains(&format!( - r#"hx-post="/dashboard/group/events/{}/surveys/add""#, - ids.event_id + r#"hx-post="/dashboard/group/events/{event_id}/surveys/add""# ))); assert!(html.contains("data-event-survey-form")); assert!(!html.contains("data-event-form")); @@ -171,28 +167,35 @@ async fn test_add_page_success() { #[tokio::test] async fn test_add_rejects_invalid_form() { // Setup identifiers and database expectations - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_add_event_survey().never(); - // Run the request with a blank title - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request with a blank title + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "POST", - &format!("/dashboard/group/events/{}/surveys/add", ids.event_id), - Some("anonymous=true&audience=confirmed&title=%20"), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/add"), + ids.session_id, + "anonymous=true&audience=confirmed&title=%20", + ); + let (parts, bytes) = send(router, request).await; // Check the response - assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + "title: value cannot be empty or whitespace-only\n" + ); } #[tokio::test] async fn test_change_status_success() { // Setup identifiers and the confirmed Mark ready values - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let opens_at = Utc.with_ymd_and_hms(2099, 5, 10, 9, 0, 0).unwrap(); let body = format!( @@ -201,14 +204,15 @@ async fn test_change_status_success() { ); // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_change_event_survey_status() .times(1) .withf( - move |actor_user_id, group_id, event_id, survey_id, status| { + move |actor_user_id, group_id, survey_event_id, survey_id, status| { *actor_user_id == ids.user_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && status.action == EventSurveyStatusAction::MarkReady && status.revision == 3 @@ -217,18 +221,15 @@ async fn test_change_status_success() { ) .returning(|_, _, _, _, _| Ok(())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "PUT", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/status", - ids.event_id - ), - Some(&body), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/status"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -242,31 +243,29 @@ async fn test_change_status_success() { #[tokio::test] async fn test_delete_success() { // Setup identifiers and database expectations - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_delete_event_survey() .times(1) - .withf(move |actor_user_id, group_id, event_id, survey_id| { + .withf(move |actor_user_id, group_id, survey_event_id, survey_id| { *actor_user_id == ids.user_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id }) .returning(|_, _, _, _| Ok(())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( "DELETE", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/delete", - ids.event_id - ), - None, - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -280,7 +279,8 @@ async fn test_delete_success() { #[tokio::test] async fn test_download_csv_anonymous_survey_exports_dates_and_answers_only() { // Setup identifiers and an anonymous export - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let export = EventSurveyExport { anonymous: true, @@ -296,22 +296,18 @@ async fn test_download_csv_anonymous_survey_exports_dates_and_answers_only() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_survey_export(&mut db, &ids, event_survey_id, export); - - // Run the request - let (parts, bytes) = send( - db, + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_survey_export(&mut db, &ids, event_id, event_survey_id, export); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses.csv"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses.csv", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the CSV has no respondent columns assert_eq!(parts.status, StatusCode::OK); @@ -320,7 +316,7 @@ async fn test_download_csv_anonymous_survey_exports_dates_and_answers_only() { &HeaderValue::from_static("text/csv; charset=utf-8"), ); assert_eq!( - String::from_utf8(bytes).unwrap(), + String::from_utf8(bytes.to_vec()).unwrap(), "Submitted,Updated,What should we improve?,How would you rate the sessions?\n\ 2099-05-10 09:00 UTC,,'=SUM(A1),Excellent\n", ); @@ -329,7 +325,8 @@ async fn test_download_csv_anonymous_survey_exports_dates_and_answers_only() { #[tokio::test] async fn test_download_csv_identified_survey_exports_protected_respondent_columns() { // Setup identifiers and an identified export with formula-like cells - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let mut questions = sample_event_survey_full(event_survey_id).questions; questions[0].prompt = "@Comments".to_string(); @@ -353,22 +350,18 @@ async fn test_download_csv_identified_survey_exports_protected_respondent_column }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_survey_export(&mut db, &ids, event_survey_id, export); - - // Run the request - let (parts, bytes) = send( - db, + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_survey_export(&mut db, &ids, event_id, event_survey_id, export); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses.csv"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses.csv", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the attachment and its protected cells assert_eq!(parts.status, StatusCode::OK); @@ -380,7 +373,7 @@ async fn test_download_csv_identified_survey_exports_protected_respondent_column .unwrap(), ); assert_eq!( - String::from_utf8(bytes).unwrap(), + String::from_utf8(bytes.to_vec()).unwrap(), "Name,Company,Title,Submitted,Updated,'@Comments,How would you rate the sessions?\n\ Jane Doe,'+Example,,2099-05-10 09:00 UTC,2099-05-11 10:30 UTC,'=SUM(A1),Excellent\n", ); @@ -389,26 +382,23 @@ Jane Doe,'+Example,,2099-05-10 09:00 UTC,2099-05-11 10:30 UTC,'=SUM(A1),Excellen #[tokio::test] async fn test_download_csv_unknown_survey_returns_not_found() { // Setup identifiers and database expectations - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); db.expect_get_event_survey_export() .times(1) .returning(|_, _, _, _, _| Ok(None)); - // Run the request - let (parts, bytes) = send( - db, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses.csv"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses.csv", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -417,63 +407,55 @@ async fn test_download_csv_unknown_survey_returns_not_found() { #[tokio::test] async fn test_list_page_read_only_disables_mutation_controls() { // Setup identifiers and a survey listed for a read-only role - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let survey = sample_event_survey_summary(Uuid::new_v4(), EventSurveyState::Draft); // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_events_write_permission(&mut db, &ids, false); - expect_event_summary(&mut db, &ids); - expect_list_event_surveys(&mut db, &ids, vec![survey]); - - // Run the request - let (parts, bytes) = send( - db, + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, false); + expect_event_summary(&mut db, &ids, event_id); + expect_list_event_surveys(&mut db, &ids, event_id, vec![survey]); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys"), ids.session_id, - "GET", - &format!("/dashboard/group/events/{}/surveys", ids.event_id), - None, - ) - .await; - - // Check results, the CSV and the read-only form stay available while - // mutation controls are disabled with the role reason + ); + let (parts, bytes) = send(router, request).await; + + // Check the survey is listed with the role reason; the disabled controls + // are covered by the template tests assert_html_response(&parts, &bytes, StatusCode::OK); - let html = normalize_whitespace(&String::from_utf8_lossy(&bytes)); + let html = String::from_utf8_lossy(&bytes); assert!(html.contains("Session feedback")); - assert!(html.contains("/results\"")); - assert!(html.contains("/responses.csv\"")); - assert!(html.contains("/update\"")); assert!(html.contains("Your role cannot manage surveys.")); - assert_eq!( - html.matches(r#"disabled title="Your role cannot add, update or delete surveys.""#) - .count(), - 3 - ); } #[tokio::test] async fn test_list_page_success() { // Setup identifiers and a draft survey - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let survey = sample_event_survey_summary(event_survey_id, EventSurveyState::Draft); // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_events_write_permission(&mut db, &ids, true); - expect_event_summary(&mut db, &ids); - expect_list_event_surveys(&mut db, &ids, vec![survey]); - - // Run the request - let (parts, bytes) = send( - db, + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_summary(&mut db, &ids, event_id); + expect_list_event_surveys(&mut db, &ids, event_id, vec![survey]); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys"), ids.session_id, - "GET", - &format!("/dashboard/group/events/{}/surveys", ids.event_id), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the row and its mutation controls assert_html_response(&parts, &bytes, StatusCode::OK); @@ -491,29 +473,28 @@ async fn test_list_page_success() { #[tokio::test] async fn test_response_modal_rejects_anonymous_survey() { // Setup identifiers and an anonymous survey - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let survey = sample_event_survey_full(event_survey_id); // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_get_event_survey_response().never(); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses/{}", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses/{}", Uuid::new_v4() ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the rejection assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -526,7 +507,8 @@ async fn test_response_modal_rejects_anonymous_survey() { #[tokio::test] async fn test_response_modal_success() { // Setup identifiers and an identified survey with three matching respondents - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let user_id = Uuid::new_v4(); let next_user_id = Uuid::new_v4(); @@ -560,15 +542,16 @@ async fn test_response_modal_success() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_get_event_survey_response() .times(1) .withf( - move |group_id, event_id, survey_id, respondent_id, filters| { + move |group_id, survey_event_id, survey_id, respondent_id, filters| { *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && *respondent_id == user_id && filters.ts_query.as_deref() == Some("jane doe") @@ -576,18 +559,15 @@ async fn test_response_modal_success() { ) .returning(move |_, _, _, _, _| Ok(Some(response.clone()))); - // Run the request with the respondents search - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request with the respondents search + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses/{user_id}?ts_query=jane+doe", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses/{user_id}?ts_query=jane+doe" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the respondent details and escaped answers assert_html_response(&parts, &bytes, StatusCode::OK); @@ -603,10 +583,8 @@ async fn test_response_modal_success() { // Check the navigation keeps the search assert!(html.contains("Respondent 2 of 3")); - let base_url = format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses", - ids.event_id - ); + let base_url = + format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses"); assert!(html.contains(&format!( r#"hx-get="{base_url}/{previous_user_id}?ts_query=jane+doe""# ))); @@ -618,15 +596,17 @@ async fn test_response_modal_success() { #[tokio::test] async fn test_response_modal_unknown_response_returns_not_found() { // Setup identifiers and an identified survey - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); survey.summary.anonymous = false; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_get_event_survey_response() .times(1) .withf(move |_, _, survey_id, _, filters| { @@ -634,29 +614,26 @@ async fn test_response_modal_unknown_response_returns_not_found() { }) .returning(|_, _, _, _, _| Ok(None)); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses/{}", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses/{}", Uuid::new_v4() ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the response is not found - assert_eq!(parts.status, StatusCode::NOT_FOUND); - assert_eq!(bytes, Vec::::new()); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_response_modal_without_neighbors_disables_navigation() { // Setup identifiers and the only respondent of an identified survey - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let user_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); @@ -679,25 +656,23 @@ async fn test_response_modal_without_neighbors_disables_navigation() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_get_event_survey_response() .times(1) .returning(move |_, _, _, _, _| Ok(Some(response.clone()))); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses/{user_id}", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses/{user_id}" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check both navigation buttons are disabled and the details fall back assert_html_response(&parts, &bytes, StatusCode::OK); @@ -711,9 +686,10 @@ async fn test_response_modal_without_neighbors_disables_navigation() { } #[tokio::test] -async fn test_responses_page_applies_default_bounds() { +async fn test_responses_page_without_responses_uses_default_bounds() { // Setup identifiers and an identified survey without responses - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); survey.summary.anonymous = false; @@ -723,60 +699,57 @@ async fn test_responses_page_applies_default_bounds() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_list_event_survey_responses() .times(1) .withf(move |_, _, survey_id, filters| { *survey_id == event_survey_id && filters.limit == Some(EVENT_SURVEY_RESPONSES_PAGINATION_LIMIT) && filters.offset == Some(0) + && filters.ts_query.is_none() }) .returning(move |_, _, _, _| Ok(Some(responses.clone()))); - // Run the request without a query string - let (parts, _) = send( - db, + // Setup router and send request without a query string + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; - // Check the page was rendered with the default bounds - assert_eq!(parts.status, StatusCode::OK); + // Check the empty state replaces the respondents search + assert_html_response(&parts, &bytes, StatusCode::OK); + let html = String::from_utf8_lossy(&bytes); + assert!(html.contains("This survey does not have any responses yet.")); + assert!(!html.contains(r#"name="ts_query""#)); } #[tokio::test] async fn test_responses_page_rejects_anonymous_survey() { // Setup identifiers and an anonymous survey - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let survey = sample_event_survey_full(event_survey_id); // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_list_event_survey_responses().never(); - // Run the request - let (parts, bytes) = send( - db, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the rejection assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -789,7 +762,8 @@ async fn test_responses_page_rejects_anonymous_survey() { #[tokio::test] async fn test_responses_page_renders_no_results_for_unmatched_search() { // Setup identifiers and an identified survey without matching respondents - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); survey.summary.anonymous = false; @@ -799,26 +773,24 @@ async fn test_responses_page_renders_no_results_for_unmatched_search() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_list_event_survey_responses() .times(1) .withf(|_, _, _, filters| filters.ts_query.as_deref() == Some("nobody")) .returning(move |_, _, _, _| Ok(Some(responses.clone()))); - // Run the request with a search - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request with a search + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses?ts_query=nobody", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses?ts_query=nobody" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the search stays editable above the no results state assert_html_response(&parts, &bytes, StatusCode::OK); @@ -831,7 +803,8 @@ async fn test_responses_page_renders_no_results_for_unmatched_search() { #[tokio::test] async fn test_responses_page_success() { // Setup identifiers and an identified survey with two respondents - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let jane_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); @@ -863,14 +836,15 @@ async fn test_responses_page_success() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); db.expect_list_event_survey_responses() .times(1) - .withf(move |group_id, event_id, survey_id, filters| { + .withf(move |group_id, survey_event_id, survey_id, filters| { *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && filters.limit == Some(EVENT_SURVEY_RESPONSES_PAGINATION_LIMIT) && filters.offset == Some(20) @@ -878,18 +852,15 @@ async fn test_responses_page_success() { }) .returning(move |_, _, _, _| Ok(Some(responses.clone()))); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/responses?offset=20&ts_query=j", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses?offset=20&ts_query=j" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the Individual view renders the searchable respondents table assert_html_response(&parts, &bytes, StatusCode::OK); @@ -913,8 +884,7 @@ async fn test_responses_page_success() { // Check rows open the responses keeping the search, and pages keep it too assert!(html.contains(&format!( - r#"hx-get="/dashboard/group/events/{}/surveys/{event_survey_id}/responses/{jane_id}?ts_query=j""#, - ids.event_id + r#"hx-get="/dashboard/group/events/{event_id}/surveys/{event_survey_id}/responses/{jane_id}?ts_query=j""# ))); assert!(html.contains("ts_query=j")); assert!(html.contains("offset=0")); @@ -923,7 +893,8 @@ async fn test_responses_page_success() { #[tokio::test] async fn test_results_page_success() { // Setup identifiers and the results of an anonymous survey with few responses - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let question_id = Uuid::new_v4(); let mut survey = sample_event_survey_summary(event_survey_id, EventSurveyState::Open); @@ -948,27 +919,25 @@ async fn test_results_page_success() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); db.expect_get_event_survey_results() .times(1) - .withf(move |group_id, event_id, survey_id| { - *group_id == ids.group_id && *event_id == ids.event_id && *survey_id == event_survey_id + .withf(move |group_id, survey_event_id, survey_id| { + *group_id == ids.group_id + && *survey_event_id == event_id + && *survey_id == event_survey_id }) .returning(move |_, _, _| Ok(Some(results.clone()))); - // Run the request - let (parts, bytes) = send( - db, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/results"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/results", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the escaped preview, the modal opener and the anonymity notice assert_html_response(&parts, &bytes, StatusCode::OK); @@ -977,8 +946,7 @@ async fn test_results_page_success() { assert!(!html.contains("talks")); assert!(html.contains("View all 11 answers")); assert!(html.contains(&format!( - r#"hx-get="/dashboard/group/events/{}/surveys/{event_survey_id}/results/questions/{question_id}/answers""#, - ids.event_id + r#"hx-get="/dashboard/group/events/{event_id}/surveys/{event_survey_id}/results/questions/{question_id}/answers""# ))); assert!(html.contains(r#"id="event-survey-text-answers-modal""#)); assert!(html.contains("data-event-survey-few-responses-notice")); @@ -992,26 +960,25 @@ async fn test_results_page_success() { #[tokio::test] async fn test_results_page_unknown_survey_returns_not_found() { // Setup identifiers and database expectations - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::Read); - expect_event_summary(&mut db, &ids); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_event_summary(&mut db, &ids, event_id); db.expect_get_event_survey_results() .times(1) .returning(|_, _, _| Ok(None)); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{}/results", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{}/results", Uuid::new_v4() ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -1020,34 +987,38 @@ async fn test_results_page_unknown_survey_returns_not_found() { #[tokio::test] async fn test_text_answers_modal_rejects_long_search() { // Setup identifiers and a search above the length limit - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::Read); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_list_event_survey_text_answers().never(); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{}/results/questions/{}/answers?ts_query={}", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{}/results/questions/{}/answers?ts_query={}", Uuid::new_v4(), Uuid::new_v4(), "a".repeat(MAX_LEN_M + 1) ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the search is rejected before loading answers - assert_non_empty_response(&parts, &bytes, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + format!("ts_query: length is greater than {MAX_LEN_M}\n") + ); } #[tokio::test] async fn test_text_answers_modal_success() { // Setup identifiers and the second page of answers matching a search - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let question_id = Uuid::new_v4(); let answers = EventSurveyTextAnswersOutput { @@ -1058,12 +1029,13 @@ async fn test_text_answers_modal_success() { }; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_list_event_survey_text_answers() .times(1) - .withf(move |group_id, event_id, survey_id, qid, filters| { + .withf(move |group_id, survey_event_id, survey_id, qid, filters| { *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && *qid == question_id && filters.limit == Some(EVENT_SURVEY_TEXT_ANSWERS_PAGINATION_LIMIT) @@ -1072,18 +1044,15 @@ async fn test_text_answers_modal_success() { }) .returning(move |_, _, _, _, _| Ok(Some(answers.clone()))); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/results/questions/{question_id}/answers?offset=10&ts_query=breaks", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/results/questions/{question_id}/answers?offset=10&ts_query=breaks" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the escaped prompt, answers and search assert_html_response(&parts, &bytes, StatusCode::OK); @@ -1101,32 +1070,31 @@ async fn test_text_answers_modal_success() { #[tokio::test] async fn test_text_answers_modal_unknown_question_returns_not_found() { // Setup identifiers and database expectations - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let question_id = Uuid::new_v4(); - let mut db = setup_db(&ids, GroupPermission::Read); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); db.expect_list_event_survey_text_answers() .times(1) - .withf(move |group_id, event_id, survey_id, qid, _| { + .withf(move |group_id, survey_event_id, survey_id, qid, _| { *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && *qid == question_id }) .returning(|_, _, _, _, _| Ok(None)); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/results/questions/{question_id}/answers", - ids.event_id + "/dashboard/group/events/{event_id}/surveys/{event_survey_id}/results/questions/{question_id}/answers" ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -1135,19 +1103,26 @@ async fn test_text_answers_modal_unknown_question_returns_not_found() { #[tokio::test] async fn test_update_clears_custom_opening_and_closing_date() { // Setup identifiers and a form clearing both dates - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let body = "anonymous=true&audience=confirmed&title=Feedback&opens_at=&closes_at=&revision=4"; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_event_survey() .times(1) .withf( - move |actor_user_id, group_id, event_id, survey_id, expected_revision, survey| { + move |actor_user_id, + group_id, + survey_event_id, + survey_id, + expected_revision, + survey| { *actor_user_id == ids.user_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id && *survey_id == event_survey_id && *expected_revision == 4 && survey.closes_at.is_none() @@ -1156,18 +1131,15 @@ async fn test_update_clears_custom_opening_and_closing_date() { ) .returning(|_, _, _, _, _, _| Ok(())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "PUT", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/update", - ids.event_id - ), - Some(body), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/update"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -1181,36 +1153,32 @@ async fn test_update_clears_custom_opening_and_closing_date() { #[tokio::test] async fn test_update_page_locked_survey_disables_locked_fields() { // Setup identifiers and a survey locked by responses - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let mut survey = sample_event_survey_full(event_survey_id); survey.summary.locked = true; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::Read); - expect_events_write_permission(&mut db, &ids, true); - expect_event_summary(&mut db, &ids); - expect_get_event_survey(&mut db, &ids, event_survey_id, survey); - - // Run the request - let (parts, bytes) = send( - db, + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_summary(&mut db, &ids, event_id); + expect_get_event_survey(&mut db, &ids, event_id, event_survey_id, survey); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/update"), ids.session_id, - "GET", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/update", - ids.event_id - ), - None, - ) - .await; + ); + let (parts, bytes) = send(router, request).await; // Check the form targets the update route and carries the revision assert_html_response(&parts, &bytes, StatusCode::OK); let html = String::from_utf8_lossy(&bytes); assert!(html.contains(&format!( - r#"hx-put="/dashboard/group/events/{}/surveys/{event_survey_id}/update""#, - ids.event_id + r#"hx-put="/dashboard/group/events/{event_id}/surveys/{event_survey_id}/update""# ))); assert!(html.contains(r#"name="revision" value="3""#)); assert!(html.contains("data-event-survey-locked-notice")); @@ -1219,25 +1187,24 @@ async fn test_update_page_locked_survey_disables_locked_fields() { #[tokio::test] async fn test_update_page_unknown_survey_returns_not_found() { // Setup identifiers and database expectations - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::Read); - expect_events_write_permission(&mut db, &ids, true); - expect_event_summary(&mut db, &ids); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::Read); + expect_group_permission_result(&mut db, &ids, GroupPermission::EventsWrite, true); + expect_event_summary(&mut db, &ids, event_id); db.expect_get_event_survey().times(1).returning(|_, _, _| Ok(None)); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, - "GET", + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( &format!( - "/dashboard/group/events/{}/surveys/{}/update", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{}/update", Uuid::new_v4() ), - None, - ) - .await; + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); @@ -1246,23 +1213,24 @@ async fn test_update_page_unknown_survey_returns_not_found() { #[tokio::test] async fn test_update_rejects_missing_revision() { // Setup identifiers and database expectations - let ids = TestIds::new(); - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_event_survey().never(); - // Run the request without the loaded revision - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request without the loaded revision + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "PUT", &format!( - "/dashboard/group/events/{}/surveys/{}/update", - ids.event_id, + "/dashboard/group/events/{event_id}/surveys/{}/update", Uuid::new_v4() ), - Some("anonymous=true&audience=confirmed&title=Feedback"), - ) - .await; + ids.session_id, + "anonymous=true&audience=confirmed&title=Feedback", + ); + let (parts, bytes) = send(router, request).await; // Check the rejection assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); @@ -1275,12 +1243,14 @@ async fn test_update_rejects_missing_revision() { #[tokio::test] async fn test_update_removing_last_question() { // Setup identifiers and a form whose editor has no questions left - let ids = TestIds::new(); + let ids = DashboardIds::new(); + let event_id = Uuid::new_v4(); let event_survey_id = Uuid::new_v4(); let body = "anonymous=true&audience=confirmed&title=Feedback&questions_present=true&revision=2"; // Setup database expectations - let mut db = setup_db(&ids, GroupPermission::EventsWrite); + let mut db = MockDB::new(); + expect_group_access(&mut db, &ids, GroupPermission::EventsWrite); db.expect_update_event_survey() .times(1) .withf(move |_, _, _, survey_id, expected_revision, survey| { @@ -1288,18 +1258,15 @@ async fn test_update_removing_last_question() { }) .returning(|_, _, _, _, _, _| Ok(())); - // Run the request - let (parts, bytes) = send( - db, - ids.session_id, + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( "PUT", - &format!( - "/dashboard/group/events/{}/surveys/{event_survey_id}/update", - ids.event_id - ), - Some(body), - ) - .await; + &format!("/dashboard/group/events/{event_id}/surveys/{event_survey_id}/update"), + ids.session_id, + body, + ); + let (parts, bytes) = send(router, request).await; // Check the response assert_empty_hx_trigger_response( @@ -1312,112 +1279,78 @@ async fn test_update_removing_last_question() { // Helpers. -/// Identifiers shared by the requests of one test. -#[derive(Clone, Copy)] -struct TestIds { - /// Selected community. - community_id: Uuid, - /// Event the surveys belong to. - event_id: Uuid, - /// Selected group. - group_id: Uuid, - /// Authenticated session. - session_id: session::Id, - /// Authenticated user. - user_id: Uuid, -} - -impl TestIds { - /// Generates the identifiers of a test. - fn new() -> Self { - Self { - community_id: Uuid::new_v4(), - event_id: Uuid::new_v4(), - group_id: Uuid::new_v4(), - session_id: session::Id::default(), - user_id: Uuid::new_v4(), - } - } -} - /// Expects the event summary read by survey pages. -fn expect_event_summary(db: &mut MockDB, ids: &TestIds) { +fn expect_event_summary(db: &mut MockDB, ids: &DashboardIds, event_id: Uuid) { let ids = *ids; - let event = sample_event_summary(ids.event_id, ids.group_id); + let event = sample_event_summary(event_id); db.expect_get_event_summary_dashboard() .times(1) - .withf(move |community_id, group_id, event_id| { + .withf(move |community_id, group_id, survey_event_id| { *community_id == ids.community_id && *group_id == ids.group_id - && *event_id == ids.event_id + && *survey_event_id == event_id }) .returning(move |_, _, _| Ok(event.clone())); } -/// Expects the events write permission check made by pages with mutations. -fn expect_events_write_permission(db: &mut MockDB, ids: &TestIds, allowed: bool) { - let ids = *ids; - db.expect_user_has_group_permission() - .times(1) - .withf(move |community_id, group_id, user_id, permission| { - *community_id == ids.community_id - && *group_id == ids.group_id - && *user_id == ids.user_id - && *permission == GroupPermission::EventsWrite - }) - .returning(move |_, _, _, _| Ok(allowed)); -} - /// Expects the survey read by the update form and the Individual view. fn expect_get_event_survey( db: &mut MockDB, - ids: &TestIds, + ids: &DashboardIds, + event_id: Uuid, event_survey_id: Uuid, survey: EventSurveyFull, ) { let ids = *ids; db.expect_get_event_survey() .times(1) - .withf(move |group_id, event_id, survey_id| { - *group_id == ids.group_id && *event_id == ids.event_id && *survey_id == event_survey_id + .withf(move |group_id, survey_event_id, survey_id| { + *group_id == ids.group_id + && *survey_event_id == event_id + && *survey_id == event_survey_id }) .returning(move |_, _, _| Ok(Some(survey.clone()))); } /// Expects the surveys read by the list page. -fn expect_list_event_surveys(db: &mut MockDB, ids: &TestIds, surveys: Vec) { +fn expect_list_event_surveys( + db: &mut MockDB, + ids: &DashboardIds, + event_id: Uuid, + surveys: Vec, +) { let ids = *ids; db.expect_list_event_surveys() .times(1) - .withf(move |group_id, event_id| *group_id == ids.group_id && *event_id == ids.event_id) + .withf(move |group_id, survey_event_id| { + *group_id == ids.group_id && *survey_event_id == event_id + }) .returning(move |_, _| Ok(surveys.clone())); } /// Expects the export read by the CSV download, within the export bounds. fn expect_survey_export( db: &mut MockDB, - ids: &TestIds, + ids: &DashboardIds, + event_id: Uuid, event_survey_id: Uuid, export: EventSurveyExport, ) { let ids = *ids; db.expect_get_event_survey_export() .times(1) - .withf(move |group_id, event_id, survey_id, max_rows, max_bytes| { - *group_id == ids.group_id - && *event_id == ids.event_id - && *survey_id == event_survey_id - && *max_rows == MAX_EVENT_SURVEY_EXPORT_ROWS - && *max_bytes == MAX_EVENT_SURVEY_EXPORT_ANSWER_BYTES - }) + .withf( + move |group_id, survey_event_id, survey_id, max_rows, max_bytes| { + *group_id == ids.group_id + && *survey_event_id == event_id + && *survey_id == event_survey_id + && *max_rows == MAX_EVENT_SURVEY_EXPORT_ROWS + && *max_bytes == MAX_EVENT_SURVEY_EXPORT_ANSWER_BYTES + }, + ) .returning(move |_, _, _, _, _| Ok(Some(export.clone()))); } -/// Collapses whitespace runs so rendered attributes can be matched exactly. -fn normalize_whitespace(html: &str) -> String { - html.split_whitespace().collect::>().join(" ") -} - /// Returns answers to the sample survey questions, with a formula-like text. fn sample_answers() -> QuestionnaireAnswers { serde_json::from_value(json!({ @@ -1428,52 +1361,3 @@ fn sample_answers() -> QuestionnaireAnswers { })) .unwrap() } - -/// Sends a request to the dashboard router and returns its parts and body. -async fn send( - db: MockDB, - session_id: session::Id, - method: &str, - uri: &str, - form: Option<&str>, -) -> (Parts, Vec) { - let router: Router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let mut request = Request::builder() - .method(method) - .uri(uri) - .header(COOKIE, format!("id={session_id}")); - if form.is_some() { - request = request.header(CONTENT_TYPE, "application/x-www-form-urlencoded"); - } - let request = request - .body(form.map_or_else(Body::empty, |form| Body::from(form.to_string()))) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - (parts, bytes.to_vec()) -} - -/// Returns a database mock with an authenticated group session holding the -/// permission the route requires. -fn setup_db(ids: &TestIds, permission: GroupPermission) -> MockDB { - let mut db = MockDB::new(); - expect_authenticated_group_session( - &mut db, - ids.session_id, - ids.user_id, - ids.community_id, - ids.group_id, - ); - expect_group_permission( - &mut db, - ids.community_id, - ids.group_id, - ids.user_id, - permission, - ); - db -} diff --git a/ocg-server/src/handlers/dashboard/group/team/tests.rs b/ocg-server/src/handlers/dashboard/group/team/tests.rs index d4ced8197..50c176011 100644 --- a/ocg-server/src/handlers/dashboard/group/team/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/team/tests.rs @@ -1,13 +1,8 @@ use anyhow::anyhow; use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, + body::Body, + http::{Request, StatusCode}, }; -use axum_login::tower_sessions::session; -use tower::ServiceExt; use uuid::Uuid; use crate::{ @@ -15,88 +10,49 @@ use crate::{ handlers::{auth::LOG_IN_URL, tests::*}, services::notifications::MockNotificationsManager, types::{ - dashboard::DASHBOARD_PAGINATION_LIMIT, group::GroupRole, notifications::NotificationKind, + dashboard::{ + DASHBOARD_PAGINATION_LIMIT, + group::team::{GroupTeamMember, GroupTeamOutput}, + }, + group::GroupRole, + notifications::NotificationKind, permissions::GroupPermission, }, }; -use super::{GROUP_TEAM_MANAGEMENT_RESTRICTED_TOOLTIP, NewTeamMember}; +use super::GROUP_TEAM_MANAGEMENT_RESTRICTED_TOOLTIP; #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let member = sample_team_member(true); - let members = vec![member.clone(), sample_team_member(false)]; - let role = sample_group_role_summary(); - let output = crate::types::dashboard::group::team::GroupTeamOutput { - members: members.clone(), - total: members.len(), - total_accepted: 1, - total_admins_accepted: 1, - }; + let output = sample_team_output(vec![member.clone(), sample_team_member(false)], 1); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_list_page_context(&mut db, &ids, true, true); db.expect_list_group_team_members() .times(1) - .withf(move |id, filters| { - *id == group_id + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); - db.expect_list_group_roles() - .times(1) - .returning(move || Ok(vec![role.clone()])); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::BadgesWrite - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/team") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/team", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=team&limit=50&offset=0", + ); assert!(body.contains("At least one accepted admin is required.")); assert_eq!(body.matches("Award badge").count(), 1); assert!(body.contains(&format!("data-user-ids=\"{}\"", member.user_id))); @@ -105,159 +61,66 @@ async fn test_list_page_success() { #[tokio::test] async fn test_list_page_with_pagination_params() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let member = sample_team_member(true); - let members = vec![member.clone(), sample_team_member(true)]; - let role = sample_group_role_summary(); - let output = crate::types::dashboard::group::team::GroupTeamOutput { - members: members.clone(), - total: members.len(), - total_accepted: 2, - total_admins_accepted: 2, - }; + let ids = DashboardIds::new(); + let output = sample_team_output(vec![sample_team_member(true), sample_team_member(true)], 2); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_list_page_context(&mut db, &ids, true, true); db.expect_list_group_team_members() .times(1) - .withf(move |id, filters| { - *id == group_id && filters.limit == Some(5) && filters.offset == Some(10) + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.limit == Some(5) && filters.offset == Some(10) }) .returning(move |_, _| Ok(output.clone())); - db.expect_list_group_roles() - .times(1) - .returning(move || Ok(vec![role.clone()])); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::BadgesWrite - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/team?limit=5&offset=10") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/team?limit=5&offset=10", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations assert_html_response(&parts, &bytes, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert_eq!( + parts.headers.get("hx-push-url").unwrap(), + "/dashboard/group?tab=team&limit=5&offset=10", + ); assert!(!body.contains("At least one accepted admin is required.")); } #[tokio::test] async fn test_list_page_shows_restricted_policy_tooltip_when_team_write_is_blocked() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let members = vec![sample_team_member(true), sample_team_member(true)]; - let role = sample_group_role_summary(); - let output = crate::types::dashboard::group::team::GroupTeamOutput { - members: members.clone(), - total: members.len(), - total_accepted: 2, - total_admins_accepted: 2, - }; - let mut community = sample_community_full(community_id); + let ids = DashboardIds::new(); + let output = sample_team_output(vec![sample_team_member(true), sample_team_member(true)], 2); + let mut community = sample_community_full(ids.community_id); community.group_team_management_restricted = true; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_list_page_context(&mut db, &ids, false, false); db.expect_list_group_team_members() .times(1) - .withf(move |id, filters| { - *id == group_id + .withf(move |group_id, filters| { + *group_id == ids.group_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) }) .returning(move |_, _| Ok(output.clone())); - db.expect_list_group_roles() - .times(1) - .returning(move || Ok(vec![role.clone()])); db.expect_get_community_full() .times(1) - .withf(move |cid| *cid == community_id) + .withf(move |community_id| *community_id == ids.community_id) .returning(move |_| Ok(community.clone())); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::BadgesWrite - }) - .returning(move |_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(false)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/group/team") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/group/team", ids.session_id); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); let expected_tooltip = askama::filters::escape( GROUP_TEAM_MANAGEMENT_RESTRICTED_TOOLTIP, askama::filters::Html, @@ -271,43 +134,28 @@ async fn test_list_page_shows_restricted_policy_tooltip_when_team_write_is_block #[tokio::test] async fn test_add_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let new_member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let form = NewTeamMember { - role: GroupRole::Admin, - user_id: new_member_id, - }; - let body = format!("role={}&user_id={}", form.role, form.user_id); - let group_summary = sample_group_summary(group_id); + let group_summary = sample_group_summary(ids.group_id); let site_settings = sample_site_settings(); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); db.expect_add_group_team_member() .times(1) - .withf(move |actor_user_id, id, uid, role| { - *actor_user_id == user_id - && *id == group_id - && *uid == new_member_id + .withf(move |actor_user_id, group_id, user_id, role| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id + && *user_id == new_member_id && role == &GroupRole::Admin }) - .returning(move |_, _, _, _| Ok(())); + .returning(|_, _, _, _| Ok(())); db.expect_get_group_summary() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id + }) .returning(move |_, _| Ok(group_summary.clone())); db.expect_get_site_settings() .times(1) @@ -324,17 +172,11 @@ async fn test_add_success() { .returning(|_| Box::pin(async { Ok(()) })); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/team/add") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let (parts, bytes) = send(router, add_request(&ids, new_member_id)).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -348,38 +190,21 @@ async fn test_add_success() { #[tokio::test] async fn test_add_db_error() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let new_member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let form = NewTeamMember { - role: GroupRole::Admin, - user_id: new_member_id, - }; - let body = format!("role={}&user_id={}", form.role, form.user_id); // Setup database mock with a failing write and no notification context reads let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); db.expect_add_group_team_member() .times(1) - .withf(move |actor_user_id, id, uid, role| { - *actor_user_id == user_id - && *id == group_id - && *uid == new_member_id + .withf(move |actor_user_id, group_id, user_id, role| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id + && *user_id == new_member_id && role == &GroupRole::Admin }) - .returning(move |_, _, _, _| Err(anyhow!("db error"))); + .returning(|_, _, _, _| Err(anyhow!("db error"))); db.expect_get_group_summary().never(); db.expect_get_site_settings().never(); @@ -388,117 +213,33 @@ async fn test_add_db_error() { nm.expect_enqueue().never(); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/team/add") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db) + .with_notifications_manager(nm) + .build() + .await; + let (parts, bytes) = send(router, add_request(&ids, new_member_id)).await; // Check response matches expectations assert_empty_response(&parts, &bytes, StatusCode::INTERNAL_SERVER_ERROR); } -#[tokio::test] -async fn test_add_forbidden_when_group_team_management_is_restricted() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let new_member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let body = format!("role={}&user_id={}", GroupRole::Admin, new_member_id); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_add_group_team_member().times(0); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("POST") - .uri("/dashboard/group/team/add") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); -} - #[tokio::test] async fn test_delete_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_delete_group_team_member() - .times(1) - .withf(move |actor_user_id, id, uid| { - *actor_user_id == user_id && *id == group_id && *uid == member_id - }) - .returning(move |_, _, _| Ok(())); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); + expect_delete_group_team_member(&mut db, &ids, member_id); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/team/{member_id}/delete")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "DELETE", + &format!("/dashboard/group/team/{member_id}/delete"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -511,55 +252,16 @@ async fn test_delete_success() { #[tokio::test] async fn test_delete_current_user_with_inherited_read_stays_logged_in() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_delete_group_team_member() - .times(1) - .withf(move |actor_user_id, id, uid| { - *actor_user_id == user_id && *id == group_id && *uid == user_id - }) - .returning(move |_, _, _| Ok(())); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(move |_, _, _, _| Ok(true)); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); + expect_delete_group_team_member(&mut db, &ids, ids.user_id); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, true); // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/team/{user_id}/delete")) - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, delete_current_user_request(&ids)).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -572,165 +274,51 @@ async fn test_delete_current_user_with_inherited_read_stays_logged_in() { #[tokio::test] async fn test_delete_current_user_logs_out() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_delete_group_team_member() - .times(1) - .withf(move |actor_user_id, id, uid| { - *actor_user_id == user_id && *id == group_id && *uid == user_id - }) - .returning(move |_, _, _| Ok(())); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(move |_, _, _, _| Ok(false)); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); + expect_delete_group_team_member(&mut db, &ids, ids.user_id); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, false); db.expect_delete_session() .times(1) - .withf(move |id| *id == session_id) + .withf(move |session_id| *session_id == ids.session_id) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/team/{user_id}/delete")) - .header("HX-Request", "true") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let (parts, bytes) = send(router, delete_current_user_request(&ids)).await; // Check response matches expectations assert_empty_hx_redirect_response(&parts, &bytes, StatusCode::OK, LOG_IN_URL); } -#[tokio::test] -async fn test_delete_forbidden_when_group_team_management_is_restricted() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read - }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_delete_group_team_member().times(0); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("DELETE") - .uri(format!("/dashboard/group/team/{member_id}/delete")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); -} - #[tokio::test] async fn test_update_role_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let form = super::NewTeamRole { - role: GroupRole::Admin, - }; - let body = format!("role={}", form.role); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(true)); + expect_group_access(&mut db, &ids, GroupPermission::TeamWrite); db.expect_update_group_team_member_role() .times(1) - .withf(move |actor_user_id, id, uid, role| { - *actor_user_id == user_id - && *id == group_id - && *uid == member_id + .withf(move |actor_user_id, group_id, user_id, role| { + *actor_user_id == ids.user_id + && *group_id == ids.group_id + && *user_id == member_id && role == &GroupRole::Admin }) .returning(move |_, _, _, _| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/team/{member_id}/role")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = form_request( + "PUT", + &format!("/dashboard/group/team/{member_id}/role"), + ids.session_id, + format!("role={}", GroupRole::Admin), + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response( @@ -741,55 +329,67 @@ async fn test_update_role_success() { ); } -#[tokio::test] -async fn test_update_role_forbidden_when_group_team_management_is_restricted() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let member_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let body = format!("role={}", GroupRole::Admin); +// Helpers. - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::TeamWrite - }) - .returning(move |_, _, _, _| Ok(false)); - db.expect_user_has_group_permission() +/// Build the request that adds the user to the team as an admin. +fn add_request(ids: &DashboardIds, new_member_id: Uuid) -> Request { + form_request( + "POST", + "/dashboard/group/team/add", + ids.session_id, + format!("role={}&user_id={new_member_id}", GroupRole::Admin), + ) +} + +/// Build the HTMX request in which the current user leaves the team. +fn delete_current_user_request(ids: &DashboardIds) -> Request { + authenticated_request( + "DELETE", + &format!("/dashboard/group/team/{}/delete", ids.user_id), + ids.session_id, + ) + .header("HX-Request", "true") + .body(Body::empty()) + .unwrap() +} + +/// Expect the team member removal performed by the current user. +fn expect_delete_group_team_member(db: &mut MockDB, ids: &DashboardIds, member_id: Uuid) { + let ids = *ids; + db.expect_delete_group_team_member() .times(1) - .withf(move |cid, gid, uid, permission| { - *cid == community_id - && *gid == group_id - && *uid == user_id - && permission == GroupPermission::Read + .withf(move |actor_user_id, group_id, user_id| { + *actor_user_id == ids.user_id && *group_id == ids.group_id && *user_id == member_id }) - .returning(move |_, _, _, _| Ok(true)); - db.expect_update_group_team_member_role().times(0); - - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); + .returning(|_, _, _| Ok(())); +} - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/team/{member_id}/role")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); +/// Expect the fixed reads of the team list page. +/// +/// The badges and team write results decide which controls the page renders. +fn expect_list_page_context( + db: &mut MockDB, + ids: &DashboardIds, + can_award_badges: bool, + can_manage_team: bool, +) { + expect_group_access(db, ids, GroupPermission::Read); + db.expect_list_group_roles() + .times(1) + .returning(|| Ok(vec![sample_group_role_summary()])); + expect_group_permission_result(db, ids, GroupPermission::BadgesWrite, can_award_badges); + expect_group_permission_result(db, ids, GroupPermission::TeamWrite, can_manage_team); +} - // Check response matches expectations - assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); +/// Sample team output with the members and accepted admins count. +fn sample_team_output( + members: Vec, + total_admins_accepted: usize, +) -> GroupTeamOutput { + GroupTeamOutput { + total: members.len(), + total_accepted: total_admins_accepted, + total_admins_accepted, + members, + } } diff --git a/ocg-server/src/handlers/dashboard/group/tests.rs b/ocg-server/src/handlers/dashboard/group/tests.rs index a7a9bcd61..02d6768fe 100644 --- a/ocg-server/src/handlers/dashboard/group/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/tests.rs @@ -1,10 +1,5 @@ -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::COOKIE}, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use serde_json::json; -use tower::ServiceExt; use uuid::Uuid; use crate::{ @@ -13,120 +8,102 @@ use crate::{ auth::session_context::{SELECTED_COMMUNITY_ID_KEY, SELECTED_GROUP_ID_KEY}, tests::*, }, - services::notifications::MockNotificationsManager, + types::permissions::GroupPermission, }; use super::payments_ready; #[test] -fn test_payments_ready_returns_false_without_config() { +fn test_payments_ready() { let payment_recipient = sample_group_payment_recipient(); - - assert!(!payments_ready(Some(&payment_recipient), None)); -} - -#[test] -fn test_payments_ready_returns_false_without_recipient() { let payments_cfg = sample_payments_cfg(); - - assert!(!payments_ready(None, Some(&payments_cfg))); -} - -#[test] -fn test_payments_ready_returns_true_for_matching_provider() { - let payment_recipient = sample_group_payment_recipient(); - let payments_cfg = sample_payments_cfg(); - - assert!(payments_ready( - Some(&payment_recipient), - Some(&payments_cfg) - )); + let cases = [ + ("without config", Some(&payment_recipient), None, false), + ("without recipient", None, Some(&payments_cfg), false), + ( + "with recipient for the configured provider", + Some(&payment_recipient), + Some(&payments_cfg), + true, + ), + ]; + + for (case, payment_recipient, payments_cfg, expected) in cases { + assert_eq!( + payments_ready(payment_recipient, payments_cfg), + expected, + "case: {case}" + ); + } } #[tokio::test] async fn test_select_community_forbidden_when_user_has_no_groups_in_community() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let other_community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(other_community_id, group_id); + let groups = sample_user_groups_by_community(other_community_id, ids.group_id); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_user_has_community_permission().times(0); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |user_id| *user_id == ids.user_id) .returning(move |_| Ok(groups.clone())); db.expect_update_session().times(0); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, _) = response.into_parts(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/group/community/{}/select", ids.community_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations - assert_eq!(parts.status, StatusCode::FORBIDDEN); + assert_empty_response(&parts, &bytes, StatusCode::FORBIDDEN); } #[tokio::test] async fn test_select_community_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let groups = sample_user_groups_by_community(community_id, group_id); + let ids = DashboardIds::new(); + let groups = sample_user_groups_by_community(ids.community_id, ids.group_id); // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_user_has_community_permission().times(0); db.expect_list_user_groups() .times(1) - .withf(move |uid| uid == &user_id) + .withf(move |user_id| *user_id == ids.user_id) .returning(move |_| Ok(groups.clone())); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_COMMUNITY_ID_KEY) - .is_some_and(|value| value == &json!(community_id)) + .is_some_and(|value| value == &json!(ids.community_id)) && record .data .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(group_id)) + .is_some_and(|value| value == &json!(ids.group_id)) }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/community/{community_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/group/community/{}/select", ids.community_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); @@ -134,50 +111,36 @@ async fn test_select_community_success() { #[tokio::test] async fn test_select_group_success() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock + // Setup identifiers and database mock + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_community_session(&mut db, session_id, user_id, community_id); + expect_community_session(&mut db, &ids); db.expect_group_belongs_to_community() .times(1) - .withf(move |cid, gid| *cid == community_id && *gid == group_id) - .returning(|_, _| Ok(true)); - db.expect_user_has_group_permission() - .times(1) - .withf(move |cid, gid, uid, _permission| { - *cid == community_id && *gid == group_id && *uid == user_id + .withf(move |community_id, group_id| { + *community_id == ids.community_id && *group_id == ids.group_id }) - .returning(|_, _, _, _| Ok(true)); + .returning(|_, _| Ok(true)); + expect_group_permission_result(&mut db, &ids, GroupPermission::Read, true); db.expect_update_session() .times(1) .withf(move |record| { - record.id == session_id + record.id == ids.session_id && record .data .get(SELECTED_GROUP_ID_KEY) - .is_some_and(|value| value == &json!(group_id)) + .is_some_and(|value| value == &json!(ids.group_id)) }) .returning(|_| Ok(())); - // Setup notifications manager mock - let nm = MockNotificationsManager::new(); - // Setup router and send request - let router = TestRouterBuilder::new(db, nm).build().await; - let request = Request::builder() - .method("PUT") - .uri(format!("/dashboard/group/{group_id}/select")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = empty_request( + "PUT", + &format!("/dashboard/group/{}/select", ids.group_id), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_empty_hx_trigger_response(&parts, &bytes, StatusCode::NO_CONTENT, "refresh-body"); diff --git a/ocg-server/src/handlers/dashboard/group/waitlist/tests.rs b/ocg-server/src/handlers/dashboard/group/waitlist/tests.rs index 0476fce5c..24ca625ff 100644 --- a/ocg-server/src/handlers/dashboard/group/waitlist/tests.rs +++ b/ocg-server/src/handlers/dashboard/group/waitlist/tests.rs @@ -1,19 +1,10 @@ -use axum::{ - body::{Body, to_bytes}, - http::{ - HeaderValue, Request, StatusCode, - header::{CONTENT_TYPE, COOKIE}, - }, -}; -use axum_login::tower_sessions::session; +use axum::http::StatusCode; use mockall::Sequence; -use tower::ServiceExt; use uuid::Uuid; use crate::{ db::mock::MockDB, handlers::tests::*, - services::notifications::MockNotificationsManager, types::{ dashboard::{ DASHBOARD_PAGINATION_LIMIT, @@ -29,43 +20,20 @@ use crate::{ #[tokio::test] async fn test_list_page_success() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let waitlist_entry = sample_waitlist_entry(); - let event = sample_event_summary(event_id, group_id); let output = WaitlistOutput { total: 1, - waitlist: vec![waitlist_entry], + waitlist: vec![sample_waitlist_entry()], }; // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); + expect_list_page_context(&mut db, &ids, event_id); db.expect_search_event_waitlist() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(DASHBOARD_PAGINATION_LIMIT) && filters.offset == Some(0) @@ -73,120 +41,75 @@ async fn test_list_page_success() { .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/group/events/{event_id}/waitlist")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/waitlist"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); + assert_html_response(&parts, &bytes, StatusCode::OK); + assert!(body.contains("Waitlisted User")); } #[tokio::test] async fn test_list_page_rejects_overlong_search_query() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( "/dashboard/group/events/{event_id}/waitlist?ts_query={}", "a".repeat(251) - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "ts_query: length is greater than 250\n" + ); } #[tokio::test] async fn test_list_page_rejects_zero_pagination_limit() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + // Setup identifiers and database mock + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - - // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); + expect_group_access(&mut db, &ids, GroupPermission::Read); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/waitlist?limit=0" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/waitlist?limit=0"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check response matches expectations assert_eq!(parts.status, StatusCode::UNPROCESSABLE_ENTITY); - assert!(!bytes.is_empty()); + assert_eq!( + String::from_utf8(bytes.to_vec()).unwrap(), + "limit: lower than 1\n" + ); } #[tokio::test] async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); let empty_page = WaitlistOutput { total: 6, waitlist: vec![], @@ -196,35 +119,15 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { waitlist: vec![sample_waitlist_entry()], }; - // Setup session, permission, and event context expectations - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - // Setup the empty requested page followed by the last page with rows + let mut db = MockDB::new(); + expect_list_page_context(&mut db, &ids, event_id); let mut sequence = Sequence::new(); db.expect_search_event_waitlist() .times(1) .in_sequence(&mut sequence) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(5) && filters.offset == Some(10) @@ -234,7 +137,7 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { .times(1) .in_sequence(&mut sequence) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(5) && filters.offset == Some(5) @@ -242,112 +145,26 @@ async fn test_list_page_steps_back_to_last_page_when_offset_is_past_end() { .return_once(move |_, _, _| Ok(last_page)); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/waitlist?limit=5&offset=10" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/group/events/{event_id}/waitlist?limit=5&offset=10"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check the last page with rows is rendered and refreshed in place - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - let body = std::str::from_utf8(&bytes).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("Waitlisted User")); assert!(body.contains("offset=5")); assert!(!body.contains("offset=10")); } -#[tokio::test] -async fn test_list_page_with_pagination_params() { - // Setup identifiers and data structures - let community_id = Uuid::new_v4(); - let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); - let output = WaitlistOutput { - total: 0, - waitlist: vec![], - }; - - // Setup database mock - let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); - db.expect_search_event_waitlist() - .times(1) - .withf(move |gid, eid, filters| { - *gid == group_id - && *eid == event_id - && filters.limit == Some(5) - && filters.offset == Some(10) - }) - .returning(move |_, _, _| Ok(output.clone())); - - // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - "/dashboard/group/events/{event_id}/waitlist?limit=5&offset=10" - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); - - // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - assert!(!bytes.is_empty()); -} - #[tokio::test] async fn test_list_page_with_search_query() { // Setup identifiers and data structures - let community_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let event_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - let event = sample_event_summary(event_id, group_id); let output = WaitlistOutput { total: 2, waitlist: vec![sample_waitlist_entry()], @@ -355,29 +172,11 @@ async fn test_list_page_with_search_query() { // Setup database mock let mut db = MockDB::new(); - expect_authenticated_group_session(&mut db, session_id, user_id, community_id, group_id); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::Read, - ); - expect_group_permission( - &mut db, - community_id, - group_id, - user_id, - GroupPermission::EventsWrite, - ); - db.expect_get_event_summary_dashboard() - .times(1) - .withf(move |cid, gid, eid| *cid == community_id && *gid == group_id && *eid == event_id) - .returning(move |_, _, _| Ok(event.clone())); + expect_list_page_context(&mut db, &ids, event_id); db.expect_search_event_waitlist() .times(1) .withf(move |gid, eid, filters| { - *gid == group_id + *gid == ids.group_id && *eid == event_id && filters.limit == Some(1) && filters.offset == Some(0) @@ -388,32 +187,19 @@ async fn test_list_page_with_search_query() { .returning(move |_, _, _| Ok(output.clone())); // Setup router and send request - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!( - concat!( - "/dashboard/group/events/{event_id}/waitlist?", - "limit=1&sort=name-asc&title=present&ts_query=wait" - ), - event_id = event_id, - )) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!( + "/dashboard/group/events/{event_id}/waitlist?\ + limit=1&sort=name-asc&title=present&ts_query=wait" + ), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; + let body = String::from_utf8(bytes.to_vec()).unwrap(); // Check response matches expectations - assert_eq!(parts.status, StatusCode::OK); - assert_eq!( - parts.headers.get(CONTENT_TYPE).unwrap(), - &HeaderValue::from_static("text/html; charset=utf-8"), - ); - let body = std::str::from_utf8(&bytes).unwrap(); + assert_html_response(&parts, &bytes, StatusCode::OK); assert!(body.contains("name=\"ts_query\"")); assert!(body.contains("value=\"wait\"")); assert!(body.contains("Waitlisted User")); @@ -421,3 +207,20 @@ async fn test_list_page_with_search_query() { assert!(body.contains("ts_query=wait")); assert!(body.contains("refresh-event-waitlist")); } + +// Helpers. + +/// Expects the session, permissions and event summary read by the list page. +fn expect_list_page_context(db: &mut MockDB, ids: &DashboardIds, event_id: Uuid) { + let ids = *ids; + let event = sample_event_summary(event_id); + + expect_group_access(db, &ids, GroupPermission::Read); + expect_group_permission_result(db, &ids, GroupPermission::EventsWrite, true); + db.expect_get_event_summary_dashboard() + .times(1) + .withf(move |cid, gid, eid| { + *cid == ids.community_id && *gid == ids.group_id && *eid == event_id + }) + .returning(move |_, _, _| Ok(event.clone())); +} diff --git a/ocg-server/src/handlers/dashboard/inbox/tests.rs b/ocg-server/src/handlers/dashboard/inbox/tests.rs index ed09be066..63eb25fd8 100644 --- a/ocg-server/src/handlers/dashboard/inbox/tests.rs +++ b/ocg-server/src/handlers/dashboard/inbox/tests.rs @@ -5,27 +5,24 @@ use uuid::Uuid; use super::requested_conversation_id; #[test] -fn test_requested_conversation_id_ignores_malformed_id() { - let query = HashMap::from([("conversation_id".to_string(), "invalid".to_string())]); - - assert_eq!(requested_conversation_id(&query), None); -} - -#[test] -fn test_requested_conversation_id_returns_none_without_parameter() { - assert_eq!(requested_conversation_id(&HashMap::new()), None); -} - -#[test] -fn test_requested_conversation_id_returns_valid_id() { +fn test_requested_conversation_id() { let inbox_conversation_id = Uuid::new_v4(); - let query = HashMap::from([( - "conversation_id".to_string(), - inbox_conversation_id.to_string(), - )]); + let cases = [ + ("missing parameter", None, None), + ("malformed id", Some("invalid".to_string()), None), + ( + "valid id", + Some(inbox_conversation_id.to_string()), + Some(inbox_conversation_id), + ), + ]; + + for (label, value, expected) in cases { + let query: HashMap = value + .map(|value| ("conversation_id".to_string(), value)) + .into_iter() + .collect(); - assert_eq!( - requested_conversation_id(&query), - Some(inbox_conversation_id) - ); + assert_eq!(requested_conversation_id(&query), expected, "case {label}"); + } } diff --git a/ocg-server/src/handlers/dashboard/user/badges/tests.rs b/ocg-server/src/handlers/dashboard/user/badges/tests.rs index ec7203707..6604287d7 100644 --- a/ocg-server/src/handlers/dashboard/user/badges/tests.rs +++ b/ocg-server/src/handlers/dashboard/user/badges/tests.rs @@ -1,29 +1,20 @@ -use std::io::Cursor; - -use axum::{ - body::{Body, to_bytes}, - http::{ - Request, StatusCode, - header::{CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE}, - }, +use axum::http::{ + StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }; -use axum_login::tower_sessions::session; use chrono::{DateTime, TimeZone, Utc}; -use image::{DynamicImage, ImageFormat}; use serde_json::json; -use ssi_jwk::JWK; -use tower::ServiceExt; use uuid::Uuid; use crate::{ - config::{BadgeSigningKeyConfig, BadgesConfig, HttpServerConfig}, + config::HttpServerConfig, db::mock::MockDB, - handlers::tests::{TestRouterBuilder, assert_empty_response, expect_authenticated_session}, + handlers::tests::*, services::{ badges::{BadgesManager, SsiBadgesManager, png}, images::MockImageStorage, - notifications::MockNotificationsManager, }, + test_util::sample_png, types::{ badges::{BadgeSnapshot, BadgeSnapshotIssuer, UserBadge, UserBadgeIdentity}, images::Image, @@ -36,15 +27,14 @@ use super::USER_PROFILE_BADGES_LIMIT; #[tokio::test] async fn test_export_missing_artwork_returns_not_found() { // Setup an authenticated owner whose active award references missing artwork - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let award = sample_active_user_badge(user_badge_id, user_id); + let award = sample_user_badge(user_badge_id, ids.user_id); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_badge() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(move |_, _| Ok(Some(award))); let mut storage = MockImageStorage::new(); storage @@ -52,88 +42,68 @@ async fn test_export_missing_artwork_returns_not_found() { .times(1) .withf(|file_name| file_name == "badge.png") .returning(|_| Box::pin(async { Ok(None) })); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_image_storage(storage) - .build() - .await; + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; // Request a PNG export whose immutable source artwork is unavailable - let response = router - .oneshot( - Request::builder() - .uri(format!("/dashboard/user/badges/{user_badge_id}/export")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let request = get_request( + &format!("/dashboard/user/badges/{user_badge_id}/export"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check missing source artwork is reported without exposing storage details - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_export_revoked_badge_returns_not_found() { // Setup an authenticated owner whose requested award is already revoked - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let award = sample_user_badge(user_badge_id, user_id); + let award = sample_revoked_user_badge(user_badge_id, ids.user_id); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_badge() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(move |_, _| Ok(Some(award))); let mut storage = MockImageStorage::new(); storage.expect_get().never(); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .with_image_storage(storage) - .build() - .await; + let router = TestRouterBuilder::new(db).with_image_storage(storage).build().await; // Request a PNG export for a revoked credential - let response = router - .oneshot( - Request::builder() - .uri(format!("/dashboard/user/badges/{user_badge_id}/export")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let request = get_request( + &format!("/dashboard/user/badges/{user_badge_id}/export"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check revoked awards are hidden before artwork loading or signing - assert_empty_response(&parts, &body, StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_export_success() { // Setup an authenticated owner, active award, signing key, and valid artwork - let config = badge_config(); + let config = sample_badges_cfg(); let base_url = "https://badges.example.test"; let verification_manager = SsiBadgesManager::new(base_url, &config); - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - let award = sample_active_user_badge(user_badge_id, user_id); + let award = sample_user_badge(user_badge_id, ids.user_id); let badge_status_list_id = award.badge_status_list_id; let identity_salt = "0123456789abcdef0123456789abcdef"; let identity_hash = compute_hash(format!("user@example.test{identity_salt}").as_bytes()); let bound_hash = identity_hash.clone(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_badge() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(move |_, _| Ok(Some(award))); db.expect_refresh_user_badge_identity() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(move |_, _| { Ok(UserBadgeIdentity { identity_bound_at: Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap(), @@ -149,12 +119,12 @@ async fn test_export_success() { .return_once(|_| { Box::pin(async { Ok(Some(Image { - bytes: png_bytes(), + bytes: sample_png(512, 512), content_type: "image/png".to_string(), })) }) }); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) + let router = TestRouterBuilder::new(db) .with_image_storage(storage) .with_server_cfg(HttpServerConfig { badges: Some(config), @@ -165,18 +135,11 @@ async fn test_export_success() { .await; // Request the composed signed PNG export - let response = router - .oneshot( - Request::builder() - .uri(format!("/dashboard/user/badges/{user_badge_id}/export")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + let request = get_request( + &format!("/dashboard/user/badges/{user_badge_id}/export"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check download headers and the embedded credential proof assert_eq!(parts.status, StatusCode::OK); @@ -185,7 +148,7 @@ async fn test_export_success() { parts.headers.get(CONTENT_DISPOSITION).unwrap(), &format!("attachment; filename=\"badge-{user_badge_id}.png\"") ); - let credential = serde_json::from_slice(&png::extract(&body).unwrap()).unwrap(); + let credential = serde_json::from_slice(&png::extract(&bytes).unwrap()).unwrap(); let verified = verification_manager.verify_credential(&credential).await.unwrap(); assert_eq!(verified.user_badge_id, user_badge_id); assert_eq!(verified.status_list_id, badge_status_list_id); @@ -213,66 +176,46 @@ async fn test_export_success() { #[tokio::test] async fn test_export_unknown_badge_returns_not_found() { // Setup an authenticated user without the requested active award - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_badge() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(|_, _| Ok(None)); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request a PNG export for the missing award - let response = router - .oneshot( - Request::builder() - .uri(format!("/dashboard/user/badges/{user_badge_id}/export")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let request = get_request( + &format!("/dashboard/user/badges/{user_badge_id}/export"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check ownership failures remain indistinguishable from missing awards - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_response(&parts, &bytes, StatusCode::NOT_FOUND); } #[tokio::test] async fn test_list_page_success() { // Setup an authenticated user with no active badges - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_list_user_badges() .times(1) - .withf(move |id| *id == user_id) + .withf(move |id| *id == ids.user_id) .return_once(|_| Ok(vec![])); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Request the badge dashboard fragment - let response = router - .oneshot( - Request::builder() - .uri("/dashboard/user/badges") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let (parts, body) = response.into_parts(); - let body = String::from_utf8(to_bytes(body, usize::MAX).await.unwrap().to_vec()).unwrap(); + let request = get_request("/dashboard/user/badges", ids.session_id); + let (parts, bytes) = send(router, request).await; // Check the empty dashboard state renders the public profile limit - assert_eq!(parts.status, StatusCode::OK); + assert_html_response(&parts, &bytes, StatusCode::OK); + let body = std::str::from_utf8(&bytes).unwrap(); assert!(body.contains(&format!( "Your profile modal displays the first {USER_PROFILE_BADGES_LIMIT} visible badges in the order shown here." ))); @@ -281,70 +224,54 @@ async fn test_list_page_success() { #[tokio::test] async fn test_revoke_success() { // Setup an authenticated badge owner - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_revoke_user_badge() .times(1) - .withf(move |owner_id, badge_id| *owner_id == user_id && *badge_id == user_badge_id) + .withf(move |owner_id, badge_id| *owner_id == ids.user_id && *badge_id == user_badge_id) .return_once(|_, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Permanently revoke the owned credential - let response = router - .oneshot( - Request::builder() - .method("DELETE") - .uri(format!("/dashboard/user/badges/{user_badge_id}")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let request = empty_request( + "DELETE", + &format!("/dashboard/user/badges/{user_badge_id}"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; // Check the mutation response is empty - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); } #[tokio::test] async fn test_update_listing_success() { // Setup an authenticated badge owner - let session_id = session::Id::default(); + let ids = DashboardIds::new(); let user_badge_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_update_user_badge_listing() .times(1) .withf(move |owner_id, badge_id, is_listed| { - *owner_id == user_id && *badge_id == user_badge_id && !is_listed + *owner_id == ids.user_id && *badge_id == user_badge_id && !is_listed }) .return_once(|_, _, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Hide the badge from public profile discovery - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri(format!("/dashboard/user/badges/{user_badge_id}/listing")) - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(json!({"is_listed": false}).to_string())) - .unwrap(), - ) - .await - .unwrap(); + let request = json_request( + "PUT", + &format!("/dashboard/user/badges/{user_badge_id}/listing"), + ids.session_id, + &json!({"is_listed": false}), + ); + let (parts, bytes) = send(router, request).await; // Check the mutation response is empty - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); } #[tokio::test] @@ -352,72 +279,43 @@ async fn test_update_order_success() { // Setup an authenticated badge owner and complete order let first_badge_id = Uuid::new_v4(); let second_badge_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_update_user_badges_order() .times(1) .withf(move |owner_id, badge_ids| { - *owner_id == user_id && badge_ids == [second_badge_id, first_badge_id] + *owner_id == ids.user_id && badge_ids == [second_badge_id, first_badge_id] }) .return_once(|_, _| Ok(())); - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; + let router = TestRouterBuilder::new(db).build().await; // Persist the complete user-selected order - let response = router - .oneshot( - Request::builder() - .method("PUT") - .uri("/dashboard/user/badges/order") - .header(COOKIE, format!("id={session_id}")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - json!({"user_badge_ids": [second_badge_id, first_badge_id]}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); + let request = json_request( + "PUT", + "/dashboard/user/badges/order", + ids.session_id, + &json!({"user_badge_ids": [second_badge_id, first_badge_id]}), + ); + let (parts, bytes) = send(router, request).await; // Check the mutation response is empty - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_empty_response(&parts, &bytes, StatusCode::NO_CONTENT); } // Helpers. -/// Build a valid active badge signing configuration. -fn badge_config() -> BadgesConfig { - BadgesConfig { - signing_key: BadgeSigningKeyConfig { - key_id: "export-test".to_string(), - private_jwk: JWK::generate_ed25519().unwrap(), - }, - verification_keys: vec![], +/// Build one revoked badge owned by the supplied user. +fn sample_revoked_user_badge(user_badge_id: Uuid, user_id: Uuid) -> UserBadge { + UserBadge { + revocation_reason: Some("user revoked badge".to_string()), + revoked_at: Some(DateTime::UNIX_EPOCH), + revoked_by_user_id: Some(user_id), + ..sample_user_badge(user_badge_id, user_id) } } -/// Encode valid badge artwork. -fn png_bytes() -> Vec { - let mut output = Cursor::new(Vec::new()); - DynamicImage::new_rgba8(512, 512) - .write_to(&mut output, ImageFormat::Png) - .unwrap(); - output.into_inner() -} - /// Build one active badge owned by the supplied user. -fn sample_active_user_badge(user_badge_id: Uuid, user_id: Uuid) -> UserBadge { - let mut badge = sample_user_badge(user_badge_id, user_id); - badge.revocation_reason = None; - badge.revoked_at = None; - badge.revoked_by_user_id = None; - badge -} - -/// Build one revoked badge owned by the supplied user. fn sample_user_badge(user_badge_id: Uuid, user_id: Uuid) -> UserBadge { let group_id = Uuid::new_v4(); UserBadge { @@ -449,9 +347,9 @@ fn sample_user_badge(user_badge_id: Uuid, user_id: Uuid) -> UserBadge { identity_salt: None, recipient_name: None, recipient_username: None, - revocation_reason: Some("user revoked badge".to_string()), - revoked_at: Some(DateTime::UNIX_EPOCH), - revoked_by_user_id: Some(user_id), + revocation_reason: None, + revoked_at: None, + revoked_by_user_id: None, user_id: Some(user_id), } } diff --git a/ocg-server/src/handlers/dashboard/user/check_in/tests.rs b/ocg-server/src/handlers/dashboard/user/check_in/tests.rs index 6c9be904c..9729064d0 100644 --- a/ocg-server/src/handlers/dashboard/user/check_in/tests.rs +++ b/ocg-server/src/handlers/dashboard/user/check_in/tests.rs @@ -1,110 +1,108 @@ -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header::COOKIE}, -}; -use axum_login::tower_sessions::session; -use tower::ServiceExt; +use axum::http::StatusCode; use uuid::Uuid; -use crate::{ - db::mock::MockDB, - handlers::tests::{TestRouterBuilder, assert_html_response, expect_authenticated_session}, - services::notifications::MockNotificationsManager, -}; +use crate::{db::mock::MockDB, handlers::tests::*}; #[tokio::test] -async fn test_list_page_returns_current_user_events() { - // Setup an authenticated user and empty credential list - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); +async fn test_list_page_falls_back_to_username_when_name_is_blank() { + // Setup identifiers and a user without a display name + let ids = DashboardIds::new(); + let user = crate::auth::User { + name: " ".to_string(), + ..sample_auth_user(ids.user_id, "hash") + }; + // Setup database mock let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session_with_user(&mut db, &ids, user); db.expect_list_user_check_in_events() .times(1) - .withf(move |uid| *uid == user_id) + .withf(move |uid| *uid == ids.user_id) .returning(|_| Ok(vec![])); - // Request the user's check-in fragment - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri("/dashboard/user/check-in") - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/user/check-in", ids.session_id); + let (parts, bytes) = send(router, request).await; - // Verify the fragment renders successfully - assert_html_response(&parts, &body, StatusCode::OK); + // Check the QR modal identifies the attendee by username + assert_html_response(&parts, &bytes, StatusCode::OK); + let body = std::str::from_utf8(&bytes).unwrap(); + assert!(body.contains(r#"data-user-name="test-user""#)); + assert!(body.contains(r#"data-username="test-user""#)); +} + +#[tokio::test] +async fn test_list_page_returns_current_user_events() { + // Setup identifiers and database mock + let ids = DashboardIds::new(); + let mut db = MockDB::new(); + expect_session(&mut db, &ids); + db.expect_list_user_check_in_events() + .times(1) + .withf(move |uid| *uid == ids.user_id) + .returning(|_| Ok(vec![])); + + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request("/dashboard/user/check-in", ids.session_id); + let (parts, bytes) = send(router, request).await; + + // Check the fragment renders the attendee display identity + assert_html_response(&parts, &bytes, StatusCode::OK); + let body = std::str::from_utf8(&bytes).unwrap(); + assert!(body.contains(r#"data-user-name="Test User""#)); + assert!(body.contains(r#"data-username="test-user""#)); } #[tokio::test] async fn test_qr_code_returns_private_uncacheable_svg() { - // Setup a confirmed credential owned by the current user + // Setup identifiers and a confirmed credential owned by the current user let check_in_code = Uuid::new_v4(); let event_id = Uuid::new_v4(); - let session_id = session::Id::default(); - let user_id = Uuid::new_v4(); - + let ids = DashboardIds::new(); let mut db = MockDB::new(); - expect_authenticated_session(&mut db, session_id, user_id); + expect_session(&mut db, &ids); db.expect_get_user_check_in_code() .times(1) - .withf(move |eid, uid| *eid == event_id && *uid == user_id) + .withf(move |eid, uid| *eid == event_id && *uid == ids.user_id) .returning(move |_, _| Ok(Some(check_in_code))); - // Request the credential image - let router = TestRouterBuilder::new(db, MockNotificationsManager::new()) - .build() - .await; - let request = Request::builder() - .method("GET") - .uri(format!("/dashboard/user/check-in/{event_id}/qr-code")) - .header(COOKIE, format!("id={session_id}")) - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body = to_bytes(body, usize::MAX).await.unwrap(); + // Setup router and send request + let router = TestRouterBuilder::new(db).build().await; + let request = get_request( + &format!("/dashboard/user/check-in/{event_id}/qr-code"), + ids.session_id, + ); + let (parts, bytes) = send(router, request).await; - // Verify the private SVG response contract + // Check the private SVG response contract assert_eq!(parts.status, StatusCode::OK); assert_eq!(parts.headers["cache-control"], "private, no-store"); assert_eq!(parts.headers["content-type"], "image/svg+xml"); - assert!(String::from_utf8_lossy(&body).contains("