Files
2026-06-18 01:43:50 +02:00

265 lines
6.7 KiB
Rust

//! 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);
}