WIP - ActixWeb multi-tenant blog and newsletter API server. Originally forked from LukeMathWalker/zero-to-production.
1use fake::Fake;
2use fake::faker::internet::en::SafeEmail;
3use fake::faker::name::en::Name;
4use newsletter_api::challenge::Base64Challenger;
5use newsletter_api::clients::cloudinary_client::CloudinaryClient;
6use newsletter_api::configuration::{DatabaseSettings, get_configuration};
7use newsletter_api::email_client::{EmailClient, EmailServer};
8use newsletter_api::issue_delivery_worker::{ExecutionOutcome, try_execute_task};
9use newsletter_api::models::{NewUser, NewUserData, UserProfile};
10use newsletter_api::startup::{Application, get_connection_pool};
11use newsletter_api::telemetry::{get_subscriber, init_subscriber};
12use secrecy::SecretString;
13use sqlx::{Connection, Executor, PgConnection, PgPool};
14use std::sync::LazyLock;
15use uuid::Uuid;
16use wiremock::matchers::{method, path};
17use wiremock::{Mock, MockServer, ResponseTemplate};
18
19// Ensure that the `tracing` stack is only initialised once using `once_cell`
20static TRACING: LazyLock<()> = LazyLock::new(|| {
21 let default_filter_level = "info".to_string();
22 let subscriber_name = "test".to_string();
23 if std::env::var("TEST_LOG").is_ok() {
24 let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
25 init_subscriber(subscriber);
26 } else {
27 let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
28 init_subscriber(subscriber);
29 };
30});
31
32pub struct TestApp {
33 pub address: String,
34 pub port: u16,
35 pub db_pool: PgPool,
36 pub email_server: MockServer,
37 pub test_user: TestUser,
38 pub api_client: reqwest::Client,
39 pub cloudinary_client: CloudinaryClient,
40 pub cloudinary_server: MockServer,
41 pub email_client: EmailClient,
42 pub captcha_secret: SecretString,
43}
44
45/// Confirmation links embedded in the request to the email API.
46pub struct ConfirmationLinks {
47 pub html: reqwest::Url,
48 pub plain_text: reqwest::Url,
49}
50
51impl TestApp {
52 pub async fn dispatch_all_pending_emails(&self) {
53 loop {
54 if let ExecutionOutcome::EmptyQueue =
55 try_execute_task(&self.db_pool, &self.email_client)
56 .await
57 .unwrap()
58 {
59 break;
60 }
61 }
62 }
63
64 pub async fn post_subscriptions<Body>(&self, body: &Body) -> reqwest::Response
65 where
66 Body: serde::Serialize,
67 {
68 self.api_client
69 .post(&format!("{}/subscriptions", &self.address))
70 .json(body)
71 .send()
72 .await
73 .expect("Failed to execute request.")
74 }
75
76 pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
77 where
78 Body: serde::Serialize,
79 {
80 self.api_client
81 .post(&format!("{}/login", &self.address))
82 .json(body)
83 .send()
84 .await
85 .expect("Failed to execute request.")
86 }
87
88 pub async fn post_logout(&self) -> reqwest::Response {
89 self.api_client
90 .post(&format!("{}/admin/logout", &self.address))
91 .send()
92 .await
93 .expect("Failed to execute request.")
94 }
95
96 pub async fn put_change_password<Body>(&self, body: &Body) -> reqwest::Response
97 where
98 Body: serde::Serialize,
99 {
100 self.api_client
101 .put(&format!("{}/admin/password", &self.address))
102 .json(body)
103 .send()
104 .await
105 .expect("Failed to execute request.")
106 }
107
108 pub async fn get_admin_newsletter_issues(&self) -> reqwest::Response {
109 self.api_client
110 .get(&format!("{}/admin/newsletters", &self.address))
111 .send()
112 .await
113 .expect("Failed to execute request.")
114 }
115
116 pub async fn get_admin_unpublished_newsletter_issues(&self) -> reqwest::Response {
117 self.api_client
118 .get(&format!("{}/admin/newsletters/drafts", &self.address))
119 .send()
120 .await
121 .expect("Failed to execute request.")
122 }
123
124 pub async fn get_admin_newsletter_issue(
125 &self,
126 newsletter_issue_id: &Uuid,
127 ) -> reqwest::Response {
128 self.api_client
129 .get(&format!(
130 "{}/admin/newsletters/{}",
131 &self.address, newsletter_issue_id
132 ))
133 .send()
134 .await
135 .expect("Failed to execute request.")
136 }
137
138 pub async fn post_admin_create_newsletter<Body>(&self, body: &Body) -> reqwest::Response
139 where
140 Body: serde::Serialize,
141 {
142 self.api_client
143 .post(&format!("{}/admin/newsletters", &self.address))
144 .json(body)
145 .send()
146 .await
147 .expect("Failed to execute request.")
148 }
149
150 pub async fn put_admin_publish_newsletter<Body>(
151 &self,
152 newsletter_issue_id: &Uuid,
153 body: &Body,
154 ) -> reqwest::Response
155 where
156 Body: serde::Serialize,
157 {
158 self.api_client
159 .put(&format!(
160 "{}/admin/newsletters/{}/publish",
161 &self.address, newsletter_issue_id
162 ))
163 .json(body)
164 .send()
165 .await
166 .expect("Failed to execute request.")
167 }
168
169 pub async fn put_admin_update_newsletter<Body>(
170 &self,
171 newsletter_issue_id: &Uuid,
172 body: &Body,
173 ) -> reqwest::Response
174 where
175 Body: serde::Serialize,
176 {
177 self.api_client
178 .put(&format!(
179 "{}/admin/newsletters/{}",
180 &self.address, newsletter_issue_id
181 ))
182 .json(body)
183 .send()
184 .await
185 .expect("Failed to execute request.")
186 }
187
188 pub async fn put_admin_update_newsletter_issue_cover_image<Body>(
189 &self,
190 newsletter_issue_id: &Uuid,
191 body: &Body,
192 ) -> reqwest::Response
193 where
194 Body: serde::Serialize,
195 {
196 self.api_client
197 .put(&format!(
198 "{}/admin/newsletter/{}/cover_image",
199 &self.address, newsletter_issue_id
200 ))
201 .json(body)
202 .send()
203 .await
204 .expect("Failed to execute request.")
205 }
206
207 pub async fn get_admin_user(&self) -> reqwest::Response {
208 self.api_client
209 .get(&format!("{}/admin/user", &self.address))
210 .send()
211 .await
212 .expect("Failed to execute request.")
213 }
214
215 pub async fn put_admin_update_user<Body>(&self, body: &Body) -> reqwest::Response
216 where
217 Body: serde::Serialize,
218 {
219 self.api_client
220 .put(&format!("{}/admin/user", &self.address))
221 .json(body)
222 .send()
223 .await
224 .expect("Failed to execute request.")
225 }
226
227 pub async fn put_admin_update_user_profile_avatar<Body>(&self, body: &Body) -> reqwest::Response
228 where
229 Body: serde::Serialize,
230 {
231 self.api_client
232 .put(&format!("{}/admin/user/avatar", &self.address))
233 .json(body)
234 .send()
235 .await
236 .expect("Failed to execute request.")
237 }
238
239 pub async fn put_admin_update_user_profile_banner<Body>(&self, body: &Body) -> reqwest::Response
240 where
241 Body: serde::Serialize,
242 {
243 self.api_client
244 .put(&format!("{}/admin/user/banner", &self.address))
245 .json(body)
246 .send()
247 .await
248 .expect("Failed to execute request.")
249 }
250
251 pub async fn get_public_newsletters(&self) -> reqwest::Response {
252 self.api_client
253 .get(&format!("{}/newsletters", &self.address))
254 .send()
255 .await
256 .expect("Failed to execute request.")
257 }
258
259 pub async fn get_public_newsletter(
260 &self,
261 username: &String,
262 slug: &String,
263 ) -> reqwest::Response {
264 self.api_client
265 .get(&format!(
266 "{}/newsletters/by_user/{}/issue/{}",
267 &self.address, username, slug
268 ))
269 .send()
270 .await
271 .expect("Failed to execute request.")
272 }
273
274 pub async fn get_public_newsletters_by_user(&self, username: &String) -> reqwest::Response {
275 self.api_client
276 .get(&format!(
277 "{}/newsletters/by_user/{}",
278 &self.address, username
279 ))
280 .send()
281 .await
282 .expect("Failed to execute request.")
283 }
284
285 pub async fn get_users(&self) -> reqwest::Response {
286 self.api_client
287 .get(&format!("{}/users", &self.address))
288 .send()
289 .await
290 .expect("Failed to execute request.")
291 }
292
293 pub async fn get_user(&self, username: &String) -> reqwest::Response {
294 self.api_client
295 .get(&format!("{}/users/{}", &self.address, username))
296 .send()
297 .await
298 .expect("Failed to execute request.")
299 }
300
301 pub async fn get_authenticate(&self) -> reqwest::Response {
302 self.api_client
303 .get(&format!("{}/admin/authenticate", &self.address))
304 .send()
305 .await
306 .expect("Failed to execute request.")
307 }
308
309 pub async fn create_unconfirmed_subscriber(
310 &self,
311 username: Option<String>,
312 email: Option<String>,
313 ) -> ConfirmationLinks {
314 let (answer, challenge) = self.get_solved_captcha_challenge();
315 // We are working with multiple subscribers now,
316 // their details must be randomised to avoid conflicts!
317 let name: String = Name().fake();
318 let email: String = email.unwrap_or(SafeEmail().fake());
319 let username: String = username.unwrap_or(self.test_user.username.clone());
320 let body = &serde_json::json!({
321 "name": name,
322 "email": email,
323 "username": username,
324 "signed_answer": challenge,
325 "answer_attempt": answer,
326 });
327
328 let _mock_guard = Mock::given(path("/api/v1/send"))
329 .and(method("POST"))
330 .respond_with(ResponseTemplate::new(200))
331 .named("Create unconfirmed subscriber")
332 .expect(1)
333 .mount_as_scoped(&self.email_server)
334 .await;
335 self.post_subscriptions(body)
336 .await
337 .error_for_status()
338 .unwrap();
339
340 let email_request = self
341 .email_server
342 .received_requests()
343 .await
344 .unwrap()
345 .pop()
346 .unwrap();
347 self.get_confirmation_links(&email_request)
348 }
349
350 pub async fn create_confirmed_subscriber(
351 &self,
352 username: Option<String>,
353 email: Option<String>,
354 ) {
355 let confirmation_link = self.create_unconfirmed_subscriber(username, email).await;
356
357 self.api_client
358 .put(confirmation_link.html)
359 .send()
360 .await
361 .expect("Failed to confirm subscriber.");
362 }
363
364 /// Extract the confirmation links embedded in the request to the email API.
365 pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
366 let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();
367
368 // Extract the link from one of the request fields.
369 let get_link = |s: &str| {
370 let links: Vec<_> = linkify::LinkFinder::new()
371 .links(s)
372 .filter(|l| *l.kind() == linkify::LinkKind::Url)
373 .collect();
374 assert_eq!(links.len(), 1);
375 let raw_link = links[0].as_str().to_owned();
376 let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap();
377 // Let's make sure we don't call random APIs on the web
378 assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
379 confirmation_link.set_port(Some(self.port)).unwrap();
380 confirmation_link
381 };
382
383 let html = match self.email_client.server {
384 EmailServer::Mailpit => get_link(body["Html"].as_str().unwrap()),
385 EmailServer::Postmark => get_link(body["HtmlBody"].as_str().unwrap()),
386 };
387 let plain_text = match self.email_client.server {
388 EmailServer::Mailpit => get_link(body["Text"].as_str().unwrap()),
389 EmailServer::Postmark => get_link(body["TextBody"].as_str().unwrap()),
390 };
391
392 ConfirmationLinks { html, plain_text }
393 }
394
395 pub fn get_solved_captcha_challenge(&self) -> (String, String) {
396 let challenge = Base64Challenger::new(self.captcha_secret.clone())
397 .expect("Creating new captcha challenge.")
398 .encrypt()
399 .expect("Encrypting message.");
400 let answer = Base64Challenger::decrypt(&challenge, self.captcha_secret.clone())
401 .expect("decrypting answer.");
402
403 (answer, challenge)
404 }
405}
406
407pub async fn spawn_app() -> TestApp {
408 LazyLock::force(&TRACING);
409
410 // Launch a mock server to stand in for Postmark's API
411 let email_server = MockServer::start().await;
412 let cloudinary_server = MockServer::start().await;
413
414 unsafe {
415 std::env::set_var("APP_ENVIRONMENT", "test");
416 }
417
418 // Randomise configuration to ensure test isolation
419 let configuration = {
420 let mut c = get_configuration().expect("Failed to read configuration.");
421 // Use a different database for each test case
422 c.database.database_name = Uuid::new_v4().to_string();
423 // Use a random OS port
424 c.application.port = 0;
425 // Use the mock server as email API
426 c.email_client.base_url = email_server.uri();
427 c.cloudinary_client.base_url = cloudinary_server.uri();
428 c
429 };
430
431 // Create and migrate the database
432 configure_database(&configuration.database).await;
433
434 // Launch the application as a background task
435 let application = Application::build(configuration.clone())
436 .await
437 .expect("Failed to build application.");
438 let application_port = application.port();
439 let _ = tokio::spawn(application.run_until_stopped());
440
441 let client = reqwest::Client::builder()
442 .redirect(reqwest::redirect::Policy::none())
443 .cookie_store(true)
444 .build()
445 .unwrap();
446
447 let db_pool = get_connection_pool(&configuration.database);
448 let test_user = TestUser::create(&db_pool)
449 .await
450 .expect("Failed to create test user.");
451 let test_app = TestApp {
452 address: format!("http://localhost:{}", application_port),
453 port: application_port,
454 cloudinary_client: configuration.cloudinary_client.client(),
455 cloudinary_server,
456 db_pool,
457 email_server,
458 test_user,
459 api_client: client,
460 email_client: configuration.email_client.client(),
461 captcha_secret: configuration.application.captcha_secret,
462 };
463
464 test_app
465}
466
467async fn configure_database(config: &DatabaseSettings) -> PgPool {
468 // Create database
469 let maintenance_settings = DatabaseSettings {
470 database_name: "postgres".to_string(),
471 username: "postgres".to_string(),
472 password: SecretString::from("password".to_string()),
473 ..config.clone()
474 };
475 let mut connection = PgConnection::connect_with(&maintenance_settings.connect_options())
476 .await
477 .expect("Failed to connect to Postgres");
478 connection
479 .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
480 .await
481 .expect("Failed to create database.");
482
483 // Migrate database
484 let connection_pool = PgPool::connect_with(config.connect_options())
485 .await
486 .expect("Failed to connect to Postgres.");
487 sqlx::migrate!("./migrations")
488 .run(&connection_pool)
489 .await
490 .expect("Failed to migrate the database");
491 connection_pool
492}
493
494pub struct TestUser {
495 pub username: String,
496 pub password: String,
497 pub user_id: Uuid,
498}
499
500impl TestUser {
501 pub async fn create(pool: &PgPool) -> Result<Self, &str> {
502 let password: String = Uuid::new_v4().to_string();
503 let new_user: NewUser = NewUserData {
504 username: Uuid::new_v4().to_string(),
505 password: SecretString::from(password.clone()),
506 email: SafeEmail().fake(),
507 }
508 .try_into()
509 .expect("Failed to initialize new user.");
510 let mut transaction = pool
511 .begin()
512 .await
513 .expect("Failed to begin database transaction.");
514 let new_user = new_user
515 .store(&mut transaction)
516 .await
517 .expect("Failed to store test user.");
518 UserProfile::initialize(&new_user.user_id)
519 .insert(&mut transaction)
520 .await
521 .unwrap();
522 transaction
523 .commit()
524 .await
525 .expect("Failed to commit database transaction.");
526
527 Ok(Self {
528 username: new_user.username,
529 password,
530 user_id: new_user.user_id,
531 })
532 }
533
534 pub async fn login(&self, app: &TestApp) {
535 app.post_login(&serde_json::json!({
536 "username": &self.username,
537 "password": &self.password
538 }))
539 .await;
540 }
541}