Compare commits

...

7 Commits

Author SHA1 Message Date
Alphonse Paix
91e8b5f001 Templates adjustments
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-19 20:07:38 +02:00
Alphonse Paix
a75c410948 404 page 2025-09-19 19:34:06 +02:00
Alphonse Paix
95c4d3fdd0 Posts dedicated page with cards linking to specific post 2025-09-19 01:04:10 +02:00
Alphonse Paix
71d4872878 Refactor test suite to handle 303 See Other 2025-09-18 21:00:01 +02:00
Alphonse Paix
3120c700a4 query metadata 2025-09-18 20:09:03 +02:00
Alphonse Paix
08d5f611b5 Brought back newsletter form on admin page 2025-09-18 18:40:03 +02:00
Alphonse Paix
54218f92a9 Admin can now write posts
Posts can be displayed on the website. Subscribers are automatically
notified by email. This gives the opportunity to track explicitly how
many people followed the link provided in the emails sent without being
intrusive (no invisible image).
2025-09-18 17:22:33 +02:00
31 changed files with 800 additions and 244 deletions

View File

@@ -54,5 +54,5 @@
true true
] ]
}, },
"hash": "ed9f14ed1476ef5a9dc8b7aabf38fd31e127e2a6246d5a14f4ef624f0302eac8" "hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
} }

View File

@@ -37,5 +37,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "0851bf5e8d147f0ace037c6f434bcc4e04d330e3c4259ef8c8097e61f77b64e2" "hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
} }

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO posts (post_id, author_id, title, content, published_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO subscriptions (id, email, subscribed_at, status)\n VALUES ($1, $2, $3, 'pending_confirmation')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at, status)\n VALUES ($1, $2, $3, $4, 'pending_confirmation')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "e6822c9e162eabc20338cc27d51a8e80578803ec1589c234d93c3919d14a96a6"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "f007c2d5d9ae67a2412c6a70a2228390c5bd4835fcf71fd17a00fe521b43415d"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
CREATE TABLE posts (
post_id UUID PRIMARY KEY,
author_id UUID NOT NULL REFERENCES users (user_id),
title TEXT NOT NULL,
content TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL
);

View File

@@ -75,8 +75,6 @@ struct EmailField<'a> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::time::Duration;
use crate::{ use crate::{
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient, configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
}; };
@@ -88,6 +86,7 @@ mod tests {
lorem::en::{Paragraph, Sentence}, lorem::en::{Paragraph, Sentence},
}, },
}; };
use std::time::Duration;
use wiremock::{ use wiremock::{
Mock, MockServer, ResponseTemplate, Mock, MockServer, ResponseTemplate,
matchers::{any, header, header_exists, method, path}, matchers::{any, header, header_exists, method, path},

View File

@@ -2,12 +2,29 @@ mod admin;
mod health_check; mod health_check;
mod home; mod home;
mod login; mod login;
mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
pub use admin::*; pub use admin::*;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
pub use posts::*;
use reqwest::StatusCode;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
#[derive(Template)]
#[template(path = "../templates/404.html")]
struct NotFoundTemplate;
pub async fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response()
}

View File

@@ -2,6 +2,7 @@ mod change_password;
mod dashboard; mod dashboard;
mod logout; mod logout;
mod newsletters; mod newsletters;
mod posts;
use crate::{routes::error_chain_fmt, templates::ErrorTemplate}; use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
use askama::Template; use askama::Template;
@@ -14,6 +15,7 @@ pub use change_password::*;
pub use dashboard::*; pub use dashboard::*;
pub use logout::*; pub use logout::*;
pub use newsletters::*; pub use newsletters::*;
pub use posts::*;
use reqwest::StatusCode; use reqwest::StatusCode;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]
@@ -56,7 +58,8 @@ impl IntoResponse for AdminError {
AdminError::NotAuthenticated => { AdminError::NotAuthenticated => {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap()); headers.insert("HX-Redirect", "/login".parse().unwrap());
(StatusCode::OK, headers).into_response() headers.insert("Location", "/login".parse().unwrap());
(StatusCode::SEE_OTHER, headers).into_response()
} }
AdminError::ChangePassword(e) => { AdminError::ChangePassword(e) => {
let template = ErrorTemplate { let template = ErrorTemplate {

View File

@@ -10,16 +10,19 @@ use uuid::Uuid;
#[template(path = "../templates/dashboard.html")] #[template(path = "../templates/dashboard.html")]
struct DashboardTemplate { struct DashboardTemplate {
username: String, username: String,
idempotency_key: String, idempotency_key_1: String,
idempotency_key_2: String,
} }
pub async fn admin_dashboard( pub async fn admin_dashboard(
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
) -> Response { ) -> Response {
let idempotency_key = Uuid::new_v4().to_string(); let idempotency_key_1 = Uuid::new_v4().to_string();
let idempotency_key_2 = Uuid::new_v4().to_string();
let template = DashboardTemplate { let template = DashboardTemplate {
username, username,
idempotency_key, idempotency_key_1,
idempotency_key_2,
}; };
Html(template.render().unwrap()).into_response() Html(template.render().unwrap()).into_response()
} }

View File

@@ -1,9 +1,8 @@
use crate::{routes::AdminError, session_state::TypedSession}; use crate::{routes::AdminError, session_state::TypedSession};
use axum::{ use axum::{
http::HeaderMap, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use reqwest::StatusCode;
#[tracing::instrument(name = "Logging out", skip(session))] #[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> { pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {

View File

@@ -48,7 +48,7 @@ pub async fn insert_newsletter_issue(
} }
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
async fn enqueue_delivery_tasks( pub async fn enqueue_delivery_tasks(
transaction: &mut Transaction<'static, Postgres>, transaction: &mut Transaction<'static, Postgres>,
newsletter_issue_id: Uuid, newsletter_issue_id: Uuid,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
@@ -76,9 +76,7 @@ pub async fn publish_newsletter(
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>, Form(form): Form<BodyData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AdminError> {
if let Err(e) = validate_form(&form) { validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
return Err(AdminError::Publish(anyhow::anyhow!(e)));
}
let idempotency_key: IdempotencyKey = form let idempotency_key: IdempotencyKey = form
.idempotency_key .idempotency_key
@@ -94,11 +92,11 @@ pub async fn publish_newsletter(
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html) let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
.await .await
.context("Failed to store newsletter issue details")?; .context("Failed to store newsletter issue details.")?;
enqueue_delivery_tasks(&mut transaction, issue_id) enqueue_delivery_tasks(&mut transaction, issue_id)
.await .await
.context("Failed to enqueue delivery tasks")?; .context("Failed to enqueue delivery tasks.")?;
let success_message = format!( let success_message = format!(
r#"The newsletter issue "{}" has been published!"#, r#"The newsletter issue "{}" has been published!"#,

120
src/routes/admin/posts.rs Normal file
View File

@@ -0,0 +1,120 @@
use crate::{
authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing},
routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue},
startup::AppState,
templates::SuccessTemplate,
};
use anyhow::Context;
use askama::Template;
use axum::{
Extension, Form,
extract::State,
response::{Html, IntoResponse, Response},
};
use chrono::Utc;
use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct CreatePostForm {
title: String,
content: String,
idempotency_key: String,
}
fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> {
if form.title.is_empty() || form.content.is_empty() {
anyhow::bail!("Fields cannot be empty.")
} else {
Ok(())
}
}
#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))]
pub async fn create_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<CreatePostForm>,
) -> Result<Response, AdminError> {
validate_form(&form).map_err(AdminError::Publish)?;
let idempotency_key: IdempotencyKey = form
.idempotency_key
.try_into()
.map_err(AdminError::Idempotency)?;
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
crate::idempotency::NextAction::StartProcessing(t) => t,
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
return Ok(response);
}
};
let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id)
.await
.context("Failed to insert new post in the database.")?;
let newsletter_uuid = create_newsletter(&mut transaction, &form.title, &form.content, &post_id)
.await
.context("Failed to create newsletter.")?;
enqueue_delivery_tasks(&mut transaction, newsletter_uuid)
.await
.context("Failed to enqueue delivery tasks.")?;
// Send emails with unique identifiers that contains link to blog post with special param
// Get handpoint that returns the post and mark the email as opened
let template = SuccessTemplate {
success_message: "Your new post has been saved. Subscribers will be notified.".into(),
};
let response = Html(template.render().unwrap()).into_response();
save_response(transaction, &idempotency_key, user_id, response)
.await
.map_err(AdminError::UnexpectedError)
}
#[tracing::instrument(
name = "Saving new post in the database",
skip(transaction, title, content, author)
)]
pub async fn insert_post(
transaction: &mut Transaction<'static, Postgres>,
title: &str,
content: &str,
author: &Uuid,
) -> Result<Uuid, AdminError> {
let post_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO posts (post_id, author_id, title, content, published_at)
VALUES ($1, $2, $3, $4, $5)
"#,
post_id,
author,
title,
content,
Utc::now()
);
transaction
.execute(query)
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?;
Ok(post_id)
}
#[tracing::instrument(
name = "Creating newsletter for new post",
skip(transaction, title, content, _post_id)
)]
pub async fn create_newsletter(
transaction: &mut Transaction<'static, Postgres>,
title: &str,
content: &str,
_post_id: &Uuid,
) -> Result<Uuid, sqlx::Error> {
insert_newsletter_issue(transaction, title, content, content).await
}

View File

@@ -9,12 +9,10 @@ use askama::Template;
use axum::{ use axum::{
Form, Json, Form, Json,
extract::State, extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum::{ use axum::{http::StatusCode, response::Redirect};
http::{HeaderMap, StatusCode},
response::Redirect,
};
use secrecy::SecretString; use secrecy::SecretString;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]

103
src/routes/posts.rs Normal file
View File

@@ -0,0 +1,103 @@
use crate::startup::AppState;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use sqlx::PgPool;
use uuid::Uuid;
struct PostEntry {
post_id: Uuid,
author: Option<String>,
title: String,
content: String,
published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
}
#[derive(Template)]
#[template(path = "../templates/posts.html")]
struct PostsTemplate {
posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "../templates/post.html")]
struct PostTemplate {
post: PostEntry,
}
pub async fn list_posts(
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Response {
match get_latest_posts(&connection_pool, 5).await {
Err(e) => {
tracing::error!("Could not fetch latest posts: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(posts) => {
let template = PostsTemplate { posts };
Html(template.render().unwrap()).into_response()
}
}
}
async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC
LIMIT $1
"#,
n
)
.fetch_all(connection_pool)
.await
}
pub async fn see_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(post_id): Path<Uuid>,
) -> Response {
match get_post(&connection_pool, post_id).await {
Err(e) => {
tracing::error!("Could not fetch post #{}: {}", post_id, e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(post) => {
let template = PostTemplate { post };
Html(template.render().unwrap()).into_response()
}
}
}
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
WHERE p.post_id = $1
"#,
post_id
)
.fetch_one(connection_pool)
.await
}

View File

@@ -119,6 +119,7 @@ pub fn app(
.route("/dashboard", get(admin_dashboard)) .route("/dashboard", get(admin_dashboard))
.route("/password", post(change_password)) .route("/password", post(change_password))
.route("/newsletters", post(publish_newsletter)) .route("/newsletters", post(publish_newsletter))
.route("/posts", post(create_post))
.route("/logout", post(logout)) .route("/logout", post(logout))
.layer(middleware::from_fn(require_auth)); .layer(middleware::from_fn(require_auth));
Router::new() Router::new()
@@ -128,6 +129,8 @@ pub fn app(
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm)) .route("/subscriptions/confirm", get(confirm))
.route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes) .nest("/admin", admin_routes)
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
@@ -147,5 +150,6 @@ pub fn app(
}), }),
) )
.layer(SessionManagerLayer::new(redis_store).with_secure(false)) .layer(SessionManagerLayer::new(redis_store).with_secure(false))
.fallback(not_found)
.with_state(app_state) .with_state(app_state)
} }

37
templates/404.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}404{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<h1 class="text-4xl font-semibold text-gray-700 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Not Found</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Sorry, we couldn't find the page you're looking for. The page may have been moved, deleted, or the URL might be incorrect.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<a href="/posts"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Read posts
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -6,7 +6,8 @@
<meta name="keywords" content="newsletter, rust, axum, htmx" /> <meta name="keywords" content="newsletter, rust, axum, htmx" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title> <title>
{% block title %}zero2prod{% endblock %} {% block title %}{% endblock %}
- zero2prod
</title> </title>
<link href="/assets/css/main.css" rel="stylesheet" /> <link href="/assets/css/main.css" rel="stylesheet" />
<script src="/assets/js/htmx.min.js"></script> <script src="/assets/js/htmx.min.js"></script>
@@ -15,13 +16,16 @@
<header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40"> <header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex-shrink-0"> <nav class="flex items-center space-x-2">
<a href="/" class="hover:opacity-80 transition-opacity"> <a href="/"
<h1 class="text-xl font-bold text-gray-900"> class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
<span class="text-blue-600">zero2prod</span> Home
</h1>
</a> </a>
</div> <a href="/posts"
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Posts
</a>
</nav>
<nav> <nav>
<a href="/admin/dashboard" <a href="/admin/dashboard"
hx-boost="true" hx-boost="true"
@@ -33,10 +37,10 @@
</div> </div>
</header> </header>
<div class="flex flex-1"> <div class="flex flex-1">
<main class="flex-1 lg:ml-0"> <main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
<div class="py-8 px-4 sm:px-6 lg:px-8"> <!-- <div class="flex-1 flex-col py-8 px-4 sm:px-6 lg:px-8"> -->
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> <!-- </div> -->
</main> </main>
</div> </div>
<footer class="bg-white border-t border-gray-200 mt-auto"> <footer class="bg-white border-t border-gray-200 mt-auto">
@@ -47,7 +51,7 @@
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod" <a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank" target="_blank"
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center"> class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
Code repository Gitea
<svg class="ml-1 h-3 w-3" <svg class="ml-1 h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}zero2prod{% endblock %} {% block title %}zero2prod{% endblock %}
{% block content %} {% block content %}
<div class="min-h-[60vh] flex items-center justify-center"> <div class="flex-1 flex items-center justify-center">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div class="text-center"> <div class="text-center">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6"> <div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
@@ -13,10 +13,7 @@
</svg> </svg>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900 mb-4">Subscription confirmed</h1> <h1 class="text-3xl font-bold text-gray-900 mb-4">Subscription confirmed</h1>
<p class="text-lg text-gray-600 mb-8"> <p class="text-lg text-gray-600 mb-8">Your email has been confirmed! You're all set to receive the latest updates.</p>
Your email has been confirmed! You're all set to receive our newsletter
updates.
</p>
</div> </div>
<div class="text-center"> <div class="text-center">
<a href="/" <a href="/"

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - zero2prod{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<div class="max-w-6xl mx-auto"> <div class="max-w-6xl mx-auto">
<div class="mb-8"> <div class="mb-8">
@@ -21,9 +21,6 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" <svg class="w-6 h-6 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -93,6 +90,56 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Write a new post
</h2>
<p class="text-sm text-gray-600 mt-1">Publish a new post online.</p>
</div>
<div class="p-6">
<form hx-post="/admin/posts"
hx-target="#post-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
<div>
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="post-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
<div>
<label for="post-content"
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="post-content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
</div>
<button type="submit"
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Publish
</button>
<div id="post-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200"> <div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center"> <h2 class="text-xl font-semibold text-gray-900 flex items-center">
@@ -102,39 +149,45 @@
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg> </svg>
Send an issue Send an email
</h2> </h2>
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p> <p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
</div> </div>
<div class="p-6"> <div class="p-6">
<form hx-post="/admin/newsletters" <form hx-post="/admin/newsletters"
hx-target="#newsletter-messages" hx-target="#newsletter-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-4"> class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" /> <input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
<div> <div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label> <label for="newsletter-title"
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
<input type="text" <input type="text"
id="title" id="newsletter-title"
name="title" name="title"
placeholder="Subject"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div> </div>
<div> <div>
<label for="html" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label> <label for="newsletter-html"
<textarea id="html" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="newsletter-html"
name="html" name="html"
rows="6" rows="6"
placeholder="HTML version"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div> </div>
<div> <div>
<label for="text" class="block text-sm font-medium text-gray-700 mb-2">Plain text content</label> <label for="newsletter-text"
<textarea id="text" class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
<textarea id="newsletter-text"
name="text" name="text"
rows="6" rows="6"
placeholder="Plain text version"
required required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea> class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div> </div>
<button type="submit" <button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center"> class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
@@ -159,7 +212,7 @@
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg> </svg>
Password Change your password
</h2> </h2>
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p> <p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
</div> </div>

View File

@@ -1,13 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Home - zero2prod{% endblock %} {% block title %}Home{% endblock %}
{% block content %} {% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8"> <div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8">
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-4xl font-bold mb-4">zero2prod</h1> <h1 class="text-4xl font-bold mb-4">zero2prod</h1>
<p class="text-xl text-blue-100 mb-6"> <p class="text-xl text-blue-100 mb-6">
Welcome to our newsletter! Stay updated on our latest projects and Welcome to my blog! Stay updated on my latest projects and
thoughts. Unsubscribe at any time. thoughts. Subscribe (and unsubscribe) at any time.
</p> </p>
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
<a href="#newsletter-signup" <a href="#newsletter-signup"
@@ -22,22 +23,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="grid md:grid-cols-3 gap-6 mb-8"> <div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Idempotent</h3>
<p class="text-gray-600 text-sm">
Smart duplicate prevention ensures you'll never receive the same email
twice.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200"> <div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4"> <div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600" <svg class="w-6 h-6 text-green-600"
@@ -73,7 +59,7 @@
class="bg-white rounded-lg shadow-md p-8 border border-gray-200"> class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center"> <div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2> <h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to our newsletter to get the latest updates.</p> <p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
<form hx-post="/subscriptions" <form hx-post="/subscriptions"
hx-target="#subscribe-messages" hx-target="#subscribe-messages"
hx-swap="innerHTML" hx-swap="innerHTML"
@@ -93,28 +79,6 @@
</form> </form>
</div> </div>
</div> </div>
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">Stats</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-blue-600" id="subscriber-count">2</div>
<div class="text-sm text-gray-600">subscribers</div>
</div>
<div>
<div>
<div class="text-2xl font-bold text-orange-600">23</div>
<div class="text-sm text-gray-600">emails sent</div>
</div>
</div>
<div>
<div class="text-2xl font-bold text-green-600">0</div>
<div class="text-sm text-gray-600">email opened</div>
</div>
<div>
<div class="text-2xl font-bold text-purple-600">3</div>
<div class="text-sm text-gray-600">issues delivered</div>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@layer utilities {
.font-inter {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
}
@layer base {
body {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
}

48
templates/post.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<article class="bg-white rounded-lg shadow-md border border-gray-200">
<header class="px-8 pt-8 pb-6 border-b border-gray-100">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
<svg class="w-4 h-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
</div>
</div>
</header>
<div class="px-8 py-8">
<div class="prose prose-lg prose-blue max-w-none">{{ post.content | safe }}</div>
</div>
</article>
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
<a href="/#newsletter-signup"
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
Subscribe
</a>
</div>
</div>
{% endblock %}

75
templates/posts.html Normal file
View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Posts{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Posts</h1>
<p class="mt-2 text-gray-600">Insights on Rust backend development, performance tips, and production stories.</p>
</div>
{% if posts.is_empty() %}
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200 text-center">
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
<p class="text-gray-600">Check back later for new content!</p>
</div>
{% else %}
<div class="space-y-6">
{% for post in posts %}
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
<a href="/posts/{{ post.post_id }}"
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
<div class="flex items-center text-sm text-gray-500">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
<span class="mx-2"></span>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
</div>
</div>
<div class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
</article>
{% endfor %}
</div>
<div class="mt-8 text-center">
<button class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Load more
</button>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -2,12 +2,13 @@ use argon2::{
Algorithm, Argon2, Params, PasswordHasher, Version, Algorithm, Argon2, Params, PasswordHasher, Version,
password_hash::{SaltString, rand_core::OsRng}, password_hash::{SaltString, rand_core::OsRng},
}; };
use fake::{Fake, faker::internet::en::SafeEmail};
use linkify::LinkFinder; use linkify::LinkFinder;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool}; use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid; use uuid::Uuid;
use wiremock::{ use wiremock::{
Mock, MockBuilder, MockServer, Mock, MockBuilder, MockServer, ResponseTemplate,
matchers::{method, path}, matchers::{method, path},
}; };
use zero2prod::{ use zero2prod::{
@@ -120,6 +121,42 @@ impl TestApp {
app app
} }
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
let email: String = SafeEmail().fake();
let body = format!("email={email}");
let _mock_guard = when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&self.email_server)
.await;
self.post_subscriptions(body)
.await
.error_for_status()
.unwrap();
let email_request = &self
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
self.get_confirmation_links(email_request)
}
pub async fn create_confirmed_subscriber(&self) {
let confirmation_links = self.create_unconfirmed_subscriber().await;
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}
pub async fn dispatch_all_pending_emails(&self) { pub async fn dispatch_all_pending_emails(&self) {
loop { loop {
if let ExecutionOutcome::EmptyQueue = if let ExecutionOutcome::EmptyQueue =
@@ -195,7 +232,7 @@ impl TestApp {
.form(body) .form(body)
.send() .send()
.await .await
.expect("failed to execute request") .expect("Failed to execute request")
} }
pub async fn admin_login(&self) { pub async fn admin_login(&self) {
@@ -211,7 +248,7 @@ impl TestApp {
.post(format!("{}/admin/logout", self.address)) .post(format!("{}/admin/logout", self.address))
.send() .send()
.await .await
.expect("failed to execute request") .expect("Failed to execute request")
} }
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
@@ -223,7 +260,19 @@ impl TestApp {
.form(body) .form(body)
.send() .send()
.await .await
.expect("failed to execute request") .expect("Failed to execute request")
}
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/admin/posts", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
} }
} }
@@ -248,9 +297,11 @@ async fn configure_database(config: &DatabaseSettings) -> PgPool {
} }
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
dbg!(&response); assert!(
assert_eq!(response.status().as_u16(), 200); response.status().as_u16() == 303
assert_eq!(response.headers().get("hx-redirect").unwrap(), location); || response.status().as_u16() == 200
&& response.headers().get("hx-redirect").unwrap() == location
);
} }
pub fn when_sending_an_email() -> MockBuilder { pub fn when_sending_an_email() -> MockBuilder {

View File

@@ -4,5 +4,6 @@ mod health_check;
mod helpers; mod helpers;
mod login; mod login;
mod newsletters; mod newsletters;
mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;

View File

@@ -1,5 +1,4 @@
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to, when_sending_an_email}; use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
use fake::{Fake, faker::internet::en::SafeEmail};
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
use wiremock::ResponseTemplate; use wiremock::ResponseTemplate;
@@ -7,7 +6,7 @@ use wiremock::ResponseTemplate;
#[tokio::test] #[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
create_unconfirmed_subscriber(&app).await; app.create_unconfirmed_subscriber().await;
app.admin_login().await; app.admin_login().await;
when_sending_an_email() when_sending_an_email()
@@ -48,7 +47,7 @@ async fn requests_without_authentication_are_redirected() {
#[tokio::test] #[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() { async fn newsletters_are_delivered_to_confirmed_subscribers() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await; app.create_confirmed_subscriber().await;
app.admin_login().await; app.admin_login().await;
when_sending_an_email() when_sending_an_email()
@@ -123,7 +122,7 @@ async fn form_shows_error_for_invalid_data() {
#[tokio::test] #[tokio::test]
async fn newsletter_creation_is_idempotent() { async fn newsletter_creation_is_idempotent() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await; app.create_confirmed_subscriber().await;
app.admin_login().await; app.admin_login().await;
when_sending_an_email() when_sending_an_email()
@@ -164,7 +163,7 @@ async fn newsletter_creation_is_idempotent() {
#[tokio::test] #[tokio::test]
async fn concurrent_form_submission_is_handled_gracefully() { async fn concurrent_form_submission_is_handled_gracefully() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await; app.create_confirmed_subscriber().await;
app.admin_login().await; app.admin_login().await;
when_sending_an_email() when_sending_an_email()
@@ -191,39 +190,3 @@ async fn concurrent_form_submission_is_handled_gracefully() {
app.dispatch_all_pending_emails().await; app.dispatch_all_pending_emails().await;
} }
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
let email: String = SafeEmail().fake();
let body = format!("email={email}");
let _mock_guard = when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&app.email_server)
.await;
app.post_subscriptions(body)
.await
.error_for_status()
.unwrap();
let email_request = &app
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
app.get_confirmation_links(email_request)
}
async fn create_confirmed_subscriber(app: &TestApp) {
let confirmation_links = create_unconfirmed_subscriber(app).await;
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}

83
tests/api/posts.rs Normal file
View File

@@ -0,0 +1,83 @@
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
use fake::{
Fake,
faker::lorem::en::{Paragraph, Sentence},
};
use uuid::Uuid;
use wiremock::ResponseTemplate;
fn subject() -> String {
Sentence(1..2).fake()
}
fn content() -> String {
Paragraph(1..10).fake()
}
#[tokio::test]
async fn you_must_be_logged_in_to_create_a_new_post() {
let app = TestApp::spawn().await;
let title = subject();
let content = content();
let body = serde_json::json!({
"title": title,
"content": content,
});
let response = app.post_create_post(&body).await;
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn new_posts_are_stored_in_the_database() {
let app = TestApp::spawn().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(),
});
let response = app.post_create_post(&body).await;
assert!(response.status().is_success());
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("Your new post has been saved"));
let saved = sqlx::query!("SELECT title, content FROM posts")
.fetch_one(&app.connection_pool)
.await
.expect("Failed to fetch saved post");
assert_eq!(saved.title, title);
assert_eq!(saved.content, content);
}
#[tokio::test]
async fn confirmed_subscribers_are_notified_when_a_new_post_is_published() {
let app = TestApp::spawn().await;
app.create_unconfirmed_subscriber().await;
app.create_confirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.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;
app.dispatch_all_pending_emails().await;
}