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