diff --git a/Cargo.lock b/Cargo.lock index 96dfc0f..0af33eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,6 +529,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -603,6 +626,26 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -656,6 +699,27 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + [[package]] name = "either" version = "1.15.0" @@ -811,6 +875,16 @@ dependencies = [ "syn", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -911,6 +985,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -921,6 +1004,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1044,6 +1136,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.3.1" @@ -1447,6 +1550,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "markdown" version = "1.0.0" @@ -1456,6 +1565,28 @@ dependencies = [ "unicode-id", ] +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1529,6 +1660,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -1763,6 +1900,58 @@ dependencies = [ "sha2", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1826,6 +2015,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1908,7 +2103,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror", "tokio", "tracing", @@ -1945,9 +2140,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2275,6 +2470,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -2285,6 +2495,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "selectors" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.26" @@ -2387,6 +2616,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2434,6 +2672,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -2692,6 +2936,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -2746,6 +3015,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "2.0.16" @@ -3208,6 +3488,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + [[package]] name = "untrusted" version = "0.9.0" @@ -3231,6 +3517,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3418,6 +3710,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3778,6 +4082,7 @@ dependencies = [ "quickcheck_macros", "rand 0.9.2", "reqwest", + "scraper", "secrecy", "serde", "serde-aux", diff --git a/Cargo.toml b/Cargo.toml index d696700..72999ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ linkify = "0.10.0" once_cell = "1.21.3" quickcheck = "1.0.3" quickcheck_macros = "1.1.0" +scraper = "0.24.0" serde_json = "1.0.143" serde_urlencoded = "0.7.1" wiremock = "0.6.4" diff --git a/templates/dashboard/stats.html b/templates/dashboard/stats.html index 46efa9c..06f9e87 100644 --- a/templates/dashboard/stats.html +++ b/templates/dashboard/stats.html @@ -17,7 +17,7 @@

Subscribers

-

{{ stats.subscribers }}

+

{{ stats.subscribers }}

@@ -33,7 +33,7 @@

Posts

-

{{ stats.posts }}

+

{{ stats.posts }}

@@ -49,7 +49,7 @@

Notifications

-

{{ stats.notifications_sent }}

+

{{ stats.notifications_sent }}

@@ -66,7 +66,7 @@

Open rate

-

{{ stats.formatted_rate() }}

+

{{ stats.formatted_rate() }}

diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs index 474aa6b..72bf9da 100644 --- a/tests/api/admin_dashboard.rs +++ b/tests/api/admin_dashboard.rs @@ -1,5 +1,7 @@ -use crate::helpers::{TestApp, assert_is_redirect_to}; +use crate::helpers::{TestApp, assert_is_redirect_to, fake_post_body, when_sending_an_email}; +use scraper::{Html, Selector}; use sqlx::PgPool; +use wiremock::ResponseTemplate; #[sqlx::test] async fn you_must_be_logged_in_to_access_the_admin_dashboard(connection_pool: PgPool) { @@ -54,3 +56,68 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) { assert!(response.contains("No data available")); assert!(!response.contains(&subscriber.email)); } + +#[sqlx::test] +async fn dashboard_shows_correct_stats(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + + app.create_confirmed_subscriber().await; + app.create_confirmed_subscriber().await; + app.create_unconfirmed_subscriber().await; + app.post_create_post(&fake_post_body()).await; + app.create_confirmed_subscriber().await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + app.dispatch_all_pending_emails().await; + + let html = app.get_admin_dashboard_html().await; + let document = Html::parse_document(&html); + + assert_element_is(&document, "p#subscribers-count", "3"); + assert_element_is(&document, "p#posts-count", "1"); + assert_element_is(&document, "p#notifications-sent", "2"); + assert_element_is(&document, "p#open-rate", "0.0%"); + + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + let links = app.get_post_urls(&email_request); + reqwest::get(links.html).await.unwrap(); + + let html = app.get_admin_dashboard_html().await; + let document = Html::parse_document(&html); + assert_element_is(&document, "p#open-rate", "50.0%"); + + app.post_create_post(&fake_post_body()).await; + app.dispatch_all_pending_emails().await; + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + let links = app.get_post_urls(&email_request); + reqwest::get(links.html).await.unwrap(); + + let html = app.get_admin_dashboard_html().await; + let document = Html::parse_document(&html); + assert_element_is(&document, "p#posts-count", "2"); + assert_element_is(&document, "p#notifications-sent", "5"); + assert_element_is(&document, "p#open-rate", "40.0%"); +} + +fn assert_element_is(document: &Html, selectors: &str, value: &str) { + let selector = Selector::parse(selectors).unwrap(); + let mut element = document.select(&selector); + assert_eq!(element.next().unwrap().text().collect::(), value); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 11c3d23..d9f9cc8 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -2,7 +2,13 @@ use argon2::{ Algorithm, Argon2, Params, PasswordHasher, Version, password_hash::{SaltString, rand_core::OsRng}, }; -use fake::{Fake, faker::internet::en::SafeEmail}; +use fake::{ + Fake, + faker::{ + internet::en::SafeEmail, + lorem::en::{Paragraph, Sentence}, + }, +}; use linkify::{Link, LinkFinder}; use once_cell::sync::Lazy; use sqlx::PgPool; @@ -183,6 +189,22 @@ impl TestApp { } } + pub fn get_post_urls(&self, request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + let get_link = |s: &str| { + let links = get_links(s); + assert!(!links.is_empty()); + let mut confirmation_link = reqwest::Url::parse(links[0].as_str()).unwrap(); + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + confirmation_link.set_port(Some(self.port)).unwrap(); + confirmation_link + }; + + let html = get_link(body["html"].as_str().unwrap()); + let text = get_link(body["text"].as_str().unwrap()); + ConfirmationLinks { html, text } + } + pub fn get_unsubscribe_links(&self, request: &wiremock::Request) -> ConfirmationLinks { let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); let get_link = |s: &str| { @@ -395,3 +417,11 @@ pub fn get_links(s: &'_ str) -> Vec> { .filter(|l| *l.kind() == linkify::LinkKind::Url) .collect() } + +pub fn subject() -> String { + Sentence(1..2).fake() +} + +pub fn content() -> String { + Paragraph(1..10).fake() +} diff --git a/tests/api/posts.rs b/tests/api/posts.rs index e6b81ed..50b9865 100644 --- a/tests/api/posts.rs +++ b/tests/api/posts.rs @@ -1,20 +1,10 @@ -use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email}; -use fake::{ - Fake, - faker::lorem::en::{Paragraph, Sentence}, +use crate::helpers::{ + TestApp, assert_is_redirect_to, content, fake_post_body, subject, when_sending_an_email, }; use sqlx::PgPool; use uuid::Uuid; use wiremock::ResponseTemplate; -fn subject() -> String { - Sentence(1..2).fake() -} - -fn content() -> String { - Paragraph(1..10).fake() -} - #[sqlx::test] async fn you_must_be_logged_in_to_create_a_new_post(connection_pool: PgPool) { let app = TestApp::spawn(connection_pool).await; @@ -83,6 +73,51 @@ async fn confirmed_subscribers_are_notified_when_a_new_post_is_published(connect app.dispatch_all_pending_emails().await; } +#[sqlx::test] +async fn notification_contains_the_blog_post_url(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.create_confirmed_subscriber().await; + app.admin_login().await; + + let title = subject(); + let content = content(); + let body = serde_json::json!({ + "title": title, + "content": content, + "idempotency_key": Uuid::new_v4(), + + }); + app.post_create_post(&body).await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.dispatch_all_pending_emails().await; + + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + + let post_id = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .post_id; + + let links = app.get_post_urls(&email_request); + let text = String::from_utf8(email_request.body).unwrap(); + + assert!(text.contains(&title)); + assert!(links.html.as_str().contains(&post_id.to_string())); +} + #[sqlx::test] async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) { let app = TestApp::spawn(connection_pool).await; @@ -165,3 +200,37 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) { let html = app.get_post_html(post.post_id).await; assert!(html.contains("Not Found")); } + +#[sqlx::test] +async fn clicking_the_notification_link_marks_the_email_as_opened(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + app.create_confirmed_subscriber().await; + app.post_create_post(&fake_post_body()).await; + + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.dispatch_all_pending_emails().await; + + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + let links = app.get_post_urls(&email_request); + reqwest::get(links.html.as_str()).await.unwrap(); + + assert!( + sqlx::query!("SELECT opened FROM notifications_delivered") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .opened + ); +} diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index f83bf2e..dbca51d 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -34,7 +34,7 @@ async fn subscribe_persists_the_new_subscriber(connection_pool: PgPool) { let response = app.post_subscriptions(body).await; assert!(response.status().is_success()); - let html_fragment = dbg!(response.text().await.unwrap()); + let html_fragment = response.text().await.unwrap(); assert!(html_fragment.contains("You'll receive a confirmation email shortly")); let saved = sqlx::query!("SELECT email, status FROM subscriptions") diff --git a/tests/api/unsubscribe.rs b/tests/api/unsubscribe.rs index 23227b3..102d6c8 100644 --- a/tests/api/unsubscribe.rs +++ b/tests/api/unsubscribe.rs @@ -161,7 +161,6 @@ async fn subscription_works_after_unsubscribe(connection_pool: PgPool) { let requests = app.email_server.received_requests().await.unwrap(); let confirmation_request = requests.last().unwrap(); let confirmation_links = app.get_confirmation_links(confirmation_request); - dbg!(&confirmation_links.html.as_str()); let response = reqwest::get(confirmation_links.html).await.unwrap(); assert_eq!(response.status().as_u16(), 200);