Skip to content
This repository was archived by the owner on Sep 30, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ panic = 'abort'
codegen-units = 1

[dependencies]
axum = { version = "0.8.4", features = ["tracing", "macros"] }
axum = { version = "0.8.4", features = ["tracing"] }
config = "0.15.15"
dotenvy = "0.15.7"
reqwest = { version = "0.12.23", default-features = false, features = [
Expand Down
8 changes: 7 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pub type Result<T = ()> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
SurrealDb(#[from] Box<surrealdb::Error>),
SurrealDb(Box<surrealdb::Error>),
#[error("{0:?}")]
Migrations(String),
#[error(transparent)]
Expand All @@ -25,6 +25,12 @@ pub enum Error {
Custom(String),
}

impl From<surrealdb::Error> for Error {
fn from(value: surrealdb::Error) -> Self {
Self::SurrealDb(Box::new(value))
}
}

impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
match self {
Expand Down
5 changes: 2 additions & 3 deletions src/handlers/health_check.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
use crate::Result;
use crate::model::ModelManager;
use crate::{AppState, Result};
use axum::extract::State;
use reqwest::StatusCode;
use std::sync::Arc;

#[axum::debug_handler(state = AppState)]
#[tracing::instrument(skip(mm))]
pub async fn health(State(mm): State<Arc<ModelManager>>) -> Result<StatusCode> {
mm.db().await?.health().await.map_err(Box::new)?;
mm.db().await?.health().await?;

Ok(StatusCode::OK)
}
2 changes: 2 additions & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod health_check;
mod newsletter;
mod subscription;

pub use health_check::*;
pub use newsletter::*;
pub use subscription::*;
40 changes: 40 additions & 0 deletions src/handlers/newsletter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use std::sync::Arc;

use crate::{Result, email_client::EmailClient, model::ModelManager};
use axum::{Json, extract::State, response::IntoResponse};
use reqwest::StatusCode;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct BodyData {
title: String,
content: Content,
}

#[derive(Debug, Deserialize)]
struct Content {
html: String,
text: String,
}

#[tracing::instrument(skip(mm, email_client))]
pub async fn publish_newsletter(
State(mm): State<Arc<ModelManager>>,
State(email_client): State<Arc<EmailClient>>,
Json(body): Json<BodyData>,
) -> Result<impl IntoResponse> {
let subscribers = mm.get_confirmed_subscribers().await?;

for subscriber in subscribers {
email_client
.send_email(
&subscriber.email.try_into()?,
&body.title,
&body.content.html,
&body.content.text,
)
.await?;
}

Ok(StatusCode::OK)
}
6 changes: 1 addition & 5 deletions src/handlers/subscription.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
use crate::{
AppState, Config, Result, domain::Subscriber, email_client::EmailClient, model::ModelManager,
};
use crate::{Config, Result, domain::Subscriber, email_client::EmailClient, model::ModelManager};
use axum::extract::Query;
use axum::{Form, extract::State};
use rand::Rng;
Expand All @@ -16,7 +14,6 @@ pub struct FormData {
pub name: String,
}

#[axum::debug_handler(state = AppState)]
#[tracing::instrument(skip(mm, config, email_client))]
pub async fn subscribe(
State(mm): State<Arc<ModelManager>>,
Expand All @@ -40,7 +37,6 @@ pub struct Params {
token: String,
}

#[axum::debug_handler(state = AppState)]
#[tracing::instrument(skip(mm))]
pub async fn confirm(
State(mm): State<Arc<ModelManager>>,
Expand Down
37 changes: 26 additions & 11 deletions src/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::{Error, Result, config::DatabaseConfig, domain};
use include_dir::include_dir;
use secrecy::ExposeSecret;
use serde::Deserialize;
use surrealdb::{Surreal, engine::any::Any, opt::auth::Database};
use surrealdb_migrations::MigrationRunner;
use tokio::sync::OnceCell;
Expand All @@ -11,6 +12,11 @@ pub struct ModelManager {
db: OnceCell<Surreal<Any>>,
}

#[derive(Debug, Deserialize)]
pub struct ConfirmedSubscriber {
pub email: String,
}

impl ModelManager {
pub fn new(config: DatabaseConfig) -> Self {
Self {
Expand Down Expand Up @@ -45,8 +51,8 @@ impl ModelManager {
.bind(("token_val", token.to_string()))
.bind(("email", subscriber.email.as_ref().to_string()))
.bind(("name", subscriber.name.as_ref().to_string()))
.await.map_err(Box::new)?
.check().map_err(Box::new)?;
.await?
.check()?;

Ok(())
}
Expand All @@ -67,20 +73,31 @@ impl ModelManager {
"#,
)
.bind(("token_val", token))
.await
.map_err(Box::new)?;
.await?;

Ok(())
}

pub async fn get_confirmed_subscribers(&self) -> Result<Vec<ConfirmedSubscriber>> {
Ok(self
.db()
.await?
.query(
r#"
SELECT * FROM subscriptions
WHERE status = 'CONFIRMED';
"#,
)
.await?
.take(0)?)
}

async fn connect(&self) -> Result<Surreal<Any>> {
let config = &self.config;
let db = Surreal::<Any>::init();

tracing::info!("Connecting to database: {}", config.base_url.as_str());
db.connect(config.base_url.as_str())
.await
.map_err(Box::new)?;
db.connect(config.base_url.as_str()).await?;

if config.base_url.scheme() == "mem" {
db.query(format!("DEFINE NAMESPACE {}", config.namespace))
Expand All @@ -91,8 +108,7 @@ impl ModelManager {
config.username,
config.password.expose_secret()
))
.await
.map_err(Box::new)?;
.await?;
}

db.signin(Database {
Expand All @@ -101,8 +117,7 @@ impl ModelManager {
namespace: &config.namespace,
database: &config.name,
})
.await
.map_err(Box::new)?;
.await?;

// Apply Migrations
MigrationRunner::new(&db)
Expand Down
3 changes: 2 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
Result,
config::Config,
handlers::{confirm, health, subscribe},
handlers::{confirm, health, publish_newsletter, subscribe},
state::AppState,
};
use axum::{
Expand Down Expand Up @@ -36,6 +36,7 @@ pub async fn init(config: Config) -> Result<(Router, AppState)> {
.route("/health", get(health))
.route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm))
.route("/newsletter", post(publish_newsletter))
.layer(middleware)
.with_state(state.clone());

Expand Down
1 change: 1 addition & 0 deletions tests/api/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod health_check;
mod helpers;
mod newsletter;
mod subscriptions;
mod subscriptions_confirm;
132 changes: 132 additions & 0 deletions tests/api/newsletter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
use reqwest::{Method, StatusCode};
use serde_json::json;
use wiremock::{
Mock, ResponseTemplate,
matchers::{any, method},
};

use crate::helpers::{ConfirmationLinks, TestApp};

async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")];

let _mock_guard = Mock::given(any())
.respond_with(ResponseTemplate::new(StatusCode::OK))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&app.email_server)
.await;

app.server.post("/subscriptions").form(&body).await;

let email_request = app
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
app.get_conformation_links(&email_request)
}

async fn create_confirmed_subscriber(app: &TestApp) {
let confirmation_links = create_unconfirmed_subscriber(app).await;

app.server
.get(&format!(
"{}?{}",
confirmation_links.html.path(),
confirmation_links.html.query().unwrap()
))
.await
.assert_status_success();
}

#[tokio::test]
async fn newsletter_are_not_delivered_to_unconfirmed_subscribers() {
// Arrange
let app = TestApp::new()
.await
.expect("Expected the app to be inisilized!");
create_unconfirmed_subscriber(&app).await;

// Act
let newsletter = serde_json::json!({
"title": "Newsletter title",
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as html</p>",
},
});
let response = app.server.post("/newsletter").json(&newsletter).await;

// Assert
assert_eq!(response.status_code(), StatusCode::OK);
}

#[tokio::test]
async fn newsletter_are_delivered_to_confirmed_subscribers() {
// Arrange
let app = TestApp::new()
.await
.expect("Expected the app to be inisilized!");
create_confirmed_subscriber(&app).await;

Mock::given(any())
.and(method(Method::POST))
.respond_with(ResponseTemplate::new(StatusCode::OK))
.expect(1)
.mount(&app.email_server)
.await;

// Act
let newsletter = serde_json::json!({
"title": "Newsletter title",
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as html</p>",
},
});
let response = app.server.post("/newsletter").json(&newsletter).await;

// Assert
assert_eq!(response.status_code(), StatusCode::OK);
}

#[tokio::test]
async fn newsletter_return_400_for_invalid_data() {
// Arrange
let app = TestApp::new()
.await
.expect("Expected the app to be inisilized!");
let test_cases = [
(
json!({
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as html</p>",
},
}),
"messing title",
),
(
json!({
"title": "Newsletter title",
}),
"messing content",
),
];

for (invalid_body, error_message) in test_cases {
// Act
let response = app.server.post("/newsletter").json(&invalid_body).await;

// Assert
assert_eq!(
StatusCode::UNPROCESSABLE_ENTITY,
response.status_code(),
"The API did not fail with 400 Bad Request when the payload was {}.",
error_message
);
}
}
Loading