This commit is contained in:
2026-06-18 01:43:50 +02:00
parent d6d61df86a
commit 7773199b91
43 changed files with 3283 additions and 279 deletions
+51
View File
@@ -359,6 +359,24 @@ dependencies = [
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -529,6 +547,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -1291,6 +1318,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1737,6 +1770,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -1757,6 +1796,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1968,6 +2017,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"http",
"http-body",
+6
View File
@@ -39,3 +39,9 @@ dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
url = "2"
scraper = "0.20"
[dev-dependencies]
# Cookie jar for end-to-end session tests (unions with the runtime feature set).
reqwest = { version = "0.12", default-features = false, features = [
"json", "rustls-tls", "gzip", "cookies",
] }
+34
View File
@@ -23,3 +23,37 @@ pub fn verify_password(plain: &str, stored_hash: &str) -> AppResult<bool> {
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_then_verify_roundtrip() {
let hash = hash_password("correct horse battery").unwrap();
// PHC string, not the plaintext.
assert!(hash.starts_with("$argon2"));
assert_ne!(hash, "correct horse battery");
assert!(verify_password("correct horse battery", &hash).unwrap());
}
#[test]
fn verify_rejects_wrong_password() {
let hash = hash_password("correct horse battery").unwrap();
assert!(!verify_password("wrong", &hash).unwrap());
}
#[test]
fn hashes_are_salted_and_unique() {
let a = hash_password("same").unwrap();
let b = hash_password("same").unwrap();
assert_ne!(a, b, "random salt should make hashes differ");
assert!(verify_password("same", &a).unwrap());
assert!(verify_password("same", &b).unwrap());
}
#[test]
fn malformed_stored_hash_errors() {
assert!(verify_password("x", "not-a-phc-string").is_err());
}
}
+23
View File
@@ -74,3 +74,26 @@ pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResu
user_id.ok_or(AppError::BadRequest("invalid or expired token".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_raw_is_64_hex_chars() {
let raw = generate_raw();
assert_eq!(raw.len(), 64, "32 bytes => 64 hex chars");
assert!(raw.chars().all(|c| c.is_ascii_hexdigit()));
// Overwhelmingly unlikely to collide.
assert_ne!(generate_raw(), raw);
}
#[test]
fn hash_token_is_deterministic_and_sized() {
let h1 = hash_token("abc");
let h2 = hash_token("abc");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 32, "SHA-256 => 32 bytes");
assert_ne!(hash_token("abc"), hash_token("abd"));
}
}
+122 -5
View File
@@ -127,9 +127,7 @@ fn find_product(v: &serde_json::Value) -> Option<&serde_json::Value> {
if let Some(t) = map.get("@type") {
let is_product = match t {
serde_json::Value::String(s) => s == "Product",
serde_json::Value::Array(a) => {
a.iter().any(|x| x.as_str() == Some("Product"))
}
serde_json::Value::Array(a) => a.iter().any(|x| x.as_str() == Some("Product")),
_ => false,
};
if is_product {
@@ -195,8 +193,7 @@ fn from_microdata(doc: &Html) -> Candidate {
fn from_meta(doc: &Html) -> Candidate {
Candidate {
title: meta_content(doc, "property", "og:title"),
price: meta_content(doc, "property", "product:price:amount")
.and_then(|s| parse_price(&s)),
price: meta_content(doc, "property", "product:price:amount").and_then(|s| parse_price(&s)),
currency: meta_content(doc, "property", "product:price:currency")
.map(|s| normalize_currency(&s)),
image: meta_content(doc, "property", "og:image"),
@@ -297,3 +294,123 @@ fn parse_price(raw: &str) -> Option<Decimal> {
}
Decimal::from_str(&s).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn parse_price_plain_dot() {
assert_eq!(parse_price("17.95"), Some(dec("17.95")));
assert_eq!(parse_price("45"), Some(dec("45")));
}
#[test]
fn parse_price_european_comma_decimal() {
assert_eq!(parse_price("17,95"), Some(dec("17.95")));
assert_eq!(parse_price("€ 17,95"), Some(dec("17.95")));
}
#[test]
fn parse_price_thousands_separators() {
// Dot thousands, comma decimal (de-DE).
assert_eq!(parse_price("1.234,56"), Some(dec("1234.56")));
// Comma thousands, dot decimal (en-US).
assert_eq!(parse_price("1,234.56"), Some(dec("1234.56")));
}
#[test]
fn parse_price_strips_currency_noise() {
assert_eq!(parse_price("USD 1,999.00"), Some(dec("1999.00")));
assert_eq!(parse_price("ab"), None);
assert_eq!(parse_price(""), None);
}
#[test]
fn availability_maps_known_states() {
for s in [
"http://schema.org/InStock",
"InStock",
"PreOrder",
"BackOrder",
] {
assert!(availability_in_stock(s), "{s} should be in stock");
}
for s in ["OutOfStock", "SoldOut", "Discontinued", ""] {
assert!(!availability_in_stock(s), "{s} should be out of stock");
}
}
#[test]
fn normalize_currency_trims_and_uppercases() {
assert_eq!(normalize_currency(" eur "), "EUR");
assert_eq!(normalize_currency("usd"), "USD");
}
#[test]
fn find_product_descends_into_graph() {
let json: serde_json::Value = serde_json::from_str(
r#"{"@context":"https://schema.org","@graph":[
{"@type":"BreadcrumbList"},
{"@type":["Product","Thing"],"name":"Widget",
"offers":{"price":"9.99","priceCurrency":"eur","availability":"InStock"},
"image":["//cdn/x.jpg"]}
]}"#,
)
.unwrap();
let node = find_product(&json).expect("product node");
let c = product_from_json(node);
assert_eq!(c.title.as_deref(), Some("Widget"));
assert_eq!(c.price, Some(dec("9.99")));
assert_eq!(c.currency.as_deref(), Some("EUR"));
assert_eq!(c.in_stock, Some(true));
assert_eq!(c.image.as_deref(), Some("//cdn/x.jpg"));
}
#[test]
fn product_from_json_handles_offer_array_and_price_spec() {
let json: serde_json::Value = serde_json::from_str(
r#"{"@type":"Product","name":"Z","offers":[
{"priceSpecification":{"price":12.5},"priceCurrency":"USD"}
]}"#,
)
.unwrap();
let c = product_from_json(&json);
assert_eq!(c.price, Some(dec("12.5")));
assert_eq!(c.currency.as_deref(), Some("USD"));
}
#[test]
fn json_first_string_unwraps_array_and_image_object() {
let arr: serde_json::Value = serde_json::json!(["a", "b"]);
assert_eq!(json_first_string(&arr).as_deref(), Some("a"));
let obj: serde_json::Value = serde_json::json!({"url": "//cdn/y.jpg"});
assert_eq!(json_first_string(&obj).as_deref(), Some("//cdn/y.jpg"));
}
#[test]
fn meta_beats_microdata_for_title() {
// OG title is the product; loose itemprop name is a breadcrumb category.
let html = r#"<html><head>
<meta property="og:title" content="Real Product">
<meta property="og:image" content="https://cdn/p.jpg">
</head><body>
<span itemprop="name">Category</span>
<span itemprop="price">19,99</span>
<span itemprop="priceCurrency">EUR</span>
<link itemprop="availability" href="https://schema.org/InStock">
</body></html>"#;
let doc = Html::parse_document(html);
let mut c = from_meta(&doc);
c.fill_from(from_microdata(&doc));
assert_eq!(c.title.as_deref(), Some("Real Product"));
assert_eq!(c.image.as_deref(), Some("https://cdn/p.jpg"));
assert_eq!(c.price, Some(dec("19.99")));
assert_eq!(c.currency.as_deref(), Some("EUR"));
assert_eq!(c.in_stock, Some(true));
}
}
+3 -1
View File
@@ -41,5 +41,7 @@ pub async fn fetch_product(
if let Some(p) = generic::fetch(client, url, default_currency).await? {
return Ok(p);
}
anyhow::bail!("no adapter could read this URL (no Shopify API and no readable product metadata)")
anyhow::bail!(
"no adapter could read this URL (no Shopify API and no readable product metadata)"
)
}
+80
View File
@@ -14,6 +14,7 @@
//! and the price is genuinely in our requested currency;
//! - if they're equal → no conversion happened, so the price is in the shop's
//! base currency (read from `/meta.json`), not our requested one.
//!
//! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN.
use rust_decimal::Decimal;
@@ -183,3 +184,82 @@ fn product_doc_url(raw: &str, ext: &str) -> Option<String> {
let origin = &u[..Position::BeforePath];
Some(format!("{origin}/products/{handle}.{ext}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn variant(price: i64, available: Option<bool>) -> JsVariant {
JsVariant { price, available }
}
#[test]
fn product_doc_url_extracts_handle() {
assert_eq!(
product_doc_url("https://shop.com/products/cool-shoe", "js").as_deref(),
Some("https://shop.com/products/cool-shoe.js")
);
// Handle already carries an extension, and there's a collection prefix.
assert_eq!(
product_doc_url(
"https://shop.com/collections/all/products/cool-shoe.json",
"js"
)
.as_deref(),
Some("https://shop.com/products/cool-shoe.js")
);
// Query/fragment are ignored.
assert_eq!(
product_doc_url("https://shop.com/products/x?variant=1#foo", "json").as_deref(),
Some("https://shop.com/products/x.json")
);
}
#[test]
fn product_doc_url_rejects_non_product_urls() {
assert_eq!(product_doc_url("https://shop.com/about", "js"), None);
assert_eq!(product_doc_url("https://shop.com/products/", "js"), None);
assert_eq!(product_doc_url("ftp://shop.com/products/x", "js"), None);
assert_eq!(product_doc_url("not a url", "js"), None);
}
#[test]
fn cheapest_prefers_available_variant() {
// 500 is cheaper but sold out; pick cheapest *available* (800).
let vs = [
variant(500, Some(false)),
variant(800, Some(true)),
variant(900, Some(true)),
];
assert_eq!(cheapest(&vs), Some(800));
}
#[test]
fn cheapest_falls_back_when_none_available() {
let vs = [variant(500, Some(false)), variant(800, Some(false))];
assert_eq!(cheapest(&vs), Some(500));
assert_eq!(cheapest(&[]), None);
}
#[test]
fn availability_reports_none_when_unknown() {
assert_eq!(availability(&[variant(1, None), variant(2, None)]), None);
assert_eq!(availability(&[variant(1, Some(false))]), Some(false));
assert_eq!(
availability(&[variant(1, Some(false)), variant(2, Some(true))]),
Some(true)
);
}
#[test]
fn normalize_image_adds_scheme_to_protocol_relative() {
assert_eq!(
normalize_image("//cdn.shopify.com/x.jpg".into()),
"https://cdn.shopify.com/x.jpg"
);
assert_eq!(
normalize_image("https://cdn/x.jpg".into()),
"https://cdn/x.jpg"
);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Library crate for shoplist-backend.
//!
//! Exposes the application modules and [`build_router`] so both the binary
//! (`main.rs`) and the integration tests under `tests/` construct the exact
//! same HTTP app. The binary owns process concerns (tracing, the refetch
//! worker, serving); everything reusable lives here.
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod fetch;
pub mod mail;
pub mod models;
pub mod notify;
pub mod routes;
pub mod state;
pub mod worker;
use axum::http::{header, HeaderValue, Method};
use axum::Router;
use time::Duration;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use state::AppState;
/// Build the full Axum app (sessions + CORS + `/api` routes) from shared state.
///
/// Runs the session store's own migrations. Does **not** spawn the refetch
/// worker or bind a listener — callers (the binary, tests) do that.
pub async fn build_router(state: AppState) -> anyhow::Result<Router> {
let cookie_secure = state.config.cookie_secure;
let cors = build_cors(&state.config.cors_origins)?;
// Session store (separate table set, managed by the store's own migrations).
let session_store = PostgresStore::new(state.pool.clone());
session_store.migrate().await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(cookie_secure) // true behind HTTPS in production
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::days(30)));
let api = Router::new()
.merge(routes::router())
.nest("/auth", auth::routes::router());
Ok(Router::new()
.nest("/api", api)
.layer(cors)
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state))
}
/// Build the CORS layer from the configured allowed origins.
pub fn build_cors(origins: &[String]) -> anyhow::Result<CorsLayer> {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| o.parse::<HeaderValue>())
.collect::<Result<_, _>>()
.map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?;
Ok(CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]))
}
+9 -58
View File
@@ -1,29 +1,11 @@
mod auth;
mod config;
mod db;
mod error;
mod fetch;
mod mail;
mod models;
mod notify;
mod routes;
mod state;
mod worker;
use std::sync::Arc;
use axum::http::{header, HeaderValue, Method};
use axum::Router;
use time::Duration;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use config::Config;
use mail::Mailer;
use state::AppState;
use shoplist_backend::config::Config;
use shoplist_backend::mail::Mailer;
use shoplist_backend::state::AppState;
use shoplist_backend::{build_router, db, fetch, worker};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -38,55 +20,24 @@ async fn main() -> anyhow::Result<()> {
let pool = db::connect(&config.database_url).await?;
let mailer = Mailer::from_config(&config.smtp)?;
// Session store (separate table set, managed by the store's own migrations).
let session_store = PostgresStore::new(pool.clone());
session_store.migrate().await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(config.cookie_secure) // true behind HTTPS in production
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::days(30)));
let cors = build_cors(&config.cors_origins)?;
let host = config.host.clone();
let port = config.port;
let state = AppState {
pool,
config: Arc::new(config.clone()),
config: Arc::new(config),
mailer,
http: fetch::http_client(),
};
worker::spawn(state.clone());
let api = Router::new()
.merge(routes::router())
.nest("/auth", auth::routes::router());
let app = build_router(state).await?;
let app = Router::new()
.nest("/api", api)
.layer(cors)
.layer(session_layer)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = format!("{}:{}", config.host, config.port);
let addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("listening on {addr}");
axum::serve(listener, app).await?;
Ok(())
}
fn build_cors(origins: &[String]) -> anyhow::Result<CorsLayer> {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| o.parse::<HeaderValue>())
.collect::<Result<_, _>>()
.map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?;
Ok(CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]))
}
+3 -3
View File
@@ -15,11 +15,11 @@ use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/lists/{id}/invites", get(list_invites).post(create_invite))
.route(
"/lists/{id}/invites",
get(list_invites).post(create_invite),
"/lists/{id}/invites/{invite_id}",
axum::routing::delete(revoke_invite),
)
.route("/lists/{id}/invites/{invite_id}", axum::routing::delete(revoke_invite))
.route("/lists/{id}/collaborators", get(list_collaborators))
.route(
"/lists/{id}/collaborators/{user_id}",
+14 -11
View File
@@ -50,7 +50,8 @@ const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::te
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
i.claimed_at, i.claimed_by_name, i.created_at, i.updated_at";
const LIST_COLS: &str = "id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \
const LIST_COLS: &str =
"id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \
position, created_at, updated_at";
// Same, prefixed with the `l` alias (for the collaborator UNION arm).
@@ -247,11 +248,7 @@ async fn shared_view(
/// The caller's role on a list: "owner", "editor", "crosser", or None (no
/// access). Owner wins over any collaborator row.
async fn list_role(
state: &AppState,
list_id: Uuid,
user_id: Uuid,
) -> AppResult<Option<String>> {
async fn list_role(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<Option<String>> {
let role = sqlx::query_scalar::<_, String>(
"SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2
UNION ALL
@@ -467,9 +464,11 @@ async fn refetch_item(
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<Item>> {
let url = viewable_item_url(&state, id, user.id).await?.ok_or_else(|| {
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
})?;
let url = viewable_item_url(&state, id, user.id)
.await?
.ok_or_else(|| {
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
})?;
worker::refetch(&state, id, &url)
.await
@@ -550,7 +549,9 @@ async fn claim_item(
let name = opt_trim(req.name)
.or_else(|| user.display_name.clone())
.unwrap_or_else(|| user.email.clone());
Ok(Json(set_claim(&state, id, Some(user.id), Some(name)).await?))
Ok(Json(
set_claim(&state, id, Some(user.id), Some(name)).await?,
))
}
async fn unclaim_item(
@@ -595,7 +596,9 @@ async fn guest_claim(
// user has no display name set.
let name = opt_trim(req.name)
.or_else(|| user.as_ref().and_then(|u| u.display_name.clone()))
.ok_or_else(|| AppError::BadRequest("add your name so others know who claimed it".into()))?;
.ok_or_else(|| {
AppError::BadRequest("add your name so others know who claimed it".into())
})?;
let uid = user.as_ref().map(|u| u.id);
Ok(Json(set_claim(&state, item_id, uid, Some(name)).await?))
}
+264
View File
@@ -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);
}
+107
View File
@@ -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;
}
}
};
}