tests
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
//! End-to-end HTTP tests against a live app + Postgres.
|
||||
//!
|
||||
//! Run with a throwaway database:
|
||||
//! TEST_DATABASE_URL=postgres://shoplist:shoplist@localhost:5432/shoplist_test \
|
||||
//! cargo test --test api
|
||||
//! Without `TEST_DATABASE_URL` every test skips (see `common::spawn`).
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Register a brand-new user and leave the client logged in. Returns the email.
|
||||
async fn register_user(app: &common::TestApp) -> String {
|
||||
let email = app.unique_email();
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&json!({ "email": email, "password": "supersecret123" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "register should create the user");
|
||||
email
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_ok() {
|
||||
let app = test_app!();
|
||||
let res = app.client.get(app.url("/health")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_login_me_logout_flow() {
|
||||
let app = test_app!();
|
||||
let email = register_user(&app).await;
|
||||
|
||||
// Cookie from register authenticates /me.
|
||||
let me: Value = app
|
||||
.client
|
||||
.get(app.url("/auth/me"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(me["user"]["email"], email);
|
||||
assert_eq!(me["user"]["email_verified"], false);
|
||||
assert_eq!(me["settings"]["currency"], "EUR");
|
||||
|
||||
// Logout clears the session.
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/logout"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 204);
|
||||
let res = app.client.get(app.url("/auth/me")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 401, "me must reject after logout");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_registration_conflicts() {
|
||||
let app = test_app!();
|
||||
let email = app.unique_email();
|
||||
let body = json!({ "email": email, "password": "supersecret123" });
|
||||
|
||||
let first = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), 201);
|
||||
|
||||
let second = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), 409, "same email must conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_validates_input() {
|
||||
let app = test_app!();
|
||||
// Bad email + too-short password => 422 Validation.
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/register"))
|
||||
.json(&json!({ "email": "nope", "password": "short" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 422);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_wrong_password() {
|
||||
let app = test_app!();
|
||||
let email = register_user(&app).await;
|
||||
let res = app
|
||||
.client
|
||||
.post(app.url("/auth/login"))
|
||||
.json(&json!({ "email": email, "password": "the-wrong-one" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_route_requires_auth() {
|
||||
let app = test_app!();
|
||||
// Fresh client, no cookies.
|
||||
let res = app.client.get(app.url("/lists")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_and_items_crud() {
|
||||
let app = test_app!();
|
||||
register_user(&app).await;
|
||||
|
||||
// Create a list.
|
||||
let list: Value = app
|
||||
.client
|
||||
.post(app.url("/lists"))
|
||||
.json(&json!({ "name": "Gadgets", "emoji": "🛒" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list["name"], "Gadgets");
|
||||
assert_eq!(list["role"], "owner");
|
||||
let list_id = list["id"].as_str().unwrap().to_string();
|
||||
|
||||
// It shows up in the overview.
|
||||
let all: Value = app
|
||||
.client
|
||||
.get(app.url("/lists"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all.as_array().unwrap().len(), 1);
|
||||
|
||||
// Add an item.
|
||||
let item: Value = app
|
||||
.client
|
||||
.post(app.url(&format!("/lists/{list_id}/items")))
|
||||
.json(&json!({ "title": "Mechanical keyboard", "target_price": 99.99 }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(item["title"], "Mechanical keyboard");
|
||||
assert_eq!(item["status"], "coveted");
|
||||
let item_id = item["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Update its status.
|
||||
let updated: Value = app
|
||||
.client
|
||||
.patch(app.url(&format!("/items/{item_id}")))
|
||||
.json(&json!({ "status": "acquired" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["status"], "acquired");
|
||||
|
||||
// Delete the item.
|
||||
let res = app
|
||||
.client
|
||||
.delete(app.url(&format!("/items/{item_id}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let items: Value = app
|
||||
.client
|
||||
.get(app.url(&format!("/lists/{list_id}/items")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(items.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_link_exposes_public_read_only_view() {
|
||||
let app = test_app!();
|
||||
register_user(&app).await;
|
||||
|
||||
let list: Value = app
|
||||
.client
|
||||
.post(app.url("/lists"))
|
||||
.json(&json!({ "name": "Wishlist" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let list_id = list["id"].as_str().unwrap().to_string();
|
||||
|
||||
app.client
|
||||
.post(app.url(&format!("/lists/{list_id}/items")))
|
||||
.json(&json!({ "title": "Telescope" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mint a share token.
|
||||
let shared: Value = app
|
||||
.client
|
||||
.post(app.url(&format!("/lists/{list_id}/share")))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let token = shared["share_token"].as_str().expect("share token minted");
|
||||
|
||||
// Anonymous client can read it.
|
||||
let anon = reqwest::Client::new();
|
||||
let view: Value = anon
|
||||
.get(app.url(&format!("/shared/{token}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(view["list"]["name"], "Wishlist");
|
||||
assert_eq!(view["items"][0]["title"], "Telescope");
|
||||
|
||||
// A bogus token 404s.
|
||||
let res = anon
|
||||
.get(app.url("/shared/definitely-not-a-real-token"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Shared harness for the HTTP integration tests.
|
||||
//!
|
||||
//! Boots the real Axum app (via [`shoplist_backend::build_router`]) against a
|
||||
//! Postgres database named by the `TEST_DATABASE_URL` env var, on an ephemeral
|
||||
//! port, with a cookie-aware client so session auth works end to end.
|
||||
//!
|
||||
//! When `TEST_DATABASE_URL` is unset, [`spawn`] returns `None` and each test
|
||||
//! skips — so `cargo test` stays green without a database, and the pre-push
|
||||
//! hook runs the full suite only when one is available.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use shoplist_backend::config::{Config, SmtpConfig, SmtpSecurity};
|
||||
use shoplist_backend::mail::Mailer;
|
||||
use shoplist_backend::state::AppState;
|
||||
use shoplist_backend::{build_router, db, fetch};
|
||||
|
||||
pub struct TestApp {
|
||||
pub base: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
/// Absolute URL for an `/api`-relative path.
|
||||
pub fn url(&self, path: &str) -> String {
|
||||
format!("{}/api{}", self.base, path)
|
||||
}
|
||||
|
||||
/// A fresh, never-before-used email (tests share one DB; avoid collisions).
|
||||
pub fn unique_email(&self) -> String {
|
||||
format!("u{}@example.test", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot the app, or `None` if no test database is configured.
|
||||
pub async fn spawn() -> Option<TestApp> {
|
||||
let database_url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
|
||||
let pool = db::connect(&database_url)
|
||||
.await
|
||||
.expect("connect + migrate test database");
|
||||
|
||||
let config = Config {
|
||||
database_url,
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
session_secret: "test-session-secret-at-least-32-chars!!".into(),
|
||||
public_app_url: "http://localhost:5173".into(),
|
||||
cors_origins: vec!["http://localhost:5173".into()],
|
||||
smtp: SmtpConfig {
|
||||
// Points nowhere; register() swallows send failures, other tests
|
||||
// never trigger mail.
|
||||
host: "localhost".into(),
|
||||
port: 1,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
from: "Test <test@localhost>".into(),
|
||||
security: SmtpSecurity::None,
|
||||
},
|
||||
refetch_interval_secs: 0, // no background worker in tests
|
||||
refetch_min_age_secs: 21_600,
|
||||
default_currency: "EUR".into(),
|
||||
cookie_secure: false, // plain HTTP in tests
|
||||
};
|
||||
|
||||
let mailer = Mailer::from_config(&config.smtp).expect("build test mailer");
|
||||
let state = AppState {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
mailer,
|
||||
http: fetch::http_client(),
|
||||
};
|
||||
|
||||
let app = build_router(state).await.expect("build router");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral port");
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.expect("build cookie client");
|
||||
|
||||
Some(TestApp {
|
||||
base: format!("http://{addr}"),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Skip-or-run boilerplate: `let app = test_app!();`.
|
||||
#[macro_export]
|
||||
macro_rules! test_app {
|
||||
() => {
|
||||
match common::spawn().await {
|
||||
Some(app) => app,
|
||||
None => {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user